Files
fidoca/src/compact.js
2026-09-23 00:50:44 +02:00

247 lines
9.9 KiB
JavaScript

'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;
}