diff --git a/.gitignore b/.gitignore index 2bdad8b..2268952 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ osm/build/ router/router router/bench router/scripts/__pycache__/ + +# per-trip uploaded documents + chat logs (PII — never commit) +mock/store/ diff --git a/mock/app.js b/mock/app.js index 5203d76..accb1c1 100644 --- a/mock/app.js +++ b/mock/app.js @@ -449,10 +449,12 @@ function toolLine(name, args) { async function askLLM(v) { if (scriptBusy) return msg('ai', 'Hold on — I’m still working on the last request.'); scriptBusy = true; + await storeReady.catch(() => {}); // make sure the persisted history is loaded pushChatLog('user', v); + persistChatTurn('user', v); let t = typing(); 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 { let finalText = null; for (let i = 0; i < LLM_TOOL_MAX; i++) { @@ -1531,9 +1533,70 @@ $('#l3-close').onclick = closeL3; let version = 0; let history = []; // [{v, label, at, days, stays}] let hIdx = -1; // pointer into history -let tripDocs = {}; // name → {name,kind,chars,text,truncated,at} — attached documents, persisted per trip -let chatLog = []; // [{role:'user'|'assistant', text}] — recent turns, so a follow-up - // ("yes, do it") can see what the model itself said earlier +let tripDocs = {}; // name → {name,kind,chars,text,truncated,at} — attached documents + // (authoritative copy lives on the server: mock/store//docs/) +let chatLog = []; // [{role:'user'|'assistant', text}] — conversation turns, persisted + // per trip on the server (mock/store//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('')).slice(-40).forEach(e => + frag.append(el('div', 'msg ' + (e.role === 'assistant' ? 'ai' : 'user'), + e.role === 'assistant' ? esc(e.text).replace(/\n+/g, '
') : 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) { if (!text || !String(text).trim()) return; chatLog.push({ role, text: String(text) }); @@ -1838,7 +1901,10 @@ async function ingestDoc(file) { try { b64 = await fileToB64(file); } catch { note.remove(); return msg('ai', 'Couldn’t read that file.'); } 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(); if (!j.ok) { note.remove(); return msg('ai', '⚠️ ' + esc(j.error || 'couldn’t read that file')); } note.remove(); @@ -1867,8 +1933,8 @@ function renderDocChips() { function reconfigureFromDoc(j) { // keep the document on file: it persists with the trip and rides in the // 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() }; - persistTrip(); renderDocChips(); + 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 }; + renderDocChips(); msg('user', '📎 ' + esc(j.name) + ' ' + (j.kind || 'doc') + ' · ' + (j.chars || 0) + ' chars — attached' + (j.truncated ? ' (truncated)' : '') + ''); if (!llmOn) return msg('ai', 'I read the document (' + (j.chars || 0) + ' chars) and kept it attached, but the model is offline so I can’t act on it yet. I’ll do it as soon as it’s back.'); const prompt = @@ -1944,7 +2010,9 @@ let persistT = 0; function persistTrip() { clearTimeout(persistT); 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//) — 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() { $('#crumb').innerHTML = `🧳 ${M.trip.title} `; @@ -1976,10 +2044,11 @@ function enterTrip(id) { 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.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 chatLog = []; compareItems = []; + loadTripStore(id); // docs (original + parsed) + conversation, per trip closeDiscover(); closeL3(); scope = 'day'; document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day')); diff --git a/mock/server.js b/mock/server.js index d65fc13..44f6b42 100644 --- a/mock/server.js +++ b/mock/server.js @@ -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 CACHE = path.join(__dirname, '.tilecache'); 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 = { '.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; }); } +// 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 aren’t 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 can’t 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: 'couldn’t 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: 'couldn’t 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) { 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; @@ -281,68 +363,118 @@ http.createServer((req, res) => { // clean text with htmlToText(); txt/ics/csv/json read directly. Images // are rejected (no OCR on this box). if (u.pathname === '/parse-doc' && req.method === 'POST') { - let body = ''; - req.on('data', c => { body += c; if (body.length > 24e6) req.destroy(); }); - req.on('end', () => { - 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 aren’t 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 can’t 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: 'couldn’t 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: 'couldn’t 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); + readBody(req, (err, in0) => { + if (err) { res.writeHead(400); return res.end('bad request'); } + finishParse(in0, sendJson(res)); }); 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//docs/- original bytes + // store//docs/-.txt extracted text + header + // store//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: 'couldn’t 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: 'couldn’t 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 ------------------------------------------------------- let p = u.pathname === '/' ? '/index.html' : u.pathname; const file = path.join(ROOT, p);