/** * Staleness-aware tool output pruning. * * Identifies tool results that have been superseded by a later result for the * same target (same file read again, same search re-run) or invalidated by a * later successful edit/write to a covered file, and replaces the stale * content with a compact digest notice. Protect-window and minimum-savings * hysteresis ensure recent results are preserved and pruning only fires when * the savings justify it. * * Ported and adapted from gajae-code's compaction/pruning.ts to pi-crew's * data shapes. Pi-crew delegates conversation management to child Pi * processes, so this module operates on a generic {@link ToolResultEntry} * sequence rather than SessionEntry[]. The primary integration point is * task-output-context.ts (dependency output context injected into worker * prompts), but the module is designed to be reusable for any in-process * tool-result sequence. */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** * A single tool result in a sequence (oldest → newest). * Adapted to pi-crew's shapes — does not depend on gajae-code's SessionEntry. */ export interface ToolResultEntry { /** Stable identifier for deduplication and correlation. */ id: string; /** Tool name: "read", "bash", "grep", "search", "edit", "write", etc. */ toolName: string; /** * Target identity: file path for read/edit/write, search pattern for * grep/search, or undefined for tools without a natural target key. */ target?: string; /** The tool result content text. */ content: string; /** Whether the tool result represents an error. */ isError?: boolean; /** * K8: the tool's REAL exit code when the original result carries one * (e.g. a bash exit status). Passed through to the digest verbatim * instead of the historical `isError ? 1 : 0` guess — exit 127 digests * as `exit=127`. Optional: callers whose source shape has no exit code * omit it and keep the legacy guess. */ exitCode?: number; } /** A file mutation event (edit/write) that can invalidate earlier reads. */ export interface FileEditEvent { /** The file path that was mutated. */ target: string; /** * Sequence index of this edit relative to tool results. A read at index * `i` is stale if an edit at index `j > i` touches the same file. */ index: number; } export interface PruneConfig { /** Keep the most recent tool output tokens intact (protect window). */ protectTokens: number; /** Only prune if total savings meets this threshold (hysteresis). */ minimumSavings: number; /** Tool names that should never be pruned. */ protectedTools: string[]; /** * Tools in `protectedTools` whose protection is waived once the result is * superseded (a later result for the same target, or a later successful * edit/write to the covered file). The most recent result per target is * never considered superseded. Optional; defaults to none. */ staleOverridableTools?: string[]; } export const DEFAULT_PRUNE_CONFIG: PruneConfig = { protectTokens: 40_000, minimumSavings: 20_000, protectedTools: ["read"], staleOverridableTools: ["read"], }; export interface PruneResult { /** Number of entries pruned. */ prunedCount: number; /** Estimated tokens saved. */ tokensSaved: number; /** The pruned result entries (same length as input, content replaced for pruned). */ results: ToolResultEntry[]; /** IDs of entries that were pruned. */ prunedIds: string[]; } // --------------------------------------------------------------------------- // Token estimation // --------------------------------------------------------------------------- // M3-10 / R34: the single char→token / text→token conversion lives in // src/utils/token-counter.ts. This site used to carry its own wrapper around // `countTokens`; it now uses the shared `estimateTokensForText` so pruner // budgets, handoff budgets (task-output-context.ts) and prompt-diet estimates // (prompt-builder.ts) all share one denominator and one unit (TOKENS). import { countTokens, estimateTokensForText } from "../../utils/token-counter.ts"; function estimateTokens(text: string): number { return estimateTokensForText(text); } // --------------------------------------------------------------------------- // Digest notice generation // --------------------------------------------------------------------------- const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25; function firstErrorLine(text: string, isError = false): string | undefined { const lines = text.split(/\r?\n/); const matched = lines.find((line) => /error|failed|exception|panic/i.test(line))?.trim(); if (matched !== undefined) return matched; // K8: multi-line stderr/stack whose HEAD carries the diagnostic but no // line matches the keyword patterns (localized messages, shell errors, // stack frames without a keyword). Fall back to the first 3 non-empty // lines of the content head — the head names the failing frame; the old // digest kept only the TAIL line, which for a stack trace is the least // informative frame. Gated on isError so success output never grows a // bogus error= part. if (!isError) return undefined; const head = lines .map((line) => line.trim()) .filter((line) => line.length > 0) .slice(0, 3); return head.length > 0 ? head.join(" | ") : undefined; } function truncateField(value: string, maxLength: number): string { if (value.length <= maxLength) return value; if (maxLength <= 1) return "…"; return `${value.slice(0, maxLength - 1)}…`; } /** * Generate a compact digest of a tool result for the digest notice. * Supports bash (exit code + tail line), grep/search (match/file counts), * and falls back to undefined for tools without a known digest format. * * K8: `exitCode` passes the REAL exit status through when the original * result carries one (exit 127 → `exit=127`, not the hardcoded `exit=1`); * when absent the legacy `isError ? 1 : 0` guess is kept. */ export function resultDigest(toolName: string, content: string, isError?: boolean, exitCode?: number): string | undefined { const name = toolName.toLowerCase(); const text = content ?? ""; if (name === "bash") { const exitCodeValue = typeof exitCode === "number" && Number.isFinite(exitCode) ? Math.trunc(exitCode) : isError ? 1 : 0; const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? ""; const error = firstErrorLine(text, isError); return [`exit=${exitCodeValue}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined] .filter((part): part is string => part !== undefined) .join("; "); } if (name === "search" || name === "grep") { const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i); const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i); const error = firstErrorLine(text, isError); return ( [match ? `matches=${match[1]}` : undefined, files ? `files=${files[1]}` : undefined, error ? `error=${error}` : undefined] .filter((part): part is string => part !== undefined) .join("; ") || "search digest unavailable" ); } return undefined; } export function createPrunedNotice(tokens: number, entry: ToolResultEntry): string { const generic = `[Output pruned — ${tokens} tokens]`; // R2-L-B7 (SEV-12): tools without a known digest format (edit/write/read…) // previously fell back to the content-free generic notice — dependency-context // consumers lost ALL evidence the tool did anything. Generic head-2/tail-2 // digest keeps a bounded content signal for every tool. const digest = resultDigest(entry.toolName, entry.content, entry.isError, entry.exitCode) ?? genericLineDigest(entry.content); if (!digest) return generic; const genericTokens = countTokens(generic); const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER)); const prefix = `[Output pruned — ${tokens} tokens; `; const suffix = "]"; const maxChars = Math.max(0, maxTokens * 4 - prefix.length - suffix.length); return `${prefix}${truncateField(digest, maxChars)}${suffix}`; } /** R2-L-B7: generic digest for tools without a dedicated format — first two * and last two non-empty lines, deduped when the content is short. Never * empty (falls back to a line count) so the pruned notice always carries a * content signal. */ function genericLineDigest(content: string): string { const lines = (content ?? "") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); if (lines.length === 0) return "empty output"; if (lines.length <= 4) return `${lines.length} line(s): ${lines.join(" ⏎ ")}`; return `head=${lines.slice(0, 2).join(" ⏎ ")}; tail=${lines.slice(-2).join(" ⏎ ")}`; } // --------------------------------------------------------------------------- // Target key resolution // --------------------------------------------------------------------------- /** * Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`, * `:raw`, `:conflicts`), possibly stacked. Stripped to resolve the * underlying file for edit invalidation. */ const READ_SELECTOR_SUFFIX = /:(?:raw|conflicts|\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/; /** Base file path of a read target with any line/mode selectors stripped. */ function readBasePath(filePath: string): string { let base = filePath; while (READ_SELECTOR_SUFFIX.test(base)) { base = base.replace(READ_SELECTOR_SUFFIX, ""); } return base; } /** * Stable identity for "the same logical lookup": same tool re-targeting the * same subject. A later result with the same key supersedes earlier ones. */ function toolTargetKey(entry: ToolResultEntry): string | undefined { if (!entry.target || entry.target.length === 0) return undefined; return JSON.stringify([entry.toolName, entry.target]); } // --------------------------------------------------------------------------- // Staleness index // --------------------------------------------------------------------------- export interface StalenessIndex { /** Indices of tool results superseded by a later same-target result or edit. */ staleIndices: Set; } /** * Build a staleness index over a sequence of tool results (oldest → newest): * - a tool result is stale when a later non-error result shares its target key; * - a `read` result is stale when a later edit event touches its file. * The most recent result per target is never stale. * * @param toolResults Ordered tool result entries (oldest first). * @param fileEdits Optional file mutation events with sequence indices. */ export function buildStalenessIndex(toolResults: ToolResultEntry[], fileEdits: FileEditEvent[] = []): StalenessIndex { // Map target key → last result index that has it. const lastResultIndexByKey = new Map(); for (let i = 0; i < toolResults.length; i++) { const entry = toolResults[i]!; if (entry.isError) continue; const key = toolTargetKey(entry); if (key !== undefined) lastResultIndexByKey.set(key, i); } // Map file path → last edit index. const lastEditIndexByPath = new Map(); for (const edit of fileEdits) { lastEditIndexByPath.set(edit.target, edit.index); } const staleIndices = new Set(); for (let i = 0; i < toolResults.length; i++) { const entry = toolResults[i]!; // Check superseded by same-target re-read. const key = toolTargetKey(entry); if (key !== undefined) { const lastIndex = lastResultIndexByKey.get(key); if (lastIndex !== undefined && lastIndex > i) { staleIndices.add(i); continue; } } // Check invalidated by later file edit (read-specific). if (entry.toolName.toLowerCase() === "read" && entry.target) { const basePath = readBasePath(entry.target); const editIndex = lastEditIndexByPath.get(basePath); if (editIndex !== undefined && editIndex > i) { staleIndices.add(i); } } } return { staleIndices }; } // --------------------------------------------------------------------------- // Pruning // --------------------------------------------------------------------------- /** * Prune stale tool outputs from a sequence, replacing superseded content with * compact digest notices. Protect-window, protected-tools immunity, and * minimum-savings hysteresis are all respected. * * OPT-IN by default: {@link DEFAULT_PRUNE_CONFIG} protects recent results via * a generous `protectTokens` window. Only results outside the window AND not * protected AND stale (or old enough) are pruned. * * @param results Ordered tool result entries (oldest first). * @param config Prune configuration. Defaults to {@link DEFAULT_PRUNE_CONFIG}. */ export function pruneToolOutputs(results: ToolResultEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult { const { staleIndices } = buildStalenessIndex(results); const staleOverridable = new Set(config.staleOverridableTools ?? []); let accumulatedTokens = 0; let tokensSaved = 0; let prunedCount = 0; interface Candidate { index: number; entry: ToolResultEntry; tokens: number; notice: string; savings: number; } const candidates: Candidate[] = []; const prunedIds: string[] = []; // Iterate newest → oldest to accumulate the protect window from the tail. for (let i = results.length - 1; i >= 0; i--) { const entry = results[i]!; const tokens = estimateTokens(entry.content); const isStale = staleIndices.has(i); // Staleness waives protected-tool immunity for overridable tools // (e.g. a superseded `read`); the most recent result per target is // never stale, so the latest read of each file stays protected. const isProtected = config.protectedTools.includes(entry.toolName) && !(isStale && staleOverridable.has(entry.toolName)); // Stale results are prunable even inside the recency protect window — // they are superseded, so recency no longer implies relevance. They // still count toward window accounting so non-stale protection is // unchanged. const insideProtectWindow = accumulatedTokens < config.protectTokens; if ((insideProtectWindow && !isStale) || isProtected) { accumulatedTokens += tokens; continue; } const notice = createPrunedNotice(tokens, entry); candidates.push({ index: i, entry, tokens, notice, savings: Math.max(0, tokens - countTokens(notice)), }); accumulatedTokens += tokens; } for (const candidate of candidates) { tokensSaved += candidate.savings; } // Hysteresis: only prune if savings meet the threshold. if (tokensSaved < config.minimumSavings || candidates.length === 0) { return { prunedCount: 0, tokensSaved: 0, results, prunedIds: [] }; } const prunedResults = [...results]; for (const candidate of candidates) { prunedResults[candidate.index] = { ...candidate.entry, content: candidate.notice, }; prunedIds.push(candidate.entry.id); prunedCount++; } return { prunedCount, tokensSaved, results: prunedResults, prunedIds }; }