This commit is contained in:
seb
2026-09-22 21:40:13 +02:00
parent ab2f953e06
commit 73e7f7e7f8
6 changed files with 246 additions and 35 deletions

94
src/compact.js Normal file
View File

@@ -0,0 +1,94 @@
'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.
*/
// 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;
}