'use strict'; /** * Transcript compaction — token-saving rewrites applied to the persisted * conversation history between turns. * * The `patch` tool's arguments embed entire file bodies: the `search` and * `replace` text of every operation. Once a patch has been applied those bodies * are pure dead weight — they are re-sent to the model on every later turn but * are never needed again. `compactPatchToolCalls` rewrites each `patch` tool * call in the transcript so its long text fields keep only a head/tail excerpt, * with a marker noting how many lines were elided. * * `invalidateStaleReadResults` attacks the same problem from the other side: * a `read_file` result carries a whole file body, and once a later `patch` * writes that file the body no longer matches what is on disk. Keeping it is * both a token waste and a correctness hazard (the model may reason from stale * contents). The stale result is replaced by a short "file was overwritten * meanwhile" marker that tells the model to re-read if it needs the file. */ import path from 'path'; import { ROOT_DIR } from './config.js'; // Lines kept from the top and bottom of each long text field. const HEAD_LINES = 5; const TAIL_LINES = 5; // Explanatory text carried by the omission marker. The marker is inserted by // the memory-management system when long tool-call text is elided between turns // to save tokens. It is NOT written by the model, and the note says so // explicitly so the model does not mistake it for its own earlier output. const OMISSION_NOTE = 'omitted from memory to save tokens — note to self: the Fido Agent did NOT ' + 'write this note; the memory management system did'; /** Escape a string so it can be embedded literally inside a RegExp. */ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Matches the omission marker line emitted below, so an already-compacted field // can be recognised and left alone (keeping repeated passes idempotent). Built // from OMISSION_NOTE so the pattern can never drift from the emitted marker. const OMISSION_MARKER_RE = new RegExp(`^… \\[\\d+ lines? ${escapeRegExp(OMISSION_NOTE)}\\] …$`); /** * Keep the first `head` and last `tail` lines of `text`, inserting a marker that * notes how many lines were omitted. Text that is already short enough * (<= head + tail lines), or that already carries an omission marker, is * returned unchanged. */ export function excerptLines(text, head = HEAD_LINES, tail = TAIL_LINES) { if (typeof text !== 'string') return text; const lines = text.split('\n'); if (lines.length <= head + tail) return text; // Already compacted — don't re-truncate (the marker itself would otherwise // be counted as an extra line and trimmed away on the next pass). if (lines.some((l) => OMISSION_MARKER_RE.test(l))) return text; const omitted = lines.length - head - tail; return [ ...lines.slice(0, head), `… [${omitted} line${omitted === 1 ? '' : 's'} ${OMISSION_NOTE}] …`, ...lines.slice(-tail), ].join('\n'); } /** * Rewrite every `patch` tool call in `messages` in place, replacing the long * `search` and `replace` fields of each operation with a head/tail excerpt. * Malformed arguments are left untouched. Returns the number of tool calls that * were actually compacted. */ export function compactPatchToolCalls(messages) { if (!Array.isArray(messages)) return 0; let compacted = 0; for (const m of messages) { if (!m || m.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue; for (const tc of m.tool_calls) { if (tc?.function?.name !== 'patch' || typeof tc.function.arguments !== 'string') continue; let args; try { args = JSON.parse(tc.function.arguments); } catch { continue; // leave malformed arguments untouched } if (!args || !Array.isArray(args.operations)) continue; let changed = false; for (const op of args.operations) { if (!op || typeof op !== 'object') continue; for (const field of ['search', 'replace']) { const next = excerptLines(op[field]); if (next !== op[field]) { op[field] = next; changed = true; } } } if (changed) { tc.function.arguments = JSON.stringify(args); compacted++; } } } return compacted; } // ─── Stale read_file results ──────────────────────────────────────────────────── // Marker carried by a replaced read_file result. Detected verbatim so repeated // passes over the transcript stay idempotent (an already-invalidated result is // left as-is instead of being wrapped again). const STALE_MARKER = 'file was overwritten meanwhile'; // Explanatory text carried by the replacement. Like the omission marker above, // this is written by the memory-management system, not by the model — the note // says so explicitly so the model does not mistake it for its own earlier // output or for real file contents. const STALE_NOTE = `${STALE_MARKER} — stale contents elided by the memory management system ` + 'to save tokens; re-read with read_file for the current contents'; // `applyOperations` tags each change it makes as either an overwrite or a // search/replace; both mean the file body a prior read returned is now stale. const APPLIED_RE = /^(?:CREATE\/WRITE|PATCH in) (.+)$/; /** * Canonical key for a workspace-relative path, so a path the model wrote as * "src/x.js" matches one written as "./src/x.js" or with different separators. */ function canonicalPath(p) { if (typeof p !== 'string' || !p) return null; try { return path.resolve(ROOT_DIR, p); } catch { return p; } } /** The replacement payload handed to the model in place of a stale read. */ function staleReadResult(filename) { return JSON.stringify({ filename, note: STALE_NOTE }); } /** * Extract the files a `patch` result actually wrote. Both successful results * (`applied`) and partially-failed ones (`appliedSoFar`) list the mutations, so * files that were never touched are correctly left alone. Returns canonical * path keys; an unparseable result yields an empty list. */ function filesWrittenByPatch(resultContent) { let res = resultContent; if (typeof res === 'string') { try { res = JSON.parse(res); } catch { return []; } } if (!res || typeof res !== 'object') return []; const applied = Array.isArray(res.applied) ? res.applied : Array.isArray(res.appliedSoFar) ? res.appliedSoFar : []; const files = new Set(); for (const entry of applied) { if (typeof entry !== 'string') continue; const hit = APPLIED_RE.exec(entry); if (hit) { const key = canonicalPath(hit[1]); if (key) files.add(key); } } return [...files]; } /** * Replace `read_file` tool results in `messages` with a short "file was * overwritten meanwhile" marker whenever a later `patch` wrote the same file. * * A read result is stale only if the file changed *after* it was read, so we * compare transcript positions: a read is invalidated when a successful patch * write sits at a later index. Reads taken after their last patch (which still * reflect disk) are left untouched, as are reads for files no patch ever wrote. * * The rewrite only fires when it strictly shrinks the payload, so a tiny read * result is never traded for a longer marker. Repeated passes are idempotent. * Returns the number of results invalidated. */ export function invalidateStaleReadResults(messages) { if (!Array.isArray(messages)) return 0; // Index every tool call so a tool result can be mapped back to (name, args). const callInfo = new Map(); for (const m of messages) { if (!m || m.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue; for (const tc of m.tool_calls) { if (!tc || !tc.id) continue; let args = {}; try { args = JSON.parse(tc.function?.arguments || '{}'); } catch { // Malformed arguments: the call stays indexed but argless. } callInfo.set(tc.id, { name: tc.function?.name, args }); } } // Every place a patch wrote a file, keyed by transcript position. const writes = []; for (let i = 0; i < messages.length; i++) { const m = messages[i]; if (!m || m.role !== 'tool') continue; if (callInfo.get(m.tool_call_id)?.name !== 'patch') continue; const files = filesWrittenByPatch(m.content); if (files.length) writes.push({ index: i, files }); } if (writes.length === 0) return 0; let invalidated = 0; for (let i = 0; i < messages.length; i++) { const m = messages[i]; if (!m || m.role !== 'tool') continue; const info = callInfo.get(m.tool_call_id); if (info?.name !== 'read_file') continue; const key = canonicalPath(info.args?.filename); if (!key) continue; const content = typeof m.content === 'string' ? m.content : ''; // Already replaced on an earlier pass — leave it alone. if (content.includes(STALE_MARKER)) continue; // Stale only if some patch wrote this file after the read happened. const stale = writes.some((w) => w.index > i && w.files.includes(key)); if (!stale) continue; const replacement = staleReadResult(info.args.filename); // Never grow the payload: only swap when the marker is actually smaller. if (replacement.length >= content.length) continue; m.content = replacement; invalidated++; } return invalidated; }