mock: server-side per-trip store — originals, parsed docs, conversation

Context and documents now live on the server between turns (and across
reloads), per project, as requested:

  mock/store/<trip>/docs/<docId>-<file>        ORIGINAL uploaded bytes
  mock/store/<trip>/docs/<docId>-<file>.txt    extracted text (+ header)
  mock/store/<trip>/chat.jsonl                 full LLM conversation

Endpoints: GET /trip-store/:trip (manifest incl. doc texts),
GET .../doc/:docId, GET .../chat (last 100 turns), POST .../doc,
POST .../chat. /parse-doc's extraction factored into shared finishParse()
so both paths use identical logic. The store dir is gitignored (PII).

Client: enterTrip rehydrates tripDocs + chatLog from the server
(loadTripStore, race-guarded, one-time migration of localStorage docs
from older builds), re-renders past turns in the chat panel, and
askLLM persists each turn fire-and-forget. localStorage now carries
only the plan state.

Verified end-to-end: drop 133 KB email → direct re-anchor, original +
parsed files on disk, both turns in chat.jsonl → full page reload →
doc chip, document and conversation restored from the server → question
asked without re-upload answered correctly (AK-77Q2ZD, 14C/22A).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Greg Pomerantz 2026-09-09 15:31:06 -04:00
parent 91e93ec265
commit bf72fdf32d
3 changed files with 272 additions and 68 deletions

3
.gitignore vendored
View File

@ -10,3 +10,6 @@ osm/build/
router/router router/router
router/bench router/bench
router/scripts/__pycache__/ router/scripts/__pycache__/
# per-trip uploaded documents + chat logs (PII — never commit)
mock/store/

View File

@ -449,10 +449,12 @@ function toolLine(name, args) {
async function askLLM(v) { async function askLLM(v) {
if (scriptBusy) return msg('ai', 'Hold on — Im still working on the last request.'); if (scriptBusy) return msg('ai', 'Hold on — Im still working on the last request.');
scriptBusy = true; scriptBusy = true;
await storeReady.catch(() => {}); // make sure the persisted history is loaded
pushChatLog('user', v); pushChatLog('user', v);
persistChatTurn('user', v);
let t = typing(); let t = typing();
const messages = [{ role: 'system', content: llmSystemPrompt() }, ...recentHistory(), { role: 'user', content: v }]; const messages = [{ role: 'system', content: llmSystemPrompt() }, ...recentHistory(), { role: 'user', content: v }];
const finish = (html, plain) => { t.remove(); msg('ai', html); if (plain) pushChatLog('assistant', plain); scriptBusy = false; }; const finish = (html, plain) => { t.remove(); msg('ai', html); if (plain) { pushChatLog('assistant', plain); persistChatTurn('assistant', plain); } scriptBusy = false; };
try { try {
let finalText = null; let finalText = null;
for (let i = 0; i < LLM_TOOL_MAX; i++) { for (let i = 0; i < LLM_TOOL_MAX; i++) {
@ -1531,9 +1533,70 @@ $('#l3-close').onclick = closeL3;
let version = 0; let version = 0;
let history = []; // [{v, label, at, days, stays}] let history = []; // [{v, label, at, days, stays}]
let hIdx = -1; // pointer into history let hIdx = -1; // pointer into history
let tripDocs = {}; // name → {name,kind,chars,text,truncated,at} — attached documents, persisted per trip let tripDocs = {}; // name → {name,kind,chars,text,truncated,at} — attached documents
let chatLog = []; // [{role:'user'|'assistant', text}] — recent turns, so a follow-up // (authoritative copy lives on the server: mock/store/<trip>/docs/)
// ("yes, do it") can see what the model itself said earlier let chatLog = []; // [{role:'user'|'assistant', text}] — conversation turns, persisted
// per trip on the server (mock/store/<trip>/chat.jsonl) so a
// follow-up ("yes, do it") can see what the model said earlier,
// even after a reload
let storeReady = Promise.resolve(); // resolve when this trip's server store has loaded
let storeToken = 0; // guards against a stale load after switchTrip
function persistChatTurn(role, text) {
if (!tripId || !text || !String(text).trim()) return;
fetch('/trip-store/' + encodeURIComponent(tripId) + '/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role, text: String(text) }),
}).catch(() => {}); // fire & forget — the model answer must not wait on the log
}
function loadTripStore(id) {
const token = ++storeToken;
storeReady = (async () => {
try {
const m = await (await fetch('/trip-store/' + encodeURIComponent(id))).json();
if (token !== storeToken) return; // user switched trips while loading
tripDocs = {};
// one-time migration: docs saved in localStorage by older builds get
// uploaded to the server (the extracted text becomes the stored file)
const local = (tripStore.load(id) || {}).docs || {};
const localNames = Object.keys(local);
if (localNames.length && !(m.docs || []).length) {
for (const n of localNames) {
const d = local[n] || {};
const b64 = btoa(unescape(encodeURIComponent(d.text || '')));
const fn = /\.[a-z0-9]{1,5}$/i.test(n) ? n : n + '.txt';
await fetch('/trip-store/' + encodeURIComponent(id) + '/doc', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: fn, mime: 'text/plain', b64 }),
}).catch(() => {});
}
const s = tripStore.load(id); // drop the migrated docs from localStorage
if (s && s.docs) { delete s.docs; tripStore.save(id, s); }
const m2 = await (await fetch('/trip-store/' + encodeURIComponent(id))).json();
if (token !== storeToken) return;
m.docs = m2.docs || [];
}
for (const d of m.docs || []) {
if (!d.name) continue;
tripDocs[d.name] = { name: d.name, kind: d.kind, chars: d.chars || 0, text: d.text || '', truncated: !!d.truncated, at: d.at || Date.now(), docId: d.docId, originalSize: d.originalSize };
}
const cj = await (await fetch('/trip-store/' + encodeURIComponent(id) + '/chat')).json();
if (token !== storeToken) return;
chatLog = (cj.messages || []).filter(x => x && x.text).map(x => ({ role: x.role === 'assistant' ? 'assistant' : 'user', text: String(x.text) }));
renderDocChips();
// re-render past turns — the chat panel is fresh after a reload (skip
// the upload messages: they carry the whole document, not worth showing)
const body = $('#chat-body');
if (body) {
const frag = document.createDocumentFragment();
chatLog.filter(e => !e.text.includes('<document>')).slice(-40).forEach(e =>
frag.append(el('div', 'msg ' + (e.role === 'assistant' ? 'ai' : 'user'),
e.role === 'assistant' ? esc(e.text).replace(/\n+/g, '<br>') : esc(e.text))));
if (frag.childNodes.length) body.prepend(frag);
}
} catch { /* store unreachable — fall back to in-memory only */ }
})();
return storeReady;
}
function pushChatLog(role, text) { function pushChatLog(role, text) {
if (!text || !String(text).trim()) return; if (!text || !String(text).trim()) return;
chatLog.push({ role, text: String(text) }); chatLog.push({ role, text: String(text) });
@ -1838,7 +1901,10 @@ async function ingestDoc(file) {
try { b64 = await fileToB64(file); } try { b64 = await fileToB64(file); }
catch { note.remove(); return msg('ai', 'Couldnt read that file.'); } catch { note.remove(); return msg('ai', 'Couldnt read that file.'); }
try { try {
const r = await fetch('/parse-doc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, mime: file.type || '', b64 }) }); // save with the trip on the server (original + extracted text); fall back
// to the stateless parser if the store endpoint is unavailable
const url = tripId ? '/trip-store/' + encodeURIComponent(tripId) + '/doc' : '/parse-doc';
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, mime: file.type || '', b64 }) });
const j = await r.json(); const j = await r.json();
if (!j.ok) { note.remove(); return msg('ai', '⚠️ ' + esc(j.error || 'couldnt read that file')); } if (!j.ok) { note.remove(); return msg('ai', '⚠️ ' + esc(j.error || 'couldnt read that file')); }
note.remove(); note.remove();
@ -1867,8 +1933,8 @@ function renderDocChips() {
function reconfigureFromDoc(j) { function reconfigureFromDoc(j) {
// keep the document on file: it persists with the trip and rides in the // keep the document on file: it persists with the trip and rides in the
// model's context on every turn // model's context on every turn
tripDocs[j.name] = { name: j.name, kind: j.kind, chars: j.chars, text: j.text, truncated: !!j.truncated, at: Date.now() }; tripDocs[j.name] = { name: j.name, kind: j.kind, chars: j.chars, text: j.text, truncated: !!j.truncated, at: Date.now(), docId: j.docId || null, originalSize: j.originalSize || 0 };
persistTrip(); renderDocChips(); renderDocChips();
msg('user', '📎 ' + esc(j.name) + ' <span class="src">' + (j.kind || 'doc') + ' · ' + (j.chars || 0) + ' chars — attached' + (j.truncated ? ' (truncated)' : '') + '</span>'); msg('user', '📎 ' + esc(j.name) + ' <span class="src">' + (j.kind || 'doc') + ' · ' + (j.chars || 0) + ' chars — attached' + (j.truncated ? ' (truncated)' : '') + '</span>');
if (!llmOn) return msg('ai', 'I read the document (' + (j.chars || 0) + ' chars) and kept it attached, but the model is offline so I cant act on it yet. Ill do it as soon as its back.'); if (!llmOn) return msg('ai', 'I read the document (' + (j.chars || 0) + ' chars) and kept it attached, but the model is offline so I cant act on it yet. Ill do it as soon as its back.');
const prompt = const prompt =
@ -1944,7 +2010,9 @@ let persistT = 0;
function persistTrip() { function persistTrip() {
clearTimeout(persistT); clearTimeout(persistT);
const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25 const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25
persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title, docs: tripDocs }), 250); // docs + chat live on the server now (mock/store/<trip>/) — localStorage
// only carries the plan state
persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title }), 250);
} }
function renderTripMenu() { function renderTripMenu() {
$('#crumb').innerHTML = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`; $('#crumb').innerHTML = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`;
@ -1976,10 +2044,11 @@ function enterTrip(id) {
days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx; days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx;
if (saved.bookings) M.trip.bookings = deep(saved.bookings); if (saved.bookings) M.trip.bookings = deep(saved.bookings);
if (saved.title != null) M.trip.title = saved.title; if (saved.title != null) M.trip.title = saved.title;
tripDocs = saved.docs ? deep(saved.docs) : {}; }
} else tripDocs = {}; tripDocs = {}; // rehydrated from the server store below
// reset per-trip UI state // reset per-trip UI state
chatLog = []; compareItems = []; chatLog = []; compareItems = [];
loadTripStore(id); // docs (original + parsed) + conversation, per trip
closeDiscover(); closeL3(); closeDiscover(); closeL3();
scope = 'day'; scope = 'day';
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day')); document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day'));

View File

@ -36,6 +36,8 @@ const routerFor = (u) => ROUTERS[u.searchParams.get('router') || 'northeast'] ||
const PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' }; const PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' };
const CACHE = path.join(__dirname, '.tilecache'); const CACHE = path.join(__dirname, '.tilecache');
fs.mkdirSync(CACHE, { recursive: true }); fs.mkdirSync(CACHE, { recursive: true });
const STORE = path.join(__dirname, 'store'); // per-trip uploads (originals + parsed) + chat logs
fs.mkdirSync(STORE, { recursive: true });
const mime = { const mime = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
@ -58,6 +60,86 @@ function decodeEntities(s) {
return Object.prototype.hasOwnProperty.call(ENT, e.toLowerCase()) ? ENT[e.toLowerCase()] : m; return Object.prototype.hasOwnProperty.call(ENT, e.toLowerCase()) ? ENT[e.toLowerCase()] : m;
}); });
} }
// extract document text: pdftotext for PDFs, MIME decoding for .eml,
// tag-aware stripping for HTML. cb({ok:false,error}) or
// cb({ok:true,kind,name,text,chars,truncated}) — text capped at 60k chars.
const DOC_CAP = 60000; // well under the 131k-token context; flag if we had to cut
function finishParse(in0, cb) {
const name = String(in0.filename || 'document');
const ext = path.extname(name).toLowerCase();
const ctype = String(in0.mime || '');
if (/^image\//.test(ctype) || ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'].includes(ext))
return cb({ ok: false, error: 'Screenshots and images arent supported yet (no OCR on this box). Save the booking as a PDF, or copy its text and paste it.' });
if (ext === '.msg')
return cb({ ok: false, error: 'Outlook .msg files cant read here — in your mail app use “Forward as HTML” (or Save As → .eml) and drop that instead.' });
let buf = in0.buf;
if (!buf) {
try { buf = Buffer.from(String(in0.b64 || ''), 'base64'); } catch { return cb({ ok: false, error: 'couldnt read the file data' }); }
if (!buf.length) return cb({ ok: false, error: 'that file looks empty' });
if (buf.length > 16e6) return cb({ ok: false, error: 'file too large (max ~16 MB)' });
}
const finish = (text) => {
text = String(text || '').replace(/\r\n/g, '\n').replace(/\t/g, ' ').replace(/[ \u00a0]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
if (!text) return cb({ ok: false, error: 'no readable text found in that file' });
// collapse heavy repetition (boilerplate fare rules etc.): keep the first
// two copies of any line that shows up 10+ times, then a single note
const lines = text.split('\n'), count = {};
lines.forEach(l => { const k = l.trim(); if (k) count[k] = (count[k] || 0) + 1; });
const seen = {}, out = [];
for (const l of lines) {
const k = l.trim();
if (k && count[k] >= 10) {
seen[k] = (seen[k] || 0) + 1;
if (seen[k] > 2) { if (out[out.length - 1] !== '__DUP__' + k) out.push('__DUP__' + k); continue; }
}
out.push(l);
}
text = out.map(l => l.startsWith('__DUP__') ? ' [… ' + count[l.slice(7)] + '× this line repeats — shown above]' : l).join('\n');
try { fs.writeFileSync('/tmp/parse-doc-last.txt', 'file: ' + name + ' (' + buf.length + ' bytes) → ' + text.length + ' chars\n' + '=== extracted text (exactly what the model receives) ===\n' + text); } catch {}
console.log(`[parse-doc] ${name} ${buf.length}B → ${text.length} chars${text.length > DOC_CAP ? ' (TRUNCATED at ' + DOC_CAP + ')' : ''} | preview: ${text.slice(0, 200).replace(/\n/g, ' ⏎ ')}`);
cb({ ok: true, kind: ext.replace('.', '') || 'text', name, text: text.slice(0, DOC_CAP), chars: text.length, truncated: text.length > DOC_CAP });
};
if (ext === '.pdf' || ctype.includes('pdf')) {
const tmp = path.join(os.tmpdir(), 'parse-doc-' + Date.now() + '-' + Math.floor(Math.random() * 1e5) + '.pdf');
fs.writeFile(tmp, buf, (werr) => {
if (werr) return cb({ ok: false, error: 'couldnt write temp file' });
execFile('pdftotext', ['-layout', tmp, '-'], (err, stdout) => {
try { fs.unlinkSync(tmp); } catch {}
finish(err ? '' : stdout);
});
});
return;
}
let text = buf.toString('utf8');
if (ext === '.eml' || looksLikeMime(text)) {
const mimeBody = decodeMime(text);
if (mimeBody != null) text = mimeBody; // not really MIME → treat as plain text
}
const head = text.slice(0, 20000);
const tagDensity = (head.match(/<[a-z][\s\/]/gi) || []).length;
if (ext === '.html' || ext === '.htm' || ctype.includes('html') || /<\s*(html|!doctype|head|body)\b/i.test(head) || tagDensity > 30)
text = htmlToText(text);
finish(text);
}
// JSON body reader: cb(errOrNull, parsedObj)
function readBody(req, cb, limit = 24e6) {
let body = '', done = false;
const finish = (err, obj) => { if (done) return; done = true; cb(err, obj); };
req.on('data', c => {
body += c;
if (body.length > limit) { req.destroy(); finish('too large'); }
});
req.on('end', () => {
if (body.length > limit) return finish('too large');
try { finish(null, JSON.parse(body)); } catch { finish('bad json'); }
});
}
function sendJson(res) {
return j => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify(j)); };
}
function htmlToText(html) { function htmlToText(html) {
const BLOCK_END = /^<\/(p|div|tr|table|h[1-6]|li|ul|ol|section|header|footer|blockquote|pre|article|main|form|dl|dd|figure)\s*>$/i; const BLOCK_END = /^<\/(p|div|tr|table|h[1-6]|li|ul|ol|section|header|footer|blockquote|pre|article|main|form|dl|dd|figure)\s*>$/i;
let out = '', i = 0, n = html.length; let out = '', i = 0, n = html.length;
@ -281,68 +363,118 @@ http.createServer((req, res) => {
// clean text with htmlToText(); txt/ics/csv/json read directly. Images // clean text with htmlToText(); txt/ics/csv/json read directly. Images
// are rejected (no OCR on this box). // are rejected (no OCR on this box).
if (u.pathname === '/parse-doc' && req.method === 'POST') { if (u.pathname === '/parse-doc' && req.method === 'POST') {
let body = ''; readBody(req, (err, in0) => {
req.on('data', c => { body += c; if (body.length > 24e6) req.destroy(); }); if (err) { res.writeHead(400); return res.end('bad request'); }
req.on('end', () => { finishParse(in0, sendJson(res));
let in0; try { in0 = JSON.parse(body); } catch { res.writeHead(400); return res.end('bad json'); }
const send = j => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify(j)); };
const name = String(in0.filename || 'document');
const ext = path.extname(name).toLowerCase();
const ctype = String(in0.mime || '');
if (/^image\//.test(ctype) || ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'].includes(ext))
return send({ ok: false, error: 'Screenshots and images arent supported yet (no OCR on this box). Save the booking as a PDF, or copy its text and paste it.' });
if (ext === '.msg')
return send({ ok: false, error: 'Outlook .msg files cant be read here — in your mail app use “Forward as HTML” (or Save As → .eml) and drop that instead.' });
let buf; try { buf = Buffer.from(String(in0.b64 || ''), 'base64'); } catch { return send({ ok: false, error: 'couldnt read the file data' }); }
if (!buf.length) return send({ ok: false, error: 'that file looks empty' });
if (buf.length > 16e6) return send({ ok: false, error: 'file too large (max ~16 MB)' });
const CAP = 60000; // well under the 131k-token context; flag if we had to cut
const finish = (text) => {
text = String(text || '').replace(/\r\n/g, '\n').replace(/\t/g, ' ').replace(/[ \u00a0]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
if (!text) return send({ ok: false, error: 'no readable text found in that file' });
// collapse heavy repetition (boilerplate fare rules etc.): keep the first
// two copies of any line that shows up 10+ times, then a single note
const lines = text.split('\n'), count = {};
lines.forEach(l => { const k = l.trim(); if (k) count[k] = (count[k] || 0) + 1; });
const seen = {}, out = [];
for (const l of lines) {
const k = l.trim();
if (k && count[k] >= 10) {
seen[k] = (seen[k] || 0) + 1;
if (seen[k] > 2) { if (out[out.length - 1] !== '__DUP__' + k) out.push('__DUP__' + k); continue; }
}
out.push(l);
}
text = out.map(l => l.startsWith('__DUP__') ? ' [… ' + count[l.slice(7)] + '× this line repeats — shown above]' : l).join('\n');
try { fs.writeFileSync('/tmp/parse-doc-last.txt', 'file: ' + name + ' (' + buf.length + ' bytes) → ' + text.length + ' chars\n' + '=== extracted text (exactly what the model receives) ===\n' + text); } catch {}
console.log(`[parse-doc] ${name} ${buf.length}B → ${text.length} chars${text.length > CAP ? ' (TRUNCATED at ' + CAP + ')' : ''} | preview: ${text.slice(0, 200).replace(/\n/g, ' ⏎ ')}`);
send({ ok: true, kind: ext.replace('.', '') || 'text', name, text: text.slice(0, CAP), chars: text.length, truncated: text.length > CAP });
};
if (ext === '.pdf' || ctype.includes('pdf')) {
const tmp = path.join(os.tmpdir(), 'parse-doc-' + Date.now() + '-' + Math.floor(Math.random() * 1e5) + '.pdf');
fs.writeFile(tmp, buf, (werr) => {
if (werr) return send({ ok: false, error: 'couldnt write temp file' });
execFile('pdftotext', ['-layout', tmp, '-'], (err, stdout) => {
try { fs.unlinkSync(tmp); } catch {}
finish(err ? '' : stdout);
});
});
return;
}
let text = buf.toString('utf8');
if (ext === '.eml' || looksLikeMime(text)) {
const mimeBody = decodeMime(text);
if (mimeBody != null) text = mimeBody; // not really MIME → treat as plain text
}
const head = text.slice(0, 20000);
const tagDensity = (head.match(/<[a-z][\s\/a-z>]/gi) || []).length;
if (ext === '.html' || ext === '.htm' || ctype.includes('html') || /<\s*(html|!doctype|head|body)\b/i.test(head) || tagDensity > 30)
text = htmlToText(text);
finish(text);
}); });
return; return;
} }
// ---- per-trip server store ------------------------------------------
// Storage is cheap: for every trip we keep the ORIGINAL uploaded files,
// the extracted text of each, and the full LLM conversation (jsonl).
// store/<trip>/docs/<docId>-<filename> original bytes
// store/<trip>/docs/<docId>-<filename>.txt extracted text + header
// store/<trip>/chat.jsonl {role,text,at} per line
if (u.pathname.startsWith('/trip-store/')) {
const seg = u.pathname.slice('/trip-store/'.length).split('/');
const tripId = decodeURIComponent(seg[0] || '');
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/i.test(tripId)) { res.writeHead(400); return res.end('bad trip id'); }
const dir = path.join(STORE, tripId);
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
const json = sendJson(res);
if (req.method === 'GET' && seg[1] === undefined) {
// manifest: docs + chat stats for this trip
const docs = fs.readdirSync(path.join(dir, 'docs'))
.filter(f => !f.endsWith('.txt'))
.map(f => {
const docId = f.split('-')[0];
let name = f.slice(docId.length + 1), originalSize = 0, text = '', chars = 0, truncated = false;
try { originalSize = fs.statSync(path.join(dir, 'docs', f)).size; } catch { originalSize = -1; }
try {
const t = fs.readFileSync(path.join(dir, 'docs', f + '.txt'), 'utf8');
const nl = t.indexOf('\n');
const m = t.slice(0, nl).match(/^file: (.+) \((\d+) bytes\) → (\d+) chars( \(truncated\))?/);
if (m) { name = m[1]; originalSize = +m[2]; chars = +m[3]; truncated = !!m[4]; }
text = t.slice(nl + 1);
} catch {}
const kind = path.extname(name).toLowerCase().replace('.', '') || 'text';
return { docId, name, kind, originalSize, chars, truncated, text };
});
let chatCount = 0, chatBytes = 0;
try {
chatBytes = fs.statSync(path.join(dir, 'chat.jsonl')).size;
chatCount = fs.readFileSync(path.join(dir, 'chat.jsonl'), 'utf8').split('\n').filter(Boolean).length;
} catch {}
return json({ trip: tripId, docs, chat: { count: chatCount, bytes: chatBytes } });
}
if (req.method === 'GET' && seg[1] === 'chat') {
let messages = [];
try {
messages = fs.readFileSync(path.join(dir, 'chat.jsonl'), 'utf8')
.split('\n').filter(Boolean).slice(-100)
.map(l => { try { return JSON.parse(l); } catch { return null; } })
.filter(Boolean);
} catch {}
return json({ messages });
}
if (req.method === 'GET' && seg[1] === 'doc' && seg[2]) {
const docId = seg[2];
if (!/^[a-z0-9_-]+$/i.test(docId)) { res.writeHead(400); return res.end('bad doc id'); }
const f = fs.readdirSync(path.join(dir, 'docs')).find(x => x.startsWith(docId + '-') && !x.endsWith('.txt'));
if (!f) { res.writeHead(404); return res.end('no such doc'); }
let name = f.slice(docId.length + 1), originalSize = 0, text = '';
try { originalSize = fs.statSync(path.join(dir, 'docs', f)).size; } catch {}
try {
const t = fs.readFileSync(path.join(dir, 'docs', f + '.txt'), 'utf8');
const nl = t.indexOf('\n');
const m = t.slice(0, nl).match(/^file: (.+) \((\d+) bytes\) → (\d+) chars/);
if (m) { name = m[1]; originalSize = +m[2]; }
text = t.slice(nl + 1);
} catch {}
const kind = path.extname(name).toLowerCase().replace('.', '') || 'text';
return json({ docId, name, kind, originalSize, text });
}
if (req.method === 'POST' && seg[1] === 'doc') {
readBody(req, (err, in0) => {
if (err) return json({ ok: false, error: err === 'too large' ? 'file too large (max ~16 MB)' : 'bad request' });
const rawName = String(in0.filename || 'document')
.replace(/[\/\\*?"<>|\x00-\x1f]/g, '_').trim().slice(-80) || 'document';
let buf; try { buf = Buffer.from(String(in0.b64 || ''), 'base64'); } catch { return json({ ok: false, error: 'couldnt read the file data' }); }
if (!buf.length) return json({ ok: false, error: 'that file looks empty' });
finishParse({ filename: rawName, mime: in0.mime || '', buf }, (out) => {
if (!out.ok) return json({ ok: false, error: out.error });
const docId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const base = path.join(dir, 'docs', docId + '-' + rawName);
try {
fs.writeFileSync(base, buf);
fs.writeFileSync(base + '.txt', 'file: ' + rawName + ' (' + buf.length + ' bytes) → ' + out.chars + ' chars' + (out.truncated ? ' (truncated)' : '') + '\n=== extracted text ===\n' + out.text);
} catch { return json({ ok: false, error: 'couldnt store the document on the server' }); }
console.log(`[store] ${tripId}/docs/${docId}-${rawName} ${buf.length}B → ${out.chars} chars`);
json({ ok: true, docId, name: rawName, kind: out.kind, text: out.text, chars: out.chars, truncated: out.truncated, originalSize: buf.length });
});
});
return;
}
if (req.method === 'POST' && seg[1] === 'chat') {
readBody(req, (err, in0) => {
if (err) return json({ ok: false });
const role = in0.role === 'assistant' ? 'assistant' : 'user';
const text = String(in0.text || '');
if (!text.trim()) return json({ ok: false });
fs.appendFile(path.join(dir, 'chat.jsonl'), JSON.stringify({ role, text, at: Date.now() }) + '\n', e => json({ ok: !e }));
});
return;
}
res.writeHead(404); return res.end('no such store route');
}
// ---- static ------------------------------------------------------- // ---- static -------------------------------------------------------
let p = u.pathname === '/' ? '/index.html' : u.pathname; let p = u.pathname === '/' ? '/index.html' : u.pathname;
const file = path.join(ROOT, p); const file = path.join(ROOT, p);