247 lines
9.9 KiB
JavaScript
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;
|
|
}
|
|
|