diff --git a/mock/app.js b/mock/app.js index ca85a50..5203d76 100644 --- a/mock/app.js +++ b/mock/app.js @@ -428,7 +428,8 @@ function llmSystemPrompt() { '- For a requested change, call the matching fix-op, then confirm in one sentence what changed. The user can undo any edit.\n' + '- Before a big rework, consider review_plan() and mention any issues it reports.\n' + '- Be efficient: call each tool at most once unless you need to, and never re-fetch facts you already have. A fix-op alone is enough — do not re-search or re-fetch the stop you are moving/editing.\n' + - '- When a real flight/booking document is attached, read out the arrival and departure (date, local time, airport, city), then call apply_flight_anchors exactly once. If the document is ambiguous or its dates don’t match the plan’s, ask me to confirm before applying.\n\n' + + '- When a real flight/booking document is attached, read out the arrival and departure (date, local time, airport, city), then call apply_flight_anchors exactly once. Apply it directly even if the booked dates differ from the current plan — the user’s ticket is the truth and the edit is undo-able — then report what changed, including the date shift. Only ask a clarifying question if the document is genuinely ambiguous (e.g. two candidate departure flights).\n' + + '- Stay consistent with your earlier turns: if I am confirming a change you already proposed, apply it using the details from your own earlier message — never claim you can’t see information you quoted yourself.\n\n' + 'Current plan:\n' + llmContext() + docSection()); } @@ -448,9 +449,10 @@ 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; + pushChatLog('user', v); let t = typing(); - const messages = [{ role: 'system', content: llmSystemPrompt() }, { role: 'user', content: v }]; - const finish = html => { t.remove(); msg('ai', html); scriptBusy = false; }; + 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; }; try { let finalText = null; for (let i = 0; i < LLM_TOOL_MAX; i++) { @@ -472,7 +474,7 @@ async function askLLM(v) { finalText = content; break; } if (finalText === null) finish('I ran out of tool steps without a final answer — please rephrase.'); - else if (finalText) finish(esc(finalText).replace(/\n+/g, '
')); + else if (finalText) finish(esc(finalText).replace(/\n+/g, '
'), finalText); else finish('The model used its whole token budget thinking and returned no visible answer — try a shorter question.'); } catch (e) { t.remove(); @@ -1530,6 +1532,27 @@ 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 +function pushChatLog(role, text) { + if (!text || !String(text).trim()) return; + chatLog.push({ role, text: String(text) }); + if (chatLog.length > 24) chatLog.splice(0, chatLog.length - 24); +} +function recentHistory() { + // most recent turns that fit the context budget; upload messages carry the + // whole document — skip them, docSection() already has it + const out = []; + let budget = 12000; + for (let i = chatLog.length - 1; i >= 0 && budget > 0; i--) { + const e = chatLog[i]; + if (e.role === 'user' && e.text.includes('')) continue; + const t = e.text.length > 3000 ? e.text.slice(0, 3000) + ' […]' : e.text; + out.unshift({ role: e.role === 'assistant' ? 'assistant' : 'user', content: t }); + budget -= t.length; + } + return out; +} const commit = (label) => { history = history.slice(0, hIdx + 1); // drop any redo branch history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays), bookings: deep(M.trip.bookings), title: M.trip.title }); @@ -1825,12 +1848,13 @@ async function ingestDoc(file) { // attached documents stay in the model's context on EVERY turn, so follow-up // questions ("what’s my confirmation code?", "when’s the flight out?") work // without a re-upload -const DOC_CTX_CAP = 20000; // per-document budget inside the system prompt function docSection() { + // the full extracted text rides in the context every turn (it's already + // capped at 60k chars server-side — well within the 131k-token window) const ds = Object.values(tripDocs); if (!ds.length) return ''; return '\n\nAttached documents (the user’s own files, kept for reference on any question). For questions about their real bookings (confirmation codes, seats, prices, flight times), answer from these documents, not from the plan’s placeholder bookings:\n' + - ds.map(d => 'DOCUMENT “' + d.name + '”' + (d.truncated ? ' (extract truncated)' : '') + '\n' + (d.text || '').slice(0, DOC_CTX_CAP)).join('\n\n'); + ds.map(d => 'DOCUMENT “' + d.name + '”' + (d.truncated ? ' (extract truncated)' : '') + '\n' + (d.text || '')).join('\n\n'); } function renderDocChips() { const el = $('#doc-chips'); if (!el) return; @@ -1853,8 +1877,8 @@ function reconfigureFromDoc(j) { '\n' + (j.text || '') + '\n\n\n' + 'If this is a flight booking (or contains actual flight times), reconfigure the trip around it:\n' + '1. Read the ARRIVAL (date, local time, airport, city) and the DEPARTURE (date, local time, airport, city). With connections, the arrival is where the trip starts (first city) and the departure where it ends (last city).\n' + - '2. Call apply_flight_anchors exactly once with those two anchors.\n' + - '3. Confirm what changed — the new first and last day with their start/end times, and any stops that had to go, in a couple of sentences.\n' + + '2. Call apply_flight_anchors exactly once with those two anchors — apply it now, do not ask first (the edit is undo-able), even if the dates differ from the current plan.\n' + + '3. Confirm what changed — the new first and last day with their start/end times, any stops that had to go, and the date shift — in a couple of sentences.\n' + 'If it is NOT a flight booking, do not change the plan — just confirm it’s attached and summarize what it contains in 1–2 sentences.'; askLLM(prompt); } @@ -1955,7 +1979,7 @@ function enterTrip(id) { tripDocs = saved.docs ? deep(saved.docs) : {}; } else tripDocs = {}; // reset per-trip UI state - compareItems = []; + chatLog = []; compareItems = []; 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 d64488e..d65fc13 100644 --- a/mock/server.js +++ b/mock/server.js @@ -138,6 +138,18 @@ function decodeMime(raw) { if (body.trim()) dec.push({ html: p.ct.includes('html'), len: body.length, body }); } dec.sort((a, b) => (b.html - a.html) || (b.len - a.len)); + if (dec.length) return dec[0].body; + // last resort: brute-force the biggest base64 block (mail clients vary in + // boundary quoting, charset headers, etc.) + const blocks = raw.match(/[A-Za-z0-9+\/\n=]{400,}/g) || []; + for (const b of blocks.slice(0, 12)) { + try { + const d = Buffer.from(b.replace(/\s+/g, ''), 'base64').toString('utf8'); + if (d.length > 200 && /[a-z]{4,}/i.test(d)) + dec.push({ html: /<\s*(html|body|div|table|p)\b/i.test(d.slice(0, 2000)), len: d.length, body: d }); + } catch {} + } + dec.sort((a, b) => (b.html - a.html) || (b.len - a.len)); return dec.length ? dec[0].body : null; } @@ -302,7 +314,8 @@ http.createServer((req, res) => { out.push(l); } text = out.map(l => l.startsWith('__DUP__') ? ' [… ' + count[l.slice(7)] + '× this line repeats — shown above]' : l).join('\n'); - console.log(`[parse-doc] ${name} ${buf.length}B → ${text.length} chars${text.length > CAP ? ' (TRUNCATED at ' + CAP + ')' : ''}`); + 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')) { @@ -321,7 +334,9 @@ http.createServer((req, res) => { const mimeBody = decodeMime(text); if (mimeBody != null) text = mimeBody; // not really MIME → treat as plain text } - if (ext === '.html' || ext === '.htm' || ctype.includes('html') || /<\s*(html|!doctype|head|body)\b/i.test(text.slice(0, 4000))) + 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); });