From 4a65bfe54e0af4b5515f71ce7bd57fd4bc641e34 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 09:47:33 -0500 Subject: [PATCH 01/45] feat(context): add working-set ledger kinds and policy contract Adds contextEviction and contextRecall session entry kinds, the working-set contract (view, policy, plan, recall types), the context.workingSet settings type, and updates every switch on entry.kind so the tree typechecks with the new kinds. No behavior change: nothing writes the new kinds yet. --- src/core/defaults.ts | 24 +++ src/domains/context/working-set/contract.ts | 155 ++++++++++++++++++++ src/domains/context/working-set/defaults.ts | 23 +++ src/domains/evidence/build.ts | 13 ++ src/domains/session/compaction/cut-point.ts | 2 + src/domains/session/compaction/tokens.ts | 4 + src/domains/session/entries.ts | 108 +++++++++++++- src/interactive/chat-renderer.ts | 7 + 8 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 src/domains/context/working-set/contract.ts create mode 100644 src/domains/context/working-set/defaults.ts diff --git a/src/core/defaults.ts b/src/core/defaults.ts index 58b33eeb8..a1e558519 100644 --- a/src/core/defaults.ts +++ b/src/core/defaults.ts @@ -98,6 +98,30 @@ export interface CompactionSettings { systemPrompt?: string; } +/** + * Working-set layer settings (`context.workingSet`). The layer decides which + * tool-result bodies and thinking blocks leave the model's working set when + * pressure crosses `compaction.threshold`; it records evictions as ledger + * entries and never rewrites history. Defaults and prose live in + * src/domains/context/working-set/defaults.ts. + * + * - enabled: master switch. Off restores the legacy destructive mask stage. + * - policy: candidate selection rule set. + * - target: used/window ratio an applied event batches down to. + * - protectLastTurns: recent user turns whose observations are never evicted. + * - minEvictableTokens: results below this estimate are never evicted; the + * marker would cost more than it saves. + */ +export type WorkingSetPolicyId = "age-horizon" | "structural-v1"; + +export interface WorkingSetSettings { + enabled: boolean; + policy: WorkingSetPolicyId; + target: number; + protectLastTurns: number; + minEvictableTokens: number; +} + /** * Transient provider retry controls for the interactive chat loop. These are * intentionally small and mirror the session retry helper defaults. Dispatched diff --git a/src/domains/context/working-set/contract.ts b/src/domains/context/working-set/contract.ts new file mode 100644 index 000000000..ca4d89e85 --- /dev/null +++ b/src/domains/context/working-set/contract.ts @@ -0,0 +1,155 @@ +/** + * Working-set layer contract. + * + * The working set is what the model sees on the next request. The ledger is + * durable, append-only truth. This layer decides which tool-result bodies and + * thinking blocks leave the working set, records that decision as + * `contextEviction` entries, and applies it as an in-memory projection when + * the replay messages are built. Nothing here rewrites the ledger and nothing + * here calls a model. + * + * Shared by the live engine and the replay-lite runner by construction: a + * policy is a pure function of `PolicyInput`, so the same selection runs in + * both places. Widening these types is an owner decision; workers build + * against them. + */ + +import type { WorkingSetPolicyId, WorkingSetSettings } from "../../../core/defaults.js"; +import type { + ContextEvictionEntry, + ContextRecallEntry, + EvictedItem, + EvictionReason, + EvictionTrigger, + RecallTrigger, + SessionEntry, + WorkingSetRef, +} from "../../session/entries.js"; + +export { EVICTION_REASONS, EVICTION_TRIGGERS, RECALL_TRIGGERS } from "../../session/entries.js"; +export type { + ContextEvictionEntry, + ContextRecallEntry, + EvictedItem, + EvictionReason, + EvictionTrigger, + RecallTrigger, + WorkingSetPolicyId, + WorkingSetRef, + WorkingSetSettings, +}; + +/** + * Ref keys index `WorkingSetView.evicted`. A key is the entry turnId + * (`ref.entry`); `fold.ts` owns the `refKey` / `parseRefKey` helpers. + */ +/** What the fold knows about one evicted unit. */ +export interface EvictedState { + reason: EvictionReason; + marker: string; + by?: string; + tokensFreed: number; + /** turnId of the `contextEviction` entry that evicted it (the latest one, after churn). */ + evictedAtTurnId: string; + policyId: string; +} + +/** + * The fold of every `contextEviction` / `contextRecall` entry on the active + * path. A recall removes its key; a later eviction of the same key re-adds it + * and counts as churn. + */ +export interface WorkingSetView { + evicted: ReadonlyMap; + /** Applied eviction events on the active path. */ + evictionEvents: number; + /** Items evicted across all events, including re-evictions after recall. */ + itemsEvicted: number; + /** Recall entries on the active path. `churn = recalls / itemsEvicted`. */ + recalls: number; + /** Policy that produced the most recent event; null when none. */ + lastPolicyId: string | null; + /** turnId of the most recent eviction event; null when none. */ + lastEvictionTurnId: string | null; +} + +export const EMPTY_WORKING_SET_VIEW: WorkingSetView = Object.freeze({ + evicted: new Map(), + evictionEvents: 0, + itemsEvicted: 0, + recalls: 0, + lastPolicyId: null, + lastEvictionTurnId: null, +}); + +export interface PressureInput { + /** Estimated tokens in the current working set (projected), same estimator as the live pressure check. */ + tokens: number; + contextWindow: number; + /** `compaction.threshold`. */ + threshold: number; + /** `context.workingSet.target`: the ratio an applied event batches down to. */ + target: number; +} + +/** + * Everything a policy may look at. Entries are the active path in ledger + * order and are NOT projected: a policy must consult `view.evicted` to skip + * units that are already out. Token counts enter selection only through + * `settings.minEvictableTokens` and the headroom arithmetic against + * `pressure.target`; no rule may rank candidates by size or recency score. + */ +export interface PolicyInput { + entries: ReadonlyArray; + view: WorkingSetView; + settings: WorkingSetSettings; + pressure: PressureInput; + /** chars/4 estimator shared with `context-accounting.ts`, so replay and live agree. */ + estimateTokens: (entry: SessionEntry) => number; +} + +/** A unit the policy wants out, with the typed reason. Ordered: apply in this order, stop when headroom is met. */ +export interface EvictionCandidate { + ref: WorkingSetRef; + reason: EvictionReason; + by?: string; +} + +export interface WorkingSetPolicy { + readonly id: WorkingSetPolicyId; + /** + * Select candidates. Must be pure and deterministic for a given input. + * Returns an empty array when nothing qualifies. Units already in + * `input.view.evicted` must not be returned. + */ + select(input: PolicyInput): ReadonlyArray; +} + +/** Materialized selection: markers rendered, tokens estimated, ready to become a ledger entry. */ +export interface EvictionPlan { + policyId: WorkingSetPolicyId; + items: ReadonlyArray; + tokensBefore: number; + tokensAfter: number; +} + +/** Fields the caller adds when appending the plan as a ledger entry. */ +export type ContextEvictionFields = Omit; +export type ContextRecallFields = Omit; + +/** Typed failure for recall by ref. */ +export type RecallError = + | { kind: "not_on_active_path"; ref: string; nearest: string | null } + | { kind: "not_evicted"; ref: string; nearest: string | null } + | { kind: "invalid_ref"; ref: string }; + +export interface RecallResult { + ref: WorkingSetRef; + /** The ledger entry whose body is readmitted. */ + entry: SessionEntry; + /** Exact original body as the projection would have rendered it before eviction. */ + body: string; + tokens: number; + /** Present when the original result was offloaded; recall returns the pointer, never the file. */ + offloadPath?: string; +} diff --git a/src/domains/context/working-set/defaults.ts b/src/domains/context/working-set/defaults.ts new file mode 100644 index 000000000..85c5b90cf --- /dev/null +++ b/src/domains/context/working-set/defaults.ts @@ -0,0 +1,23 @@ +/** + * Working-set settings: user-visible defaults. The structural type lives in + * `src/core/defaults.ts` beside the rest of the settings tree so core stays + * free of a backward domain dependency; this module pairs it with the value + * the DEFAULT_SETTINGS tree and the engine read at runtime. + * + * `enabled: true` with `policy: "age-horizon"` is today's selection (every + * tool-result body and thinking block beyond the protection horizon) recorded + * as a projection instead of a ledger rewrite. `structural-v1` stays opt-in + * until replay-lite shows it ahead of `age-horizon`. + */ + +import type { WorkingSetSettings } from "../../../core/defaults.js"; + +export type { WorkingSetPolicyId, WorkingSetSettings } from "../../../core/defaults.js"; + +export const DEFAULT_WORKING_SET_SETTINGS: WorkingSetSettings = { + enabled: true, + policy: "age-horizon", + target: 0.6, + protectLastTurns: 6, + minEvictableTokens: 200, +}; diff --git a/src/domains/evidence/build.ts b/src/domains/evidence/build.ts index fb3027c68..1c95b3bf0 100644 --- a/src/domains/evidence/build.ts +++ b/src/domains/evidence/build.ts @@ -1372,6 +1372,19 @@ function renderSessionTranscriptEntry(linked: LinkedSessionEntry): string[] { ]; } if (entry.kind === "custom") return [`${prefix} custom:${entry.customType} ${previewUnknown(entry.data)}`]; + if (entry.kind === "contextEviction") { + const pressure = entry.pressureBefore === null ? "" : ` pressure=${entry.pressureBefore.toFixed(3)}`; + return [ + `${prefix} contextEviction policy=${entry.policyId} trigger=${entry.trigger} items=${entry.evicted.length} tokens=${entry.tokensBefore}->${entry.tokensAfter}${pressure}`, + ...entry.evicted.map( + (item) => + ` evicted ref=${item.ref.entry} reason=${item.reason}${item.by === undefined ? "" : ` by=${item.by}`} freed=${item.tokensFreed}`, + ), + ]; + } + if (entry.kind === "contextRecall") { + return [`${prefix} contextRecall ref=${entry.ref.entry} trigger=${entry.trigger} tokens=${entry.tokensReadmitted}`]; + } const _exhaustive: never = entry; return [`${prefix} ${String(_exhaustive)}`]; } diff --git a/src/domains/session/compaction/cut-point.ts b/src/domains/session/compaction/cut-point.ts index 7e63ed678..d8e379556 100644 --- a/src/domains/session/compaction/cut-point.ts +++ b/src/domains/session/compaction/cut-point.ts @@ -72,6 +72,8 @@ function isValidCutPoint(entry: SessionEntry): boolean { case "taskLedger": case "decisionLedger": case "workerRun": + case "contextEviction": + case "contextRecall": return false; } } diff --git a/src/domains/session/compaction/tokens.ts b/src/domains/session/compaction/tokens.ts index eaf0286f4..66a0c18b8 100644 --- a/src/domains/session/compaction/tokens.ts +++ b/src/domains/session/compaction/tokens.ts @@ -78,6 +78,10 @@ export function estimateTokens(entry: SessionEntry): number { case "taskLedger": case "decisionLedger": case "workerRun": + // Working-set bookkeeping: refs and markers, never bodies. The + // projection accounts for marker cost on the projected messages. + case "contextEviction": + case "contextRecall": return 0; } } diff --git a/src/domains/session/entries.ts b/src/domains/session/entries.ts index d8fdb22b8..d492dfc8a 100644 --- a/src/domains/session/entries.ts +++ b/src/domains/session/entries.ts @@ -276,6 +276,75 @@ export interface WorkerRunEntry extends BaseSessionEntry { parentToolCallId?: string; } +/** + * Working-set layer (context domain) ledger records. Eviction is a projection: + * these entries say what left the model's working set and what came back; the + * original bodies stay in the ledger untouched. The context domain owns the + * semantics (`src/domains/context/working-set/contract.ts`); the session + * domain owns the wire shape because it owns the ledger format. + */ +export const EVICTION_REASONS = [ + "superseded_read", + "stale_after_mutation", + "listing_consumed", + "failure_resolved", + "thinking_turn_closed", + "age_horizon", + "operator", +] as const; +export type EvictionReason = (typeof EVICTION_REASONS)[number]; + +export const EVICTION_TRIGGERS = ["pressure", "operator"] as const; +export type EvictionTrigger = (typeof EVICTION_TRIGGERS)[number]; + +export const RECALL_TRIGGERS = ["tool", "operator"] as const; +export type RecallTrigger = (typeof RECALL_TRIGGERS)[number]; + +/** + * Identity of an evictable unit: the `turnId` of a ledger entry. For a + * `tool_result` message the unit is the result body; for an `assistant` + * message the unit is every thinking block it carries. Partial (per-block) + * eviction is deliberately not modelled; add a `block` field here when it is. + */ +export interface WorkingSetRef { + entry: string; +} + +export interface EvictedItem { + ref: WorkingSetRef; + reason: EvictionReason; + /** Estimated tokens the projection removes for this item (marker cost already subtracted). */ + tokensFreed: number; + /** + * Byte-stable one-line stub the projection renders in place of the body. + * Empty for thinking-block eviction, which removes without a marker. + */ + marker: string; + /** Ref key of the entry that superseded or resolved this one, when the reason names one. */ + by?: string; +} + +export interface ContextEvictionEntry extends BaseSessionEntry { + kind: "contextEviction"; + policyId: string; + trigger: EvictionTrigger; + evicted: ReadonlyArray; + tokensBefore: number; + tokensAfter: number; + /** Used/window ratio that fired the event; null for operator-triggered events. */ + pressureBefore: number | null; + snapshotIdBefore: string | null; +} + +export interface ContextRecallEntry extends BaseSessionEntry { + kind: "contextRecall"; + ref: WorkingSetRef; + trigger: RecallTrigger; + tokensReadmitted: number; + /** The tool call that performed the recall, when `trigger` is `tool`. */ + toolCallId?: string; +} + export type SessionEntry = | MessageEntry | BashExecutionEntry @@ -291,7 +360,9 @@ export type SessionEntry = | SkillActivationEntry | TaskLedgerEntry | DecisionLedgerEntry - | WorkerRunEntry; + | WorkerRunEntry + | ContextEvictionEntry + | ContextRecallEntry; export type SessionFileEntry = SessionHeader | SessionEntry; @@ -317,6 +388,8 @@ export const SESSION_ENTRY_KINDS = [ "taskLedger", "decisionLedger", "workerRun", + "contextEviction", + "contextRecall", ] as const; export type SessionEntryKind = (typeof SESSION_ENTRY_KINDS)[number]; @@ -477,6 +550,21 @@ export function isSessionHeader(value: unknown): value is SessionHeader { ); } +function isWorkingSetRef(value: unknown): value is WorkingSetRef { + return isRecord(value) && isString(value.entry); +} + +function isEvictedItem(value: unknown): value is EvictedItem { + return ( + isRecord(value) && + isWorkingSetRef(value.ref) && + isOneOf(value.reason, EVICTION_REASONS) && + isNumber(value.tokensFreed) && + isString(value.marker) && + isOptionalString(value.by) + ); +} + export function isSessionEntry(value: unknown): value is SessionEntry { if (!value || typeof value !== "object") return false; const v = value as Record; @@ -567,6 +655,24 @@ export function isSessionEntry(value: unknown): value is SessionEntry { isWorkerRunRuntime(v.runtime) && isOptionalString(v.parentToolCallId) ); + case "contextEviction": + return ( + isString(v.policyId) && + isOneOf(v.trigger, EVICTION_TRIGGERS) && + Array.isArray(v.evicted) && + v.evicted.every(isEvictedItem) && + isNumber(v.tokensBefore) && + isNumber(v.tokensAfter) && + (v.pressureBefore === null || isNumber(v.pressureBefore)) && + (v.snapshotIdBefore === null || isString(v.snapshotIdBefore)) + ); + case "contextRecall": + return ( + isWorkingSetRef(v.ref) && + isOneOf(v.trigger, RECALL_TRIGGERS) && + isNumber(v.tokensReadmitted) && + isOptionalString(v.toolCallId) + ); } return false; } diff --git a/src/interactive/chat-renderer.ts b/src/interactive/chat-renderer.ts index 7925b37ef..6627905c6 100644 --- a/src/interactive/chat-renderer.ts +++ b/src/interactive/chat-renderer.ts @@ -1043,6 +1043,11 @@ export function buildReplayAgentMessagesFromTurns( // It reaches the model only when an operator shares it, and a share // is already a user message by the time it lands in the ledger. case "workerRun": + // Working-set entries are folded into a view by the context domain + // and applied as a projection before this builder runs; the + // entries themselves never become messages. + case "contextEviction": + case "contextRecall": break; } } @@ -1239,6 +1244,8 @@ export function rehydrateChatPanelFromTurns( case "label": case "taskLedger": case "decisionLedger": + case "contextEviction": + case "contextRecall": break; } } From 0709fd6ba41e7930f8861ffbffae9245f8c26141 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 09:51:17 -0500 Subject: [PATCH 02/45] feat(context): add working-set fold over the active path foldWorkingSet turns contextEviction/contextRecall entries into a view, selecting the active path through filterEntriesToActivePath so forks and /tree switches never project an abandoned branch's evictions (#94). --- src/domains/context/working-set/fold.ts | 59 +++++++++++++ tests/contracts/working-set-fold.test.ts | 107 +++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/domains/context/working-set/fold.ts create mode 100644 tests/contracts/working-set-fold.test.ts diff --git a/src/domains/context/working-set/fold.ts b/src/domains/context/working-set/fold.ts new file mode 100644 index 000000000..74f298464 --- /dev/null +++ b/src/domains/context/working-set/fold.ts @@ -0,0 +1,59 @@ +/** + * Fold the working-set ledger records on the active path into a view. + * + * Pure over entries. The active path is selected here, through + * `filterEntriesToActivePath`, so every consumer (live projection, recall, + * overlay, replay-lite) shares the same #94 discipline: after a `/tree` switch + * the raw file still holds abandoned turns, and an eviction recorded on an + * abandoned branch must not project onto the live one. Forks inherit the view + * of their shared prefix for the same reason compaction summaries do. + */ + +import type { SessionEntry } from "../../session/entries.js"; +import { filterEntriesToActivePath } from "../../session/tree/active-path.js"; +import type { EvictedState, WorkingSetRef, WorkingSetView } from "./contract.js"; + +/** Ref keys index `WorkingSetView.evicted`. Today a key is the entry turnId. */ +export function refKey(ref: WorkingSetRef): string { + return ref.entry; +} + +export function parseRefKey(key: string): WorkingSetRef | null { + const trimmed = key.trim(); + if (trimmed.length === 0 || /\s/.test(trimmed)) return null; + return { entry: trimmed }; +} + +export function foldWorkingSet(entries: ReadonlyArray, activeLeafTurnId?: string): WorkingSetView { + const active = filterEntriesToActivePath(entries, activeLeafTurnId); + const evicted = new Map(); + let evictionEvents = 0; + let itemsEvicted = 0; + let recalls = 0; + let lastPolicyId: string | null = null; + let lastEvictionTurnId: string | null = null; + for (const entry of active) { + if (entry.kind === "contextEviction") { + evictionEvents += 1; + lastPolicyId = entry.policyId; + lastEvictionTurnId = entry.turnId; + for (const item of entry.evicted) { + itemsEvicted += 1; + evicted.set(refKey(item.ref), { + reason: item.reason, + marker: item.marker, + ...(item.by === undefined ? {} : { by: item.by }), + tokensFreed: item.tokensFreed, + evictedAtTurnId: entry.turnId, + policyId: entry.policyId, + }); + } + continue; + } + if (entry.kind === "contextRecall") { + recalls += 1; + evicted.delete(refKey(entry.ref)); + } + } + return { evicted, evictionEvents, itemsEvicted, recalls, lastPolicyId, lastEvictionTurnId }; +} diff --git a/tests/contracts/working-set-fold.test.ts b/tests/contracts/working-set-fold.test.ts new file mode 100644 index 000000000..d65ad297a --- /dev/null +++ b/tests/contracts/working-set-fold.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { foldWorkingSet, parseRefKey, refKey } from "../../src/domains/context/working-set/fold.js"; +import type { SessionEntry } from "../../src/domains/session/entries.js"; + +let clock = 0; +function stamp(): string { + clock += 1; + return new Date(1_700_000_000_000 + clock * 1000).toISOString(); +} + +function message( + turnId: string, + parentTurnId: string | null, + role: "user" | "assistant" | "tool_result", +): SessionEntry { + return { kind: "message", turnId, parentTurnId, timestamp: stamp(), role, payload: { text: turnId } }; +} + +function eviction(turnId: string, parentTurnId: string, refs: string[]): SessionEntry { + return { + kind: "contextEviction", + turnId, + parentTurnId, + timestamp: stamp(), + policyId: "age-horizon", + trigger: "pressure", + evicted: refs.map((entry) => ({ + ref: { entry }, + reason: "age_horizon", + tokensFreed: 100, + marker: `[evicted ref=${entry}]`, + })), + tokensBefore: 1000, + tokensAfter: 900, + pressureBefore: 0.85, + snapshotIdBefore: null, + }; +} + +function recall(turnId: string, parentTurnId: string, ref: string): SessionEntry { + return { + kind: "contextRecall", + turnId, + parentTurnId, + timestamp: stamp(), + ref: { entry: ref }, + trigger: "tool", + tokensReadmitted: 100, + }; +} + +test("fold: eviction then recall removes the key and counts churn", () => { + const entries: SessionEntry[] = [ + message("u1", null, "user"), + message("a1", "u1", "assistant"), + message("t1", "a1", "tool_result"), + message("t2", "t1", "tool_result"), + eviction("e1", "t2", ["t1", "t2"]), + recall("r1", "t2", "t1"), + ]; + const view = foldWorkingSet(entries); + assert.deepEqual([...view.evicted.keys()], ["t2"]); + assert.equal(view.evicted.get("t2")?.evictedAtTurnId, "e1"); + assert.equal(view.evictionEvents, 1); + assert.equal(view.itemsEvicted, 2); + assert.equal(view.recalls, 1); + assert.equal(view.lastPolicyId, "age-horizon"); + assert.equal(view.lastEvictionTurnId, "e1"); +}); + +test("fold: a re-eviction after recall points at the newer event", () => { + const entries: SessionEntry[] = [ + message("u1", null, "user"), + message("t1", "u1", "tool_result"), + eviction("e1", "t1", ["t1"]), + recall("r1", "t1", "t1"), + eviction("e2", "t1", ["t1"]), + ]; + const view = foldWorkingSet(entries); + assert.equal(view.evicted.get("t1")?.evictedAtTurnId, "e2"); + assert.equal(view.itemsEvicted, 2); + assert.equal(view.recalls, 1); +}); + +test("fold: forks see only evictions on their own active path (#94)", () => { + const entries: SessionEntry[] = [ + message("u1", null, "user"), + message("t1", "u1", "tool_result"), + // branch A evicts t1 + message("u2a", "t1", "user"), + eviction("e1", "u2a", ["t1"]), + // branch B, forked from t1, never evicted anything + message("u2b", "t1", "user"), + ]; + assert.equal(foldWorkingSet(entries, "u2b").evicted.size, 0); + assert.deepEqual([...foldWorkingSet(entries, "u2a").evicted.keys()], ["t1"]); + // Without a leaf the latest append wins, which is branch B here. + assert.equal(foldWorkingSet(entries).evicted.size, 0); +}); + +test("ref keys: round-trip and reject blanks", () => { + assert.equal(refKey({ entry: "01J8" }), "01J8"); + assert.deepEqual(parseRefKey(" 01J8 "), { entry: "01J8" }); + assert.equal(parseRefKey(""), null); + assert.equal(parseRefKey("a b"), null); +}); From 9d8e8b4ffd3bd2872c9027fdb6f1a34e621170bf Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:03:32 -0500 Subject: [PATCH 03/45] feat(context): add scope=recall, recall.ts, and working-set overlay section resolveRecall/buildRecallFields resolve an evicted ref on the active path to its byte-exact tool_result body with typed errors naming the nearest evicted ref. context(scope="recall", ref=...) returns the body through the observation envelope, appends a contextRecall entry with the tool call id, and errors cleanly without a session. The /context overlay gains a working set section (policy, evicted items and tokens, events, recalls, churn) and an evicted-tokens line outside the meter categories. --- src/domains/context/working-set/recall.ts | 178 +++++++++++ src/entry/orchestrator.ts | 10 +- src/interactive/context-meter.ts | 10 + src/interactive/context-overlay.ts | 54 +++- src/interactive/overlay-general-openers.ts | 6 + src/tools/builtin-tool-catalog.ts | 2 +- src/tools/context/index.ts | 115 +++++++- src/tools/context/surface.ts | 5 +- src/tools/core-bootstrap.ts | 17 ++ .../context-overlay-working-set.test.ts | 111 +++++++ tests/contracts/context-tool-recall.test.ts | 184 ++++++++++++ tests/contracts/lazy-tools.test.ts | 2 +- tests/contracts/working-set-recall.test.ts | 279 ++++++++++++++++++ 13 files changed, 961 insertions(+), 12 deletions(-) create mode 100644 src/domains/context/working-set/recall.ts create mode 100644 tests/contracts/context-overlay-working-set.test.ts create mode 100644 tests/contracts/context-tool-recall.test.ts create mode 100644 tests/contracts/working-set-recall.test.ts diff --git a/src/domains/context/working-set/recall.ts b/src/domains/context/working-set/recall.ts new file mode 100644 index 000000000..1364ee0d7 --- /dev/null +++ b/src/domains/context/working-set/recall.ts @@ -0,0 +1,178 @@ +/** + * Exact recall by ref. + * + * A `contextEviction` entry removes a tool-result body from the projection + * and leaves a marker naming the ref. Recall is the reverse move: given a ref + * on the active path whose key the fold still lists as evicted, hand back the + * original body byte-exact and describe the `contextRecall` entry the caller + * appends so the next fold readmits it. Pure over entries: nothing here reads + * the session, writes the ledger, or calls a model. + * + * The body is read the way `compaction/mask-observations.ts` reads a + * tool_result payload (`resultText`), so what recall returns is exactly what + * the projection would have rendered before eviction. No truncation happens + * here; the observation envelope applies the per-turn caps. + */ + +import { ceilChars } from "../../session/context-accounting.js"; +import type { MessageEntry, SessionEntry } from "../../session/entries.js"; +import { filterEntriesToActivePath } from "../../session/tree/active-path.js"; +import type { ContextRecallFields, RecallError, RecallResult, RecallTrigger, WorkingSetView } from "./contract.js"; +import { parseRefKey, refKey } from "./fold.js"; + +export type RecallOutcome = { ok: true; result: RecallResult } | { ok: false; error: RecallError }; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function textFromContent(content: unknown): string { + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (!isRecord(block)) continue; + if (block.type === "text" && typeof block.text === "string") parts.push(block.text); + } + return parts.join(""); +} + +function stringifyWhole(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + try { + return JSON.stringify(value) ?? ""; + } catch { + return String(value); + } +} + +/** Same field precedence as `resultText` in mask-observations.ts, without the preview cap. */ +function resultText(result: unknown): string { + if (typeof result === "string") return result; + if (!isRecord(result)) return stringifyWhole(result); + const contentText = textFromContent(result.content); + if (contentText.length > 0) return contentText; + if (typeof result.text === "string") return result.text; + if (typeof result.output === "string") return result.output; + if (typeof result.message === "string") return result.message; + return stringifyWhole(result); +} + +function extractToolResult(payload: unknown): unknown { + const obj = isRecord(payload) ? payload : { result: payload }; + return obj.result ?? obj.output ?? obj.out ?? obj.content ?? payload; +} + +function offloadPathOf(result: unknown): string | undefined { + if (!isRecord(result) || !isRecord(result.details)) return undefined; + const details = result.details; + for (const key of ["resultSize", "observation"] as const) { + const record = details[key]; + if (isRecord(record) && typeof record.offloadPath === "string" && record.offloadPath.length > 0) { + return record.offloadPath; + } + } + return undefined; +} + +function commonPrefixLength(a: string, b: string): number { + const limit = Math.min(a.length, b.length); + let i = 0; + while (i < limit && a.charCodeAt(i) === b.charCodeAt(i)) i += 1; + return i; +} + +/** + * The evicted ref key sharing the longest non-empty common prefix with `key`, + * or null when no evicted key shares a prefix. Ties keep fold order, which is + * ledger order of the eviction events. + */ +function nearestEvictedRef(view: WorkingSetView, key: string): string | null { + let best: string | null = null; + let bestLength = 0; + for (const candidate of view.evicted.keys()) { + const length = commonPrefixLength(candidate, key); + if (length > bestLength) { + best = candidate; + bestLength = length; + } + } + return best; +} + +function isThinkingEntry(entry: SessionEntry): boolean { + return entry.kind === "message" && entry.role === "assistant"; +} + +function isToolResultEntry(entry: SessionEntry): entry is MessageEntry { + return entry.kind === "message" && entry.role === "tool_result"; +} + +export function resolveRecall( + entries: ReadonlyArray, + view: WorkingSetView, + ref: string, + activeLeafTurnId?: string, +): RecallOutcome { + const parsed = parseRefKey(ref); + if (parsed === null) return { ok: false, error: { kind: "invalid_ref", ref } }; + const key = refKey(parsed); + const active = filterEntriesToActivePath(entries, activeLeafTurnId); + const entry = active.find((candidate) => candidate.turnId === key); + if (entry === undefined) { + return { ok: false, error: { kind: "not_on_active_path", ref: key, nearest: nearestEvictedRef(view, key) } }; + } + // Thinking leaves the working set without a marker and is not recallable + // in this slice; `recallErrorMessage` names that case from the entry. + if (isThinkingEntry(entry) || !view.evicted.has(key) || !isToolResultEntry(entry)) { + return { ok: false, error: { kind: "not_evicted", ref: key, nearest: nearestEvictedRef(view, key) } }; + } + const result = extractToolResult(entry.payload); + const body = resultText(result); + const offloadPath = offloadPathOf(result); + return { + ok: true, + result: { + ref: parsed, + entry, + body, + tokens: ceilChars(body.length), + ...(offloadPath !== undefined ? { offloadPath } : {}), + }, + }; +} + +export function buildRecallFields( + result: RecallResult, + meta: { trigger: RecallTrigger; toolCallId?: string }, +): ContextRecallFields { + return { + kind: "contextRecall", + ref: { entry: result.ref.entry }, + trigger: meta.trigger, + tokensReadmitted: result.tokens, + ...(meta.toolCallId !== undefined ? { toolCallId: meta.toolCallId } : {}), + }; +} + +/** + * One-line operator/model-facing message for a recall failure. Names the + * nearest valid ref when one exists so the next call can succeed, and says + * why an assistant turn is refused instead of calling it "not evicted". + */ +export function recallErrorMessage(error: RecallError, entries: ReadonlyArray = []): string { + const nearest = "nearest" in error && error.nearest !== null ? ` Nearest evicted ref: ${error.nearest}.` : ""; + switch (error.kind) { + case "invalid_ref": + return `recall ref must be a single turnId without whitespace; got '${error.ref}'.`; + case "not_on_active_path": + return `ref ${error.ref} is not on the active path of this session (unknown or on an abandoned branch).${nearest}`; + case "not_evicted": { + const entry = entries.find((candidate) => candidate.turnId === error.ref); + if (entry !== undefined && isThinkingEntry(entry)) { + return `ref ${error.ref} is an assistant turn; thinking is not recallable.${nearest}`; + } + return `ref ${error.ref} is not evicted; its content is already in context.${nearest}`; + } + } +} diff --git a/src/entry/orchestrator.ts b/src/entry/orchestrator.ts index e1da35a05..6f228c9f5 100644 --- a/src/entry/orchestrator.ts +++ b/src/entry/orchestrator.ts @@ -1236,7 +1236,15 @@ export async function bootOrchestrator(options: BootOptions = {}): Promise { + const meta = session.current(); + return meta ? readSessionEntriesForCompact(meta.id) : []; + }, + } + : {}), taskBoard, userTasks, dispatch, diff --git a/src/interactive/context-meter.ts b/src/interactive/context-meter.ts index 2be728f4d..86f9834e3 100644 --- a/src/interactive/context-meter.ts +++ b/src/interactive/context-meter.ts @@ -156,6 +156,16 @@ export function renderContextMeterGrid( return lines; } +/** + * Evicted tokens live outside the window: the working-set projection removed + * them from what the model sees, so they are not a meter category and must + * not claim cells or a legend row. One line after the legend names them. + */ +export function renderEvictedTokensLine(evictedTokens: number, theme: ClioTheme = clioTheme()): string { + const tokens = Math.round(Math.max(0, evictedTokens)).toLocaleString("en-US"); + return `${theme.fg("dim", GLYPH.contextReserve)} ${theme.fg("muted", "evicted (outside window)")} ${theme.fg("dim", `${tokens} tokens`)}`; +} + /** A small colored swatch for a category, for inline legends. */ export function contextCategorySwatch(category: ContextLedgerCategory, theme: ClioTheme = clioTheme()): string { return theme.fg(CONTEXT_CATEGORY_TOKEN[category], contextCategoryGlyph(category)); diff --git a/src/interactive/context-overlay.ts b/src/interactive/context-overlay.ts index 38c4c0f6f..d57cd0b45 100644 --- a/src/interactive/context-overlay.ts +++ b/src/interactive/context-overlay.ts @@ -2,9 +2,10 @@ import { homedir } from "node:os"; import { relative } from "node:path"; import { BusChannels } from "../core/bus-events.js"; import type { SafeEventBus } from "../core/event-bus.js"; +import type { WorkingSetView } from "../domains/context/working-set/contract.js"; import type { ContextLedger, ContextLedgerGroup } from "../domains/session/context-ledger.js"; import { type OverlayHandle, Text, type TUI, visibleWidth } from "../engine/tui.js"; -import { contextCategorySwatch, renderContextMeterGrid } from "./context-meter.js"; +import { contextCategorySwatch, renderContextMeterGrid, renderEvictedTokensLine } from "./context-meter.js"; import { buildHint, showClioOverlayFrame } from "./overlay-frame.js"; import { abbreviateModelId, type ClioToken, clioTheme, formatContextPercent } from "./theme/index.js"; @@ -81,7 +82,43 @@ function legendRow(group: ContextLedgerGroup, contentWidth: number): string { return `${swatch} ${theme.fg(labelToken, labelText)} ${theme.fg("muted", right)}`; } -export function renderContextLedgerLines(ledger: ContextLedger, contentWidth: number): string[] { +function evictedTokens(view: WorkingSetView): number { + let total = 0; + for (const state of view.evicted.values()) total += state.tokensFreed; + return total; +} + +function formatChurn(view: WorkingSetView): string { + if (view.itemsEvicted === 0) return "n/a"; + return (view.recalls / view.itemsEvicted).toFixed(2); +} + +/** + * The working-set section: what the projection has taken out of the window + * and how often the model has asked for it back. Churn is recalls over items + * evicted; a high number means the policy evicts what is still needed. + */ +function renderWorkingSetLines(view: WorkingSetView): string[] { + const theme = clioTheme(); + const items = view.evicted.size; + const summary = [ + `${items} evicted item${items === 1 ? "" : "s"}`, + `${formatTokens(evictedTokens(view))} tokens`, + `${view.evictionEvents} event${view.evictionEvents === 1 ? "" : "s"}`, + `${view.recalls} recall${view.recalls === 1 ? "" : "s"}`, + `churn ${formatChurn(view)}`, + ].join(" · "); + return [ + `${theme.fg("muted", "working set")} ${theme.fg("dim", "·")} ${theme.fg("accent", `policy ${view.lastPolicyId ?? "none"}`)}`, + theme.fg("dim", summary), + ]; +} + +export function renderContextLedgerLines( + ledger: ContextLedger, + contentWidth: number, + workingSet?: WorkingSetView | null, +): string[] { const theme = clioTheme(); const lines: string[] = []; @@ -110,8 +147,13 @@ export function renderContextLedgerLines(ledger: ContextLedger, contentWidth: nu lines.push(""); for (const group of ledger.meter) lines.push(legendRow(group, contentWidth)); + if (workingSet && workingSet.evicted.size > 0) lines.push(renderEvictedTokensLine(evictedTokens(workingSet), theme)); lines.push(""); + if (workingSet) { + for (const line of renderWorkingSetLines(workingSet)) lines.push(line); + lines.push(""); + } if (ledger.projectPreload && ledger.groups.some((group) => group.category === "project")) { lines.push(theme.fg("dim", `project preload: ${ledger.projectPreload}`)); } @@ -170,6 +212,8 @@ export interface OpenContextOverlayOptions { onEvent(handler: (event: { type: string }) => void): () => void; isStreaming(): boolean; }; + /** Working-set fold at the live leaf; null or absent hides the section. */ + getWorkingSet?: () => WorkingSetView | null; } /** @@ -183,7 +227,9 @@ export function openContextOverlay( getLedger: () => ContextLedger, options?: OpenContextOverlayOptions, ): OverlayHandle { - const text = new Text(renderContextLedgerLines(getLedger(), DEFAULT_CONTENT_WIDTH).join("\n"), 0, 0); + const render = (): string => + renderContextLedgerLines(getLedger(), DEFAULT_CONTENT_WIDTH, options?.getWorkingSet?.() ?? null).join("\n"); + const text = new Text(render(), 0, 0); const handle = showClioOverlayFrame(tui, text, { anchor: "center", width: CONTEXT_OVERLAY_WIDTH, @@ -192,7 +238,7 @@ export function openContextOverlay( }); const refresh = (): void => { - text.setText(renderContextLedgerLines(getLedger(), DEFAULT_CONTENT_WIDTH).join("\n")); + text.setText(render()); text.invalidate(); tui.requestRender(); }; diff --git a/src/interactive/overlay-general-openers.ts b/src/interactive/overlay-general-openers.ts index e9d11779e..7c36a0c2d 100644 --- a/src/interactive/overlay-general-openers.ts +++ b/src/interactive/overlay-general-openers.ts @@ -1,4 +1,5 @@ import type { SafeEventBus } from "../core/event-bus.js"; +import { foldWorkingSet } from "../domains/context/working-set/fold.js"; import type { DispatchContract } from "../domains/dispatch/index.js"; import { loadMemoryRecordsSync, type MemoryRecord } from "../domains/memory/index.js"; import type { ObservabilityContract } from "../domains/observability/index.js"; @@ -107,6 +108,11 @@ export function createOverlayGeneralOpeners(deps: OverlayGeneralOpenersDeps): Ov deps.transitions.handle = openContextOverlayFactory(deps.tui, deps.getContextLedger, { bus: deps.bus, chat: deps.contextChat, + getWorkingSet: () => { + const readSessionEntries = deps.readSessionEntries; + if (!readSessionEntries) return null; + return foldWorkingSet(readSessionEntries(), deps.getSessionMeta()?.pinnedLeafTurnId ?? undefined); + }, }); deps.requestRender(); }; diff --git a/src/tools/builtin-tool-catalog.ts b/src/tools/builtin-tool-catalog.ts index 57ec4d5fa..22efd344b 100644 --- a/src/tools/builtin-tool-catalog.ts +++ b/src/tools/builtin-tool-catalog.ts @@ -93,7 +93,7 @@ const TOOL_METADATA: Readonly> = { ), costLatency: "local_fast", promptHint: - 'Call context with scope="skills" to list installed and marketplace skills; when one matches the task, or the operator names a skill or asks how one works, suggest the operator run /skill (a marketplace skill is offered for install) and never load it uninvited. When the user message carries a skill request, first load that skill via context (scope="skills", name=) before doing anything else.', + 'Call context with scope="skills" to list installed and marketplace skills; when one matches the task, or the operator names a skill or asks how one works, suggest the operator run /skill (a marketplace skill is offered for install) and never load it uninvited. When the user message carries a skill request, first load that skill via context (scope="skills", name=) before doing anything else. When an [evicted ...] marker names content you need, recall it with context(scope="recall", ref=...); re-read the file only when the marker says it changed.', }, [ToolNames.CredentialPresent]: { objective: "Check whether a credential key is present without returning its value.", diff --git a/src/tools/context/index.ts b/src/tools/context/index.ts index 78f4fd2df..62a0c78b2 100644 --- a/src/tools/context/index.ts +++ b/src/tools/context/index.ts @@ -2,6 +2,8 @@ import { type Dirent, readdirSync } from "node:fs"; import path from "node:path"; import { SKILL_SUGGESTION_ANCHOR } from "../../core/skill-activation.js"; import { ToolNames } from "../../core/tool-names.js"; +import { foldWorkingSet } from "../../domains/context/working-set/fold.js"; +import { buildRecallFields, recallErrorMessage, resolveRecall } from "../../domains/context/working-set/recall.js"; import { checkSkillDrift, discoverMarketplaceSkills, @@ -11,6 +13,9 @@ import { modelVisibleSkills, type Skill, } from "../../domains/resources/index.js"; +import type { SessionEntryInput } from "../../domains/session/contract.js"; +import type { SessionEntry } from "../../domains/session/entries.js"; +import { filterEntriesToActivePath } from "../../domains/session/tree/active-path.js"; import type { WorkspaceSnapshot } from "../../domains/session/workspace/index.js"; import { finalizeObservation, @@ -30,7 +35,8 @@ import { contextToolSurface } from "./surface.js"; * workspace snapshot, scope=docs retrieves cited sections from Clio's bundled * documentation, scope=skills lists available skills or loads a requested * skill body (the skill-activation and pending-request contracts are - * unchanged from the absorbed read_skill tool). + * unchanged from the absorbed read_skill tool), scope=recall readmits an + * evicted tool-result body by ref and records the `contextRecall` entry. */ const DEFAULT_TREE_ENTRIES = 50; @@ -42,6 +48,15 @@ export interface ContextWorkspaceDeps { saveSnapshot(snapshot: WorkspaceSnapshot): void; } +/** Ledger access for scope=recall: read the full ledger, fold it at the live leaf, append the recall record. */ +export interface ContextSessionDeps { + hasSession(): boolean; + readEntries(): ReadonlyArray; + /** The live append point (`/tree` pin or tree leaf); undefined lets the fold infer it. */ + activeLeafTurnId(): string | undefined; + appendEntry(entry: SessionEntryInput): SessionEntry; +} + export interface ContextToolDeps { getCwd?: () => string; getSkillLoaderOptions?: () => Pick< @@ -50,6 +65,8 @@ export interface ContextToolDeps { >; /** Absent in worker registries without a session; scope=workspace errors cleanly. */ workspace?: ContextWorkspaceDeps; + /** Absent in worker registries without a session; scope=recall errors cleanly. */ + session?: ContextSessionDeps; /** * Whether scope=skills may list marketplace entries beside installed * skills. Worker registries set false: a worker can neither install a @@ -482,13 +499,104 @@ function runSkillsScope( }); } +/** + * scope=recall: the body goes back through the observation envelope like any + * OBSERVE result, so the per-turn pool and the self cap still apply; an + * oversize body is offloaded by the envelope and the notice carries the + * pointer. A body whose original result was itself offloaded already ends in + * that tool's own `full: ` pointer, which is what the model gets back; + * the file is never inlined. The `contextRecall` entry is appended before the + * result returns so the next fold readmits the ref. + */ +function runRecallScope( + deps: ContextToolDeps, + args: Record, + reservation: ObservationReservation, + options: ToolInvokeOptions | undefined, +): ToolResult { + const session = deps.session; + if (!session?.hasSession()) { + return { kind: "error", message: "context: recall scope requires a bound session; none is active here" }; + } + const ref = typeof args.ref === "string" ? args.ref.trim() : ""; + if (ref.length === 0) { + return { + kind: "error", + message: "context: recall scope requires ref=, the ref named in the [evicted ...] marker", + }; + } + const entries = session.readEntries(); + const leaf = session.activeLeafTurnId(); + const view = foldWorkingSet(entries, leaf); + const resolved = resolveRecall(entries, view, ref, leaf); + if (!resolved.ok) { + const evictedRefs = [...view.evicted.keys()]; + const listing = + "nearest" in resolved.error && resolved.error.nearest === null && evictedRefs.length > 0 + ? ` Evicted refs on the active path: ${evictedRefs.slice(0, 8).join(", ")}${evictedRefs.length > 8 ? ", …" : ""}.` + : ""; + return { kind: "error", message: `context: ${recallErrorMessage(resolved.error, entries)}${listing}` }; + } + const { result } = resolved; + const fields = buildRecallFields(result, { + trigger: "tool", + ...(options?.toolCallId ? { toolCallId: options.toolCallId } : {}), + }); + // The recall record parents onto the live leaf so the fold sees it on + // this branch and only this branch. + const active = filterEntriesToActivePath(entries, leaf); + let parentTurnId: string | null = null; + for (let i = active.length - 1; i >= 0; i -= 1) { + const candidate = active[i]; + if (candidate?.kind === "message") { + parentTurnId = candidate.turnId; + break; + } + } + let recorded: SessionEntry; + try { + recorded = session.appendEntry({ ...fields, parentTurnId }); + } catch (err) { + return { + kind: "error", + message: `context: recall of ${result.ref.entry} could not be recorded: ${err instanceof Error ? err.message : String(err)}`, + }; + } + // TODO(ws/wiring): emit BusChannels.ContextRecalled { ref, trigger: "tool", tokensReadmitted, at } once worker B lands the channel. + const evictedState = view.evicted.get(result.ref.entry); + const truncation = truncateHead(result.body, { + maxBytes: reservation.callCapBytes, + maxLines: Number.MAX_SAFE_INTEGER, + }); + return finalizeObservation({ + tool: ToolNames.Context, + unit: "results", + output: truncation.content, + ...(truncation.truncated ? { fullOutput: result.body } : {}), + shownCount: 1, + totalCount: 1, + truncated: truncation.truncated, + details: { + recall: { + ref: result.ref.entry, + tokensReadmitted: result.tokens, + recallTurnId: recorded.turnId, + ...(evictedState ? { reason: evictedState.reason, evictedAtTurnId: evictedState.evictedAtTurnId } : {}), + ...(result.offloadPath !== undefined ? { offloadPath: result.offloadPath } : {}), + }, + }, + reservation, + ...(options ? { options } : {}), + }); +} + export function createContextTool(deps: ContextToolDeps = {}): ToolSpec { return { ...contextToolSurface, async run(args, options): Promise { const scope = typeof args.scope === "string" ? args.scope : ""; - if (scope !== "workspace" && scope !== "docs" && scope !== "skills") { - return { kind: "error", message: `context: scope must be workspace, docs, or skills; got '${scope}'` }; + if (scope !== "workspace" && scope !== "docs" && scope !== "skills" && scope !== "recall") { + return { kind: "error", message: `context: scope must be workspace, docs, skills, or recall; got '${scope}'` }; } const selfCap = scope === "docs" @@ -508,6 +616,7 @@ export function createContextTool(deps: ContextToolDeps = {}): ToolSpec { } if (scope === "workspace") return runWorkspaceScope(deps, reservation, options); if (scope === "docs") return runDocsScope(args, reservation, options); + if (scope === "recall") return runRecallScope(deps, args, reservation, options); return runSkillsScope(deps, args, reservation, options); }, }; diff --git a/src/tools/context/surface.ts b/src/tools/context/surface.ts index 605f94586..c5b4992fa 100644 --- a/src/tools/context/surface.ts +++ b/src/tools/context/surface.ts @@ -6,12 +6,13 @@ import type { ToolSurface } from "../lazy-tool.js"; export const contextToolSurface = { name: ToolNames.Context, description: - "Environment context: scope=workspace returns the git/project snapshot, scope=docs searches Clio's bundled documentation (omit query to list the corpus), scope=skills lists installed and marketplace skills or loads an installed one by name. For repository code and the repo's generated wiki use code_nav (mode=wiki).", + "Environment context: scope=workspace returns the git/project snapshot, scope=docs searches Clio's bundled documentation (omit query to list the corpus), scope=skills lists installed and marketplace skills or loads an installed one by name, scope=recall returns the exact body of an evicted tool result by ref (the turnId named in an [evicted ...] marker). For repository code and the repo's generated wiki use code_nav (mode=wiki).", parameters: Type.Object({ - scope: StringEnum(["workspace", "docs", "skills"], { description: "Context source." }), + scope: StringEnum(["workspace", "docs", "skills", "recall"], { description: "Context source." }), query: Type.Optional(Type.String({ description: "scope=docs: question or terms; omit to list the corpus." })), name: Type.Optional(Type.String({ description: "scope=skills: skill name to load; omit to list." })), limit: Type.Optional(Type.Number({ description: "scope=docs: max sections (default 5, max 12)." })), + ref: Type.Optional(Type.String({ description: "scope=recall: ref of the evicted item, as named in its marker." })), include_tree: Type.Optional(Type.Boolean({ description: "scope=skills: list files under the skill base_dir." })), }), baseActionClass: "read", diff --git a/src/tools/core-bootstrap.ts b/src/tools/core-bootstrap.ts index 80ea06990..41311beaf 100644 --- a/src/tools/core-bootstrap.ts +++ b/src/tools/core-bootstrap.ts @@ -1,5 +1,6 @@ import type { LoadSkillsInput } from "../domains/resources/index.js"; import type { SessionContract } from "../domains/session/contract.js"; +import type { SessionEntry } from "../domains/session/entries.js"; import { createTaskBoardStore, type TaskBoardStore } from "../domains/session/task-board.js"; import type { UserTasksStore } from "../domains/user-tasks/store.js"; import type { AgentLedgerPort } from "../worker/protocol.js"; @@ -28,6 +29,8 @@ import { writeTool } from "./write.js"; export interface CoreToolBootstrapDeps { session?: SessionContract; + /** Full ledger of the current session; context(scope=recall) folds it. Absent in worker registries. */ + readSessionEntries?: () => ReadonlyArray; askUser?: AskUserHandler; taskBoard?: TaskBoardStore; userTasks?: UserTasksStore; @@ -108,6 +111,7 @@ export function registerCoreTools(registry: ToolRegistry, deps: CoreToolBootstra ...builtin(credentialPresentTool, { path: "src/tools/credential-present.ts", scope: "core" }), }); const session = deps.session; + const readSessionEntries = deps.readSessionEntries; registry.register({ ...builtin( lazyTool(contextToolSurface, async () => { @@ -116,6 +120,19 @@ export function registerCoreTools(registry: ToolRegistry, deps: CoreToolBootstra const { probeWorkspace } = await import("../domains/session/workspace/index.js"); return createContextTool({ ...skillToolDeps, + ...(readSessionEntries + ? { + session: { + hasSession: () => session.current() !== null, + readEntries: readSessionEntries, + activeLeafTurnId: () => { + const meta = session.current(); + return meta ? (session.tree(meta.id).leafId ?? undefined) : undefined; + }, + appendEntry: (entry) => session.appendEntry(entry), + }, + } + : {}), workspace: { hasSession: () => session.current() !== null, getSnapshot: () => session.current()?.workspace ?? null, diff --git a/tests/contracts/context-overlay-working-set.test.ts b/tests/contracts/context-overlay-working-set.test.ts new file mode 100644 index 000000000..abe634c7c --- /dev/null +++ b/tests/contracts/context-overlay-working-set.test.ts @@ -0,0 +1,111 @@ +import { ok, strictEqual } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { EMPTY_WORKING_SET_VIEW, type WorkingSetView } from "../../src/domains/context/working-set/contract.js"; +import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; +import { buildContextLedger } from "../../src/domains/session/context-ledger.js"; +import type { SessionEntry } from "../../src/domains/session/entries.js"; +import { renderEvictedTokensLine } from "../../src/interactive/context-meter.js"; +import { renderContextLedgerLines } from "../../src/interactive/context-overlay.js"; + +const ESC = String.fromCharCode(27); +const strip = (text: string): string => text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), ""); + +function ledger() { + return buildContextLedger({ provider: "mock", model: "model-a", contextWindow: 4000, messageTokens: 1200 }); +} + +function view(): WorkingSetView { + const entries: SessionEntry[] = [ + { + kind: "message", + turnId: "u1", + parentTurnId: null, + timestamp: "2026-01-01T00:00:01.000Z", + role: "user", + payload: {}, + }, + { + kind: "message", + turnId: "t1", + parentTurnId: "u1", + timestamp: "2026-01-01T00:00:02.000Z", + role: "tool_result", + payload: {}, + }, + { + kind: "message", + turnId: "t2", + parentTurnId: "t1", + timestamp: "2026-01-01T00:00:03.000Z", + role: "tool_result", + payload: {}, + }, + { + kind: "message", + turnId: "t3", + parentTurnId: "t2", + timestamp: "2026-01-01T00:00:04.000Z", + role: "tool_result", + payload: {}, + }, + { + kind: "contextEviction", + turnId: "e1", + parentTurnId: "t3", + timestamp: "2026-01-01T00:00:05.000Z", + policyId: "age-horizon", + trigger: "pressure", + evicted: [ + { ref: { entry: "t1" }, reason: "age_horizon", tokensFreed: 700, marker: "[evicted ref=t1]" }, + { ref: { entry: "t2" }, reason: "age_horizon", tokensFreed: 500, marker: "[evicted ref=t2]" }, + { ref: { entry: "t3" }, reason: "age_horizon", tokensFreed: 300, marker: "[evicted ref=t3]" }, + ], + tokensBefore: 3000, + tokensAfter: 1500, + pressureBefore: 0.9, + snapshotIdBefore: null, + }, + { + kind: "contextRecall", + turnId: "r1", + parentTurnId: "t3", + timestamp: "2026-01-01T00:00:06.000Z", + ref: { entry: "t2" }, + trigger: "tool", + tokensReadmitted: 500, + }, + ]; + return foldWorkingSet(entries); +} + +describe("context overlay working-set section", () => { + it("renders policy, evicted items and tokens, events, recalls, and churn", () => { + const text = strip(renderContextLedgerLines(ledger(), 68, view()).join("\n")); + ok(text.includes("working set · policy age-horizon"), text); + ok(text.includes("2 evicted items · 1,000 tokens · 1 event · 1 recall · churn 0.33"), text); + ok(text.includes("evicted (outside window) 1,000 tokens"), text); + }); + + it("evicted tokens are one line after the legend, not a meter category", () => { + const lines = renderContextLedgerLines(ledger(), 68, view()).map(strip); + const legendIndex = lines.findIndex((line) => line.includes("Free space")); + const evictedIndex = lines.findIndex((line) => line.includes("evicted (outside window)")); + ok(legendIndex >= 0 && evictedIndex === legendIndex + 1, lines.join("\n")); + strictEqual( + ledger().meter.some((group) => group.label.toLowerCase().includes("evicted")), + false, + ); + ok(strip(renderEvictedTokensLine(12_345)).endsWith("12,345 tokens")); + }); + + it("churn is n/a with nothing evicted, and the section is absent without a fold", () => { + const empty = strip(renderContextLedgerLines(ledger(), 68, EMPTY_WORKING_SET_VIEW).join("\n")); + ok(empty.includes("working set · policy none"), empty); + ok(empty.includes("0 evicted items · 0 tokens · 0 events · 0 recalls · churn n/a"), empty); + ok(!empty.includes("outside window"), empty); + const withoutView = strip(renderContextLedgerLines(ledger(), 68).join("\n")); + ok(!withoutView.includes("working set"), withoutView); + const nullView = strip(renderContextLedgerLines(ledger(), 68, null).join("\n")); + strictEqual(nullView, withoutView); + }); +}); diff --git a/tests/contracts/context-tool-recall.test.ts b/tests/contracts/context-tool-recall.test.ts new file mode 100644 index 000000000..d3f478645 --- /dev/null +++ b/tests/contracts/context-tool-recall.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { SessionEntryInput } from "../../src/domains/session/contract.js"; +import type { MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; +import { type ContextSessionDeps, createContextTool } from "../../src/tools/context/index.js"; + +let clock = 0; +function stamp(): string { + clock += 1; + return new Date(1_700_000_000_000 + clock * 1000).toISOString(); +} + +function user(turnId: string, parentTurnId: string | null): SessionEntry { + return { kind: "message", turnId, parentTurnId, timestamp: stamp(), role: "user", payload: { text: turnId } }; +} + +function toolResult(turnId: string, parentTurnId: string, text: string): MessageEntry { + return { + kind: "message", + turnId, + parentTurnId, + timestamp: stamp(), + role: "tool_result", + payload: { + toolCallId: `call-${turnId}`, + toolName: "read", + result: { content: [{ type: "text", text }], details: { resultSize: { bytes: text.length, truncated: false } } }, + isError: false, + resultSummary: { bytes: text.length, truncated: false }, + }, + }; +} + +function eviction(turnId: string, parentTurnId: string, refs: string[]): SessionEntry { + return { + kind: "contextEviction", + turnId, + parentTurnId, + timestamp: stamp(), + policyId: "age-horizon", + trigger: "pressure", + evicted: refs.map((entry) => ({ + ref: { entry }, + reason: "age_horizon", + tokensFreed: 50, + marker: `[evicted ref=${entry}]`, + })), + tokensBefore: 1000, + tokensAfter: 900, + pressureBefore: 0.85, + snapshotIdBefore: null, + }; +} + +const BODY = "alpha\n\tbeta \nγάμμα\n"; + +function fakeSession(entries: SessionEntry[]): { deps: ContextSessionDeps; entries: SessionEntry[] } { + const deps: ContextSessionDeps = { + hasSession: () => true, + readEntries: () => entries, + activeLeafTurnId: () => undefined, + appendEntry(input: SessionEntryInput): SessionEntry { + const entry = { ...input, turnId: input.turnId ?? `gen-${entries.length}`, timestamp: stamp() } as SessionEntry; + entries.push(entry); + return entry; + }, + }; + return { deps, entries }; +} + +function baseEntries(): SessionEntry[] { + return [ + user("u1", null), + toolResult("t1", "u1", BODY), + toolResult("t2", "t1", "other"), + user("u2", "t2"), + eviction("e1", "u2", ["t1"]), + ]; +} + +describe("contracts/context recall scope", () => { + it("returns the body byte-exact and appends a contextRecall entry with the tool call id", async () => { + const { deps, entries } = fakeSession(baseEntries()); + const tool = createContextTool({ session: deps }); + const result = await tool.run( + { scope: "recall", ref: "t1" }, + { toolCallId: "call-recall", turnId: "turn-9", sessionId: "s1" }, + ); + assert.equal(result.kind, "ok"); + if (result.kind !== "ok") return; + assert.equal(result.output, BODY); + const details = result.details as { recall: Record; observation: Record }; + assert.equal(details.recall.ref, "t1"); + assert.equal(details.recall.tokensReadmitted, Math.ceil(BODY.length / 4)); + assert.equal(details.recall.reason, "age_horizon"); + assert.equal(details.recall.evictedAtTurnId, "e1"); + assert.equal(details.observation.truncated, false); + + const appended = entries[entries.length - 1]; + assert.ok(appended && appended.kind === "contextRecall"); + if (appended?.kind !== "contextRecall") return; + assert.deepEqual(appended.ref, { entry: "t1" }); + assert.equal(appended.trigger, "tool"); + assert.equal(appended.toolCallId, "call-recall"); + assert.equal(appended.tokensReadmitted, Math.ceil(BODY.length / 4)); + // Parents onto the last message on the active path, so the fold sees it on this branch. + assert.equal(appended.parentTurnId, "u2"); + assert.equal(details.recall.recallTurnId, appended.turnId); + + // The second call sees the fold with the recall applied. + const again = await tool.run({ scope: "recall", ref: "t1" }, { toolCallId: "call-2" }); + assert.equal(again.kind, "error"); + if (again.kind !== "error") return; + assert.match(again.message, /^context: ref t1 is not evicted/); + }); + + it("errors name the nearest valid ref", async () => { + const { deps } = fakeSession(baseEntries()); + const tool = createContextTool({ session: deps }); + const notEvicted = await tool.run({ scope: "recall", ref: "t2" }, undefined); + assert.equal(notEvicted.kind, "error"); + if (notEvicted.kind === "error") assert.match(notEvicted.message, /not evicted.*Nearest evicted ref: t1\./); + const offPath = await tool.run({ scope: "recall", ref: "nope" }, undefined); + assert.equal(offPath.kind, "error"); + if (offPath.kind === "error") + assert.match(offPath.message, /not on the active path.*Evicted refs on the active path: t1\./); + const missing = await tool.run({ scope: "recall" }, undefined); + assert.equal(missing.kind, "error"); + if (missing.kind === "error") assert.match(missing.message, /requires ref=/); + const invalid = await tool.run({ scope: "recall", ref: "a b" }, undefined); + assert.equal(invalid.kind, "error"); + if (invalid.kind === "error") assert.match(invalid.message, /single turnId/); + }); + + it("a registry without a session errors cleanly like workspace", async () => { + const bare = createContextTool(); + const result = await bare.run({ scope: "recall", ref: "t1" }, undefined); + assert.equal(result.kind, "error"); + if (result.kind === "error") assert.match(result.message, /requires a bound session/); + const unbound = createContextTool({ + session: { + hasSession: () => false, + readEntries: () => [], + activeLeafTurnId: () => undefined, + appendEntry: () => { + throw new Error("unreachable"); + }, + }, + }); + const unboundResult = await unbound.run({ scope: "recall", ref: "t1" }, undefined); + assert.equal(unboundResult.kind, "error"); + }); + + it("an oversize body goes through the envelope: truncated, offloaded, pointer in the notice, never inlined whole", async () => { + const big = `${"x".repeat(70_000)}\nEND`; + const { deps, entries } = fakeSession([ + user("u1", null), + toolResult("t1", "u1", big), + user("u2", "t1"), + eviction("e1", "u2", ["t1"]), + ]); + const tool = createContextTool({ session: deps }); + const result = await tool.run({ scope: "recall", ref: "t1" }, { toolCallId: "call-big", sessionId: "s-big" }); + assert.equal(result.kind, "ok"); + if (result.kind !== "ok") return; + assert.ok(result.output.length < big.length); + assert.ok(!result.output.includes("\nEND")); + const observation = (result.details as { observation: Record }).observation; + assert.equal(observation.truncated, true); + assert.equal(typeof observation.offloadPath, "string"); + assert.match(result.output, /full: .* \(overflow copy, read-only; not the workspace\)/); + // The recall is still recorded with the full token count. + const appended = entries[entries.length - 1]; + assert.ok(appended && appended.kind === "contextRecall"); + if (appended && appended.kind === "contextRecall") assert.equal(appended.tokensReadmitted, Math.ceil(big.length / 4)); + }); + + it("an unknown scope lists recall among the accepted scopes", async () => { + const tool = createContextTool(); + const result = await tool.run({ scope: "bogus" }, undefined); + assert.equal(result.kind, "error"); + if (result.kind === "error") assert.match(result.message, /workspace, docs, skills, or recall/); + }); +}); diff --git a/tests/contracts/lazy-tools.test.ts b/tests/contracts/lazy-tools.test.ts index 0e120d7f6..ff2323c21 100644 --- a/tests/contracts/lazy-tools.test.ts +++ b/tests/contracts/lazy-tools.test.ts @@ -34,7 +34,7 @@ function advertised(spec: ToolSurface | ToolSpec): unknown { describe("contracts/lazy tool stubs", () => { const SURFACE_SHA256 = { code_nav: "bbdb58bd9e57679d34ff1c966d2ddd4929fef12419cb0e0971ddbf0025a9e4a9", - context: "ce8654dad18e8e9d5503f11d4280851daf9d3978ea149e4ef32d7b049ccdcb5f", + context: "c6a4035f70c0657c1af97bc93beb1565ca79db071404965d5305aea9b9fa7d00", verify: "f7c499209b02f6ddc0cf3938681bb748aa544f8aaa40610fcd2ffa5ab70c5a80", web_fetch: "6b9c7e66866cc5f8c7cef21bcc6d1dd301692b051ad339f8ec3764ba14d6f164", } as const; diff --git a/tests/contracts/working-set-recall.test.ts b/tests/contracts/working-set-recall.test.ts new file mode 100644 index 000000000..0e8d8798d --- /dev/null +++ b/tests/contracts/working-set-recall.test.ts @@ -0,0 +1,279 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; +import { buildRecallFields, recallErrorMessage, resolveRecall } from "../../src/domains/context/working-set/recall.js"; +import type { MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; + +let clock = 0; +function stamp(): string { + clock += 1; + return new Date(1_700_000_000_000 + clock * 1000).toISOString(); +} + +function user(turnId: string, parentTurnId: string | null, text = turnId): SessionEntry { + return { kind: "message", turnId, parentTurnId, timestamp: stamp(), role: "user", payload: { text } }; +} + +function assistant(turnId: string, parentTurnId: string): SessionEntry { + return { + kind: "message", + turnId, + parentTurnId, + timestamp: stamp(), + role: "assistant", + payload: { + content: [ + { type: "thinking", thinking: "private" }, + { type: "text", text: "ok" }, + ], + }, + }; +} + +/** The shape `turn-persistence.ts` writes for a tool_result turn. */ +function toolResult( + turnId: string, + parentTurnId: string, + text: string, + details?: Record, +): MessageEntry { + return { + kind: "message", + turnId, + parentTurnId, + timestamp: stamp(), + role: "tool_result", + payload: { + toolCallId: `call-${turnId}`, + toolName: "read", + result: { + content: [{ type: "text", text }], + details: { resultSize: { bytes: text.length, shownBytes: text.length, truncated: false }, ...(details ?? {}) }, + }, + isError: false, + resultSummary: { bytes: text.length, truncated: false }, + }, + }; +} + +function eviction(turnId: string, parentTurnId: string, refs: string[]): SessionEntry { + return { + kind: "contextEviction", + turnId, + parentTurnId, + timestamp: stamp(), + policyId: "age-horizon", + trigger: "pressure", + evicted: refs.map((entry) => ({ + ref: { entry }, + reason: "age_horizon", + tokensFreed: 100, + marker: `[evicted ref=${entry}]`, + })), + tokensBefore: 1000, + tokensAfter: 900, + pressureBefore: 0.85, + snapshotIdBefore: null, + }; +} + +// Multi-line, trailing whitespace, tabs, non-ASCII, and a CRLF: everything a +// lossy reader would normalize away. +const BODY = "line one \n\tindented line two\r\nüñîçødé — 日本語\n\n \nend without newline"; + +function fixture(): SessionEntry[] { + return [ + user("u1", null), + assistant("a1", "u1"), + toolResult("t1", "a1", BODY), + toolResult("t2", "t1", "second body"), + user("u2", "t2"), + eviction("e1", "u2", ["t1", "t2"]), + ]; +} + +test("recall: round-trips the original tool_result body byte-exact", () => { + const entries = fixture(); + const view = foldWorkingSet(entries); + const outcome = resolveRecall(entries, view, "t1"); + assert.ok(outcome.ok); + assert.equal(outcome.result.body, BODY); + assert.equal(Buffer.from(outcome.result.body, "utf8").equals(Buffer.from(BODY, "utf8")), true); + assert.equal(outcome.result.entry.turnId, "t1"); + assert.deepEqual(outcome.result.ref, { entry: "t1" }); + assert.equal(outcome.result.tokens, Math.ceil(BODY.length / 4)); + assert.equal(outcome.result.offloadPath, undefined); +}); + +test("recall: buildRecallFields carries trigger, tokens, and the tool call id", () => { + const entries = fixture(); + const outcome = resolveRecall(entries, foldWorkingSet(entries), "t2"); + assert.ok(outcome.ok); + assert.deepEqual(buildRecallFields(outcome.result, { trigger: "tool", toolCallId: "call-9" }), { + kind: "contextRecall", + ref: { entry: "t2" }, + trigger: "tool", + tokensReadmitted: Math.ceil("second body".length / 4), + toolCallId: "call-9", + }); + const operator = buildRecallFields(outcome.result, { trigger: "operator" }); + assert.equal(operator.trigger, "operator"); + assert.equal("toolCallId" in operator, false); +}); + +test("recall: a recalled ref folds out of the view and the body can be appended back", () => { + const entries = fixture(); + const outcome = resolveRecall(entries, foldWorkingSet(entries), "t1"); + assert.ok(outcome.ok); + const fields = buildRecallFields(outcome.result, { trigger: "tool", toolCallId: "c" }); + const next: SessionEntry[] = [...entries, { ...fields, turnId: "r1", parentTurnId: "u2", timestamp: stamp() }]; + const view = foldWorkingSet(next); + assert.deepEqual([...view.evicted.keys()], ["t2"]); + assert.equal(view.recalls, 1); + const again = resolveRecall(next, view, "t1"); + assert.ok(!again.ok); + assert.equal(again.error.kind, "not_evicted"); +}); + +test("recall: invalid refs", () => { + const entries = fixture(); + const view = foldWorkingSet(entries); + for (const bad of ["", " ", "t1 t2", "t\n1"]) { + const outcome = resolveRecall(entries, view, bad); + assert.ok(!outcome.ok); + assert.equal(outcome.error.kind, "invalid_ref"); + assert.equal(outcome.error.ref, bad); + } + const outcome = resolveRecall(entries, view, "t1 t2"); + assert.ok(!outcome.ok); + assert.match(recallErrorMessage(outcome.error), /single turnId/); +}); + +test("recall: not_evicted names the nearest evicted ref by longest common prefix", () => { + const entries = [ + user("u1", null), + toolResult("turn-a1", "u1", "a"), + toolResult("turn-a2", "turn-a1", "b"), + toolResult("turn-b1", "turn-a2", "c"), + eviction("e1", "turn-b1", ["turn-a1", "turn-a2"]), + ]; + const view = foldWorkingSet(entries); + const outcome = resolveRecall(entries, view, "turn-b1"); + assert.ok(!outcome.ok); + assert.equal(outcome.error.kind, "not_evicted"); + // "turn-b1" shares "turn-" (5) with both; the tie keeps fold order. + assert.deepEqual(outcome.error, { kind: "not_evicted", ref: "turn-b1", nearest: "turn-a1" }); + assert.match(recallErrorMessage(outcome.error, entries), /not evicted.*Nearest evicted ref: turn-a1/); + + // A closer prefix wins over an earlier one. + const closer = resolveRecall(entries, view, "turn-a2x"); + assert.ok(!closer.ok); + assert.equal(closer.error.kind, "not_on_active_path"); + assert.equal(closer.error.nearest, "turn-a2"); +}); + +test("recall: not_on_active_path for an unknown ref, nearest null when nothing shares a prefix", () => { + const entries = fixture(); + const view = foldWorkingSet(entries); + const outcome = resolveRecall(entries, view, "zzz"); + assert.ok(!outcome.ok); + assert.deepEqual(outcome.error, { kind: "not_on_active_path", ref: "zzz", nearest: null }); + assert.match(recallErrorMessage(outcome.error), /not on the active path/); + assert.doesNotMatch(recallErrorMessage(outcome.error), /Nearest/); +}); + +test("recall: a ref on an abandoned branch is not_on_active_path after a fork", () => { + // u1 -> t1 (evicted, then abandoned) ; u1 -> t1b (live branch) -> e2 evicts t1b. + const entries: SessionEntry[] = [ + user("u1", null), + toolResult("t1", "u1", "abandoned body"), + eviction("e1", "t1", ["t1"]), + toolResult("t1b", "u1", "live body"), + user("u2", "t1b"), + eviction("e2", "u2", ["t1b"]), + ]; + const view = foldWorkingSet(entries, "u2"); + assert.deepEqual([...view.evicted.keys()], ["t1b"]); + const abandoned = resolveRecall(entries, view, "t1", "u2"); + assert.ok(!abandoned.ok); + assert.deepEqual(abandoned.error, { kind: "not_on_active_path", ref: "t1", nearest: "t1b" }); + const live = resolveRecall(entries, view, "t1b", "u2"); + assert.ok(live.ok); + assert.equal(live.result.body, "live body"); + + // Pinning the leaf back onto the abandoned branch flips both answers. + const other = foldWorkingSet(entries, "t1"); + const nowLive = resolveRecall(entries, other, "t1", "t1"); + assert.ok(nowLive.ok); + assert.equal(nowLive.result.body, "abandoned body"); + const nowAbandoned = resolveRecall(entries, other, "t1b", "t1"); + assert.ok(!nowAbandoned.ok); + assert.equal(nowAbandoned.error.kind, "not_on_active_path"); +}); + +test("recall: an offloaded result returns the pointer path, never the file", () => { + const shown = "first 16KB of output…\n\n[read: 400/1200 lines shown | full: /state/scratch/s/c.txt]"; + const entries: SessionEntry[] = [ + user("u1", null), + toolResult("t1", "u1", shown, { + resultSize: { bytes: 99_999, shownBytes: shown.length, truncated: true, offloadPath: "/state/scratch/s/c.txt" }, + }), + toolResult("t2", "t1", "obs", { + observation: { tool: "grep", truncated: true, offloadPath: "/state/scratch/s/d.txt" }, + }), + user("u2", "t2"), + eviction("e1", "u2", ["t1", "t2"]), + ]; + const view = foldWorkingSet(entries); + const fromResultSize = resolveRecall(entries, view, "t1"); + assert.ok(fromResultSize.ok); + assert.equal(fromResultSize.result.offloadPath, "/state/scratch/s/c.txt"); + assert.equal(fromResultSize.result.body, shown); + const fromObservation = resolveRecall(entries, view, "t2"); + assert.ok(fromObservation.ok); + assert.equal(fromObservation.result.offloadPath, "/state/scratch/s/d.txt"); +}); + +test("recall: an assistant turn is refused with the thinking message", () => { + const entries = [ + user("u1", null), + assistant("a1", "u1"), + toolResult("t1", "a1", "x"), + eviction("e1", "t1", ["a1", "t1"]), + ]; + const view = foldWorkingSet(entries); + const outcome = resolveRecall(entries, view, "a1"); + assert.ok(!outcome.ok); + assert.equal(outcome.error.kind, "not_evicted"); + assert.match(recallErrorMessage(outcome.error, entries), /thinking is not recallable/); +}); + +test("recall: legacy payload shapes read the same fields resultText reads", () => { + const entries: SessionEntry[] = [ + user("u1", null), + { + kind: "message", + turnId: "t1", + parentTurnId: "u1", + timestamp: stamp(), + role: "tool_result", + payload: { toolName: "bash", output: "plain output string" }, + }, + { + kind: "message", + turnId: "t2", + parentTurnId: "t1", + timestamp: stamp(), + role: "tool_result", + payload: { result: { text: "text field" } }, + }, + eviction("e1", "t2", ["t1", "t2"]), + ]; + const view = foldWorkingSet(entries); + const a = resolveRecall(entries, view, "t1"); + assert.ok(a.ok); + assert.equal(a.result.body, "plain output string"); + const b = resolveRecall(entries, view, "t2"); + assert.ok(b.ok); + assert.equal(b.result.body, "text field"); +}); From c302ef88254b68b682010470ec82e1bbee725fdc Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:05:04 -0500 Subject: [PATCH 04/45] fix(context): recall keeps the ref evicted in the fold The recalled body is returned in the recall tool result at the tail of the working set. Readmitting it at the original position would duplicate the bytes and cold the prefix cache for every later message; the marker stays byte-stable and repeated recalls of one ref are the churn signal. --- src/domains/context/working-set/contract.ts | 6 ++++-- src/domains/context/working-set/fold.ts | 11 +++++++---- tests/contracts/working-set-fold.test.ts | 4 ++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/domains/context/working-set/contract.ts b/src/domains/context/working-set/contract.ts index ca4d89e85..2127dfd76 100644 --- a/src/domains/context/working-set/contract.ts +++ b/src/domains/context/working-set/contract.ts @@ -56,8 +56,10 @@ export interface EvictedState { /** * The fold of every `contextEviction` / `contextRecall` entry on the active - * path. A recall removes its key; a later eviction of the same key re-adds it - * and counts as churn. + * path. A recall does not remove its key: the recalled body lives in the + * recall tool result at the tail of the working set, the marker stays at the + * original position so the prefix cache is untouched, and repeated recalls of + * one ref are the churn signal. */ export interface WorkingSetView { evicted: ReadonlyMap; diff --git a/src/domains/context/working-set/fold.ts b/src/domains/context/working-set/fold.ts index 74f298464..242c0572a 100644 --- a/src/domains/context/working-set/fold.ts +++ b/src/domains/context/working-set/fold.ts @@ -50,10 +50,13 @@ export function foldWorkingSet(entries: ReadonlyArray, activeLeafT } continue; } - if (entry.kind === "contextRecall") { - recalls += 1; - evicted.delete(refKey(entry.ref)); - } + // A recall does not un-evict. The recalled body rides the recall tool + // result at the tail of the working set, which is where the model asked + // for it and where it costs no cold prefix; readmitting it at the + // original position would duplicate the bytes and invalidate the cache + // for everything after it. The marker stays, byte-stable, and a second + // recall of the same ref is the churn signal. + if (entry.kind === "contextRecall") recalls += 1; } return { evicted, evictionEvents, itemsEvicted, recalls, lastPolicyId, lastEvictionTurnId }; } diff --git a/tests/contracts/working-set-fold.test.ts b/tests/contracts/working-set-fold.test.ts index d65ad297a..47c0a3ed4 100644 --- a/tests/contracts/working-set-fold.test.ts +++ b/tests/contracts/working-set-fold.test.ts @@ -50,7 +50,7 @@ function recall(turnId: string, parentTurnId: string, ref: string): SessionEntry }; } -test("fold: eviction then recall removes the key and counts churn", () => { +test("fold: a recall counts churn and leaves the key evicted", () => { const entries: SessionEntry[] = [ message("u1", null, "user"), message("a1", "u1", "assistant"), @@ -60,7 +60,7 @@ test("fold: eviction then recall removes the key and counts churn", () => { recall("r1", "t2", "t1"), ]; const view = foldWorkingSet(entries); - assert.deepEqual([...view.evicted.keys()], ["t2"]); + assert.deepEqual([...view.evicted.keys()], ["t1", "t2"]); assert.equal(view.evicted.get("t2")?.evictedAtTurnId, "e1"); assert.equal(view.evictionEvents, 1); assert.equal(view.itemsEvicted, 2); From 9031073c2f928557c62b305a343f0bf904c166cf Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:07:13 -0500 Subject: [PATCH 05/45] test(context): align recall tests with keep-evicted fold semantics --- src/domains/context/working-set/recall.ts | 5 ++++- src/tools/context/index.ts | 3 ++- tests/contracts/context-overlay-working-set.test.ts | 4 ++-- tests/contracts/context-tool-recall.test.ts | 6 ++---- tests/contracts/working-set-recall.test.ts | 8 ++++---- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/domains/context/working-set/recall.ts b/src/domains/context/working-set/recall.ts index 1364ee0d7..2a189a0bd 100644 --- a/src/domains/context/working-set/recall.ts +++ b/src/domains/context/working-set/recall.ts @@ -5,7 +5,10 @@ * and leaves a marker naming the ref. Recall is the reverse move: given a ref * on the active path whose key the fold still lists as evicted, hand back the * original body byte-exact and describe the `contextRecall` entry the caller - * appends so the next fold readmits it. Pure over entries: nothing here reads + * appends. The ref stays evicted in the fold: the body rides the recall tool + * result at the tail of the working set, so the marker and the prefix cache + * are untouched and a repeat recall is the churn signal. Pure over entries: + * nothing here reads * the session, writes the ledger, or calls a model. * * The body is read the way `compaction/mask-observations.ts` reads a diff --git a/src/tools/context/index.ts b/src/tools/context/index.ts index 62a0c78b2..9f55a0c0b 100644 --- a/src/tools/context/index.ts +++ b/src/tools/context/index.ts @@ -506,7 +506,8 @@ function runSkillsScope( * pointer. A body whose original result was itself offloaded already ends in * that tool's own `full: ` pointer, which is what the model gets back; * the file is never inlined. The `contextRecall` entry is appended before the - * result returns so the next fold readmits the ref. + * result returns; it is the churn record, not an un-eviction, so the marker + * and the prefix cache stay where they are. */ function runRecallScope( deps: ContextToolDeps, diff --git a/tests/contracts/context-overlay-working-set.test.ts b/tests/contracts/context-overlay-working-set.test.ts index abe634c7c..ff4e59ab4 100644 --- a/tests/contracts/context-overlay-working-set.test.ts +++ b/tests/contracts/context-overlay-working-set.test.ts @@ -82,8 +82,8 @@ describe("context overlay working-set section", () => { it("renders policy, evicted items and tokens, events, recalls, and churn", () => { const text = strip(renderContextLedgerLines(ledger(), 68, view()).join("\n")); ok(text.includes("working set · policy age-horizon"), text); - ok(text.includes("2 evicted items · 1,000 tokens · 1 event · 1 recall · churn 0.33"), text); - ok(text.includes("evicted (outside window) 1,000 tokens"), text); + ok(text.includes("3 evicted items · 1,500 tokens · 1 event · 1 recall · churn 0.33"), text); + ok(text.includes("evicted (outside window) 1,500 tokens"), text); }); it("evicted tokens are one line after the legend, not a meter category", () => { diff --git a/tests/contracts/context-tool-recall.test.ts b/tests/contracts/context-tool-recall.test.ts index d3f478645..fefdcb135 100644 --- a/tests/contracts/context-tool-recall.test.ts +++ b/tests/contracts/context-tool-recall.test.ts @@ -107,11 +107,9 @@ describe("contracts/context recall scope", () => { assert.equal(appended.parentTurnId, "u2"); assert.equal(details.recall.recallTurnId, appended.turnId); - // The second call sees the fold with the recall applied. + // The ref stays evicted after a recall; a second recall is churn, not an error. const again = await tool.run({ scope: "recall", ref: "t1" }, { toolCallId: "call-2" }); - assert.equal(again.kind, "error"); - if (again.kind !== "error") return; - assert.match(again.message, /^context: ref t1 is not evicted/); + assert.equal(again.kind, "ok"); }); it("errors name the nearest valid ref", async () => { diff --git a/tests/contracts/working-set-recall.test.ts b/tests/contracts/working-set-recall.test.ts index 0e8d8798d..8c6be8e31 100644 --- a/tests/contracts/working-set-recall.test.ts +++ b/tests/contracts/working-set-recall.test.ts @@ -121,18 +121,18 @@ test("recall: buildRecallFields carries trigger, tokens, and the tool call id", assert.equal("toolCallId" in operator, false); }); -test("recall: a recalled ref folds out of the view and the body can be appended back", () => { +test("recall: a recalled ref stays evicted; a second recall succeeds and counts as churn", () => { const entries = fixture(); const outcome = resolveRecall(entries, foldWorkingSet(entries), "t1"); assert.ok(outcome.ok); const fields = buildRecallFields(outcome.result, { trigger: "tool", toolCallId: "c" }); const next: SessionEntry[] = [...entries, { ...fields, turnId: "r1", parentTurnId: "u2", timestamp: stamp() }]; const view = foldWorkingSet(next); - assert.deepEqual([...view.evicted.keys()], ["t2"]); + assert.deepEqual([...view.evicted.keys()], ["t1", "t2"]); assert.equal(view.recalls, 1); const again = resolveRecall(next, view, "t1"); - assert.ok(!again.ok); - assert.equal(again.error.kind, "not_evicted"); + assert.ok(again.ok); + assert.equal(again.result.body, outcome.result.body); }); test("recall: invalid refs", () => { From 49e271db6f34ee248c9768550cc03c5d73854008 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:13:58 -0500 Subject: [PATCH 06/45] feat(context): project working-set evictions instead of rewriting the ledger Slice 1 core: the projection, the marker, the age-horizon policy, and the planner that turns a selection into a ledger entry. marker.ts renders one byte-stable line per evicted body (no timestamp, no counter) naming the ref, reason, tool, size, and the exact recall call. project.ts applies a folded view to a ledger slice: tool-result bodies become their marker with pairing and details intact, thinking blocks leave closed turns, and usage recorded before the event stops anchoring the estimate. Entries the view does not name come back by reference. policies/age-horizon.ts reproduces maskStaleObservations' selection exactly (same turn-start definition, same cutoff, same skips) so slice 1 changes one thing: the ledger stops being rewritten. engine.ts prices each candidate against the projection the model will receive. Session format bumps to 4 and runMigrations now refuses a session written by a newer Clio instead of silently dropping kinds it cannot read. The transcript tags evicted tool rows with their reason and still renders the full body: it shows the ledger, never the projection. --- docs/session-lifecycle.md | 4 +- src/domains/context/working-set/engine.ts | 167 ++++++++++++ src/domains/context/working-set/marker.ts | 71 +++++ src/domains/context/working-set/payload.ts | 146 +++++++++++ .../working-set/policies/age-horizon.ts | 81 ++++++ .../context/working-set/policies/index.ts | 17 ++ src/domains/context/working-set/project.ts | 119 +++++++++ src/domains/session/migrations/index.ts | 13 +- src/engine/session.ts | 12 +- src/interactive/chat-panel.ts | 15 ++ src/interactive/chat-renderer.ts | 9 + src/interactive/renderers/tool-execution.ts | 9 + tests/contracts/session-boundary.test.ts | 33 ++- .../contracts/working-set-age-horizon.test.ts | 243 ++++++++++++++++++ .../contracts/working-set-entry-kinds.test.ts | 68 +++++ tests/contracts/working-set-marker.test.ts | 92 +++++++ tests/contracts/working-set-project.test.ts | 191 ++++++++++++++ .../contracts/working-set-replay-tag.test.ts | 112 ++++++++ 18 files changed, 1392 insertions(+), 10 deletions(-) create mode 100644 src/domains/context/working-set/engine.ts create mode 100644 src/domains/context/working-set/marker.ts create mode 100644 src/domains/context/working-set/payload.ts create mode 100644 src/domains/context/working-set/policies/age-horizon.ts create mode 100644 src/domains/context/working-set/policies/index.ts create mode 100644 src/domains/context/working-set/project.ts create mode 100644 tests/contracts/working-set-age-horizon.test.ts create mode 100644 tests/contracts/working-set-entry-kinds.test.ts create mode 100644 tests/contracts/working-set-marker.test.ts create mode 100644 tests/contracts/working-set-project.test.ts create mode 100644 tests/contracts/working-set-replay-tag.test.ts diff --git a/docs/session-lifecycle.md b/docs/session-lifecycle.md index ca8b00482..3aa216cb0 100644 --- a/docs/session-lifecycle.md +++ b/docs/session-lifecycle.md @@ -39,11 +39,11 @@ export interface ClioSessionMeta { piMonoVersion: string; platform: string; nodeVersion: string; - sessionFormatVersion?: number; // CURRENT_SESSION_FORMAT_VERSION = 3 + sessionFormatVersion?: number; // CURRENT_SESSION_FORMAT_VERSION = 4 } ``` -Format version `CURRENT_SESSION_FORMAT_VERSION = 3` (`src/engine/session.ts:66`) is stamped on all sessions created in `v0.3.3`. Sessions with missing or earlier format versions trigger schema migrations in `src/domains/session/migrations/` on `/resume`. +Format version `CURRENT_SESSION_FORMAT_VERSION = 4` (`src/engine/session.ts`) is stamped on all sessions created since the working-set layer landed. Version 4 adds the `contextEviction` and `contextRecall` ledger kinds. `runMigrations` in `src/domains/session/migrations/` rejects both directions on `/resume`: a missing or earlier version names the remedy (remove the session directory), and a version from the future says the session was written by a newer Clio and must not be read by this build. --- diff --git a/src/domains/context/working-set/engine.ts b/src/domains/context/working-set/engine.ts new file mode 100644 index 000000000..eda2ea1a9 --- /dev/null +++ b/src/domains/context/working-set/engine.ts @@ -0,0 +1,167 @@ +/** + * Turn a policy's selection into something the session can append. + * + * `planEviction` is the whole decision: it asks the policy what should leave, + * materializes each candidate into an `EvictedItem` (marker rendered, tokens + * measured), and prices the result against the projection the model will + * actually receive. It writes nothing and calls no model, so the live engine + * and the replay-lite runner drive it identically. + * + * `buildEvictionFields` is the boring half: the plan plus the trigger facts, + * shaped as the ledger entry minus the three fields the session owns + * (`turnId`, `parentTurnId`, `timestamp`). + */ + +import type { EvictedItem, SessionEntry } from "../../session/entries.js"; +import type { + ContextEvictionFields, + EvictedState, + EvictionCandidate, + EvictionPlan, + EvictionTrigger, + PolicyInput, + WorkingSetPolicy, + WorkingSetView, +} from "./contract.js"; +import { refKey } from "./fold.js"; +import { renderMarker } from "./marker.js"; +import { hasThinking, offloadPathOf, primaryPathOf, toolResultPayload, toolResultText } from "./payload.js"; +import { projectWorkingSet } from "./project.js"; + +/** + * The event has no turnId until `session.appendEntry` gives it one, and the + * projection reads only `reason` and `marker`, so plan-time states carry this + * placeholder rather than a fabricated id. + */ +const PENDING_EVENT_TURN_ID = ""; + +/** + * The stub that replaces this unit's body, or null when there is nothing to + * evict. Thinking eviction renders no marker at all: the reasoning simply + * stops being replayed. + */ +function markerFor(entry: SessionEntry, candidate: EvictionCandidate): string | null { + if (entry.kind !== "message") return null; + if (entry.role === "assistant") return hasThinking(entry.payload) ? "" : null; + if (entry.role !== "tool_result") return null; + const payload = toolResultPayload(entry.payload); + return renderMarker({ + ref: candidate.ref, + reason: candidate.reason, + by: candidate.by, + toolName: payload.toolName, + text: toolResultText(payload.result), + offloadPath: offloadPathOf(payload), + path: primaryPathOf(payload), + }); +} + +function pendingState(candidate: EvictionCandidate, marker: string, policyId: string): EvictedState { + return { + reason: candidate.reason, + marker, + ...(candidate.by === undefined ? {} : { by: candidate.by }), + tokensFreed: 0, + evictedAtTurnId: PENDING_EVENT_TURN_ID, + policyId, + }; +} + +/** A view holding exactly one item, for pricing that item on its own. */ +function soloView(key: string, state: EvictedState): WorkingSetView { + return { + evicted: new Map([[key, state]]), + // Zero events: pricing one body must not also stamp usage invalidation, + // which would put the cost of a different mechanism in this item's total. + evictionEvents: 0, + itemsEvicted: 1, + recalls: 0, + lastPolicyId: null, + lastEvictionTurnId: null, + }; +} + +/** + * The view this plan would produce. `evictionEvents` and `lastEvictionTurnId` + * stay where they were on purpose: both totals are then measured under the same + * usage-invalidation state, so `tokensBefore - tokensAfter` is exactly the + * bodies this event removes and nothing else. + */ +function viewWithItems(view: WorkingSetView, items: ReadonlyArray, policyId: string): WorkingSetView { + const evicted = new Map(view.evicted); + for (const item of items) { + evicted.set(refKey(item.ref), { + reason: item.reason, + marker: item.marker, + ...(item.by === undefined ? {} : { by: item.by }), + tokensFreed: item.tokensFreed, + evictedAtTurnId: PENDING_EVENT_TURN_ID, + policyId, + }); + } + return { ...view, evicted, itemsEvicted: view.itemsEvicted + items.length }; +} + +function sumTokens(entries: ReadonlyArray, estimate: (entry: SessionEntry) => number): number { + let total = 0; + for (const entry of entries) total += estimate(entry); + return total; +} + +export function planEviction(policy: WorkingSetPolicy, input: PolicyInput): EvictionPlan | null { + const candidates = policy.select(input); + if (candidates.length === 0) return null; + + const byTurnId = new Map(); + for (const entry of input.entries) byTurnId.set(entry.turnId, entry); + + const items: EvictedItem[] = []; + const claimed = new Set(); + for (const candidate of candidates) { + const key = refKey(candidate.ref); + // A policy is contractually forbidden from returning a unit that is + // already out, and a duplicate inside one selection would double-count + // the tokens it frees. Both are cheap to refuse here. + if (input.view.evicted.has(key) || claimed.has(key)) continue; + const entry = byTurnId.get(key); + if (entry === undefined) continue; + const marker = markerFor(entry, candidate); + if (marker === null) continue; + claimed.add(key); + const projected = projectWorkingSet([entry], soloView(key, pendingState(candidate, marker, policy.id)))[0] ?? entry; + items.push({ + ref: candidate.ref, + reason: candidate.reason, + tokensFreed: Math.max(0, input.estimateTokens(entry) - input.estimateTokens(projected)), + marker, + ...(candidate.by === undefined ? {} : { by: candidate.by }), + }); + } + if (items.length === 0) return null; + + return { + policyId: policy.id, + items, + tokensBefore: sumTokens(projectWorkingSet(input.entries, input.view), input.estimateTokens), + tokensAfter: sumTokens( + projectWorkingSet(input.entries, viewWithItems(input.view, items, policy.id)), + input.estimateTokens, + ), + }; +} + +export function buildEvictionFields( + plan: EvictionPlan, + meta: { trigger: EvictionTrigger; pressureBefore: number | null; snapshotIdBefore: string | null }, +): ContextEvictionFields { + return { + kind: "contextEviction", + policyId: plan.policyId, + trigger: meta.trigger, + evicted: plan.items, + tokensBefore: plan.tokensBefore, + tokensAfter: plan.tokensAfter, + pressureBefore: meta.pressureBefore, + snapshotIdBefore: meta.snapshotIdBefore, + }; +} diff --git a/src/domains/context/working-set/marker.ts b/src/domains/context/working-set/marker.ts new file mode 100644 index 000000000..c84c98aad --- /dev/null +++ b/src/domains/context/working-set/marker.ts @@ -0,0 +1,71 @@ +/** + * The one-line stub the projection renders in place of an evicted tool-result + * body. + * + * Byte-stable by construction: same input, same bytes, forever. No timestamp, + * no counter, no `Date.now()`. Two reasons. The marker is persisted inside the + * `contextEviction` entry and replayed by the projection on every request, so a + * marker that changed between renders would invalidate the prompt cache on a + * turn that evicted nothing new. And replay-lite reruns the same policy over + * recorded ledgers; a marker carrying wall-clock state would make two runs of + * the same trace disagree. + * + * The field order is fixed (ref, reason, by, tool, path, size, offload, + * recall, preview) so a diff between two markers is readable and so a model + * reading many of them sees the same shape every time. Undefined fields are + * omitted rather than rendered empty. `recall` spells out the exact tool call + * that brings the body back, which is the only affordance the model has once + * the body is gone. + */ + +import { formatSize } from "../../../engine/truncate.js"; +import type { EvictionReason, WorkingSetRef } from "./contract.js"; + +/** Characters of the original body the marker keeps as a preview. */ +const PREVIEW_LIMIT = 120; + +export interface MarkerInput { + ref: WorkingSetRef; + reason: EvictionReason; + /** Ref key of the entry that superseded or resolved this one, when the reason names one. */ + by?: string | undefined; + toolName: string; + /** The body leaving the working set; drives size and preview. */ + text: string; + /** Set when the original result was offloaded to scratch. Replaces the preview. */ + offloadPath?: string | undefined; + /** Primary file the result was about, when the payload names exactly one. */ + path?: string | undefined; +} + +function lineCount(text: string): number { + if (text.length === 0) return 0; + return text.split(/\r\n|\r|\n/).length; +} + +/** + * First `PREVIEW_LIMIT` characters with whitespace collapsed to single spaces + * and double quotes escaped, so the preview never breaks the quoted field or + * spills onto a second line. + */ +function preview(text: string): string { + return text.trim().replace(/\s+/g, " ").slice(0, PREVIEW_LIMIT).replace(/"/g, '\\"'); +} + +export function renderMarker(input: MarkerInput): string { + const ref = input.ref.entry; + const fields: string[] = [`ref=${ref}`, `reason=${input.reason}`]; + if (input.by !== undefined) fields.push(`by=${input.by}`); + fields.push(`tool=${input.toolName}`); + if (input.path !== undefined) fields.push(`path=${input.path}`); + fields.push(`size=${lineCount(input.text)} lines/${formatSize(Buffer.byteLength(input.text, "utf8"))}`); + if (input.offloadPath !== undefined) fields.push(`offload=${input.offloadPath}`); + fields.push(`recall=context(scope="recall", ref="${ref}")`); + // An offloaded body is one `read` away at a stable path; a preview of it + // would spend tokens repeating what the pointer already promises. + if (input.offloadPath === undefined) { + const head = preview(input.text); + if (head.length > 0) fields.push(`preview="${head}"`); + } + return `[evicted ${fields.join(" ")}]`; +} diff --git a/src/domains/context/working-set/payload.ts b/src/domains/context/working-set/payload.ts new file mode 100644 index 000000000..0d538c39e --- /dev/null +++ b/src/domains/context/working-set/payload.ts @@ -0,0 +1,146 @@ +/** + * Readers for the two message payload shapes the working set acts on: + * `tool_result` (whose body is replaced by a marker) and `assistant` (whose + * thinking blocks are dropped). + * + * These deliberately mirror the private helpers in + * `src/domains/session/compaction/mask-observations.ts`. That module is the + * destructive stage this layer replaces and it goes away once the legacy path + * is retired, so the working set carries its own copy rather than importing + * from a module scheduled for deletion. The shapes themselves are not this + * layer's invention: `turn-persistence.ts` writes + * `{ toolCallId, toolName, result, isError, resultSummary }`, and `result` is + * whatever the tool returned, usually `{ content: [...], details: {...} }`. + */ + +import type { SessionEntry } from "../../session/entries.js"; + +export function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function cloneEntry(entry: T): T { + return structuredClone(entry) as T; +} + +export interface ToolResultPayload { + /** The payload object itself, or a synthetic wrapper when the payload is a bare value. */ + obj: Record; + /** The tool's own result value, wherever the payload put it. */ + result: unknown; + toolName: string; +} + +export function toolResultPayload(payload: unknown): ToolResultPayload { + const obj = isRecord(payload) ? payload : { result: payload }; + const result = obj.result ?? obj.output ?? obj.out ?? obj.content ?? payload; + const toolName = + (typeof obj.toolName === "string" && obj.toolName) || + (typeof obj.name === "string" && obj.name) || + (typeof obj.tool === "string" && obj.tool) || + "tool"; + return { obj, result, toolName }; +} + +function textFromContent(content: unknown): string { + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (!isRecord(block)) continue; + if (block.type === "text" && typeof block.text === "string") parts.push(block.text); + } + return parts.join(""); +} + +function stringifyBody(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + try { + return JSON.stringify(value) ?? ""; + } catch { + return String(value); + } +} + +/** The displayed body of a tool result: content text first, then the legacy single-field shapes. */ +export function toolResultText(result: unknown): string { + if (typeof result === "string") return result; + if (!isRecord(result)) return stringifyBody(result); + const contentText = textFromContent(result.content); + if (contentText.length > 0) return contentText; + if (typeof result.text === "string") return result.text; + if (typeof result.output === "string") return result.output; + if (typeof result.message === "string") return result.message; + return stringifyBody(result); +} + +function nestedRecord(parent: Record | null, key: string): Record | null { + if (parent === null) return null; + const value = parent[key]; + return isRecord(value) ? value : null; +} + +function stringField(record: Record | null, key: string): string | undefined { + if (record === null) return undefined; + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Scratch path holding the complete output, when the tool spilled one. Checked + * in the same order `toolResultSummary` writes it: the OBSERVE envelope, then + * bash-style result shaping, then the persisted summary that mirrors both. + */ +export function offloadPathOf(payload: ToolResultPayload): string | undefined { + const details = nestedRecord(isRecord(payload.result) ? payload.result : null, "details"); + return ( + stringField(nestedRecord(details, "observation"), "offloadPath") ?? + stringField(nestedRecord(details, "resultSize"), "offloadPath") ?? + stringField(nestedRecord(payload.obj, "resultSummary"), "offloadPath") + ); +} + +/** + * The one file this result was about, when the tool named exactly one. + * `edit`, `write`, and `artifact` record `details.paths`; a result naming + * several is left without a path rather than picking one arbitrarily. The + * structural policy's path index (slice 2) replaces this with the call's own + * arguments. + */ +export function primaryPathOf(payload: ToolResultPayload): string | undefined { + const details = nestedRecord(isRecord(payload.result) ? payload.result : null, "details"); + const paths = details?.paths; + if (!Array.isArray(paths) || paths.length !== 1) return undefined; + const first = paths[0]; + return typeof first === "string" && first.length > 0 ? first : undefined; +} + +function isThinkingBlock(block: unknown): boolean { + return isRecord(block) && block.type === "thinking"; +} + +/** Both shapes the ledger holds reasoning in: `thinking` content blocks and the payload-level string. */ +export function hasThinking(payload: unknown): boolean { + const obj = isRecord(payload) ? payload : null; + if (obj === null) return false; + if (Array.isArray(obj.content) && obj.content.some(isThinkingBlock)) return true; + return typeof obj.thinking === "string" && obj.thinking.length > 0; +} + +export function withoutThinkingBlocks(content: unknown): unknown[] | undefined { + if (!Array.isArray(content)) return undefined; + return content.filter((block) => !isThinkingBlock(block)); +} + +/** + * True when an earlier destructive compaction run already replaced this body. + * Such a result has no body left to evict, and re-marking it would spend a + * marker on a marker. Mirrors `alreadyCompacted()` in mask-observations.ts. + */ +export function hasLegacyCompactionMarker(payload: unknown): boolean { + if (!isRecord(payload)) return false; + if (isRecord(payload.contextCompaction)) return true; + if (isRecord(nestedRecord(payload, "resultSummary")?.contextCompaction)) return true; + const result = payload.result ?? payload.output ?? payload.out; + return isRecord(nestedRecord(isRecord(result) ? result : null, "details")?.contextCompaction); +} diff --git a/src/domains/context/working-set/policies/age-horizon.ts b/src/domains/context/working-set/policies/age-horizon.ts new file mode 100644 index 000000000..49c7382f8 --- /dev/null +++ b/src/domains/context/working-set/policies/age-horizon.ts @@ -0,0 +1,81 @@ +/** + * `age-horizon`: today's selection, recorded instead of destroyed. + * + * The rule is exactly what `maskStaleObservations` applied before this layer + * existed. Every tool-result body older than the protected recent-turn horizon + * leaves the working set, and every assistant message older than the horizon + * loses its thinking blocks. Same turn-start definition, same cutoff, same + * skip conditions. The difference is that the bodies stay in the ledger and + * come back with `context(scope="recall", ref=...)`. + * + * It ships as the default so slice 1 changes one thing at a time: the ledger + * stops being rewritten, while what the model sees on the next request stays + * what it saw before. `structural-v1` replaces the age rule with typed + * structural ones once replay-lite shows it ahead on retention. + * + * Age is not a quality signal, which is the whole reason for slice 2: a file + * read twenty turns ago and never touched since is more useful than a + * directory listing from two turns ago. Nothing here scores candidates by size + * or recency beyond that ordering; the only token input is the + * `minEvictableTokens` floor, below which the marker costs more than the body. + */ + +import type { SessionEntry } from "../../../session/entries.js"; +import type { EvictionCandidate, PolicyInput, WorkingSetPolicy } from "../contract.js"; +import { hasLegacyCompactionMarker, hasThinking } from "../payload.js"; + +/** + * What starts a turn, in the sense the protection horizon counts. A local `!` + * bash execution and a branch summary both open a new stretch of work the same + * way an operator message does. + */ +function isTurnStart(entry: SessionEntry): boolean { + if (entry.kind === "bashExecution" || entry.kind === "branchSummary") return true; + return entry.kind === "message" && entry.role === "user"; +} + +/** + * Index of the first protected entry: walk back until `protectLastTurns` turn + * starts have been seen. Entries before it are candidates, entries from it on + * are the recent window nothing touches. + */ +function recentTurnCutoff(entries: ReadonlyArray, protectLastTurns: number): number { + const horizon = Math.max(1, Math.floor(protectLastTurns)); + let seen = 0; + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (!entry || !isTurnStart(entry)) continue; + seen += 1; + if (seen >= horizon) return i; + } + return 0; +} + +export const ageHorizonPolicy: WorkingSetPolicy = { + id: "age-horizon", + select(input: PolicyInput): ReadonlyArray { + const { entries, view, settings, estimateTokens } = input; + const cutoff = recentTurnCutoff(entries, settings.protectLastTurns); + const candidates: EvictionCandidate[] = []; + // Newest-safe-first: the entry closest to the protection horizon is the + // least likely to be re-read, and a caller that stops early has then + // evicted the oldest nothing and the newest something. + for (let i = cutoff - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (entry === undefined || entry.kind !== "message") continue; + if (view.evicted.has(entry.turnId)) continue; + if (entry.role === "tool_result") { + if (hasLegacyCompactionMarker(entry.payload)) continue; + if (estimateTokens(entry) < settings.minEvictableTokens) continue; + candidates.push({ ref: { entry: entry.turnId }, reason: "age_horizon" }); + continue; + } + // Thinking has no size floor: dropping it costs no marker, so even a + // short stretch of reasoning is free to remove. + if (entry.role === "assistant" && hasThinking(entry.payload)) { + candidates.push({ ref: { entry: entry.turnId }, reason: "thinking_turn_closed" }); + } + } + return candidates; + }, +}; diff --git a/src/domains/context/working-set/policies/index.ts b/src/domains/context/working-set/policies/index.ts new file mode 100644 index 000000000..adc6d63b7 --- /dev/null +++ b/src/domains/context/working-set/policies/index.ts @@ -0,0 +1,17 @@ +/** + * Policy registry. One id in, one pure policy out, so the live engine and the + * replay-lite runner resolve the same object from the same settings value and + * cannot drift into running different selections. + */ + +import type { WorkingSetPolicy, WorkingSetPolicyId } from "../contract.js"; +import { ageHorizonPolicy } from "./age-horizon.js"; + +export { ageHorizonPolicy }; + +export function resolveWorkingSetPolicy(id: WorkingSetPolicyId): WorkingSetPolicy { + if (id === "age-horizon") return ageHorizonPolicy; + throw new Error( + `working-set policy "${id}" is not implemented in this slice; set context.workingSet.policy to "age-horizon"`, + ); +} diff --git a/src/domains/context/working-set/project.ts b/src/domains/context/working-set/project.ts new file mode 100644 index 000000000..6ba5f05cb --- /dev/null +++ b/src/domains/context/working-set/project.ts @@ -0,0 +1,119 @@ +/** + * Apply a `WorkingSetView` to a ledger slice as an in-memory projection. + * + * This is the whole point of the layer: the ledger keeps every byte the tools + * produced, and the model sees a narrower view of it. Nothing here writes, and + * nothing here decides what leaves; `fold.ts` says what is out and this module + * renders that decision onto the entries the replay builder consumes. + * + * Pure and idempotent. Projecting an already-projected slice reproduces it + * byte for byte, because the marker comes from the ledger entry rather than + * from the body being replaced. Entries the view does not name are returned by + * reference, so a session with one eviction clones one entry instead of the + * whole history. + * + * Callers may pass raw ledger entries: only entries whose `turnId` is a key in + * `view.evicted` change, and the view was already narrowed to the active path + * by the fold, so an eviction recorded on an abandoned branch cannot reach a + * live one (issue #94). + */ + +import type { MessageEntry, SessionEntry } from "../../session/entries.js"; +import type { EvictedState, WorkingSetView } from "./contract.js"; +import { cloneEntry, hasThinking, isRecord, toolResultPayload, withoutThinkingBlocks } from "./payload.js"; + +/** + * Replace the observation body with its marker. Tool pairing (`toolCallId`, + * `toolName`) and `details` survive untouched, so replay still matches the + * result to its call and the renderer still knows what the call was; only the + * text the model reads changes. The `workingSet` stamp on `details` is how a + * reader tells a marker from a genuinely tiny tool result. + */ +function projectToolResult(entry: MessageEntry, state: EvictedState): MessageEntry { + const next = cloneEntry(entry); + const { obj, result } = toolResultPayload(next.payload); + const details = isRecord(result) && isRecord(result.details) ? result.details : {}; + next.payload = { + ...obj, + result: { + content: [{ type: "text", text: state.marker }], + details: { + ...details, + workingSet: { evicted: true, reason: state.reason, ref: entry.turnId }, + }, + }, + output: undefined, + out: undefined, + content: undefined, + }; + return next; +} + +/** + * Drop reasoning from a closed turn. No marker replaces it: thinking is + * model-internal, the Anthropic API discards it after every turn anyway, and a + * marker would spend tokens to say that something the model cannot act on is + * gone. Both persisted shapes go: `thinking` content blocks and the + * payload-level string the local engine adapters write. + */ +function projectAssistant(entry: MessageEntry): MessageEntry { + const obj = isRecord(entry.payload) ? entry.payload : null; + if (obj === null || !hasThinking(obj)) return entry; + const next = cloneEntry(entry); + const content = withoutThinkingBlocks(obj.content); + next.payload = { + ...obj, + ...(content !== undefined ? { content } : {}), + thinking: undefined, + }; + return next; +} + +/** + * Usage recorded before the projection existed described a longer prompt than + * the model will now receive. `calculateContextTokens` anchors on the newest + * assistant usage it trusts, so leaving those anchors in place would report the + * pre-eviction size forever and the pressure estimator would never see the + * space the eviction freed. Mirrors `invalidateUsage()` in + * mask-observations.ts, bounded to the entries that precede the event. + */ +function invalidateUsage(entry: SessionEntry): SessionEntry { + if (entry.kind !== "message" || entry.role !== "assistant") return entry; + const obj = isRecord(entry.payload) ? entry.payload : null; + if (obj === null || obj.contextUsageInvalidated === true) return entry; + const next = cloneEntry(entry); + next.payload = { ...obj, contextUsageInvalidated: true }; + return next; +} + +/** + * Index of the newest eviction event within this slice. An event the slice does + * not contain (a caller that truncated before it) is treated as later than + * everything here: every usage anchor in the slice predates the projection. + */ +function eventIndex(entries: ReadonlyArray, lastEvictionTurnId: string | null): number { + if (lastEvictionTurnId === null) return entries.length; + const index = entries.findIndex((entry) => entry.turnId === lastEvictionTurnId); + return index < 0 ? entries.length : index; +} + +export function projectWorkingSet(entries: ReadonlyArray, view: WorkingSetView): SessionEntry[] { + if (view.evicted.size === 0 && view.evictionEvents === 0) return [...entries]; + const cutoff = view.evictionEvents > 0 ? eventIndex(entries, view.lastEvictionTurnId) : -1; + const out: SessionEntry[] = []; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry === undefined) continue; + let next = entry; + if (entry.kind === "message") { + const state = view.evicted.get(entry.turnId); + if (state !== undefined) { + if (entry.role === "tool_result") next = projectToolResult(entry, state); + else if (entry.role === "assistant") next = projectAssistant(entry); + } + } + if (index < cutoff) next = invalidateUsage(next); + out.push(next); + } + return out; +} diff --git a/src/domains/session/migrations/index.ts b/src/domains/session/migrations/index.ts index 294e378ad..363120c4a 100644 --- a/src/domains/session/migrations/index.ts +++ b/src/domains/session/migrations/index.ts @@ -2,7 +2,11 @@ * Strict session-format version reader. * * Runs before every session resume. Earlier pre-1.0 formats are disposable - * local state and must not be transformed into the current format. + * local state and must not be transformed into the current format. A version + * from the future is refused for the opposite reason: the file is not + * disposable, it belongs to a newer Clio, and this build would silently drop + * whatever that build understood and this one does not. Downgrading and + * resuming would then write the truncated reading back over the original. */ import { CURRENT_SESSION_FORMAT_VERSION } from "../../../engine/session.js"; @@ -20,7 +24,12 @@ export function runMigrations(meta: SessionMeta, sessionPath: string): Migration const from = meta.sessionFormatVersion ?? 1; if (from < CURRENT_SESSION_FORMAT_VERSION) { throw new Error( - `session metadata has an unsupported format version (expected version 3, got ${from}): ${sessionPath}. Remove the session directory to start a new session.`, + `session metadata has an unsupported format version (expected version ${CURRENT_SESSION_FORMAT_VERSION}, got ${from}): ${sessionPath}. Remove the session directory to start a new session.`, + ); + } + if (from > CURRENT_SESSION_FORMAT_VERSION) { + throw new Error( + `session was written by a newer Clio (format version ${from}, this build reads version ${CURRENT_SESSION_FORMAT_VERSION}): ${sessionPath}. Upgrade clio-coder to resume this session.`, ); } return { migrated: false, from, to: from }; diff --git a/src/engine/session.ts b/src/engine/session.ts index 8384be75b..a05e9f2e7 100644 --- a/src/engine/session.ts +++ b/src/engine/session.ts @@ -57,13 +57,19 @@ export interface ClioSessionMeta { platform: string; nodeVersion: string; /** - * Version 3 on every new session. Readers reject earlier versions and - * sessions that omit this field. + * Version 4 on every new session. Readers reject earlier versions, sessions + * that omit this field, and versions from the future. */ sessionFormatVersion?: number; } -export const CURRENT_SESSION_FORMAT_VERSION = 3; +/** + * Version 4 adds the working-set ledger kinds (`contextEviction`, + * `contextRecall`). A version-3 reader would parse those entries as unknown + * kinds and replay a session as if nothing had been evicted, so the bump is + * what makes an older Clio refuse the file instead of misreading it. + */ +export const CURRENT_SESSION_FORMAT_VERSION = 4; export interface ClioSessionJsonlHeader { type: "session"; diff --git a/src/interactive/chat-panel.ts b/src/interactive/chat-panel.ts index 46277da4b..03d2fa7ee 100644 --- a/src/interactive/chat-panel.ts +++ b/src/interactive/chat-panel.ts @@ -202,6 +202,12 @@ type ToolSegment = { * was refused and leaves the operator no way to learn why. */ blockReason?: string | undefined; + /** + * Working-set eviction reason for a replayed result whose body the + * projection has replaced. Set only on rehydrate, from the folded ledger; + * a live call is never evicted while it is still being rendered. + */ + evictedReason?: string | undefined; /** View-only marker: historical calls render mutation diffs without live color. */ replayed?: true; }; @@ -884,6 +890,7 @@ function renderToolSegmentLines( resultSummary: seg.resultSummary, outcome: seg.settlement, blockReason: seg.blockReason, + evictedReason: seg.evictedReason, } : { toolCallId: seg.id, toolName: seg.name, args: seg.args, elapsedMs, phase }, width, @@ -916,6 +923,7 @@ function renderToolSegmentLines( resultSummary: seg.resultSummary, outcome: seg.settlement, blockReason: seg.blockReason, + evictedReason: seg.evictedReason, }, width, { unbounded: unboundedToolBodies, diffStyle: seg.replayed === true ? "plain" : "color" }, @@ -1936,7 +1944,14 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel { resultSummary?: unknown; outcome?: unknown; blockReason?: unknown; + evictedReason?: unknown; }; + // Replay-only: the rehydrator reads the working-set fold and tags + // the rows whose bodies the projection has replaced for the model. + tool.evictedReason = + typeof enriched.evictedReason === "string" && enriched.evictedReason.length > 0 + ? enriched.evictedReason + : undefined; // Settlement is that verdict, never an inference from result text. // The text of a tool result is the tool's own output: `node --test` // prints `cancelled 0` on every run and a linter can print diff --git a/src/interactive/chat-renderer.ts b/src/interactive/chat-renderer.ts index 6627905c6..8161ddfff 100644 --- a/src/interactive/chat-renderer.ts +++ b/src/interactive/chat-renderer.ts @@ -14,6 +14,7 @@ */ import { ToolNames } from "../core/tool-names.js"; +import { foldWorkingSet } from "../domains/context/working-set/fold.js"; import type { BashExecutionEntry, BranchSummaryEntry, @@ -1085,6 +1086,11 @@ export function rehydrateChatPanelFromTurns( const pendingToolIds: string[] = []; let runAssistantMessages: AgentMessage[] = []; const selected = selectReplayEntries(turns, options); + // The transcript shows the ledger, never the projection: an evicted result + // still renders its full body here, tagged with the reason it left the + // model's working set. Folded once over the same active path the replay + // uses, so a /tree switch cannot tag a row from an abandoned branch. + const workingSet = foldWorkingSet(turns, options.activeLeafTurnId ?? options.uptoTurnId); // One block per assignment, drawn where its first attempt started. Later // attempts of the same assignment fold into that block as `↻` rail lines, so // a failover replays as the one run it was rather than as two. @@ -1132,6 +1138,7 @@ export function rehydrateChatPanelFromTurns( } if (entry.role === "tool_result") { const result = extractToolResult(entry); + const evictedReason = workingSet.evicted.get(entry.turnId)?.reason; const fallbackId = result.id ?? pendingToolIds.pop() ?? null; if (fallbackId) { const pendingIndex = pendingToolIds.indexOf(fallbackId); @@ -1146,6 +1153,7 @@ export function rehydrateChatPanelFromTurns( ...(result.resultSummary !== undefined ? { resultSummary: result.resultSummary } : {}), ...(result.outcome !== undefined ? { outcome: result.outcome } : {}), ...(result.blockReason !== undefined ? { blockReason: result.blockReason } : {}), + ...(evictedReason !== undefined ? { evictedReason } : {}), } as ChatLoopEvent); } else { chatPanel.appendReplayBlock((width) => @@ -1159,6 +1167,7 @@ export function rehydrateChatPanelFromTurns( ...(result.resultSummary !== undefined ? { resultSummary: result.resultSummary } : {}), ...(result.outcome === "blocked" ? { outcome: "blocked" as const } : {}), ...(result.blockReason !== undefined ? { blockReason: result.blockReason } : {}), + ...(evictedReason !== undefined ? { evictedReason } : {}), }, width, { unbounded: options.unboundedToolBodies === true }, diff --git a/src/interactive/renderers/tool-execution.ts b/src/interactive/renderers/tool-execution.ts index 029c66313..7b5377273 100644 --- a/src/interactive/renderers/tool-execution.ts +++ b/src/interactive/renderers/tool-execution.ts @@ -81,6 +81,13 @@ export interface ToolExecutionFinished { * model reading the same transcript, to guess at the rule. */ blockReason?: string | undefined; + /** + * Working-set eviction reason, when the projection has replaced this + * result's body for the model. The transcript still renders the full body: + * the ledger is what the operator scrolls, the projection is only what the + * next request carries. The tag says the two now differ here. + */ + evictedReason?: string | undefined; /** Structured exit status when the caller has one; text parsing is legacy fallback only. */ exitCode?: number | string | null | undefined; /** Local `!!` bash output is visible to the operator but excluded from model context. */ @@ -456,6 +463,7 @@ function ledgerTail(finished: ToolExecutionFinished): { facts: string; offload: if (details?.outputCapped === true) parts.push("output capped"); } if (finished.excludeFromContext === true) parts.push("excluded from context"); + if (finished.evictedReason !== undefined) parts.push("evicted", finished.evictedReason); const offloadPath = executed ? offloadPathOf(finished) : null; return { facts: parts.length > 0 ? dim(` · ${parts.join(" · ")}`) : "", @@ -1041,6 +1049,7 @@ function outputFacts(finished: ToolExecutionFinished): string[] { } if (isPlainObject(finished.result) && finished.result.terminate === true) parts.push("terminal result"); if (finished.excludeFromContext === true) parts.push("excluded from context"); + if (finished.evictedReason !== undefined) parts.push("evicted", finished.evictedReason); return parts; } diff --git a/tests/contracts/session-boundary.test.ts b/tests/contracts/session-boundary.test.ts index abca027bc..0c0c1bb51 100644 --- a/tests/contracts/session-boundary.test.ts +++ b/tests/contracts/session-boundary.test.ts @@ -5,7 +5,13 @@ import { afterEach, beforeEach, describe, it } from "node:test"; import { clioStateDir } from "../../src/core/xdg.js"; import { listSessionsForCwd } from "../../src/domains/session/history.js"; import { resumeSessionState } from "../../src/domains/session/manager.js"; -import { createSession, openSession, resumeSession, sessionPaths } from "../../src/engine/session.js"; +import { + CURRENT_SESSION_FORMAT_VERSION, + createSession, + openSession, + resumeSession, + sessionPaths, +} from "../../src/engine/session.js"; import { clearScratchClioHome, newScratchClioHome } from "../harness/scratch-env.js"; // BUG-013: session ids are identifiers, not paths. findSessionDir joined a @@ -104,7 +110,7 @@ describe("contracts/session-boundary", () => { }); }); - for (const version of [1, 2] as const) { + for (const version of [1, 2, 3] as const) { it(`rejects session format version ${version} with an operator remedy`, async () => { const { meta, writer } = createSession({ cwd: scratch }); await writer.close(); @@ -112,8 +118,29 @@ describe("contracts/session-boundary", () => { writeFileSync(paths.meta, JSON.stringify({ ...meta, sessionFormatVersion: version })); throws(() => resumeSessionState(meta.id), { - message: `session metadata has an unsupported format version (expected version 3, got ${version}): ${paths.meta}. Remove the session directory to start a new session.`, + message: `session metadata has an unsupported format version (expected version ${CURRENT_SESSION_FORMAT_VERSION}, got ${version}): ${paths.meta}. Remove the session directory to start a new session.`, }); }); } + + // A newer Clio may have written kinds this build does not know. Reading the + // file anyway would drop them silently, and the next append would write that + // truncated reading back over the operator's session. + it("rejects a session written by a newer Clio instead of downgrading it", async () => { + const { meta, writer } = createSession({ cwd: scratch }); + await writer.close(); + const paths = sessionPaths(meta); + const newer = CURRENT_SESSION_FORMAT_VERSION + 1; + writeFileSync(paths.meta, JSON.stringify({ ...meta, sessionFormatVersion: newer })); + + throws(() => resumeSessionState(meta.id), { + message: `session was written by a newer Clio (format version ${newer}, this build reads version ${CURRENT_SESSION_FORMAT_VERSION}): ${paths.meta}. Upgrade clio-coder to resume this session.`, + }); + }); + + it("stamps the current format version on a new session", () => { + const { meta } = createSession({ cwd: scratch }); + strictEqual(meta.sessionFormatVersion, CURRENT_SESSION_FORMAT_VERSION); + strictEqual(CURRENT_SESSION_FORMAT_VERSION, 4); + }); }); diff --git a/tests/contracts/working-set-age-horizon.test.ts b/tests/contracts/working-set-age-horizon.test.ts new file mode 100644 index 000000000..69aa3591c --- /dev/null +++ b/tests/contracts/working-set-age-horizon.test.ts @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { PolicyInput, WorkingSetSettings } from "../../src/domains/context/working-set/contract.js"; +import { EMPTY_WORKING_SET_VIEW } from "../../src/domains/context/working-set/contract.js"; +import { DEFAULT_WORKING_SET_SETTINGS } from "../../src/domains/context/working-set/defaults.js"; +import { buildEvictionFields, planEviction } from "../../src/domains/context/working-set/engine.js"; +import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; +import { resolveWorkingSetPolicy } from "../../src/domains/context/working-set/policies/index.js"; +import { maskStaleObservations } from "../../src/domains/session/compaction/mask-observations.js"; +import { estimateTokens } from "../../src/domains/session/compaction/tokens.js"; +import type { MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; + +const PROTECT_LAST_TURNS = 3; +const TURNS = 12; + +function base( + turnId: string, + parentTurnId: string | null, +): { turnId: string; parentTurnId: string | null; timestamp: string } { + return { turnId, parentTurnId, timestamp: "2026-08-08T00:00:00.000Z" }; +} + +function body(turn: number): string { + if (turn === 3) return "ok"; + return `${`turn ${turn} observation line\n`.repeat(40)}tail ${turn}`; +} + +/** + * Twelve turns of user / assistant / tool_call / tool_result. Even turns carry + * thinking, turn 3 returns a result too small to be worth a marker, and turn 5 + * was already rewritten by the legacy destructive mask. + */ +function ledger(): SessionEntry[] { + const entries: SessionEntry[] = []; + let parent: string | null = null; + for (let turn = 1; turn <= TURNS; turn += 1) { + const user: MessageEntry = { + kind: "message", + ...base(`u${turn}`, parent), + role: "user", + payload: { text: `question ${turn}` }, + }; + entries.push(user); + const content: unknown[] = [{ type: "text", text: `answer ${turn}` }]; + if (turn % 2 === 0) content.unshift({ type: "thinking", thinking: `reasoning about turn ${turn}` }); + const assistant: MessageEntry = { + kind: "message", + ...base(`a${turn}`, `u${turn}`), + role: "assistant", + payload: { + content, + stopReason: "stop", + usage: { input: 1000 * turn, output: 10, cacheRead: 0, cacheWrite: 0, totalTokens: 1000 * turn }, + }, + }; + entries.push(assistant); + entries.push({ + kind: "message", + ...base(`c${turn}`, `a${turn}`), + role: "tool_call", + payload: { toolCallId: `call-${turn}`, name: "read", args: { path: `src/f${turn}.ts` } }, + }); + const text = body(turn); + entries.push({ + kind: "message", + ...base(`t${turn}`, `c${turn}`), + role: "tool_result", + payload: { + toolCallId: `call-${turn}`, + toolName: "read", + result: { content: [{ type: "text", text }], details: { paths: [`src/f${turn}.ts`] } }, + isError: false, + resultSummary: + turn === 5 + ? { bytes: 0, truncated: true, contextCompaction: { stage: "mask_observations" } } + : { bytes: text.length, truncated: false }, + }, + }); + parent = `t${turn}`; + } + return entries; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function resultText(entry: SessionEntry): string { + if (entry.kind !== "message") return ""; + const payload = isRecord(entry.payload) ? entry.payload : {}; + const result = isRecord(payload.result) ? payload.result : {}; + const content = Array.isArray(result.content) ? result.content : []; + return content.map((block) => (isRecord(block) && typeof block.text === "string" ? block.text : "")).join(""); +} + +function hasThinking(entry: SessionEntry): boolean { + if (entry.kind !== "message") return false; + const payload = isRecord(entry.payload) ? entry.payload : {}; + const content = Array.isArray(payload.content) ? payload.content : []; + return content.some((block) => isRecord(block) && block.type === "thinking"); +} + +/** + * Refs the destructive stage actually rewrote: a tool result whose body text + * changed, or an assistant that lost its thinking. Compared this way rather + * than by object identity because `maskStaleObservations` also stamps + * `contextUsageInvalidated` on assistant turns it did not otherwise touch. + */ +function maskedRefs(entries: ReadonlyArray, protectLastTurns: number): Set { + const masked = maskStaleObservations(entries, protectLastTurns).entries; + const refs = new Set(); + for (let i = 0; i < entries.length; i += 1) { + const before = entries[i]; + const after = masked[i]; + if (before === undefined || after === undefined || before.kind !== "message") continue; + if (before.role === "tool_result" && resultText(before) !== resultText(after)) refs.add(before.turnId); + if (before.role === "assistant" && hasThinking(before) && !hasThinking(after)) refs.add(before.turnId); + } + return refs; +} + +function policyInput(entries: ReadonlyArray, overrides: Partial = {}): PolicyInput { + return { + entries, + view: EMPTY_WORKING_SET_VIEW, + settings: { ...DEFAULT_WORKING_SET_SETTINGS, protectLastTurns: PROTECT_LAST_TURNS, ...overrides }, + pressure: { tokens: 90_000, contextWindow: 100_000, threshold: 0.8, target: 0.6 }, + estimateTokens, + }; +} + +const agePolicy = resolveWorkingSetPolicy("age-horizon"); + +test("age-horizon: selection is token-identical to the destructive mask", () => { + const entries = ledger(); + const selected = agePolicy.select(policyInput(entries, { minEvictableTokens: 0 })).map((c) => c.ref.entry); + assert.deepEqual(new Set(selected), maskedRefs(entries, PROTECT_LAST_TURNS)); + assert.equal(new Set(selected).size, selected.length, "no ref selected twice"); +}); + +test("age-horizon: reasons split tool results from closed thinking turns", () => { + const entries = ledger(); + const byRef = new Map(agePolicy.select(policyInput(entries, { minEvictableTokens: 0 })).map((c) => [c.ref.entry, c])); + assert.equal(byRef.get("t1")?.reason, "age_horizon"); + assert.equal(byRef.get("a2")?.reason, "thinking_turn_closed"); + // Odd turns carry no thinking, so there is nothing to evict on them. + assert.equal(byRef.has("a1"), false); + // A body the legacy stage already replaced is never re-marked. + assert.equal(byRef.has("t5"), false); + // Nothing inside the protected horizon: turns 10-12 are the recent window. + for (const turn of [10, 11, 12]) { + assert.equal(byRef.has(`t${turn}`), false); + assert.equal(byRef.has(`a${turn}`), false); + } +}); + +test("age-horizon: candidates arrive newest-safe-first", () => { + const entries = ledger(); + const selected = agePolicy.select(policyInput(entries, { minEvictableTokens: 0 })).map((c) => c.ref.entry); + // Turn 9 carries no thinking, so its assistant turn is not a candidate. + assert.deepEqual(selected.slice(0, 4), ["t9", "t8", "a8", "t7"]); + assert.equal(selected[selected.length - 1], "t1"); +}); + +test("age-horizon: the default floor keeps a result too small to be worth a marker", () => { + const entries = ledger(); + const selected = new Set(agePolicy.select(policyInput(entries)).map((c) => c.ref.entry)); + assert.equal(selected.has("t3"), false, "a two-byte result costs more as a marker than as a body"); + assert.equal(selected.has("t1"), true); + // Thinking has no floor: it is dropped without a marker, so it is free. + assert.equal(selected.has("a2"), true); + assert.equal(selected.size + 1, maskedRefs(entries, PROTECT_LAST_TURNS).size); +}); + +test("age-horizon: units already out of the working set are never re-selected", () => { + const entries = ledger(); + entries.push({ + kind: "contextEviction", + ...base("e1", "t12"), + policyId: "age-horizon", + trigger: "pressure", + evicted: [{ ref: { entry: "t1" }, reason: "age_horizon", tokensFreed: 100, marker: "[evicted ref=t1]" }], + tokensBefore: 1000, + tokensAfter: 900, + pressureBefore: 0.85, + snapshotIdBefore: null, + }); + const input = { ...policyInput(entries, { minEvictableTokens: 0 }), view: foldWorkingSet(entries) }; + assert.equal( + agePolicy.select(input).some((c) => c.ref.entry === "t1"), + false, + ); +}); + +test("age-horizon: an all-protected ledger selects nothing and plans nothing", () => { + const entries = ledger(); + const input = policyInput(entries, { protectLastTurns: 100 }); + assert.deepEqual(agePolicy.select(input), []); + assert.equal(planEviction(agePolicy, input), null); +}); + +test("policies: structural-v1 is not implemented in this slice", () => { + assert.throws(() => resolveWorkingSetPolicy("structural-v1"), /not implemented in this slice/); +}); + +test("planEviction: materializes markers, prices them, and shrinks the working set", () => { + const entries = ledger(); + const plan = planEviction(agePolicy, policyInput(entries)); + assert.ok(plan); + assert.equal(plan.policyId, "age-horizon"); + assert.ok(plan.tokensAfter < plan.tokensBefore); + + const toolItem = plan.items.find((item) => item.ref.entry === "t1"); + assert.ok(toolItem); + assert.equal(toolItem.reason, "age_horizon"); + assert.match(toolItem.marker, /^\[evicted ref=t1 reason=age_horizon tool=read path=src\/f1\.ts size=41 lines\//); + assert.ok(toolItem.tokensFreed > 0); + + const thinkingItem = plan.items.find((item) => item.ref.entry === "a2"); + assert.ok(thinkingItem); + assert.equal(thinkingItem.reason, "thinking_turn_closed"); + assert.equal(thinkingItem.marker, "", "thinking leaves without a marker"); + assert.ok(thinkingItem.tokensFreed > 0); + + // The freed totals agree with the projection the model will receive. + const freed = plan.items.reduce((sum, item) => sum + item.tokensFreed, 0); + assert.equal(plan.tokensBefore - plan.tokensAfter, freed); +}); + +test("buildEvictionFields: carries the plan plus the trigger facts", () => { + const entries = ledger(); + const plan = planEviction(agePolicy, policyInput(entries)); + assert.ok(plan); + const fields = buildEvictionFields(plan, { trigger: "pressure", pressureBefore: 0.87, snapshotIdBefore: "snap-1" }); + assert.equal(fields.kind, "contextEviction"); + assert.equal(fields.policyId, "age-horizon"); + assert.equal(fields.trigger, "pressure"); + assert.equal(fields.evicted, plan.items); + assert.equal(fields.tokensBefore, plan.tokensBefore); + assert.equal(fields.tokensAfter, plan.tokensAfter); + assert.equal(fields.pressureBefore, 0.87); + assert.equal(fields.snapshotIdBefore, "snap-1"); +}); diff --git a/tests/contracts/working-set-entry-kinds.test.ts b/tests/contracts/working-set-entry-kinds.test.ts new file mode 100644 index 000000000..b1d653df7 --- /dev/null +++ b/tests/contracts/working-set-entry-kinds.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { SESSION_ENTRY_KINDS } from "../../src/domains/session/entries.js"; + +/** + * Every reader that switches on `entry.kind`, named here so a new ledger kind + * cannot be added with a reader left behind. TypeScript's never-check catches + * the exhaustive switches at compile time; this test is the list itself, which + * is what a reviewer needs when adding a kind, plus the coverage of the two + * readers that use if-chains instead. + * + * Grep-based on purpose: the assertion is about source text naming the kind, + * which is exactly how `scripts/check-hygiene.ts` checks the same class of + * drift. + */ +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +/** Exhaustive `switch (entry.kind)` sites, with how many switches each file holds. */ +const SWITCH_SITES: ReadonlyArray<{ file: string; switches: number }> = [ + { file: "src/domains/session/compaction/tokens.ts", switches: 1 }, + { file: "src/domains/session/compaction/cut-point.ts", switches: 1 }, + // buildReplayAgentMessagesFromTurns and rehydrateChatPanelFromTurns. + { file: "src/interactive/chat-renderer.ts", switches: 2 }, +]; + +/** Readers that dispatch on `entry.kind` without an exhaustive switch. */ +const PREDICATE_SITES: ReadonlyArray = ["src/domains/evidence/build.ts"]; + +function read(file: string): string { + return readFileSync(join(ROOT, file), "utf8"); +} + +function count(source: string, needle: string): number { + return source.split(needle).length - 1; +} + +test("entry kinds: every switch site handles the working-set kinds", () => { + for (const site of SWITCH_SITES) { + const source = read(site.file); + assert.equal(count(source, 'case "contextEviction":'), site.switches, `${site.file} misses contextEviction`); + assert.equal(count(source, 'case "contextRecall":'), site.switches, `${site.file} misses contextRecall`); + } +}); + +test("entry kinds: every switch site names every ledger kind", () => { + for (const site of SWITCH_SITES) { + const source = read(site.file); + for (const kind of SESSION_ENTRY_KINDS) { + assert.equal(source.includes(`case "${kind}"`), true, `${site.file} never names ${kind}`); + } + } +}); + +test("entry kinds: the predicate readers handle the working-set kinds", () => { + for (const file of PREDICATE_SITES) { + const source = read(file); + assert.equal(source.includes('entry.kind === "contextEviction"'), true, `${file} misses contextEviction`); + assert.equal(source.includes('entry.kind === "contextRecall"'), true, `${file} misses contextRecall`); + } +}); + +test("entry kinds: the canonical list carries both working-set kinds", () => { + assert.equal(SESSION_ENTRY_KINDS.includes("contextEviction"), true); + assert.equal(SESSION_ENTRY_KINDS.includes("contextRecall"), true); +}); diff --git a/tests/contracts/working-set-marker.test.ts b/tests/contracts/working-set-marker.test.ts new file mode 100644 index 000000000..21924ed34 --- /dev/null +++ b/tests/contracts/working-set-marker.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { renderMarker } from "../../src/domains/context/working-set/marker.js"; + +const BODY = "line one\nline two"; + +test("marker: fixed field order and byte-stable output", () => { + const marker = renderMarker({ + ref: { entry: "01J8" }, + reason: "age_horizon", + toolName: "read", + text: BODY, + }); + assert.equal( + marker, + '[evicted ref=01J8 reason=age_horizon tool=read size=2 lines/17B recall=context(scope="recall", ref="01J8") preview="line one line two"]', + ); + // Same input, same bytes: the marker is persisted and replayed on every + // request, so a marker that drifted would cold-start the prompt cache. + assert.equal(marker, renderMarker({ ref: { entry: "01J8" }, reason: "age_horizon", toolName: "read", text: BODY })); +}); + +test("marker: one line, no timestamp, no counter", () => { + const marker = renderMarker({ + ref: { entry: "01J8" }, + reason: "age_horizon", + toolName: "bash", + text: `${"output line\n".repeat(400)}tail`, + }); + assert.equal(marker.split("\n").length, 1); + assert.match(marker, /^\[evicted .*\]$/); + assert.doesNotMatch(marker, /\d{4}-\d{2}-\d{2}T/); + assert.equal(marker.includes("size=401 lines/4.7KB"), true, marker); +}); + +test("marker: optional fields render in order and are omitted when absent", () => { + assert.equal( + renderMarker({ + ref: { entry: "01J8" }, + reason: "superseded_read", + by: "01JC", + toolName: "read", + text: BODY, + path: "src/a.ts", + }), + '[evicted ref=01J8 reason=superseded_read by=01JC tool=read path=src/a.ts size=2 lines/17B recall=context(scope="recall", ref="01J8") preview="line one line two"]', + ); + // `by` and `path` gone, everything else identical. + assert.equal( + renderMarker({ ref: { entry: "01J8" }, reason: "superseded_read", toolName: "read", text: BODY }), + '[evicted ref=01J8 reason=superseded_read tool=read size=2 lines/17B recall=context(scope="recall", ref="01J8") preview="line one line two"]', + ); +}); + +test("marker: an offloaded body carries the pointer instead of a preview", () => { + const marker = renderMarker({ + ref: { entry: "01J8" }, + reason: "age_horizon", + toolName: "bash", + text: BODY, + offloadPath: "/state/scratch/session-1/call-1.txt", + }); + assert.equal( + marker, + '[evicted ref=01J8 reason=age_horizon tool=bash size=2 lines/17B offload=/state/scratch/session-1/call-1.txt recall=context(scope="recall", ref="01J8")]', + ); + assert.equal(marker.includes("preview="), false); +}); + +test("marker: preview collapses whitespace, escapes quotes, and stops at 120 chars", () => { + const marker = renderMarker({ + ref: { entry: "01J8" }, + reason: "age_horizon", + toolName: "grep", + text: ' he said\t"hi"\n\n then left ', + }); + assert.equal(marker.includes('preview="he said \\"hi\\" then left"'), true, marker); + + const long = renderMarker({ + ref: { entry: "01J8" }, + reason: "age_horizon", + toolName: "grep", + text: "x".repeat(500), + }); + assert.equal(long.includes(`preview="${"x".repeat(120)}"`), true); + + // Nothing to preview leaves the field out rather than rendering `preview=""`. + assert.equal( + renderMarker({ ref: { entry: "01J8" }, reason: "age_horizon", toolName: "grep", text: " " }).includes("preview="), + false, + ); +}); diff --git a/tests/contracts/working-set-project.test.ts b/tests/contracts/working-set-project.test.ts new file mode 100644 index 000000000..e6c9a443e --- /dev/null +++ b/tests/contracts/working-set-project.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { EMPTY_WORKING_SET_VIEW } from "../../src/domains/context/working-set/contract.js"; +import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; +import { renderMarker } from "../../src/domains/context/working-set/marker.js"; +import { projectWorkingSet } from "../../src/domains/context/working-set/project.js"; +import type { MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; + +const BODY = `${"observation line\n".repeat(60)}final secret body`; + +const TOOL_MARKER = renderMarker({ + ref: { entry: "t1" }, + reason: "age_horizon", + toolName: "read", + text: BODY, +}); + +function base( + turnId: string, + parentTurnId: string | null, +): { turnId: string; parentTurnId: string | null; timestamp: string } { + return { turnId, parentTurnId, timestamp: `2026-08-08T00:00:${turnId.slice(-2).padStart(2, "0")}.000Z` }; +} + +function user(turnId: string, parentTurnId: string | null): MessageEntry { + return { kind: "message", ...base(turnId, parentTurnId), role: "user", payload: { text: `ask ${turnId}` } }; +} + +function assistant(turnId: string, parentTurnId: string, content: unknown[], usageTokens: number): MessageEntry { + return { + kind: "message", + ...base(turnId, parentTurnId), + role: "assistant", + payload: { + content, + thinking: "payload-level reasoning", + stopReason: "stop", + usage: { input: usageTokens, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: usageTokens }, + }, + }; +} + +function toolCall(turnId: string, parentTurnId: string): MessageEntry { + return { + kind: "message", + ...base(turnId, parentTurnId), + role: "tool_call", + payload: { toolCallId: "call-1", name: "read", args: { path: "src/huge.ts" } }, + }; +} + +function toolResult(turnId: string, parentTurnId: string): MessageEntry { + return { + kind: "message", + ...base(turnId, parentTurnId), + role: "tool_result", + payload: { + toolCallId: "call-1", + toolName: "read", + result: { content: [{ type: "text", text: BODY }], details: { paths: ["src/huge.ts"], kind: "file" } }, + isError: false, + resultSummary: { bytes: BODY.length, truncated: false }, + }, + }; +} + +function eviction(turnId: string, parentTurnId: string): SessionEntry { + return { + kind: "contextEviction", + ...base(turnId, parentTurnId), + policyId: "age-horizon", + trigger: "pressure", + evicted: [ + { ref: { entry: "t1" }, reason: "age_horizon", tokensFreed: 260, marker: TOOL_MARKER }, + { ref: { entry: "a1" }, reason: "thinking_turn_closed", tokensFreed: 40, marker: "" }, + ], + tokensBefore: 1000, + tokensAfter: 700, + pressureBefore: 0.86, + snapshotIdBefore: null, + }; +} + +/** u1 a1 c1 t1 u2 e1 a2: the event sits between the two assistant turns. */ +function ledger(): SessionEntry[] { + return [ + user("u1", null), + assistant( + "a1", + "u1", + [ + { type: "thinking", thinking: "long reasoning" }, + { type: "text", text: "reading it" }, + ], + 90_000, + ), + toolCall("c1", "a1"), + toolResult("t1", "c1"), + user("u2", "t1"), + eviction("e1", "u2"), + assistant("a2", "u2", [{ type: "text", text: "done" }], 12_000), + ]; +} + +function payloadOf(entry: SessionEntry | undefined): Record { + assert.ok(entry && entry.kind === "message"); + return entry.payload as Record; +} + +test("project: an evicted tool result keeps its pairing and carries the marker", () => { + const entries = ledger(); + const projected = projectWorkingSet(entries, foldWorkingSet(entries)); + const payload = payloadOf(projected[3]) as { + toolCallId?: string; + toolName?: string; + result?: { + content?: Array<{ text?: string }>; + details?: { paths?: unknown; kind?: unknown; workingSet?: unknown }; + }; + }; + assert.equal(payload.toolCallId, "call-1"); + assert.equal(payload.toolName, "read"); + assert.equal(payload.result?.content?.[0]?.text, TOOL_MARKER); + assert.deepEqual(payload.result?.details?.paths, ["src/huge.ts"]); + assert.equal(payload.result?.details?.kind, "file"); + assert.deepEqual(payload.result?.details?.workingSet, { evicted: true, reason: "age_horizon", ref: "t1" }); + assert.equal(JSON.stringify(payload).includes("final secret body"), false); + // The ledger entry itself is untouched: eviction is a projection. + assert.equal(JSON.stringify(entries[3]).includes("final secret body"), true); +}); + +test("project: an evicted assistant loses both thinking shapes", () => { + const entries = ledger(); + const projected = projectWorkingSet(entries, foldWorkingSet(entries)); + const payload = payloadOf(projected[1]) as { content?: unknown[]; thinking?: unknown }; + assert.deepEqual(payload.content, [{ type: "text", text: "reading it" }]); + assert.equal(payload.thinking, undefined); + assert.equal(payloadOf(entries[1]).thinking, "payload-level reasoning"); +}); + +test("project: is idempotent", () => { + const entries = ledger(); + const view = foldWorkingSet(entries); + const once = projectWorkingSet(entries, view); + const twice = projectWorkingSet(once, view); + assert.deepEqual(twice, once); + assert.equal(JSON.stringify(twice), JSON.stringify(once)); +}); + +test("project: entries the view does not name are returned by reference", () => { + const entries = ledger(); + const projected = projectWorkingSet(entries, foldWorkingSet(entries)); + assert.equal(projected[0], entries[0]); + assert.equal(projected[2], entries[2]); + assert.equal(projected[4], entries[4]); + assert.equal(projected[5], entries[5]); + assert.equal(projected[6], entries[6]); +}); + +test("project: an empty view is a no-op", () => { + const entries = ledger(); + const projected = projectWorkingSet(entries, EMPTY_WORKING_SET_VIEW); + assert.equal(projected.length, entries.length); + for (let i = 0; i < entries.length; i += 1) assert.equal(projected[i], entries[i]); +}); + +test("project: usage recorded before the event stops anchoring the estimate", () => { + const entries = ledger(); + const projected = projectWorkingSet(entries, foldWorkingSet(entries)); + assert.equal(payloadOf(projected[1]).contextUsageInvalidated, true); + // The assistant turn after the event measured the projected prompt, so its + // usage is still the honest anchor. + assert.equal(payloadOf(projected[6]).contextUsageInvalidated, undefined); + assert.equal(payloadOf(entries[1]).contextUsageInvalidated, undefined); +}); + +test("project: a recall puts the body back without touching the marker path", () => { + const entries = ledger(); + entries.push({ + kind: "contextRecall", + ...base("r1", "u2"), + ref: { entry: "t1" }, + trigger: "tool", + tokensReadmitted: 260, + }); + const projected = projectWorkingSet(entries, foldWorkingSet(entries)); + assert.equal(projected[3], entries[3]); + assert.equal(JSON.stringify(projected[3]).includes("final secret body"), true); + // The thinking eviction is unaffected by a recall of a different ref. + assert.equal(payloadOf(projected[1]).thinking, undefined); +}); diff --git a/tests/contracts/working-set-replay-tag.test.ts b/tests/contracts/working-set-replay-tag.test.ts new file mode 100644 index 000000000..d8854fc99 --- /dev/null +++ b/tests/contracts/working-set-replay-tag.test.ts @@ -0,0 +1,112 @@ +import { ok, strictEqual } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderMarker } from "../../src/domains/context/working-set/marker.js"; +import type { SessionEntry } from "../../src/domains/session/entries.js"; +import { createChatPanel } from "../../src/interactive/chat-panel.js"; +import { rehydrateChatPanelFromTurns } from "../../src/interactive/chat-renderer.js"; + +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, "g"); +const strip = (s: string): string => s.replace(ANSI, ""); + +const BODY = `${"grep hit line\n".repeat(40)}the body only the ledger keeps`; +const TS = "2026-08-08T00:00:00.000Z"; + +function turns(withEviction: boolean, leafBranch: "a" | "b" = "a"): SessionEntry[] { + const entries: SessionEntry[] = [ + { kind: "message", role: "user", turnId: "u1", parentTurnId: null, timestamp: TS, payload: { text: "search" } }, + { + kind: "message", + role: "tool_call", + turnId: "c1", + parentTurnId: "u1", + timestamp: TS, + payload: { toolCallId: "call-1", toolName: "grep", args: { pattern: "hit" } }, + }, + { + kind: "message", + role: "tool_result", + turnId: "t1", + parentTurnId: "c1", + timestamp: TS, + payload: { + toolCallId: "call-1", + toolName: "grep", + result: { content: [{ type: "text", text: BODY }] }, + isError: false, + resultSummary: { bytes: BODY.length, truncated: false }, + }, + }, + { kind: "message", role: "user", turnId: "u2", parentTurnId: "t1", timestamp: TS, payload: { text: "next" } }, + ]; + if (withEviction) { + entries.push({ + kind: "contextEviction", + turnId: "e1", + parentTurnId: leafBranch === "a" ? "u2" : "u3", + timestamp: TS, + policyId: "age-horizon", + trigger: "pressure", + evicted: [ + { + ref: { entry: "t1" }, + reason: "age_horizon", + tokensFreed: 140, + marker: renderMarker({ ref: { entry: "t1" }, reason: "age_horizon", toolName: "grep", text: BODY }), + }, + ], + tokensBefore: 900, + tokensAfter: 760, + pressureBefore: 0.88, + snapshotIdBefore: null, + }); + } + return entries; +} + +describe("contracts/working-set replay tag", () => { + it("tags an evicted tool row with its reason", () => { + const panel = createChatPanel(); + rehydrateChatPanelFromTurns(panel, turns(true)); + const rendered = strip(panel.render(120).join("\n")); + ok(rendered.includes("evicted · age_horizon"), `expected an evicted tag, got:\n${rendered}`); + }); + + it("leaves an untouched ledger untagged", () => { + const panel = createChatPanel(); + rehydrateChatPanelFromTurns(panel, turns(false)); + const rendered = strip(panel.render(120).join("\n")); + strictEqual(rendered.includes("evicted"), false); + }); + + it("still renders the full body: the transcript shows the ledger, not the projection", () => { + const panel = createChatPanel({ getOutputVerbosity: () => "verbose" }); + rehydrateChatPanelFromTurns(panel, turns(true)); + const rendered = strip(panel.render(120).join("\n")); + ok(rendered.includes("the body only the ledger keeps"), "the evicted body must still replay in the transcript"); + ok(rendered.includes("evicted · age_horizon"), "the expanded row carries the tag too"); + strictEqual(rendered.includes("[evicted ref=t1"), false, "the marker belongs to the projection, not the transcript"); + }); + + it("does not tag a row from an abandoned branch (#94)", () => { + // Branch B forks off t1 and evicts it; branch A never did. A replay + // pinned to branch A must not inherit branch B's eviction. + const entries = turns(false); + entries.push({ + kind: "message", + role: "user", + turnId: "u3", + parentTurnId: "t1", + timestamp: TS, + payload: { text: "other branch" }, + }); + entries.push(...turns(true, "b").filter((entry) => entry.kind === "contextEviction")); + + const branchA = createChatPanel(); + rehydrateChatPanelFromTurns(branchA, entries, { activeLeafTurnId: "u2" }); + strictEqual(strip(branchA.render(120).join("\n")).includes("evicted"), false); + + const branchB = createChatPanel(); + rehydrateChatPanelFromTurns(branchB, entries, { activeLeafTurnId: "u3" }); + ok(strip(branchB.render(120).join("\n")).includes("evicted · age_horizon")); + }); +}); From b4446fb6c7db71b857c96ae62ae178087aecda8e Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:15:32 -0500 Subject: [PATCH 07/45] test(context): projection keeps the marker after a recall --- tests/contracts/working-set-project.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/contracts/working-set-project.test.ts b/tests/contracts/working-set-project.test.ts index e6c9a443e..c9e2d0a83 100644 --- a/tests/contracts/working-set-project.test.ts +++ b/tests/contracts/working-set-project.test.ts @@ -174,7 +174,7 @@ test("project: usage recorded before the event stops anchoring the estimate", () assert.equal(payloadOf(entries[1]).contextUsageInvalidated, undefined); }); -test("project: a recall puts the body back without touching the marker path", () => { +test("project: a recall leaves the marker in place; the body rides the recall tool result", () => { const entries = ledger(); entries.push({ kind: "contextRecall", @@ -183,9 +183,11 @@ test("project: a recall puts the body back without touching the marker path", () trigger: "tool", tokensReadmitted: 260, }); + const before = projectWorkingSet(ledger(), foldWorkingSet(ledger())); const projected = projectWorkingSet(entries, foldWorkingSet(entries)); - assert.equal(projected[3], entries[3]); - assert.equal(JSON.stringify(projected[3]).includes("final secret body"), true); + // Same marker bytes before and after the recall: the prefix stays cache-stable. + assert.equal(JSON.stringify(projected[3]), JSON.stringify(before[3])); + assert.equal(JSON.stringify(projected[3]).includes("final secret body"), false); // The thinking eviction is unaffected by a recall of a different ref. assert.equal(payloadOf(projected[1]).thinking, undefined); }); From 2a76ce0ada0441a38b867522475dd29ff5274c57 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:24:46 -0500 Subject: [PATCH 08/45] feat(context): wire non-destructive working set (cherry picked from commit 7a10385479f5241bc4e9007143b1f0ae94194f2a) --- docs/configuration-and-targets.md | 14 + docs/context-engine.md | 20 +- docs/environment-variables.md | 1 + src/core/bus-events.ts | 23 +- src/core/config.ts | 54 +++ src/core/defaults.ts | 29 +- src/domains/config/classify.ts | 1 + src/domains/middleware/memory-intervention.ts | 3 + src/domains/session/context-ledger.ts | 2 + src/entry/orchestrator.ts | 9 +- src/interactive/model-session-replay.ts | 21 ++ src/interactive/overlay-session-lifecycle.ts | 12 +- src/interactive/session-transcript.ts | 4 +- src/interactive/turn-context.ts | 262 ++++++++++----- src/interactive/turn-middleware.ts | 12 +- tests/contracts/bus-wiring.test.ts | 2 + tests/contracts/compaction-activity.test.ts | 9 +- tests/contracts/config-working-set.test.ts | 84 +++++ .../context-working-set-wiring.test.ts | 311 ++++++++++++++++++ tests/contracts/memory-intervention.test.ts | 13 + 20 files changed, 779 insertions(+), 107 deletions(-) create mode 100644 src/interactive/model-session-replay.ts create mode 100644 tests/contracts/config-working-set.test.ts create mode 100644 tests/contracts/context-working-set-wiring.test.ts diff --git a/docs/configuration-and-targets.md b/docs/configuration-and-targets.md index 5756f2c51..7baa1b71d 100644 --- a/docs/configuration-and-targets.md +++ b/docs/configuration-and-targets.md @@ -224,6 +224,15 @@ compaction: excludeLastTurns: 6 # model: provider/summary-model-id # systemPrompt: ~/.config/clio-coder/prompts/compaction.md + +context: + workingSet: + enabled: true + policy: age-horizon + target: 0.6 + protectLastTurns: 6 + minEvictableTokens: 200 + retry: enabled: true maxRetries: 3 @@ -575,6 +584,11 @@ Every one of these has an environment override for a single process; see [enviro | `compaction.auto` | `true` | boolean | next turn | | `compaction.threshold` | `0.8` | number in 0 to 1 | next turn | | `compaction.excludeLastTurns` | `6` | integer ≥ 1 | next turn | +| `context.workingSet.enabled` | `true` | boolean | next turn | +| `context.workingSet.policy` | `age-horizon` | `age-horizon` or `structural-v1` | next turn | +| `context.workingSet.target` | `0.6` | number greater than 0 and less than 1 | next turn | +| `context.workingSet.protectLastTurns` | `6` | integer ≥ 1 | next turn | +| `context.workingSet.minEvictableTokens` | `200` | integer ≥ 0 | next turn | | `defaults.maxTokens` | `32768` | integer ≥ 0 | next turn | | `budget.sessionCeilingUsd` | `5` | number ≥ 0 | immediately | | `budget.concurrency` | `auto` | `auto` or integer ≥ 1 | next dispatch | diff --git a/docs/context-engine.md b/docs/context-engine.md index d447855bb..b7b388472 100644 --- a/docs/context-engine.md +++ b/docs/context-engine.md @@ -31,15 +31,15 @@ The `/context` overlay and footer meter read the same ledger categories: `system Auto-compaction is controlled by one pressure threshold. Pressure is `estimated_tokens / context_window`. The default threshold is `0.8`. -When `compaction.auto` is enabled and pressure crosses the threshold before a request, Clio first masks stale tool observations and stale thinking older than `excludeLastTurns`. This is a cheap local rewrite. Tool call and result structure remain present, but the observation body is replaced with a marker and stale assistant thinking content is dropped from replay. +When `compaction.auto` is enabled and pressure crosses the threshold before a request, Clio first applies the configured working-set policy. The policy appends a `contextEviction` ledger entry and projects selected tool observations and thinking out of model replay; the original entries remain intact and recallable. The one-release destructive mask compatibility path runs only when `CLIO_CODER_LEGACY_MASK=1`. -Marker format: +The legacy escape hatch uses the old marker format: ```text [Observation masked: output was lines, chars - contents masked to save context. Re-run the tool for current content.] Preview: ``` -Already-compacted entries are not masked again. Recent turns keep their full observations and thinking. If masking drops pressure below the threshold, Clio sends the request without an LLM summary. If pressure remains above the threshold, Clio runs the summary compaction path, appends a compaction summary entry, refreshes replay messages from the session, and continues. +Already-evicted entries are not selected again. Recent turns keep their full observations and thinking. If projection drops pressure below the threshold, Clio sends the request without an LLM summary. If pressure remains above the threshold, Clio runs the summary compaction path, appends a compaction summary entry, refreshes projected replay messages from the session, and continues. When the ledger is replayed to the model, compaction summaries, branch summaries, and bash executions become standardized user-role message text. Clio imports `COMPACTION_SUMMARY_PREFIX`, `BRANCH_SUMMARY_PREFIX`, their suffixes, and `bashExecutionToText` through `src/engine/messages.ts`; `src/interactive/chat-renderer.ts` maps Clio's entry shapes onto them and applies replay truncation. @@ -55,7 +55,7 @@ Per-call cache verdicts are `hot`, `partial`, `cold`, and `small`. They are deri ## Settings -The public settings block has one threshold and one recent-turn horizon: +The public settings use one compaction threshold plus a non-destructive working-set stage: ```yaml compaction: @@ -64,9 +64,19 @@ compaction: excludeLastTurns: 6 # model: provider/summary-model-id # systemPrompt: ~/.config/clio-coder/prompts/compaction.md + +context: + workingSet: + enabled: true + policy: age-horizon + target: 0.6 + protectLastTurns: 6 + minEvictableTokens: 200 ``` -`auto` controls the pre-request trigger. Manual `/context compact` still runs when `auto` is false. `model` optionally selects a dedicated summarization model. `systemPrompt` optionally points at a prompt override file for compaction. +`compaction.auto` controls the pre-request trigger. Manual `/context compact` still runs when `auto` is false. `compaction.model` optionally selects a dedicated summarization model, and `compaction.systemPrompt` optionally points at a prompt override file. `compaction.excludeLastTurns` only governs the temporary legacy mask path; working-set protection uses `context.workingSet.protectLastTurns`. + +`context.workingSet.enabled: false` skips eviction and goes directly to summary compaction; it does not restore the destructive mask. `policy` selects `age-horizon` or the opt-in `structural-v1` policy. `target` is the pressure ratio an eviction event batches down to, `protectLastTurns` is the recent user-turn horizon whose observations remain available, and `minEvictableTokens` keeps results whose estimated savings would be too small. Set `CLIO_CODER_LEGACY_MASK=1` only as a temporary compatibility escape hatch for the old destructive mask stage. Settings validation is strict: an older file still carrying the removed `compaction.thresholds` block fails to load with the exact key path during normal startup. Edit removed or unknown keys deliberately; `clio-coder doctor --fix` does not transform settings into the current schema. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index b24826e28..5fdaf039d 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -30,6 +30,7 @@ Durable values live in the `guardrails:` section of settings.yaml (see [configur | `CLIO_CODER_TRUST_PROJECT_SKILLS` | off | `1` trusts project-local skills for execution (`src/domains/resources/skills/loader.ts`). | | `CLIO_CODER_ALLOW_EXTERNAL_FULL_ACCESS` | off | `1` lets full-auto pass through to external CLI runtimes with their own full access (`src/engine/claude/subprocess-runtime.ts`, `src/engine/antigravity/subprocess-runtime.ts`). | | `CLIO_CODER_FORCE_COMPACT` | off | `1` forces compaction on the next interactive turn (`src/interactive/chat-loop.ts`). | +| `CLIO_CODER_LEGACY_MASK` | off | `1` temporarily restores the destructive stale-observation mask before summary compaction; remove it after compatibility diagnosis. | | `CLIO_CODER_STATUS_STUCK_MS` | 180000 | Stuck-turn watchdog threshold (`src/interactive/status/watchdog.ts`). | | `CLIO_CODER_SHUTDOWN_HOOK_MS` | 500 | Wall-clock budget per shutdown hook (`src/core/termination.ts`). | | `CLIO_CODER_HOOK_BUDGET_MS` | per-phase built-ins | Global middleware hook wall-clock budget (`src/domains/middleware/budget.ts`). | diff --git a/src/core/bus-events.ts b/src/core/bus-events.ts index 3115fd375..e6924b100 100644 --- a/src/core/bus-events.ts +++ b/src/core/bus-events.ts @@ -61,6 +61,7 @@ export const BusChannels = { ContextActivity: "context.activity", ContextWarning: "context.warning", ContextPruned: "context.pruned", + ContextRecalled: "context.recalled", AgentStatusChanged: "agent.status.changed", RunAborted: "run.aborted", BudgetAlert: "budget.alert", @@ -253,21 +254,34 @@ export function isRunAbortedPayload(value: unknown): value is RunAbortedPayload return true; } -/** Payload published on {@link BusChannels.ContextPruned} after compaction reclaims tokens. */ +/** Payload published on {@link BusChannels.ContextPruned} after projected or summarized context shrinks. */ export interface ContextPrunedPayload { - stage: "mask_observations" | "llm_summary"; + stage: "mask_observations" | "working_set" | "llm_summary"; tokensBefore: number; tokensAfter: number; trigger: string; snapshotIdBefore: string | null; snapshotIdAfter: string; at: number; - /** Used/window ratio at trigger time; mask stage only. */ + /** Used/window ratio at trigger time; working-set and legacy-mask stages only. */ pressure?: number | null; + /** Legacy destructive-mask count. */ maskedObservations?: number; - /** Thinking blocks stripped from stale assistant messages; mask stage only. */ + /** Thinking blocks stripped from stale assistant messages; legacy mask only. */ maskedThinkingBlocks?: number; maskedThinkingChars?: number; + /** Working-set policy that selected an applied eviction event. */ + policyId?: string; + /** Number of working-set units evicted by the event. */ + evictedItems?: number; +} + +/** Payload published on {@link BusChannels.ContextRecalled} after an exact working-set recall. */ +export interface ContextRecalledPayload { + ref: string; + trigger: "tool" | "operator"; + tokensReadmitted: number; + at: number; } // --------------------------------------------------------------------------- @@ -727,6 +741,7 @@ export type BusPayloadMap = { [BusChannels.ContextActivity]: ContextActivityPayload; [BusChannels.ContextWarning]: ContextWarningPayload; [BusChannels.ContextPruned]: ContextPrunedPayload; + [BusChannels.ContextRecalled]: ContextRecalledPayload; [BusChannels.AgentStatusChanged]: AgentStatusChangedPayload; [BusChannels.RunAborted]: RunAbortedPayload; [BusChannels.BudgetAlert]: BudgetAlertPayload; diff --git a/src/core/config.ts b/src/core/config.ts index 9ac03eb5b..b025fefc9 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -968,6 +968,7 @@ const TOP_LEVEL_KEYS = [ "delegation", "keybindings", "compaction", + "context", "retry", "guardrails", ] as const; @@ -1405,6 +1406,59 @@ export function validateSettings(raw: unknown): SettingsValidationResult { } } + if ("context" in raw) { + if (!isPlainObject(raw.context)) { + issues.add("context", `expected a map, got ${describe(raw.context)}`); + } else { + issues.unknownKeys("context", raw.context, ["workingSet"]); + if ("workingSet" in raw.context) { + if (!isPlainObject(raw.context.workingSet)) { + issues.add("context.workingSet", `expected a map, got ${describe(raw.context.workingSet)}`); + } else { + const workingSet = raw.context.workingSet; + issues.unknownKeys("context.workingSet", workingSet, [ + "enabled", + "policy", + "target", + "protectLastTurns", + "minEvictableTokens", + ]); + if ("enabled" in workingSet) { + const v = expectBoolean(issues, "context.workingSet.enabled", workingSet.enabled); + if (v !== undefined) settings.context.workingSet.enabled = v; + } + if ("policy" in workingSet) { + const v = expectEnum(issues, "context.workingSet.policy", workingSet.policy, [ + "age-horizon", + "structural-v1", + ] as const); + if (v !== undefined) settings.context.workingSet.policy = v; + } + if ("target" in workingSet) { + const v = expectNumber(issues, "context.workingSet.target", workingSet.target); + if (v !== undefined && (v <= 0 || v >= 1)) { + issues.add("context.workingSet.target", `expected a number > 0 and < 1, got ${v}`); + } else if (v !== undefined) { + settings.context.workingSet.target = v; + } + } + if ("protectLastTurns" in workingSet) { + const v = expectInteger(issues, "context.workingSet.protectLastTurns", workingSet.protectLastTurns, { + min: 1, + }); + if (v !== undefined) settings.context.workingSet.protectLastTurns = v; + } + if ("minEvictableTokens" in workingSet) { + const v = expectInteger(issues, "context.workingSet.minEvictableTokens", workingSet.minEvictableTokens, { + min: 0, + }); + if (v !== undefined) settings.context.workingSet.minEvictableTokens = v; + } + } + } + } + } + if ("retry" in raw) { if (!isPlainObject(raw.retry)) { issues.add("retry", `expected a map, got ${describe(raw.retry)}`); diff --git a/src/core/defaults.ts b/src/core/defaults.ts index a1e558519..2fa8ed98f 100644 --- a/src/core/defaults.ts +++ b/src/core/defaults.ts @@ -4,6 +4,7 @@ * exist. Users edit the file directly or through TUI overlays. */ +import { DEFAULT_WORKING_SET_SETTINGS } from "../domains/context/working-set/defaults.js"; import type { TargetDescriptor } from "../domains/providers/types/target-descriptor.js"; import type { AutonomyLevel } from "../domains/safety/autonomy.js"; import { GUARDRAIL_DEFAULTS, type GuardrailValues } from "./guardrails.js"; @@ -105,7 +106,9 @@ export interface CompactionSettings { * entries and never rewrites history. Defaults and prose live in * src/domains/context/working-set/defaults.ts. * - * - enabled: master switch. Off restores the legacy destructive mask stage. + * - enabled: master switch. Off skips eviction and goes straight to summary + * compaction (the legacy destructive mask is only reachable through + * CLIO_CODER_LEGACY_MASK=1). * - policy: candidate selection rule set. * - target: used/window ratio an applied event batches down to. * - protectLastTurns: recent user turns whose observations are never evicted. @@ -381,6 +384,9 @@ export const DEFAULT_SETTINGS = { threshold: 0.8, excludeLastTurns: 6, } as CompactionSettings, + context: { + workingSet: DEFAULT_WORKING_SET_SETTINGS, + }, retry: { enabled: true, maxRetries: 3, @@ -634,9 +640,9 @@ keybindings: {} # auto master switch for the pre-request compaction trigger. # Manual /context compact always runs the LLM summary. # threshold pressure = estimated_tokens / context_window. Crossing -# it masks stale tool observations first, then runs a -# full LLM summary if pressure stays above the threshold. -# excludeLastTurns recent user turns protected from observation masking. +# it evicts from the working set first, then runs a full +# LLM summary if pressure stays above the threshold. +# excludeLastTurns recent turns protected only by the temporary legacy mask. # model optional pattern (e.g. provider/summary-model-id) for a # dedicated summarization model. Absent ⇒ orchestrator target. # systemPrompt optional path to a prompt-override file. @@ -647,6 +653,21 @@ compaction: # model: provider/summary-model-id # systemPrompt: ~/.config/clio-coder/prompts/compaction.md +# Non-destructive working-set eviction before summary compaction. +# enabled false skips eviction and goes directly to the summary stage. +# policy age-horizon preserves today's age-based selection; +# structural-v1 opts into structure-aware selection. +# target pressure ratio an applied eviction batches down to. +# protectLastTurns recent user turns whose observations remain in the working set. +# minEvictableTokens entries below this estimate remain in the working set. +context: + workingSet: + enabled: true + policy: age-horizon + target: 0.6 + protectLastTurns: 6 + minEvictableTokens: 200 + # Transient provider/stream retry controls for interactive chat. # Retryable errors include overloads, rate limits, 5xx responses, network # resets, and timeouts. Context overflow uses compaction recovery instead. diff --git a/src/domains/config/classify.ts b/src/domains/config/classify.ts index 03ff1fece..5fe8cdd2e 100644 --- a/src/domains/config/classify.ts +++ b/src/domains/config/classify.ts @@ -56,6 +56,7 @@ const NEXT_TURN_FIELDS = new Set([ "skills", "delegation", "compaction", + "context", "retry", ]); diff --git a/src/domains/middleware/memory-intervention.ts b/src/domains/middleware/memory-intervention.ts index 764cd6670..833b06243 100644 --- a/src/domains/middleware/memory-intervention.ts +++ b/src/domains/middleware/memory-intervention.ts @@ -202,6 +202,9 @@ export function createMemoryInterventionRegistration(deps: MemoryInterventionDep return effects; } case "on_compaction": + // Recall grows the working set; it is an observability point, not + // context loss that should reactivate compacted task memory. + if (input.metadata?.stage === "working_set_recall") return NO_EFFECTS; reactivateAfterCompaction = true; return NO_EFFECTS; case "turn_start": { diff --git a/src/domains/session/context-ledger.ts b/src/domains/session/context-ledger.ts index b83845455..f509b7a87 100644 --- a/src/domains/session/context-ledger.ts +++ b/src/domains/session/context-ledger.ts @@ -122,6 +122,8 @@ export interface PromptCacheStats { * combination the overlay renders as a warning. */ backendVerdict: "hot" | "partial" | "cold" | "small" | null; + /** Cache disturbances Clio expected before the last settled run. */ + expectedColdReasons?: ReadonlyArray; } export interface ContextLedgerGroup { diff --git a/src/entry/orchestrator.ts b/src/entry/orchestrator.ts index 6f228c9f5..ece7a0210 100644 --- a/src/entry/orchestrator.ts +++ b/src/entry/orchestrator.ts @@ -161,8 +161,8 @@ import { import { openSession, readSessionTailTurns, sessionCurrentPath, sessionPaths } from "../engine/session.js"; import type { EngineModel } from "../engine/types.js"; import { createChatLoop } from "../interactive/chat-loop.js"; -import { buildReplayAgentMessagesFromTurns } from "../interactive/chat-renderer.js"; import { type RunIo, startInteractive } from "../interactive/index.js"; +import { buildModelReplayAgentMessagesFromTurns } from "../interactive/model-session-replay.js"; import type { BootOptions } from "./boot-options.js"; export type { BootOptions, HeadlessSamplingOverrides } from "./boot-options.js"; @@ -1620,7 +1620,10 @@ export async function bootOrchestrator(options: BootOptions = {}): Promise, leafTurnId: string | null) => - buildReplayAgentMessagesFromTurns(entries, leafTurnId === null ? {} : { activeLeafTurnId: leafTurnId }), + buildModelReplayAgentMessagesFromTurns(entries, leafTurnId === null ? {} : { activeLeafTurnId: leafTurnId }), } : {}), providers, diff --git a/src/interactive/model-session-replay.ts b/src/interactive/model-session-replay.ts new file mode 100644 index 000000000..c4595b32e --- /dev/null +++ b/src/interactive/model-session-replay.ts @@ -0,0 +1,21 @@ +import { foldWorkingSet } from "../domains/context/working-set/fold.js"; +import { projectWorkingSet } from "../domains/context/working-set/project.js"; +import type { SessionEntry } from "../domains/session/entries.js"; +import type { AgentMessage } from "../engine/types.js"; +import { buildReplayAgentMessagesFromTurns, type RehydrateChatPanelOptions } from "./chat-renderer.js"; + +/** + * Build provider-facing replay messages from the durable session ledger. + * Projection always honors existing eviction and recall entries; the enabled + * setting gates creation of new evictions, not replay of durable state. + * Visible transcript and export callers intentionally keep using the raw + * rehydration helpers so eviction remains a model projection, not data loss. + */ +export function buildModelReplayAgentMessagesFromTurns( + entries: ReadonlyArray, + options: RehydrateChatPanelOptions = {}, +): AgentMessage[] { + const activeLeafTurnId = options.activeLeafTurnId ?? options.uptoTurnId; + const projected = projectWorkingSet(entries, foldWorkingSet(entries, activeLeafTurnId)); + return buildReplayAgentMessagesFromTurns(projected, options); +} diff --git a/src/interactive/overlay-session-lifecycle.ts b/src/interactive/overlay-session-lifecycle.ts index 49b3506b4..f89803059 100644 --- a/src/interactive/overlay-session-lifecycle.ts +++ b/src/interactive/overlay-session-lifecycle.ts @@ -3,9 +3,10 @@ import type { SessionContract, SessionEntry } from "../domains/session/index.js" import type { TUI } from "../engine/tui.js"; import type { ChatLoop } from "./chat-loop.js"; import type { ChatPanel } from "./chat-panel.js"; -import { buildReplayAgentMessagesFromTurns, rehydrateChatPanelFromTurns } from "./chat-renderer.js"; +import { rehydrateChatPanelFromTurns } from "./chat-renderer.js"; import { emitCommandNotice } from "./command-fallbacks.js"; import type { InteractiveNoticeLevel } from "./interactive-subscriptions.js"; +import { buildModelReplayAgentMessagesFromTurns } from "./model-session-replay.js"; import type { OverlayTransitions } from "./overlay-transitions.js"; import { openCwdFallbackOverlay } from "./overlays/cwd-fallback.js"; import { openMessagePickerOverlay } from "./overlays/message-picker.js"; @@ -159,7 +160,7 @@ export function createOverlaySessionLifecycle(deps: OverlaySessionLifecycleDeps) const replayOptions = leafTurnId ? { activeLeafTurnId: leafTurnId } : {}; deps.resetTranscript(); rehydrateChatPanelFromTurns(deps.chatPanel, turns, replayOptions); - const replayMessages = buildReplayAgentMessagesFromTurns(turns, replayOptions); + const replayMessages = buildModelReplayAgentMessagesFromTurns(turns, replayOptions); deps.chat.resetForSession(leafTurnId, replayMessages); rescopeToBranch(session, turns, leafTurnId); } catch (error) { @@ -209,7 +210,7 @@ export function createOverlaySessionLifecycle(deps: OverlaySessionLifecycleDeps) const turns = deps.readStructuredEntries(sessionId); deps.resetTranscript(); rehydrateChatPanelFromTurns(deps.chatPanel, turns, { uptoTurnId: turnId }); - const replayMessages = buildReplayAgentMessagesFromTurns(turns, { uptoTurnId: turnId }); + const replayMessages = buildModelReplayAgentMessagesFromTurns(turns, { uptoTurnId: turnId }); deps.chat.resetForSession(turnId, replayMessages); // The same branch the transcript above was just scoped to. Without the // leaf, /cost, the footer Σ, and the last-turn line kept reporting the @@ -287,8 +288,11 @@ export function createOverlaySessionLifecycle(deps: OverlaySessionLifecycleDeps) try { const turns = deps.readStructuredEntries(forkedSessionId); rehydrateChatPanelFromTurns(deps.chatPanel, turns); - const replayMessages = buildReplayAgentMessagesFromTurns(turns); const leafTurnId = session.tree(forkedSessionId).leafId ?? parentTurnId; + const replayMessages = buildModelReplayAgentMessagesFromTurns( + turns, + leafTurnId ? { activeLeafTurnId: leafTurnId } : {}, + ); deps.chat.resetForSession(leafTurnId, replayMessages); rescopeToBranch(session, turns, leafTurnId); } catch (error) { diff --git a/src/interactive/session-transcript.ts b/src/interactive/session-transcript.ts index 6d8eff382..9698bd236 100644 --- a/src/interactive/session-transcript.ts +++ b/src/interactive/session-transcript.ts @@ -3,7 +3,7 @@ import { collectSessionEntries } from "../domains/session/compaction/session-ent import type { SessionContract, SessionEntry } from "../domains/session/index.js"; import { openSession, sessionPaths } from "../engine/session.js"; import type { ChatLoop } from "./chat-loop.js"; -import { buildReplayAgentMessagesFromTurns } from "./chat-renderer.js"; +import { buildModelReplayAgentMessagesFromTurns } from "./model-session-replay.js"; type SessionOwner = Pick; type SessionChat = Pick; @@ -87,7 +87,7 @@ export function createSessionTranscript(deps: SessionTranscriptDeps): SessionTra const turns = deps.readSessionEntries(); deps.chat.resetForSession( leafTurnId, - buildReplayAgentMessagesFromTurns(turns, { + buildModelReplayAgentMessagesFromTurns(turns, { ...(leafTurnId ? { activeLeafTurnId: leafTurnId } : {}), }), ); diff --git a/src/interactive/turn-context.ts b/src/interactive/turn-context.ts index 094610587..31b7e55a1 100644 --- a/src/interactive/turn-context.ts +++ b/src/interactive/turn-context.ts @@ -10,11 +10,15 @@ import { BusChannels, type ContextActivityStatus, type ContextPrunedPayload, + type ContextRecalledPayload, type ContextWarningPayload, } from "../core/bus-events.js"; import type { ClioSettings } from "../core/config.js"; import type { SafeEventBus } from "../core/event-bus.js"; import type { ToolName } from "../core/tool-names.js"; +import { buildEvictionFields, planEviction } from "../domains/context/working-set/engine.js"; +import { foldWorkingSet } from "../domains/context/working-set/fold.js"; +import { resolveWorkingSetPolicy } from "../domains/context/working-set/policies/index.js"; import type { ObservabilityContract } from "../domains/observability/contract.js"; import type { CompiledSessionPrompt, SessionPromptInputs } from "../domains/prompts/compiler.js"; import type { PromptsContract } from "../domains/prompts/contract.js"; @@ -27,6 +31,7 @@ import { } from "../domains/session/compaction/auto.js"; import type { CompactResult } from "../domains/session/compaction/compact.js"; import { maskStaleObservations } from "../domains/session/compaction/mask-observations.js"; +import { estimateTokens } from "../domains/session/compaction/tokens.js"; import { appendContextSnapshot, type CaptureContextSnapshotInput, @@ -47,6 +52,7 @@ import { buildContextLedger, type ContextLedger, type PromptCacheStats } from ". import type { SessionContract } from "../domains/session/contract.js"; import type { CompactionTrigger, SessionEntry } from "../domains/session/entries.js"; import { appendPromptCompileRecord, type SessionPromptCompileRecord } from "../domains/session/prompt-manifest.js"; +import { filterEntriesToActivePath } from "../domains/session/tree/active-path.js"; import type { AgentMessage, Usage } from "../engine/types.js"; import type { ToolRegistry } from "../tools/registry.js"; import { @@ -57,7 +63,7 @@ import { sumRunUsage, toolNamesFromAgentState, } from "./chat-loop-messages.js"; -import { buildReplayAgentMessagesFromTurns } from "./chat-renderer.js"; +import { buildModelReplayAgentMessagesFromTurns } from "./model-session-replay.js"; import { renderCompactionSummaryLine } from "./renderers/compaction-summary.js"; import type { TurnMiddleware } from "./turn-middleware.js"; import type { AgentRuntime, ChatTurnState } from "./turn-state.js"; @@ -73,6 +79,8 @@ export interface TurnContextDeps { bus?: SafeEventBus | undefined; readSessionEntries?: (() => ReadonlyArray) | undefined; autoCompact?: ((instructions?: string, trigger?: CompactionTrigger) => Promise) | undefined; + /** Test seam for the pure Worker A planner; production uses planEviction. */ + planEviction?: typeof planEviction; getMemorySection?: (() => string) | undefined; middleware: TurnMiddleware; emitNotice: (text: string) => void; @@ -167,20 +175,35 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { // its first assistant entry, and shows one dim notice. const pendingColdReasons = new Set(); let runExpectedColdReasons: string[] = []; - let stampColdReasonsPending = false; + let nextAssistantColdReasons: string[] = []; + const noteColdReason = (reason: string): void => { + if (!state.streaming) { + pendingColdReasons.add(reason); + return; + } + const runtimeId = state.runtime?.runtimeId; + if (!runtimeId || deps.providers.getRuntime(runtimeId)?.tier !== "local-native") return; + if (!runExpectedColdReasons.includes(reason)) runExpectedColdReasons.push(reason); + if (nextAssistantColdReasons.includes(reason)) return; + nextAssistantColdReasons.push(reason); + deps.emitNotice(`[context engine] backend prefix cache likely cold this turn: ${reason}`); + }; const unsubscribeColdReasonSources = [ ...[BusChannels.DispatchStarted, BusChannels.DispatchCompleted, BusChannels.DispatchFailed].map( (channel) => deps.bus?.on(channel, () => { - pendingColdReasons.add("dispatch"); + noteColdReason("dispatch"); }) ?? null, ), ...[BusChannels.CompactionBegin, BusChannels.CompactionEnd].map( (channel) => deps.bus?.on(channel, () => { - pendingColdReasons.add("compaction"); + noteColdReason("compaction"); }) ?? null, ), + deps.bus?.on(BusChannels.ContextRecalled, (payload: ContextRecalledPayload) => { + middleware.fireCompactionHook("working_set_recall", payload.trigger); + }) ?? null, ]; /** @@ -292,7 +315,7 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { const refreshAgentMessagesFromSession = (agentRuntime: AgentRuntime): ReadonlyArray => { const refreshedEntries = deps.readSessionEntries?.() ?? []; - agentRuntime.agent.state.messages = buildReplayAgentMessagesFromTurns(refreshedEntries, { + agentRuntime.agent.state.messages = buildModelReplayAgentMessagesFromTurns(refreshedEntries, { ...(state.lastTurnId ? { activeLeafTurnId: state.lastTurnId } : {}), }); state.replayedContextMessages = []; @@ -339,12 +362,12 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { /** * Two-mechanism context protection. When pressure crosses the single - * threshold, first mask the bodies of tool observations older than - * `excludeLastTurns` (cheap, no LLM call). If pressure stays above the - * threshold, delegate to the pi-style LLM compaction path: append a - * compaction summary entry, then replay from the session view. + * threshold, first apply a non-destructive working-set eviction. If pressure + * stays above the threshold, delegate to the pi-style LLM compaction path: + * append a compaction summary entry, then replay from the session view. The + * destructive observation mask remains only as a one-release escape hatch. * - * `force = true` skips the pressure check and the mask pre-stage and runs + * `force = true` skips the pressure check and every pre-stage and runs * the LLM summary directly. Used for `/context compact`, CLIO_CODER_FORCE_COMPACT=1, * and overflow recovery. */ @@ -355,7 +378,7 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { triggerOverride?: CompactionTrigger, pendingUserText?: string, ): Promise => { - if (!deps.autoCompact || !deps.readSessionEntries) return false; + if (!deps.readSessionEntries) return false; const settings = deps.getSettings(); const cfg = settings.compaction; const autoEnabled = cfg?.auto !== false; @@ -369,74 +392,156 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { const verdict = shouldCompact(estimate.tokens, compactionThreshold, estimate.contextWindow); if (!verdict.shouldCompact) return false; - // Mechanism B pre-stage: mask stale observations before paying for - // an LLM summary. History rewrites here invalidate the backend - // prefix cache; the "compaction" expectedColdReasons stamp from the - // CompactionBegin/End subscription explains the next cold turn. + // One-release compatibility escape hatch. This is the destructive + // pre-stage that working-set eviction replaces; keep it byte-for-byte + // reachable only when explicitly requested. if (deps.session?.current()) { const beforeSnapshotId = currentContextSnapshot?.snapshotId ?? null; - let masked: ReturnType; - try { - masked = maskStaleObservations(deps.readSessionEntries() ?? [], cfg?.excludeLastTurns ?? 6); - } catch (error) { - middleware.fireCompactionHook("mask_observations", trigger, estimate.tokens); - deps.bus?.emit(BusChannels.CompactionBegin, { trigger, at: Date.now() }); - emitCompactionActivity("started", "compacting context (mask stage)"); - emitCompactionActivity("failed", compactionFailureMessage(error)); - deps.bus?.emit(BusChannels.CompactionEnd, { trigger, at: Date.now() }); - throw error; - } - if (masked.changed) { - middleware.fireCompactionHook("mask_observations", trigger, estimate.tokens); - deps.bus?.emit(BusChannels.CompactionBegin, { trigger, at: Date.now() }); - emitCompactionActivity("started", "compacting context (mask stage)"); - deps.session.replaceEntries(masked.entries); - refreshAgentMessagesFromSession(agentRuntime); - deps.bus?.emit(BusChannels.CompactionEnd, { trigger, at: Date.now() }); - - const postMaskSnapshot = captureRuntimeContextSnapshot( - agentRuntime, - state.activeUserTurnId || "compaction", - compactionThreshold, - ); - currentContextSnapshot = postMaskSnapshot; - persistContextSnapshot(postMaskSnapshot); - - const tokensAfterMask = snapshotInputTokens(postMaskSnapshot); - lastCompactionEvent = { - stage: "mask_observations", - tokensBefore: estimate.tokens, - tokensAfter: tokensAfterMask, - trigger, - }; - deps.bus?.emit(BusChannels.ContextPruned, { - stage: "mask_observations", - pressure: verdict.pressure, - tokensBefore: estimate.tokens, - tokensAfter: tokensAfterMask, - maskedObservations: masked.maskedObservations, - maskedThinkingBlocks: masked.maskedThinkingBlocks, - maskedThinkingChars: masked.maskedThinkingChars, - trigger, - snapshotIdBefore: beforeSnapshotId, - snapshotIdAfter: postMaskSnapshot.snapshotId, - at: Date.now(), - } satisfies ContextPrunedPayload); - emitCompactionActivity("completed", `${masked.maskedObservations} observations masked`); - const thinkingNote = - masked.maskedThinkingBlocks > 0 - ? `, ${masked.maskedThinkingBlocks} thinking blocks dropped (~${masked.maskedThinkingChars} chars)` - : ""; - deps.emitNotice( - `[context engine] mask_observations: ${masked.maskedObservations} observations masked${thinkingNote}; ~${estimate.tokens} tokens -> ~${tokensAfterMask} tokens`, - ); - - const after = liveContextEstimate(agentRuntime, pendingUserText); - if (!shouldCompact(after.tokens, compactionThreshold, after.contextWindow).shouldCompact) return true; + if (process.env.CLIO_CODER_LEGACY_MASK === "1") { + let masked: ReturnType; + try { + masked = maskStaleObservations(deps.readSessionEntries() ?? [], cfg?.excludeLastTurns ?? 6); + } catch (error) { + middleware.fireCompactionHook("mask_observations", trigger, estimate.tokens); + deps.bus?.emit(BusChannels.CompactionBegin, { trigger, at: Date.now() }); + emitCompactionActivity("started", "compacting context (mask stage)"); + emitCompactionActivity("failed", compactionFailureMessage(error)); + deps.bus?.emit(BusChannels.CompactionEnd, { trigger, at: Date.now() }); + throw error; + } + if (masked.changed) { + middleware.fireCompactionHook("mask_observations", trigger, estimate.tokens); + deps.bus?.emit(BusChannels.CompactionBegin, { trigger, at: Date.now() }); + emitCompactionActivity("started", "compacting context (mask stage)"); + deps.session.replaceEntries(masked.entries); + refreshAgentMessagesFromSession(agentRuntime); + deps.bus?.emit(BusChannels.CompactionEnd, { trigger, at: Date.now() }); + + const postMaskSnapshot = captureRuntimeContextSnapshot( + agentRuntime, + state.activeUserTurnId || "compaction", + compactionThreshold, + ); + currentContextSnapshot = postMaskSnapshot; + persistContextSnapshot(postMaskSnapshot); + + const tokensAfterMask = snapshotInputTokens(postMaskSnapshot); + lastCompactionEvent = { + stage: "mask_observations", + tokensBefore: estimate.tokens, + tokensAfter: tokensAfterMask, + trigger, + }; + deps.bus?.emit(BusChannels.ContextPruned, { + stage: "mask_observations", + pressure: verdict.pressure, + tokensBefore: estimate.tokens, + tokensAfter: tokensAfterMask, + maskedObservations: masked.maskedObservations, + maskedThinkingBlocks: masked.maskedThinkingBlocks, + maskedThinkingChars: masked.maskedThinkingChars, + trigger, + snapshotIdBefore: beforeSnapshotId, + snapshotIdAfter: postMaskSnapshot.snapshotId, + at: Date.now(), + } satisfies ContextPrunedPayload); + emitCompactionActivity("completed", `${masked.maskedObservations} observations masked`); + const thinkingNote = + masked.maskedThinkingBlocks > 0 + ? `, ${masked.maskedThinkingBlocks} thinking blocks dropped (~${masked.maskedThinkingChars} chars)` + : ""; + deps.emitNotice( + `[context engine] mask_observations: ${masked.maskedObservations} observations masked${thinkingNote}; ~${estimate.tokens} tokens -> ~${tokensAfterMask} tokens`, + ); + + const after = liveContextEstimate(agentRuntime, pendingUserText); + if (!shouldCompact(after.tokens, compactionThreshold, after.contextWindow).shouldCompact) return true; + } + } else if (settings.context.workingSet.enabled) { + let planned: ReturnType; + try { + const entries = deps.readSessionEntries() ?? []; + const view = foldWorkingSet(entries, state.lastTurnId ?? undefined); + const policy = resolveWorkingSetPolicy(settings.context.workingSet.policy); + planned = (deps.planEviction ?? planEviction)(policy, { + entries: filterEntriesToActivePath(entries, state.lastTurnId ?? undefined), + view, + settings: settings.context.workingSet, + pressure: { + tokens: estimate.tokens, + contextWindow: estimate.contextWindow, + threshold: compactionThreshold, + target: settings.context.workingSet.target, + }, + estimateTokens, + }); + } catch (error) { + middleware.fireCompactionHook("working_set_evict", "pressure", estimate.tokens); + emitCompactionActivity("started", "compacting context (working-set eviction)"); + emitCompactionActivity("failed", compactionFailureMessage(error)); + throw error; + } + if (planned) { + middleware.fireCompactionHook("working_set_evict", "pressure", estimate.tokens); + emitCompactionActivity("started", "compacting context (working-set eviction)"); + try { + deps.session.appendEntry({ + ...buildEvictionFields(planned, { + trigger: "pressure", + pressureBefore: verdict.pressure, + snapshotIdBefore: beforeSnapshotId, + }), + // appendEntry does not infer this anchor; the interactive + // cursor is the leaf the next message will extend. + parentTurnId: state.lastTurnId, + }); + noteColdReason("working_set_evict"); + refreshAgentMessagesFromSession(agentRuntime); + + const postEvictionSnapshot = captureRuntimeContextSnapshot( + agentRuntime, + state.activeUserTurnId || "compaction", + compactionThreshold, + ); + currentContextSnapshot = postEvictionSnapshot; + persistContextSnapshot(postEvictionSnapshot); + + const tokensAfterEviction = snapshotInputTokens(postEvictionSnapshot); + lastCompactionEvent = { + stage: "working_set", + tokensBefore: estimate.tokens, + tokensAfter: tokensAfterEviction, + trigger, + }; + deps.bus?.emit(BusChannels.ContextPruned, { + stage: "working_set", + pressure: verdict.pressure, + tokensBefore: estimate.tokens, + tokensAfter: tokensAfterEviction, + trigger, + snapshotIdBefore: beforeSnapshotId, + snapshotIdAfter: postEvictionSnapshot.snapshotId, + policyId: planned.policyId, + evictedItems: planned.items.length, + at: Date.now(), + } satisfies ContextPrunedPayload); + emitCompactionActivity("completed", `${planned.items.length} working-set items evicted`); + deps.emitNotice( + `[context engine] working_set: ${planned.items.length} items evicted by ${planned.policyId}; ~${estimate.tokens} tokens -> ~${tokensAfterEviction} tokens`, + ); + + const after = liveContextEstimate(agentRuntime, pendingUserText); + if (!shouldCompact(after.tokens, compactionThreshold, after.contextWindow).shouldCompact) return true; + } catch (error) { + emitCompactionActivity("failed", compactionFailureMessage(error)); + throw error; + } + } } } } + if (!deps.autoCompact) return false; middleware.fireCompactionHook("llm_summary", trigger); deps.bus?.emit(BusChannels.CompactionBegin, { trigger, at: Date.now() }); emitCompactionActivity("started", "compacting context (summary)"); @@ -793,13 +898,13 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { // prefix cache to interleaved work, so only local-native targets // stamp reasons and notify; other tiers just clear the set. runExpectedColdReasons = []; - stampColdReasonsPending = false; + nextAssistantColdReasons = []; if (pendingColdReasons.size > 0) { const reasons = [...pendingColdReasons]; pendingColdReasons.clear(); if (deps.providers.getRuntime(runtimeId)?.tier === "local-native") { runExpectedColdReasons = reasons; - stampColdReasonsPending = true; + nextAssistantColdReasons = reasons; deps.emitNotice(`[context engine] backend prefix cache likely cold this turn: ${reasons.join(", ")}`); } } @@ -818,9 +923,9 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { cacheWrite, backendVerdict: backendCacheVerdict(input, cacheRead), }; - if (stampColdReasonsPending && runExpectedColdReasons.length > 0) { - promptCache.expectedColdReasons = [...runExpectedColdReasons]; - stampColdReasonsPending = false; + if (nextAssistantColdReasons.length > 0) { + promptCache.expectedColdReasons = [...nextAssistantColdReasons]; + nextAssistantColdReasons = []; } return promptCache; }, @@ -834,6 +939,7 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { cacheWriteTokens: cacheSummary.cacheRead > 0 || cacheSummary.cacheWrite > 0 ? cacheSummary.cacheWrite : null, uncachedInputTokens: cacheSummary.input, backendVerdict: runFirstCallVerdict, + ...(runExpectedColdReasons.length > 0 ? { expectedColdReasons: [...runExpectedColdReasons] } : {}), }; } }, diff --git a/src/interactive/turn-middleware.ts b/src/interactive/turn-middleware.ts index 823d61489..ea68fc8b5 100644 --- a/src/interactive/turn-middleware.ts +++ b/src/interactive/turn-middleware.ts @@ -15,7 +15,7 @@ import { type MiddlewareToolChoiceControl, } from "../domains/middleware/index.js"; import type { SessionContract } from "../domains/session/contract.js"; -import type { CompactionTrigger } from "../domains/session/entries.js"; +import type { CompactionTrigger, EvictionTrigger, RecallTrigger } from "../domains/session/entries.js"; import type { AgentMessage } from "../engine/types.js"; import { extractText, hasStructuredToolCall, toolNamesFromAgentState } from "./chat-loop-messages.js"; import type { AgentRuntime, ChatTurnState } from "./turn-state.js"; @@ -42,8 +42,8 @@ export interface TurnMiddleware { terminalToolResult?: { toolCallId: string; toolName: string }, ): Promise; fireCompactionHook( - stage: "mask_observations" | "llm_summary", - trigger: CompactionTrigger, + stage: "mask_observations" | "working_set_evict" | "working_set_recall" | "llm_summary", + trigger: CompactionTrigger | EvictionTrigger | RecallTrigger, tokensBefore?: number, ): void; flushPendingReminders(): string; @@ -240,9 +240,9 @@ export function createTurnMiddleware(deps: TurnMiddlewareDeps): TurnMiddleware { }, /** - * Observe-only lifecycle point fired before each compaction stage, at the - * existing CompactionBegin emit sites. Consumers record telemetry or state - * ahead of context loss; returned effects are discarded by design. + * Observe-only lifecycle point fired before each compaction or working-set + * stage. Consumers record telemetry or state around projected context + * changes; returned effects are discarded by design. */ fireCompactionHook(stage, trigger, tokensBefore): void { if (!deps.middleware) return; diff --git a/tests/contracts/bus-wiring.test.ts b/tests/contracts/bus-wiring.test.ts index 249c750f8..a91669669 100644 --- a/tests/contracts/bus-wiring.test.ts +++ b/tests/contracts/bus-wiring.test.ts @@ -28,6 +28,8 @@ const EMIT_ALLOWLIST: Record = { "emitted through the channel-selecting ternary in src/domains/config/extension.ts dispatch()", [BusChannels.ConfigRestartRequired]: "emitted through the channel-selecting ternary in src/domains/config/extension.ts dispatch()", + [BusChannels.ContextRecalled]: + "successful recall emission is owned by the parallel ws/recall slice and lands when Worker C is merged", }; /** Channels with no direct subscribe site, and why that is correct today. */ diff --git a/tests/contracts/compaction-activity.test.ts b/tests/contracts/compaction-activity.test.ts index f84f163ae..697a767fa 100644 --- a/tests/contracts/compaction-activity.test.ts +++ b/tests/contracts/compaction-activity.test.ts @@ -528,6 +528,8 @@ describe("contracts/compaction context-island activity (S3 Part A)", () => { }); it("a mask-stage auto compaction emits its own started -> completed pair", async () => { + const previousLegacyMask = process.env.CLIO_CODER_LEGACY_MASK; + process.env.CLIO_CODER_LEGACY_MASK = "1"; const bus = createSafeEventBus(); const activities = compactionActivities(bus); // A stale, maskable tool observation followed by a recent protected turn. @@ -604,7 +606,12 @@ describe("contracts/compaction context-island activity (S3 Part A)", () => { }, seedMessages), } as never); - await loop.submit("recent protected turn"); + try { + await loop.submit("recent protected turn"); + } finally { + if (previousLegacyMask === undefined) delete process.env.CLIO_CODER_LEGACY_MASK; + else process.env.CLIO_CODER_LEGACY_MASK = previousLegacyMask; + } const started = activities.find((a) => a.status === "started" && a.message.includes("mask stage")); ok(started, "the mask stage emits a started activity"); diff --git a/tests/contracts/config-working-set.test.ts b/tests/contracts/config-working-set.test.ts new file mode 100644 index 000000000..c1478ed3a --- /dev/null +++ b/tests/contracts/config-working-set.test.ts @@ -0,0 +1,84 @@ +import { deepStrictEqual, strictEqual } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { validateSettings } from "../../src/core/config.js"; +import { DEFAULT_SETTINGS } from "../../src/core/defaults.js"; +import { diffSettings } from "../../src/domains/config/classify.js"; + +describe("contracts/context working-set settings", () => { + it("accepts the complete strict block and applies changes next turn", () => { + const result = validateSettings({ + context: { + workingSet: { + enabled: false, + policy: "structural-v1", + target: 0.55, + protectLastTurns: 3, + minEvictableTokens: 0, + }, + }, + }); + + deepStrictEqual(result.issues, []); + deepStrictEqual(result.settings.context.workingSet, { + enabled: false, + policy: "structural-v1", + target: 0.55, + protectLastTurns: 3, + minEvictableTokens: 0, + }); + deepStrictEqual(diffSettings(DEFAULT_SETTINGS, result.settings).nextTurn.sort(), [ + "context.workingSet.enabled", + "context.workingSet.minEvictableTokens", + "context.workingSet.policy", + "context.workingSet.protectLastTurns", + "context.workingSet.target", + ]); + }); + + it("rejects unknown keys, invalid enums, open-interval endpoints, and integer range violations", () => { + const result = validateSettings({ + context: { + extra: true, + workingSet: { + enabled: "yes", + policy: "newest", + target: 1, + protectLastTurns: 0, + minEvictableTokens: -1, + unknown: true, + }, + }, + }); + + deepStrictEqual(result.issues.map((issue) => issue.path).sort(), [ + "context.extra", + "context.workingSet.enabled", + "context.workingSet.minEvictableTokens", + "context.workingSet.policy", + "context.workingSet.protectLastTurns", + "context.workingSet.target", + "context.workingSet.unknown", + ]); + deepStrictEqual(result.settings.context.workingSet, DEFAULT_SETTINGS.context.workingSet); + }); + + it("requires context and workingSet to be maps and target to stay strictly between zero and one", () => { + const badContext = validateSettings({ context: false }); + deepStrictEqual( + badContext.issues.map((issue) => issue.path), + ["context"], + ); + + const badWorkingSet = validateSettings({ context: { workingSet: [] } }); + deepStrictEqual( + badWorkingSet.issues.map((issue) => issue.path), + ["context.workingSet"], + ); + + for (const target of [0, 1]) { + const result = validateSettings({ context: { workingSet: { target } } }); + strictEqual(result.issues[0]?.path, "context.workingSet.target"); + strictEqual(result.settings.context.workingSet.target, DEFAULT_SETTINGS.context.workingSet.target); + } + }); +}); diff --git a/tests/contracts/context-working-set-wiring.test.ts b/tests/contracts/context-working-set-wiring.test.ts new file mode 100644 index 000000000..35ace7b39 --- /dev/null +++ b/tests/contracts/context-working-set-wiring.test.ts @@ -0,0 +1,311 @@ +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { BusChannels, type ContextPrunedPayload } from "../../src/core/bus-events.js"; +import type { ClioSettings } from "../../src/core/config.js"; +import { DEFAULT_SETTINGS } from "../../src/core/defaults.js"; +import { createSafeEventBus } from "../../src/core/event-bus.js"; +import type { EvictionPlan } from "../../src/domains/context/working-set/contract.js"; +import type { CompactResult } from "../../src/domains/session/compaction/compact.js"; +import type { SessionContract, SessionEntryInput, SessionMeta } from "../../src/domains/session/contract.js"; +import type { MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; +import type { AgentMessage } from "../../src/engine/types.js"; +import { createTurnContext } from "../../src/interactive/turn-context.js"; +import { type AgentRuntime, createTurnState } from "../../src/interactive/turn-state.js"; + +const priorLegacyMask = process.env.CLIO_CODER_LEGACY_MASK; + +afterEach(() => { + if (priorLegacyMask === undefined) delete process.env.CLIO_CODER_LEGACY_MASK; + else process.env.CLIO_CODER_LEGACY_MASK = priorLegacyMask; +}); + +function fixtureEntries(): SessionEntry[] { + const staleBody = `${"large observation line\n".repeat(300)}done`; + return [ + { + kind: "message", + turnId: "user-old", + parentTurnId: null, + timestamp: "2026-08-21T00:00:00.000Z", + role: "user", + payload: { text: "read the large fixture" }, + } satisfies MessageEntry, + { + kind: "message", + turnId: "result-old", + parentTurnId: "user-old", + timestamp: "2026-08-21T00:00:01.000Z", + role: "tool_result", + payload: { + toolCallId: "call-read", + toolName: "read", + result: { content: [{ type: "text", text: staleBody }] }, + isError: false, + }, + } satisfies MessageEntry, + { + kind: "message", + turnId: "user-recent", + parentTurnId: "result-old", + timestamp: "2026-08-21T00:00:02.000Z", + role: "user", + payload: { text: "continue" }, + } satisfies MessageEntry, + ]; +} + +function testSettings(enabled = true): ClioSettings { + const settings = structuredClone(DEFAULT_SETTINGS) as ClioSettings; + settings.compaction.threshold = 0.5; + settings.compaction.excludeLastTurns = 1; + settings.context.workingSet.enabled = enabled; + return settings; +} + +function fakeRuntime(): AgentRuntime { + return { + targetId: "test-target", + runtimeId: "test-runtime", + wireModelId: "test-model", + agent: { + sessionId: undefined, + state: { + systemPrompt: "", + messages: [ + { + role: "user", + content: [{ type: "text", text: "x".repeat(5_000) }], + timestamp: Date.now(), + } as AgentMessage, + ], + tools: [], + model: undefined, + thinkingLevel: "off", + }, + } as never, + runtimeResolution: { + contextWindowDetails: { + desiredContextWindow: 1_000, + effectiveContextWindow: 1_000, + contextWindowSource: "descriptor-default", + }, + } as never, + }; +} + +function fakeSession(entries: SessionEntry[]): { + contract: SessionContract; + appended: SessionEntry[]; + replaceCalls: () => number; +} { + const appended: SessionEntry[] = []; + let replaces = 0; + let nextId = 0; + const meta = { + id: "session-working-set", + createdAt: "2026-08-21T00:00:00.000Z", + cwd: process.cwd(), + } as SessionMeta; + const contract = { + current: () => meta, + appendEntry(input: SessionEntryInput) { + const entry = { + ...input, + turnId: input.turnId ?? `sidecar-${++nextId}`, + timestamp: input.timestamp ?? "2026-08-21T00:00:03.000Z", + } as SessionEntry; + entries.push(entry); + appended.push(entry); + return entry; + }, + replaceEntries(next: ReadonlyArray) { + replaces += 1; + entries.splice(0, entries.length, ...next); + }, + } as SessionContract; + return { contract, appended, replaceCalls: () => replaces }; +} + +function fakePlan(): EvictionPlan { + return { + policyId: "age-horizon", + items: [ + { + ref: { entry: "result-old" }, + reason: "age_horizon", + tokensFreed: 1_000, + marker: "[working-set ref=result-old]", + }, + ], + tokensBefore: 1_250, + tokensAfter: 250, + }; +} + +function harness(enabled = true, withSummary = true) { + const entries = fixtureEntries(); + const session = fakeSession(entries); + const settings = testSettings(enabled); + const bus = createSafeEventBus(); + const pruned: ContextPrunedPayload[] = []; + bus.on(BusChannels.ContextPruned, (payload) => { + pruned.push(payload); + }); + const hookStages: string[] = []; + const hookTriggers: string[] = []; + let summaryCalls = 0; + let plannerCalls = 0; + const state = createTurnState("off"); + state.lastTurnId = "user-recent"; + const runtime = fakeRuntime(); + state.runtime = runtime; + const context = createTurnContext({ + state, + getSettings: () => settings, + providers: { getRuntime: () => ({ tier: "local-native" }) } as never, + session: session.contract, + readSessionEntries: () => entries, + ...(withSummary + ? { + autoCompact: async (): Promise => { + summaryCalls += 1; + return null; + }, + } + : {}), + planEviction: () => { + plannerCalls += 1; + return fakePlan(); + }, + bus, + middleware: { + fireCompactionHook(stage: string, trigger: string) { + hookStages.push(stage); + hookTriggers.push(trigger); + }, + } as never, + emitNotice: () => {}, + }); + return { + context, + state, + bus, + entries, + runtime, + session, + pruned, + hookStages, + hookTriggers, + summaryCalls: () => summaryCalls, + plannerCalls: () => plannerCalls, + }; +} + +describe("contracts/context working-set compaction wiring", () => { + it("appends one active-path eviction, never rewrites entries, and emits the working-set stage", async () => { + delete process.env.CLIO_CODER_LEGACY_MASK; + const h = harness(true); + + await h.context.runAutoCompact(h.runtime, false); + + const evictions = h.session.appended.filter((entry) => entry.kind === "contextEviction"); + strictEqual(evictions.length, 1); + strictEqual(evictions[0]?.parentTurnId, "user-recent", "eviction sidecar is anchored to the current leaf"); + strictEqual(h.session.replaceCalls(), 0, "normal working-set eviction must keep the ledger append-only"); + strictEqual(h.pruned[0]?.stage, "working_set"); + strictEqual(h.pruned[0]?.policyId, "age-horizon"); + strictEqual(h.pruned[0]?.evictedItems, 1); + ok(h.hookStages.includes("working_set_evict")); + strictEqual(h.hookTriggers[h.hookStages.indexOf("working_set_evict")], "pressure"); + }); + + it("attributes the next local cold run and context ledger to working-set eviction", async () => { + delete process.env.CLIO_CODER_LEGACY_MASK; + const h = harness(true, false); + await h.context.runAutoCompact(h.runtime, false); + + h.context.consumeExpectedColdReasons("test-runtime"); + const usage = { + input: 1_000, + output: 10, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1_010, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + const promptCache = h.context.promptCachePayloadForAssistant(usage); + deepStrictEqual(promptCache.expectedColdReasons, ["working_set_evict"]); + + h.context.noteRunCacheSummary( + [{ role: "assistant", content: [], stopReason: "stop", timestamp: Date.now(), usage } as unknown as AgentMessage], + "cold", + ); + deepStrictEqual(h.context.contextLedger().promptCache?.expectedColdReasons, ["working_set_evict"]); + }); + + it("attributes an in-run post-tool eviction to the immediate continuation", async () => { + delete process.env.CLIO_CODER_LEGACY_MASK; + const h = harness(true, false); + h.state.streaming = true; + + await h.context.runAutoCompact(h.runtime, false); + + const usage = { + input: 1_000, + output: 10, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1_010, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + const continuationCache = h.context.promptCachePayloadForAssistant(usage); + deepStrictEqual(continuationCache.expectedColdReasons, ["working_set_evict"]); + strictEqual( + h.context.promptCachePayloadForAssistant(usage).expectedColdReasons, + undefined, + "the reason is stamped once, on the immediate continuation", + ); + }); + + it("routes successful recall events through on_compaction without adding a new hook", () => { + const h = harness(true, false); + h.bus.emit(BusChannels.ContextRecalled, { + ref: "result-old", + trigger: "tool", + tokensReadmitted: 1_000, + at: Date.now(), + }); + + deepStrictEqual(h.hookStages, ["working_set_recall"]); + deepStrictEqual(h.hookTriggers, ["tool"]); + }); + + it("keeps the destructive mask path only behind CLIO_CODER_LEGACY_MASK=1", async () => { + process.env.CLIO_CODER_LEGACY_MASK = "1"; + const h = harness(true); + + await h.context.runAutoCompact(h.runtime, false); + + strictEqual(h.session.replaceCalls(), 1); + strictEqual( + h.session.appended.some((entry) => entry.kind === "contextEviction"), + false, + ); + strictEqual(h.plannerCalls(), 0); + strictEqual(h.pruned[0]?.stage, "mask_observations"); + ok(h.hookStages.includes("mask_observations")); + }); + + it("skips eviction and masking when disabled and reaches summary compaction", async () => { + delete process.env.CLIO_CODER_LEGACY_MASK; + const h = harness(false); + + await h.context.runAutoCompact(h.runtime, false); + + strictEqual(h.plannerCalls(), 0); + strictEqual(h.session.replaceCalls(), 0); + deepStrictEqual(h.session.appended, []); + strictEqual(h.summaryCalls(), 1); + deepStrictEqual(h.hookStages, ["llm_summary"]); + }); +}); diff --git a/tests/contracts/memory-intervention.test.ts b/tests/contracts/memory-intervention.test.ts index 5927a71f4..f11517673 100644 --- a/tests/contracts/memory-intervention.test.ts +++ b/tests/contracts/memory-intervention.test.ts @@ -476,6 +476,19 @@ describe("contracts/memory intervention rules tier", () => { strictEqual(calls, 0); }); + it("does not treat working-set recall as context loss that needs memory reactivation", () => { + const bank = new TaskMemoryBank(); + bank.saveKnowledge("Keep the operator's required branch."); + const registration = createMemoryInterventionRegistration({ bank }); + + registration.evaluate({ + hook: "on_compaction", + metadata: { stage: "working_set_recall", trigger: "tool" }, + }); + + deepStrictEqual(registration.evaluate({ hook: "turn_start", text: "resume task" }), []); + }); + it("reads next-turn trigger settings from the live settings layer", async () => { const bank = new TaskMemoryBank(); let calls = 0; From eeefab2d04c8422e3ce74df7956092a4af740561 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:27:11 -0500 Subject: [PATCH 09/45] feat(context): add the working-set path index One pure pass over the active-path entries answering what the session has observed about which files: op, canonical path, line range, the paths a listing surfaced, whether the call failed, and where in the turn sequence it sits. The structural policy reads it to decide staleness, supersession, failure resolution, and listing consumption; the replay reference graph reads the same index to label file_reread, file_discovery, and file_rewrite. One index, two consumers, so a rule and its measurement cannot disagree about what the session did. Generalizes extractFileOps in compaction/compact.ts with the same argument reading and the same tool-call pairing chat-renderer.ts uses. No filesystem access: paths resolve lexically against the session cwd when the slice carries the JSONL header, and stay as written when it does not, so a replay run and a live run index the same ledger identically. --- src/domains/context/working-set/path-index.ts | 404 ++++++++++++++++++ .../contracts/working-set-path-index.test.ts | 300 +++++++++++++ 2 files changed, 704 insertions(+) create mode 100644 src/domains/context/working-set/path-index.ts create mode 100644 tests/contracts/working-set-path-index.test.ts diff --git a/src/domains/context/working-set/path-index.ts b/src/domains/context/working-set/path-index.ts new file mode 100644 index 000000000..180a5bf9f --- /dev/null +++ b/src/domains/context/working-set/path-index.ts @@ -0,0 +1,404 @@ +/** + * What the session has observed about which files. + * + * One pass over the active-path entries produces one `PathObservation` per + * tool result (and per `fileEntry`) that names a path: which file, which line + * range, which paths a listing surfaced, whether the call failed, and where in + * the turn sequence it sits. The structural policy reads it to answer the four + * questions its rules ask ("was this file written after I read it", "did a + * later read cover this range", "did this failure get resolved", "has this + * listing been consumed"), and the replay reference graph reads the same index + * to label `file_reread`, `file_discovery`, and `file_rewrite` edges. One + * index, two consumers, so a rule and its measurement can never disagree about + * what the session did. + * + * Pure, deterministic, single pass. No filesystem access: paths are resolved + * lexically against the session cwd when the entries carry the session header, + * and left as written when they do not. No `process.cwd()` fallback, because a + * replay run and a live run would then index the same ledger differently. + * + * This is `extractFileOps` in `compaction/compact.ts` generalized: same + * `path | file_path | filePath` argument reading, same tool-call pairing as + * `chat-renderer.ts`, plus ranges, listings, failures, and turn positions. + */ + +import { basename, isAbsolute, normalize, resolve } from "node:path"; +import type { MessageEntry, SessionEntry } from "../../session/entries.js"; +import type { WorkingSetRef } from "./contract.js"; +import { isRecord, toolResultText } from "./payload.js"; + +/** + * The observing verb. Clio's `git` and `verify` are command runners with an + * exit status, so they index as `bash`; `artifact` writes a file, so it indexes + * as `write`. Tools that observe no path at all (dispatch, web_fetch, tasks, + * ask_user, context, ...) produce no observation. + */ +export type PathOp = "read" | "grep" | "find" | "ls" | "code_nav" | "write" | "edit" | "bash"; + +/** + * Lines of a file an observation covers. `offset` counts lines skipped from the + * top (0-based), so a whole-file read is `{ offset: 0, limit: null }` and + * `read(offset: 1)` normalizes to it. `limit: null` means "to EOF". + */ +export interface PathRange { + offset: number; + limit: number | null; +} + +export interface PathObservation { + /** The evictable unit: the tool_result entry, or the fileEntry entry for write/edit evidence. */ + ref: WorkingSetRef; + toolCallId: string | null; + toolName: string; + op: PathOp; + /** + * Canonical absolute path when the session cwd is known, else the path as + * the call wrote it. Empty when the call named no path (a bash command with + * no cwd argument), which keeps it out of `byPath` without dropping the + * observation the failure rules need. + */ + path: string; + /** + * Line coverage for `read`, null for everything else and for a `tail` read, + * whose coverage is unknowable without the file. Supersession treats a null + * range as unknown: it covers nothing and only a full read covers it. + */ + range: PathRange | null; + /** Listing ops only: concrete file paths the result surfaced, resolved like `path`. */ + surfaced: ReadonlyArray; + isError: boolean; + /** Turn starts (user message, bashExecution, branchSummary) strictly before this entry. */ + turnIndex: number; + /** Index in the entries array this index was built from. */ + entryIndex: number; + /** Tool-call arguments as deterministic JSON with sorted keys; empty when the call is unknown. */ + argsKey: string; +} + +export interface PathIndex { + /** Ledger order. */ + observations: ReadonlyArray; + byRef: ReadonlyMap; + byPath: ReadonlyMap>; + /** Every entry's turn position, including entries that observe no path. */ + turnIndexOf: ReadonlyMap; + turnCount: number; +} + +/** Commands whose stdout is a list of paths. Anything else surfaces nothing. */ +const LISTING_COMMANDS = new Set(["ls", "find", "tree", "fd", "rg", "grep"]); +/** Commands whose stdout is `path:line:text` rather than one path per line. */ +const MATCH_LINE_COMMANDS = new Set(["rg", "grep"]); + +const TOOL_OPS: ReadonlyMap = new Map([ + ["read", "read"], + ["grep", "grep"], + ["find", "find"], + ["ls", "ls"], + ["code_nav", "code_nav"], + ["write", "write"], + ["edit", "edit"], + ["artifact", "write"], + ["bash", "bash"], + ["git", "bash"], + ["verify", "bash"], +]); + +/** code_nav modes whose `query` is a file path rather than a symbol or page name. */ +const CODE_NAV_PATH_MODES = new Set(["path", "outline", "deps", "dependents"]); + +/** Ops whose result is a list of other paths. */ +const LISTING_OPS = new Set(["grep", "find", "ls", "bash"]); + +/** + * Turn starts, the same three kinds the protection horizon counts. A local `!` + * bash execution and a branch summary each open a stretch of work the way an + * operator message does. + */ +function isTurnStart(entry: SessionEntry): boolean { + if (entry.kind === "bashExecution" || entry.kind === "branchSummary") return true; + return entry.kind === "message" && entry.role === "user"; +} + +/** + * The session cwd, when the caller kept the JSONL header in the slice. The + * header is a `SessionFileEntry`, not a `SessionEntry`, so this is a runtime + * shape check rather than a `kind` test; without it every relative path stays + * exactly as the call wrote it. + */ +function sessionCwd(entries: ReadonlyArray): string | null { + for (const entry of entries) { + const record = entry as unknown as Record; + if (record.type !== "session") continue; + const cwd = record.cwd; + if (typeof cwd === "string" && cwd.length > 0 && isAbsolute(cwd)) return normalize(cwd); + } + return null; +} + +/** Lexical canonicalization only: no realpath, no `process.cwd()`, no `~` expansion. */ +function canonicalize(value: string, cwd: string | null): string { + const trimmed = value.trim(); + if (trimmed.length === 0) return ""; + if (isAbsolute(trimmed)) return normalize(trimmed); + return cwd === null ? trimmed : resolve(cwd, trimmed); +} + +function stableStringify(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (isRecord(value)) { + const keys = Object.keys(value).sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; + } + if (value === undefined) return "null"; + return JSON.stringify(value) ?? "null"; +} + +interface ToolCallFacts { + toolName: string; + args: unknown; + argsKey: string; +} + +function callFacts(toolName: string, args: unknown): ToolCallFacts { + return { toolName, args, argsKey: args === undefined ? "" : stableStringify(args) }; +} + +function stringField(record: Record | null, ...keys: string[]): string | null { + if (record === null) return null; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + } + return null; +} + +function payloadRecord(payload: unknown): Record | null { + return isRecord(payload) ? payload : null; +} + +/** Record every tool call the ledger holds, in both shapes it persists them. */ +function collectToolCalls(entries: ReadonlyArray): Map { + const calls = new Map(); + for (const entry of entries) { + if (entry.kind !== "message") continue; + const obj = payloadRecord(entry.payload); + if (entry.role === "tool_call" && obj !== null) { + const id = stringField(obj, "toolCallId", "tool_call_id", "id") ?? entry.turnId; + const name = stringField(obj, "name", "toolName", "tool") ?? "tool"; + calls.set(id, callFacts(name, obj.args ?? obj.arguments ?? obj.input)); + continue; + } + if (entry.role !== "assistant" || obj === null || !Array.isArray(obj.content)) continue; + for (const block of obj.content) { + if (!isRecord(block) || block.type !== "toolCall") continue; + const id = stringField(block, "id", "toolCallId") ?? entry.turnId; + const name = stringField(block, "name", "toolName") ?? "tool"; + calls.set(id, callFacts(name, block.arguments ?? block.args ?? block.input)); + } + } + return calls; +} + +/** + * The path the call was about. Search tools default to the working directory, + * which is what they actually searched, so a `grep` with no `path` argument is + * an observation of the cwd rather than of nothing. + */ +function observedPath(op: PathOp, args: Record | null, cwd: string | null): string { + if (op === "bash") { + const explicit = stringField(args, "cwd"); + return explicit === null ? "" : canonicalize(explicit, cwd); + } + if (op === "code_nav") { + const mode = stringField(args, "mode"); + if (mode === null || !CODE_NAV_PATH_MODES.has(mode)) return ""; + const query = stringField(args, "query"); + return query === null ? "" : canonicalize(query, cwd); + } + const named = stringField(args, "path", "file_path", "filePath"); + if (named !== null) return canonicalize(named, cwd); + // grep, find, and ls all default to ".". + if (op === "grep" || op === "find" || op === "ls") return cwd ?? "."; + return ""; +} + +function readRange(args: Record | null): PathRange | null { + if (args === null) return { offset: 0, limit: null }; + const tail = args.tail; + // A tail read covers an unknown suffix; claiming a range would let it + // supersede reads it may not contain. + if (typeof tail === "number" && Number.isFinite(tail) && tail > 0) return null; + const rawOffset = args.offset; + const rawLimit = args.limit; + const offset = + typeof rawOffset === "number" && Number.isFinite(rawOffset) && rawOffset > 1 ? Math.floor(rawOffset) - 1 : 0; + const limit = typeof rawLimit === "number" && Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : null; + return { offset, limit }; +} + +/** + * Is this line a concrete file path rather than prose, a directory, or a + * tool's own notice? Deliberately permissive about extensions (`Makefile` and + * `LICENSE` are files) and strict about whitespace, because every listing this + * parses prints one path per line. Over-counting only keeps a listing in the + * working set longer; under-counting would evict a listing whose paths the + * agent still needs. + */ +function looksLikeConcreteFilePath(line: string): boolean { + const text = line.trim(); + if (text.length === 0) return false; + // The observation envelope's own trailer, e.g. `[grep: 261/261+ matches ...]`. + if (text.startsWith("[")) return false; + if (text.endsWith("/")) return false; + return !/\s/.test(text); +} + +/** `path:line: text` from grep content mode and from bash `rg`/`grep`. */ +function pathFromMatchLine(line: string): string | null { + const match = /^([^\s:]+):\d+[:-]/.exec(line.trim()); + return match?.[1] ?? null; +} + +function surfacedPaths( + op: PathOp, + args: Record | null, + text: string, + root: string, + cwd: string | null, +): string[] { + const lines = text.split("\n"); + const matchLines = op === "grep" || (op === "bash" && isMatchLineCommand(args)); + const out: string[] = []; + const seen = new Set(); + for (const line of lines) { + const raw = matchLines ? (pathFromMatchLine(line) ?? candidateWholeLine(line)) : candidateWholeLine(line); + if (raw === null) continue; + const resolved = resolveSurfaced(raw, root, cwd); + if (resolved.length === 0 || seen.has(resolved)) continue; + seen.add(resolved); + out.push(resolved); + } + return out; +} + +function candidateWholeLine(line: string): string | null { + return looksLikeConcreteFilePath(line) ? line.trim() : null; +} + +function isMatchLineCommand(args: Record | null): boolean { + const verb = commandVerb(args); + return verb !== null && MATCH_LINE_COMMANDS.has(verb); +} + +function commandVerb(args: Record | null): string | null { + const command = stringField(args, "command"); + if (command === null) return null; + const first = command.trimStart().split(/\s+/)[0]; + return first === undefined || first.length === 0 ? null : basename(first); +} + +/** + * A listing prints paths relative to what it searched, so they resolve against + * the observation's own path. A single-file search root surfaces its own + * basename, which resolves back to the root rather than to a child of it. + */ +function resolveSurfaced(value: string, root: string, cwd: string | null): string { + if (isAbsolute(value)) return normalize(value); + if (root.length > 0 && isAbsolute(root)) { + return basename(root) === value ? root : resolve(root, value); + } + return canonicalize(value, cwd); +} + +/** A listing result only surfaces paths when the call was a listing in the first place. */ +function shouldParseSurfaced(op: PathOp, args: Record | null, isError: boolean): boolean { + if (isError || !LISTING_OPS.has(op)) return false; + if (op !== "bash") return true; + const verb = commandVerb(args); + return verb !== null && LISTING_COMMANDS.has(verb); +} + +function fileEntryOp(operation: "read" | "write" | "edit" | "create" | "delete"): PathOp { + if (operation === "read") return "read"; + if (operation === "edit") return "edit"; + return "write"; +} + +function toolResultObservation( + entry: MessageEntry, + context: { entryIndex: number; turnIndex: number; cwd: string | null; calls: ReadonlyMap }, +): PathObservation | null { + const obj = payloadRecord(entry.payload); + const toolCallId = stringField(obj, "toolCallId", "tool_call_id", "id"); + const call = toolCallId === null ? undefined : context.calls.get(toolCallId); + const toolName = stringField(obj, "toolName", "name", "tool") ?? call?.toolName ?? "tool"; + const op = TOOL_OPS.get(toolName); + if (op === undefined) return null; + const args = call !== undefined && isRecord(call.args) ? call.args : null; + const isError = obj?.isError === true || obj?.error === true; + const path = observedPath(op, args, context.cwd); + const surfaced = shouldParseSurfaced(op, args, isError) + ? surfacedPaths(op, args, toolResultText(obj?.result ?? entry.payload), path, context.cwd) + : []; + return { + ref: { entry: entry.turnId }, + toolCallId, + toolName, + op, + path, + range: op === "read" ? readRange(args) : null, + surfaced, + isError, + turnIndex: context.turnIndex, + entryIndex: context.entryIndex, + argsKey: call?.argsKey ?? "", + }; +} + +export function buildPathIndex(entries: ReadonlyArray): PathIndex { + const cwd = sessionCwd(entries); + const calls = collectToolCalls(entries); + const observations: PathObservation[] = []; + const byRef = new Map(); + const byPath = new Map(); + const turnIndexOf = new Map(); + let turnIndex = 0; + + for (let entryIndex = 0; entryIndex < entries.length; entryIndex += 1) { + const entry = entries[entryIndex]; + if (entry === undefined) continue; + turnIndexOf.set(entry.turnId, turnIndex); + let observation: PathObservation | null = null; + if (entry.kind === "fileEntry") { + observation = { + ref: { entry: entry.turnId }, + toolCallId: null, + toolName: "fileEntry", + op: fileEntryOp(entry.operation), + path: canonicalize(entry.path, cwd), + range: null, + surfaced: [], + isError: false, + turnIndex, + entryIndex, + argsKey: "", + }; + } else if (entry.kind === "message" && entry.role === "tool_result") { + observation = toolResultObservation(entry, { entryIndex, turnIndex, cwd, calls }); + } + if (observation !== null) { + observations.push(observation); + byRef.set(observation.ref.entry, observation); + if (observation.path.length > 0) { + const bucket = byPath.get(observation.path); + if (bucket === undefined) byPath.set(observation.path, [observation]); + else bucket.push(observation); + } + } + if (isTurnStart(entry)) turnIndex += 1; + } + + return { observations, byRef, byPath, turnIndexOf, turnCount: turnIndex }; +} diff --git a/tests/contracts/working-set-path-index.test.ts b/tests/contracts/working-set-path-index.test.ts new file mode 100644 index 000000000..8860595de --- /dev/null +++ b/tests/contracts/working-set-path-index.test.ts @@ -0,0 +1,300 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { buildPathIndex, type PathObservation } from "../../src/domains/context/working-set/path-index.js"; +import type { SessionEntry } from "../../src/domains/session/entries.js"; + +const CWD = "/repo"; +const TS = "2026-08-21T00:00:00.000Z"; + +/** The JSONL header is a SessionFileEntry, not a SessionEntry; callers that keep it pass it through. */ +const HEADER = { type: "session", version: 4, id: "s1", timestamp: TS, cwd: CWD } as unknown as SessionEntry; + +let seq = 0; +function nextId(prefix: string): string { + seq += 1; + return `${prefix}${seq}`; +} + +function user(text: string): SessionEntry { + return { kind: "message", turnId: nextId("u"), parentTurnId: null, timestamp: TS, role: "user", payload: { text } }; +} + +function call(toolName: string, args: unknown, id = nextId("call-")): { entry: SessionEntry; id: string } { + return { + id, + entry: { + kind: "message", + turnId: nextId("c"), + parentTurnId: null, + timestamp: TS, + role: "tool_call", + payload: { toolCallId: id, name: toolName, args }, + }, + }; +} + +function result( + toolName: string, + callId: string, + text: string, + options: { isError?: boolean; turnId?: string } = {}, +): SessionEntry { + return { + kind: "message", + turnId: options.turnId ?? nextId("t"), + parentTurnId: null, + timestamp: TS, + role: "tool_result", + payload: { + toolCallId: callId, + toolName, + result: { content: [{ type: "text", text }] }, + isError: options.isError === true, + }, + }; +} + +/** One call/result pair plus the entries around it, indexed. */ +function indexOne(toolName: string, args: unknown, text = "", options: { isError?: boolean } = {}): PathObservation { + const made = call(toolName, args); + const entries: SessionEntry[] = [HEADER, user("go"), made.entry, result(toolName, made.id, text, options)]; + const observations = buildPathIndex(entries).observations; + const first = observations[0]; + assert.ok(first, `expected one observation for ${toolName}`); + return first; +} + +test("path index: a read carries the canonical path and a full range", () => { + const observation = indexOne("read", { path: "src/a.ts" }, "file body"); + assert.equal(observation.op, "read"); + assert.equal(observation.path, "/repo/src/a.ts"); + assert.deepEqual(observation.range, { offset: 0, limit: null }); + assert.equal(observation.isError, false); + assert.deepEqual(observation.surfaced, []); + assert.equal(observation.toolName, "read"); +}); + +test("path index: read ranges normalize the 1-indexed offset argument", () => { + assert.deepEqual(indexOne("read", { path: "src/a.ts", offset: 51, limit: 100 }).range, { offset: 50, limit: 100 }); + // offset 1 is the top of the file, which is the full-read shape. + assert.deepEqual(indexOne("read", { path: "src/a.ts", offset: 1 }).range, { offset: 0, limit: null }); + assert.deepEqual(indexOne("read", { path: "src/a.ts", limit: 40 }).range, { offset: 0, limit: 40 }); + // A tail read covers an unknown suffix, so it claims no range at all. + assert.equal(indexOne("read", { path: "src/a.ts", tail: 30 }).range, null); +}); + +test("path index: an absolute argument is kept, a relative one without a header is left as written", () => { + assert.equal(indexOne("read", { path: "/elsewhere/b.ts" }).path, "/elsewhere/b.ts"); + + const made = call("read", { path: "src/a.ts" }); + const entries: SessionEntry[] = [user("go"), made.entry, result("read", made.id, "body")]; + assert.equal(buildPathIndex(entries).observations[0]?.path, "src/a.ts"); +}); + +test("path index: grep surfaces the path before the line number", () => { + // grep prints paths relative to the directory it searched. + const body = [ + "a.ts:12: const x = 1;", + "a.ts-13- context line", + "b.ts:4: const y = 2;", + "[grep: 3/3 matches shown (1.0KB of 1.0KB)]", + ].join("\n"); + const observation = indexOne("grep", { pattern: "const", path: "src" }, body); + assert.equal(observation.op, "grep"); + assert.equal(observation.path, "/repo/src"); + assert.deepEqual(observation.surfaced, ["/repo/src/a.ts", "/repo/src/b.ts"]); +}); + +test("path index: a search with no path argument observes the session cwd", () => { + assert.equal(indexOne("grep", { pattern: "x" }, "a.ts:1: x").path, CWD); + assert.equal(indexOne("find", { pattern: "**/*.ts" }, "a.ts").path, CWD); + assert.equal(indexOne("ls", {}, "a.ts").path, CWD); +}); + +test("path index: find surfaces concrete files and skips directories and notices", () => { + const body = ["a.ts", "nested/", "b.ts", "[find: 3/3 paths shown]", " ", "Makefile"].join("\n"); + const observation = indexOne("find", { pattern: "**/*", path: "src" }, body); + assert.deepEqual(observation.surfaced, ["/repo/src/a.ts", "/repo/src/b.ts", "/repo/src/Makefile"]); +}); + +test("path index: ls surfaces its entries against the listed directory", () => { + const observation = indexOne("ls", { path: "/repo/docs" }, ["guide.md", "images/", "README.md"].join("\n")); + assert.equal(observation.op, "ls"); + assert.deepEqual(observation.surfaced, ["/repo/docs/guide.md", "/repo/docs/README.md"]); +}); + +test("path index: code_nav observes a path only in its path-shaped modes", () => { + assert.equal(indexOne("code_nav", { mode: "path", query: "src/a.ts" }).path, "/repo/src/a.ts"); + assert.equal(indexOne("code_nav", { mode: "outline", query: "src/a.ts" }).path, "/repo/src/a.ts"); + assert.equal(indexOne("code_nav", { mode: "symbol", query: "buildPathIndex" }).path, ""); + assert.equal(indexOne("code_nav", { mode: "wiki", query: "architecture" }).path, ""); +}); + +test("path index: write, edit, and artifact are mutations of their path", () => { + assert.equal(indexOne("write", { path: "src/a.ts", content: "x" }).op, "write"); + assert.equal(indexOne("edit", { path: "src/a.ts", edits: [] }).op, "edit"); + const artifact = indexOne("artifact", { kind: "report", content: "x", path: "docs/r.md" }); + assert.equal(artifact.op, "write"); + assert.equal(artifact.path, "/repo/docs/r.md"); +}); + +test("path index: bash takes its cwd argument and parses only listing commands", () => { + const listing = indexOne("bash", { command: "ls -1", cwd: "src" }, ["a.ts", "b.ts"].join("\n")); + assert.equal(listing.op, "bash"); + assert.equal(listing.path, "/repo/src"); + assert.deepEqual(listing.surfaced, ["/repo/src/a.ts", "/repo/src/b.ts"]); + + const rg = indexOne("bash", { command: "rg const src", cwd: "/repo" }, "src/a.ts:3: const x = 1;"); + assert.deepEqual(rg.surfaced, ["/repo/src/a.ts"]); + + // Not a listing verb: the output is prose as far as this index is concerned. + const build = indexOne("bash", { command: "npm run build" }, "dist/index.js"); + assert.deepEqual(build.surfaced, []); + assert.equal(build.path, "", "a bash call with no cwd argument names no path"); +}); + +test("path index: git and verify index as commands", () => { + assert.equal(indexOne("git", { op: "status" }, "clean").op, "bash"); + assert.equal(indexOne("verify", { check: "typecheck" }, "ok").op, "bash"); +}); + +test("path index: an error result keeps its observation and surfaces nothing", () => { + const observation = indexOne("find", { pattern: "**/*", path: "src" }, "find: path not found: src", { + isError: true, + }); + assert.equal(observation.isError, true); + assert.deepEqual(observation.surfaced, []); +}); + +test("path index: a fileEntry is write evidence with no tool call", () => { + const entries: SessionEntry[] = [ + HEADER, + user("go"), + { kind: "fileEntry", turnId: "f1", parentTurnId: null, timestamp: TS, path: "src/a.ts", operation: "create" }, + { kind: "fileEntry", turnId: "f2", parentTurnId: null, timestamp: TS, path: "src/b.ts", operation: "edit" }, + { kind: "fileEntry", turnId: "f3", parentTurnId: null, timestamp: TS, path: "src/c.ts", operation: "read" }, + ]; + const index = buildPathIndex(entries); + assert.deepEqual( + index.observations.map((observation) => [observation.ref.entry, observation.op, observation.path]), + [ + ["f1", "write", "/repo/src/a.ts"], + ["f2", "edit", "/repo/src/b.ts"], + ["f3", "read", "/repo/src/c.ts"], + ], + ); + assert.equal(index.byRef.get("f1")?.toolCallId, null); + assert.equal(index.byRef.get("f1")?.argsKey, ""); +}); + +test("path index: argsKey is order-independent and distinguishes different arguments", () => { + const a = indexOne("read", { path: "src/a.ts", limit: 10, offset: 2 }); + const b = indexOne("read", { offset: 2, path: "src/a.ts", limit: 10 }); + const c = indexOne("read", { path: "src/a.ts", limit: 11, offset: 2 }); + assert.equal(a.argsKey, b.argsKey); + assert.notEqual(a.argsKey, c.argsKey); + assert.equal(a.argsKey, '{"limit":10,"offset":2,"path":"src/a.ts"}'); +}); + +test("path index: an unpaired result carries an empty argsKey rather than a guess", () => { + const entries: SessionEntry[] = [HEADER, user("go"), result("read", "call-missing", "body")]; + const observation = buildPathIndex(entries).observations[0]; + assert.equal(observation?.argsKey, ""); + assert.equal(observation?.path, ""); + assert.equal(observation?.toolCallId, "call-missing"); +}); + +test("path index: a call streamed as an assistant content block still pairs", () => { + const entries: SessionEntry[] = [ + HEADER, + user("go"), + { + kind: "message", + turnId: "a1", + parentTurnId: null, + timestamp: TS, + role: "assistant", + payload: { + content: [{ type: "toolCall", id: "call-block", name: "read", arguments: { path: "src/a.ts" } }], + }, + }, + result("read", "call-block", "body"), + ]; + const observation = buildPathIndex(entries).observations[0]; + assert.equal(observation?.path, "/repo/src/a.ts"); + assert.equal(observation?.argsKey, '{"path":"src/a.ts"}'); +}); + +test("path index: turn positions count turn starts strictly before an entry", () => { + const first = call("read", { path: "a.ts" }); + const second = call("read", { path: "b.ts" }); + const entries: SessionEntry[] = [ + HEADER, + user("one"), + first.entry, + result("read", first.id, "body", { turnId: "r1" }), + { + kind: "bashExecution", + turnId: "b1", + parentTurnId: null, + timestamp: TS, + command: "ls", + output: "a.ts", + exitCode: 0, + cancelled: false, + truncated: false, + }, + second.entry, + result("read", second.id, "body", { turnId: "r2" }), + ]; + const index = buildPathIndex(entries); + assert.equal(index.turnCount, 2); + assert.equal(index.byRef.get("r1")?.turnIndex, 1); + assert.equal(index.byRef.get("r2")?.turnIndex, 2); + // A turn start is not before itself. + assert.equal(index.turnIndexOf.get("b1"), 1); + assert.equal(index.turnIndexOf.get("r1"), 1); +}); + +test("path index: byPath groups every observation of one file in ledger order", () => { + const read = call("read", { path: "src/a.ts" }); + const edit = call("edit", { path: "src/a.ts", edits: [] }); + const other = call("read", { path: "src/b.ts" }); + const entries: SessionEntry[] = [ + HEADER, + user("go"), + read.entry, + result("read", read.id, "body", { turnId: "r1" }), + edit.entry, + result("edit", edit.id, "edited", { turnId: "e1" }), + other.entry, + result("read", other.id, "body", { turnId: "r2" }), + ]; + const index = buildPathIndex(entries); + assert.deepEqual( + index.byPath.get("/repo/src/a.ts")?.map((observation) => observation.ref.entry), + ["r1", "e1"], + ); + assert.deepEqual( + index.byPath.get("/repo/src/b.ts")?.map((observation) => observation.ref.entry), + ["r2"], + ); + // A pathless observation never lands in byPath. + assert.equal(index.byPath.has(""), false); + assert.equal( + index.observations.every((observation) => observation.entryIndex > 0), + true, + ); +}); + +test("path index: unobserved tools produce no observation", () => { + const made = call("web_fetch", { url: "https://example.com" }); + const entries: SessionEntry[] = [HEADER, user("go"), made.entry, result("web_fetch", made.id, "page")]; + assert.deepEqual(buildPathIndex(entries).observations, []); +}); + +test("path index: the same ledger indexes identically twice", () => { + const made = call("grep", { pattern: "x", path: "src" }); + const entries: SessionEntry[] = [HEADER, user("go"), made.entry, result("grep", made.id, "src/a.ts:1: x")]; + assert.deepEqual(buildPathIndex(entries).observations, buildPathIndex(entries).observations); +}); From aa12f06cf38e3b9d1ef34a4af4305eb1366d91a5 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:29:09 -0500 Subject: [PATCH 10/45] feat(context): publish ContextRecalled from the recall scope The context tool reports a successful recall through an injected onRecalled callback; the orchestrator maps it onto BusChannels.ContextRecalled. Drops the temporary bus-wiring allowlist. --- src/entry/orchestrator.ts | 1 + src/tools/context/index.ts | 5 ++++- src/tools/core-bootstrap.ts | 4 ++++ tests/contracts/bus-wiring.test.ts | 2 -- tests/contracts/context-tool-recall.test.ts | 21 ++++++++++++++++++--- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/entry/orchestrator.ts b/src/entry/orchestrator.ts index ece7a0210..5e9bc2294 100644 --- a/src/entry/orchestrator.ts +++ b/src/entry/orchestrator.ts @@ -1243,6 +1243,7 @@ export async function bootOrchestrator(options: BootOptions = {}): Promise bus.emit(BusChannels.ContextRecalled, payload), } : {}), taskBoard, diff --git a/src/tools/context/index.ts b/src/tools/context/index.ts index 9f55a0c0b..ce8b6d188 100644 --- a/src/tools/context/index.ts +++ b/src/tools/context/index.ts @@ -1,5 +1,6 @@ import { type Dirent, readdirSync } from "node:fs"; import path from "node:path"; +import type { ContextRecalledPayload } from "../../core/bus-events.js"; import { SKILL_SUGGESTION_ANCHOR } from "../../core/skill-activation.js"; import { ToolNames } from "../../core/tool-names.js"; import { foldWorkingSet } from "../../domains/context/working-set/fold.js"; @@ -55,6 +56,8 @@ export interface ContextSessionDeps { /** The live append point (`/tree` pin or tree leaf); undefined lets the fold infer it. */ activeLeafTurnId(): string | undefined; appendEntry(entry: SessionEntryInput): SessionEntry; + /** Called after the recall entry is recorded; the orchestrator publishes it as BusChannels.ContextRecalled. */ + onRecalled?: (payload: ContextRecalledPayload) => void; } export interface ContextToolDeps { @@ -563,7 +566,7 @@ function runRecallScope( message: `context: recall of ${result.ref.entry} could not be recorded: ${err instanceof Error ? err.message : String(err)}`, }; } - // TODO(ws/wiring): emit BusChannels.ContextRecalled { ref, trigger: "tool", tokensReadmitted, at } once worker B lands the channel. + session.onRecalled?.({ ref: result.ref.entry, trigger: "tool", tokensReadmitted: result.tokens, at: Date.now() }); const evictedState = view.evicted.get(result.ref.entry); const truncation = truncateHead(result.body, { maxBytes: reservation.callCapBytes, diff --git a/src/tools/core-bootstrap.ts b/src/tools/core-bootstrap.ts index 41311beaf..30222abda 100644 --- a/src/tools/core-bootstrap.ts +++ b/src/tools/core-bootstrap.ts @@ -1,3 +1,4 @@ +import type { ContextRecalledPayload } from "../core/bus-events.js"; import type { LoadSkillsInput } from "../domains/resources/index.js"; import type { SessionContract } from "../domains/session/contract.js"; import type { SessionEntry } from "../domains/session/entries.js"; @@ -31,6 +32,8 @@ export interface CoreToolBootstrapDeps { session?: SessionContract; /** Full ledger of the current session; context(scope=recall) folds it. Absent in worker registries. */ readSessionEntries?: () => ReadonlyArray; + /** Publishes a successful context(scope=recall) on the bus; absent where no bus is wired. */ + onContextRecalled?: (payload: ContextRecalledPayload) => void; askUser?: AskUserHandler; taskBoard?: TaskBoardStore; userTasks?: UserTasksStore; @@ -130,6 +133,7 @@ export function registerCoreTools(registry: ToolRegistry, deps: CoreToolBootstra return meta ? (session.tree(meta.id).leafId ?? undefined) : undefined; }, appendEntry: (entry) => session.appendEntry(entry), + ...(deps.onContextRecalled ? { onRecalled: deps.onContextRecalled } : {}), }, } : {}), diff --git a/tests/contracts/bus-wiring.test.ts b/tests/contracts/bus-wiring.test.ts index a91669669..249c750f8 100644 --- a/tests/contracts/bus-wiring.test.ts +++ b/tests/contracts/bus-wiring.test.ts @@ -28,8 +28,6 @@ const EMIT_ALLOWLIST: Record = { "emitted through the channel-selecting ternary in src/domains/config/extension.ts dispatch()", [BusChannels.ConfigRestartRequired]: "emitted through the channel-selecting ternary in src/domains/config/extension.ts dispatch()", - [BusChannels.ContextRecalled]: - "successful recall emission is owned by the parallel ws/recall slice and lands when Worker C is merged", }; /** Channels with no direct subscribe site, and why that is correct today. */ diff --git a/tests/contracts/context-tool-recall.test.ts b/tests/contracts/context-tool-recall.test.ts index fefdcb135..53d18a8ca 100644 --- a/tests/contracts/context-tool-recall.test.ts +++ b/tests/contracts/context-tool-recall.test.ts @@ -54,7 +54,12 @@ function eviction(turnId: string, parentTurnId: string, refs: string[]): Session const BODY = "alpha\n\tbeta \nγάμμα\n"; -function fakeSession(entries: SessionEntry[]): { deps: ContextSessionDeps; entries: SessionEntry[] } { +function fakeSession(entries: SessionEntry[]): { + deps: ContextSessionDeps; + entries: SessionEntry[]; + recalled: Array<{ ref: string; trigger: string; tokensReadmitted: number }>; +} { + const recalled: Array<{ ref: string; trigger: string; tokensReadmitted: number }> = []; const deps: ContextSessionDeps = { hasSession: () => true, readEntries: () => entries, @@ -64,8 +69,11 @@ function fakeSession(entries: SessionEntry[]): { deps: ContextSessionDeps; entri entries.push(entry); return entry; }, + onRecalled: (payload) => { + recalled.push({ ref: payload.ref, trigger: payload.trigger, tokensReadmitted: payload.tokensReadmitted }); + }, }; - return { deps, entries }; + return { deps, entries, recalled }; } function baseEntries(): SessionEntry[] { @@ -80,7 +88,7 @@ function baseEntries(): SessionEntry[] { describe("contracts/context recall scope", () => { it("returns the body byte-exact and appends a contextRecall entry with the tool call id", async () => { - const { deps, entries } = fakeSession(baseEntries()); + const { deps, entries, recalled } = fakeSession(baseEntries()); const tool = createContextTool({ session: deps }); const result = await tool.run( { scope: "recall", ref: "t1" }, @@ -110,6 +118,13 @@ describe("contracts/context recall scope", () => { // The ref stays evicted after a recall; a second recall is churn, not an error. const again = await tool.run({ scope: "recall", ref: "t1" }, { toolCallId: "call-2" }); assert.equal(again.kind, "ok"); + // Each successful recall is published once for the bus. + assert.deepEqual( + recalled.map((r) => r.ref), + ["t1", "t1"], + ); + assert.equal(recalled[0]?.trigger, "tool"); + assert.equal(recalled[0]?.tokensReadmitted, Math.ceil(BODY.length / 4)); }); it("errors name the nearest valid ref", async () => { From 9daedda0b848b644f221c4b96a13f45260f7b1db Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:27:11 -0500 Subject: [PATCH 11/45] feat(context): add the working-set path index One pure pass over the active-path entries answering what the session has observed about which files: op, canonical path, line range, the paths a listing surfaced, whether the call failed, and where in the turn sequence it sits. The structural policy reads it to decide staleness, supersession, failure resolution, and listing consumption; the replay reference graph reads the same index to label file_reread, file_discovery, and file_rewrite. One index, two consumers, so a rule and its measurement cannot disagree about what the session did. Generalizes extractFileOps in compaction/compact.ts with the same argument reading and the same tool-call pairing chat-renderer.ts uses. No filesystem access: paths resolve lexically against the session cwd when the slice carries the JSONL header, and stay as written when it does not, so a replay run and a live run index the same ledger identically. (cherry picked from commit eeefab2d04c8422e3ce74df7956092a4af740561) --- src/domains/context/working-set/path-index.ts | 404 ++++++++++++++++++ .../contracts/working-set-path-index.test.ts | 300 +++++++++++++ 2 files changed, 704 insertions(+) create mode 100644 src/domains/context/working-set/path-index.ts create mode 100644 tests/contracts/working-set-path-index.test.ts diff --git a/src/domains/context/working-set/path-index.ts b/src/domains/context/working-set/path-index.ts new file mode 100644 index 000000000..180a5bf9f --- /dev/null +++ b/src/domains/context/working-set/path-index.ts @@ -0,0 +1,404 @@ +/** + * What the session has observed about which files. + * + * One pass over the active-path entries produces one `PathObservation` per + * tool result (and per `fileEntry`) that names a path: which file, which line + * range, which paths a listing surfaced, whether the call failed, and where in + * the turn sequence it sits. The structural policy reads it to answer the four + * questions its rules ask ("was this file written after I read it", "did a + * later read cover this range", "did this failure get resolved", "has this + * listing been consumed"), and the replay reference graph reads the same index + * to label `file_reread`, `file_discovery`, and `file_rewrite` edges. One + * index, two consumers, so a rule and its measurement can never disagree about + * what the session did. + * + * Pure, deterministic, single pass. No filesystem access: paths are resolved + * lexically against the session cwd when the entries carry the session header, + * and left as written when they do not. No `process.cwd()` fallback, because a + * replay run and a live run would then index the same ledger differently. + * + * This is `extractFileOps` in `compaction/compact.ts` generalized: same + * `path | file_path | filePath` argument reading, same tool-call pairing as + * `chat-renderer.ts`, plus ranges, listings, failures, and turn positions. + */ + +import { basename, isAbsolute, normalize, resolve } from "node:path"; +import type { MessageEntry, SessionEntry } from "../../session/entries.js"; +import type { WorkingSetRef } from "./contract.js"; +import { isRecord, toolResultText } from "./payload.js"; + +/** + * The observing verb. Clio's `git` and `verify` are command runners with an + * exit status, so they index as `bash`; `artifact` writes a file, so it indexes + * as `write`. Tools that observe no path at all (dispatch, web_fetch, tasks, + * ask_user, context, ...) produce no observation. + */ +export type PathOp = "read" | "grep" | "find" | "ls" | "code_nav" | "write" | "edit" | "bash"; + +/** + * Lines of a file an observation covers. `offset` counts lines skipped from the + * top (0-based), so a whole-file read is `{ offset: 0, limit: null }` and + * `read(offset: 1)` normalizes to it. `limit: null` means "to EOF". + */ +export interface PathRange { + offset: number; + limit: number | null; +} + +export interface PathObservation { + /** The evictable unit: the tool_result entry, or the fileEntry entry for write/edit evidence. */ + ref: WorkingSetRef; + toolCallId: string | null; + toolName: string; + op: PathOp; + /** + * Canonical absolute path when the session cwd is known, else the path as + * the call wrote it. Empty when the call named no path (a bash command with + * no cwd argument), which keeps it out of `byPath` without dropping the + * observation the failure rules need. + */ + path: string; + /** + * Line coverage for `read`, null for everything else and for a `tail` read, + * whose coverage is unknowable without the file. Supersession treats a null + * range as unknown: it covers nothing and only a full read covers it. + */ + range: PathRange | null; + /** Listing ops only: concrete file paths the result surfaced, resolved like `path`. */ + surfaced: ReadonlyArray; + isError: boolean; + /** Turn starts (user message, bashExecution, branchSummary) strictly before this entry. */ + turnIndex: number; + /** Index in the entries array this index was built from. */ + entryIndex: number; + /** Tool-call arguments as deterministic JSON with sorted keys; empty when the call is unknown. */ + argsKey: string; +} + +export interface PathIndex { + /** Ledger order. */ + observations: ReadonlyArray; + byRef: ReadonlyMap; + byPath: ReadonlyMap>; + /** Every entry's turn position, including entries that observe no path. */ + turnIndexOf: ReadonlyMap; + turnCount: number; +} + +/** Commands whose stdout is a list of paths. Anything else surfaces nothing. */ +const LISTING_COMMANDS = new Set(["ls", "find", "tree", "fd", "rg", "grep"]); +/** Commands whose stdout is `path:line:text` rather than one path per line. */ +const MATCH_LINE_COMMANDS = new Set(["rg", "grep"]); + +const TOOL_OPS: ReadonlyMap = new Map([ + ["read", "read"], + ["grep", "grep"], + ["find", "find"], + ["ls", "ls"], + ["code_nav", "code_nav"], + ["write", "write"], + ["edit", "edit"], + ["artifact", "write"], + ["bash", "bash"], + ["git", "bash"], + ["verify", "bash"], +]); + +/** code_nav modes whose `query` is a file path rather than a symbol or page name. */ +const CODE_NAV_PATH_MODES = new Set(["path", "outline", "deps", "dependents"]); + +/** Ops whose result is a list of other paths. */ +const LISTING_OPS = new Set(["grep", "find", "ls", "bash"]); + +/** + * Turn starts, the same three kinds the protection horizon counts. A local `!` + * bash execution and a branch summary each open a stretch of work the way an + * operator message does. + */ +function isTurnStart(entry: SessionEntry): boolean { + if (entry.kind === "bashExecution" || entry.kind === "branchSummary") return true; + return entry.kind === "message" && entry.role === "user"; +} + +/** + * The session cwd, when the caller kept the JSONL header in the slice. The + * header is a `SessionFileEntry`, not a `SessionEntry`, so this is a runtime + * shape check rather than a `kind` test; without it every relative path stays + * exactly as the call wrote it. + */ +function sessionCwd(entries: ReadonlyArray): string | null { + for (const entry of entries) { + const record = entry as unknown as Record; + if (record.type !== "session") continue; + const cwd = record.cwd; + if (typeof cwd === "string" && cwd.length > 0 && isAbsolute(cwd)) return normalize(cwd); + } + return null; +} + +/** Lexical canonicalization only: no realpath, no `process.cwd()`, no `~` expansion. */ +function canonicalize(value: string, cwd: string | null): string { + const trimmed = value.trim(); + if (trimmed.length === 0) return ""; + if (isAbsolute(trimmed)) return normalize(trimmed); + return cwd === null ? trimmed : resolve(cwd, trimmed); +} + +function stableStringify(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (isRecord(value)) { + const keys = Object.keys(value).sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; + } + if (value === undefined) return "null"; + return JSON.stringify(value) ?? "null"; +} + +interface ToolCallFacts { + toolName: string; + args: unknown; + argsKey: string; +} + +function callFacts(toolName: string, args: unknown): ToolCallFacts { + return { toolName, args, argsKey: args === undefined ? "" : stableStringify(args) }; +} + +function stringField(record: Record | null, ...keys: string[]): string | null { + if (record === null) return null; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + } + return null; +} + +function payloadRecord(payload: unknown): Record | null { + return isRecord(payload) ? payload : null; +} + +/** Record every tool call the ledger holds, in both shapes it persists them. */ +function collectToolCalls(entries: ReadonlyArray): Map { + const calls = new Map(); + for (const entry of entries) { + if (entry.kind !== "message") continue; + const obj = payloadRecord(entry.payload); + if (entry.role === "tool_call" && obj !== null) { + const id = stringField(obj, "toolCallId", "tool_call_id", "id") ?? entry.turnId; + const name = stringField(obj, "name", "toolName", "tool") ?? "tool"; + calls.set(id, callFacts(name, obj.args ?? obj.arguments ?? obj.input)); + continue; + } + if (entry.role !== "assistant" || obj === null || !Array.isArray(obj.content)) continue; + for (const block of obj.content) { + if (!isRecord(block) || block.type !== "toolCall") continue; + const id = stringField(block, "id", "toolCallId") ?? entry.turnId; + const name = stringField(block, "name", "toolName") ?? "tool"; + calls.set(id, callFacts(name, block.arguments ?? block.args ?? block.input)); + } + } + return calls; +} + +/** + * The path the call was about. Search tools default to the working directory, + * which is what they actually searched, so a `grep` with no `path` argument is + * an observation of the cwd rather than of nothing. + */ +function observedPath(op: PathOp, args: Record | null, cwd: string | null): string { + if (op === "bash") { + const explicit = stringField(args, "cwd"); + return explicit === null ? "" : canonicalize(explicit, cwd); + } + if (op === "code_nav") { + const mode = stringField(args, "mode"); + if (mode === null || !CODE_NAV_PATH_MODES.has(mode)) return ""; + const query = stringField(args, "query"); + return query === null ? "" : canonicalize(query, cwd); + } + const named = stringField(args, "path", "file_path", "filePath"); + if (named !== null) return canonicalize(named, cwd); + // grep, find, and ls all default to ".". + if (op === "grep" || op === "find" || op === "ls") return cwd ?? "."; + return ""; +} + +function readRange(args: Record | null): PathRange | null { + if (args === null) return { offset: 0, limit: null }; + const tail = args.tail; + // A tail read covers an unknown suffix; claiming a range would let it + // supersede reads it may not contain. + if (typeof tail === "number" && Number.isFinite(tail) && tail > 0) return null; + const rawOffset = args.offset; + const rawLimit = args.limit; + const offset = + typeof rawOffset === "number" && Number.isFinite(rawOffset) && rawOffset > 1 ? Math.floor(rawOffset) - 1 : 0; + const limit = typeof rawLimit === "number" && Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : null; + return { offset, limit }; +} + +/** + * Is this line a concrete file path rather than prose, a directory, or a + * tool's own notice? Deliberately permissive about extensions (`Makefile` and + * `LICENSE` are files) and strict about whitespace, because every listing this + * parses prints one path per line. Over-counting only keeps a listing in the + * working set longer; under-counting would evict a listing whose paths the + * agent still needs. + */ +function looksLikeConcreteFilePath(line: string): boolean { + const text = line.trim(); + if (text.length === 0) return false; + // The observation envelope's own trailer, e.g. `[grep: 261/261+ matches ...]`. + if (text.startsWith("[")) return false; + if (text.endsWith("/")) return false; + return !/\s/.test(text); +} + +/** `path:line: text` from grep content mode and from bash `rg`/`grep`. */ +function pathFromMatchLine(line: string): string | null { + const match = /^([^\s:]+):\d+[:-]/.exec(line.trim()); + return match?.[1] ?? null; +} + +function surfacedPaths( + op: PathOp, + args: Record | null, + text: string, + root: string, + cwd: string | null, +): string[] { + const lines = text.split("\n"); + const matchLines = op === "grep" || (op === "bash" && isMatchLineCommand(args)); + const out: string[] = []; + const seen = new Set(); + for (const line of lines) { + const raw = matchLines ? (pathFromMatchLine(line) ?? candidateWholeLine(line)) : candidateWholeLine(line); + if (raw === null) continue; + const resolved = resolveSurfaced(raw, root, cwd); + if (resolved.length === 0 || seen.has(resolved)) continue; + seen.add(resolved); + out.push(resolved); + } + return out; +} + +function candidateWholeLine(line: string): string | null { + return looksLikeConcreteFilePath(line) ? line.trim() : null; +} + +function isMatchLineCommand(args: Record | null): boolean { + const verb = commandVerb(args); + return verb !== null && MATCH_LINE_COMMANDS.has(verb); +} + +function commandVerb(args: Record | null): string | null { + const command = stringField(args, "command"); + if (command === null) return null; + const first = command.trimStart().split(/\s+/)[0]; + return first === undefined || first.length === 0 ? null : basename(first); +} + +/** + * A listing prints paths relative to what it searched, so they resolve against + * the observation's own path. A single-file search root surfaces its own + * basename, which resolves back to the root rather than to a child of it. + */ +function resolveSurfaced(value: string, root: string, cwd: string | null): string { + if (isAbsolute(value)) return normalize(value); + if (root.length > 0 && isAbsolute(root)) { + return basename(root) === value ? root : resolve(root, value); + } + return canonicalize(value, cwd); +} + +/** A listing result only surfaces paths when the call was a listing in the first place. */ +function shouldParseSurfaced(op: PathOp, args: Record | null, isError: boolean): boolean { + if (isError || !LISTING_OPS.has(op)) return false; + if (op !== "bash") return true; + const verb = commandVerb(args); + return verb !== null && LISTING_COMMANDS.has(verb); +} + +function fileEntryOp(operation: "read" | "write" | "edit" | "create" | "delete"): PathOp { + if (operation === "read") return "read"; + if (operation === "edit") return "edit"; + return "write"; +} + +function toolResultObservation( + entry: MessageEntry, + context: { entryIndex: number; turnIndex: number; cwd: string | null; calls: ReadonlyMap }, +): PathObservation | null { + const obj = payloadRecord(entry.payload); + const toolCallId = stringField(obj, "toolCallId", "tool_call_id", "id"); + const call = toolCallId === null ? undefined : context.calls.get(toolCallId); + const toolName = stringField(obj, "toolName", "name", "tool") ?? call?.toolName ?? "tool"; + const op = TOOL_OPS.get(toolName); + if (op === undefined) return null; + const args = call !== undefined && isRecord(call.args) ? call.args : null; + const isError = obj?.isError === true || obj?.error === true; + const path = observedPath(op, args, context.cwd); + const surfaced = shouldParseSurfaced(op, args, isError) + ? surfacedPaths(op, args, toolResultText(obj?.result ?? entry.payload), path, context.cwd) + : []; + return { + ref: { entry: entry.turnId }, + toolCallId, + toolName, + op, + path, + range: op === "read" ? readRange(args) : null, + surfaced, + isError, + turnIndex: context.turnIndex, + entryIndex: context.entryIndex, + argsKey: call?.argsKey ?? "", + }; +} + +export function buildPathIndex(entries: ReadonlyArray): PathIndex { + const cwd = sessionCwd(entries); + const calls = collectToolCalls(entries); + const observations: PathObservation[] = []; + const byRef = new Map(); + const byPath = new Map(); + const turnIndexOf = new Map(); + let turnIndex = 0; + + for (let entryIndex = 0; entryIndex < entries.length; entryIndex += 1) { + const entry = entries[entryIndex]; + if (entry === undefined) continue; + turnIndexOf.set(entry.turnId, turnIndex); + let observation: PathObservation | null = null; + if (entry.kind === "fileEntry") { + observation = { + ref: { entry: entry.turnId }, + toolCallId: null, + toolName: "fileEntry", + op: fileEntryOp(entry.operation), + path: canonicalize(entry.path, cwd), + range: null, + surfaced: [], + isError: false, + turnIndex, + entryIndex, + argsKey: "", + }; + } else if (entry.kind === "message" && entry.role === "tool_result") { + observation = toolResultObservation(entry, { entryIndex, turnIndex, cwd, calls }); + } + if (observation !== null) { + observations.push(observation); + byRef.set(observation.ref.entry, observation); + if (observation.path.length > 0) { + const bucket = byPath.get(observation.path); + if (bucket === undefined) byPath.set(observation.path, [observation]); + else bucket.push(observation); + } + } + if (isTurnStart(entry)) turnIndex += 1; + } + + return { observations, byRef, byPath, turnIndexOf, turnCount: turnIndex }; +} diff --git a/tests/contracts/working-set-path-index.test.ts b/tests/contracts/working-set-path-index.test.ts new file mode 100644 index 000000000..8860595de --- /dev/null +++ b/tests/contracts/working-set-path-index.test.ts @@ -0,0 +1,300 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { buildPathIndex, type PathObservation } from "../../src/domains/context/working-set/path-index.js"; +import type { SessionEntry } from "../../src/domains/session/entries.js"; + +const CWD = "/repo"; +const TS = "2026-08-21T00:00:00.000Z"; + +/** The JSONL header is a SessionFileEntry, not a SessionEntry; callers that keep it pass it through. */ +const HEADER = { type: "session", version: 4, id: "s1", timestamp: TS, cwd: CWD } as unknown as SessionEntry; + +let seq = 0; +function nextId(prefix: string): string { + seq += 1; + return `${prefix}${seq}`; +} + +function user(text: string): SessionEntry { + return { kind: "message", turnId: nextId("u"), parentTurnId: null, timestamp: TS, role: "user", payload: { text } }; +} + +function call(toolName: string, args: unknown, id = nextId("call-")): { entry: SessionEntry; id: string } { + return { + id, + entry: { + kind: "message", + turnId: nextId("c"), + parentTurnId: null, + timestamp: TS, + role: "tool_call", + payload: { toolCallId: id, name: toolName, args }, + }, + }; +} + +function result( + toolName: string, + callId: string, + text: string, + options: { isError?: boolean; turnId?: string } = {}, +): SessionEntry { + return { + kind: "message", + turnId: options.turnId ?? nextId("t"), + parentTurnId: null, + timestamp: TS, + role: "tool_result", + payload: { + toolCallId: callId, + toolName, + result: { content: [{ type: "text", text }] }, + isError: options.isError === true, + }, + }; +} + +/** One call/result pair plus the entries around it, indexed. */ +function indexOne(toolName: string, args: unknown, text = "", options: { isError?: boolean } = {}): PathObservation { + const made = call(toolName, args); + const entries: SessionEntry[] = [HEADER, user("go"), made.entry, result(toolName, made.id, text, options)]; + const observations = buildPathIndex(entries).observations; + const first = observations[0]; + assert.ok(first, `expected one observation for ${toolName}`); + return first; +} + +test("path index: a read carries the canonical path and a full range", () => { + const observation = indexOne("read", { path: "src/a.ts" }, "file body"); + assert.equal(observation.op, "read"); + assert.equal(observation.path, "/repo/src/a.ts"); + assert.deepEqual(observation.range, { offset: 0, limit: null }); + assert.equal(observation.isError, false); + assert.deepEqual(observation.surfaced, []); + assert.equal(observation.toolName, "read"); +}); + +test("path index: read ranges normalize the 1-indexed offset argument", () => { + assert.deepEqual(indexOne("read", { path: "src/a.ts", offset: 51, limit: 100 }).range, { offset: 50, limit: 100 }); + // offset 1 is the top of the file, which is the full-read shape. + assert.deepEqual(indexOne("read", { path: "src/a.ts", offset: 1 }).range, { offset: 0, limit: null }); + assert.deepEqual(indexOne("read", { path: "src/a.ts", limit: 40 }).range, { offset: 0, limit: 40 }); + // A tail read covers an unknown suffix, so it claims no range at all. + assert.equal(indexOne("read", { path: "src/a.ts", tail: 30 }).range, null); +}); + +test("path index: an absolute argument is kept, a relative one without a header is left as written", () => { + assert.equal(indexOne("read", { path: "/elsewhere/b.ts" }).path, "/elsewhere/b.ts"); + + const made = call("read", { path: "src/a.ts" }); + const entries: SessionEntry[] = [user("go"), made.entry, result("read", made.id, "body")]; + assert.equal(buildPathIndex(entries).observations[0]?.path, "src/a.ts"); +}); + +test("path index: grep surfaces the path before the line number", () => { + // grep prints paths relative to the directory it searched. + const body = [ + "a.ts:12: const x = 1;", + "a.ts-13- context line", + "b.ts:4: const y = 2;", + "[grep: 3/3 matches shown (1.0KB of 1.0KB)]", + ].join("\n"); + const observation = indexOne("grep", { pattern: "const", path: "src" }, body); + assert.equal(observation.op, "grep"); + assert.equal(observation.path, "/repo/src"); + assert.deepEqual(observation.surfaced, ["/repo/src/a.ts", "/repo/src/b.ts"]); +}); + +test("path index: a search with no path argument observes the session cwd", () => { + assert.equal(indexOne("grep", { pattern: "x" }, "a.ts:1: x").path, CWD); + assert.equal(indexOne("find", { pattern: "**/*.ts" }, "a.ts").path, CWD); + assert.equal(indexOne("ls", {}, "a.ts").path, CWD); +}); + +test("path index: find surfaces concrete files and skips directories and notices", () => { + const body = ["a.ts", "nested/", "b.ts", "[find: 3/3 paths shown]", " ", "Makefile"].join("\n"); + const observation = indexOne("find", { pattern: "**/*", path: "src" }, body); + assert.deepEqual(observation.surfaced, ["/repo/src/a.ts", "/repo/src/b.ts", "/repo/src/Makefile"]); +}); + +test("path index: ls surfaces its entries against the listed directory", () => { + const observation = indexOne("ls", { path: "/repo/docs" }, ["guide.md", "images/", "README.md"].join("\n")); + assert.equal(observation.op, "ls"); + assert.deepEqual(observation.surfaced, ["/repo/docs/guide.md", "/repo/docs/README.md"]); +}); + +test("path index: code_nav observes a path only in its path-shaped modes", () => { + assert.equal(indexOne("code_nav", { mode: "path", query: "src/a.ts" }).path, "/repo/src/a.ts"); + assert.equal(indexOne("code_nav", { mode: "outline", query: "src/a.ts" }).path, "/repo/src/a.ts"); + assert.equal(indexOne("code_nav", { mode: "symbol", query: "buildPathIndex" }).path, ""); + assert.equal(indexOne("code_nav", { mode: "wiki", query: "architecture" }).path, ""); +}); + +test("path index: write, edit, and artifact are mutations of their path", () => { + assert.equal(indexOne("write", { path: "src/a.ts", content: "x" }).op, "write"); + assert.equal(indexOne("edit", { path: "src/a.ts", edits: [] }).op, "edit"); + const artifact = indexOne("artifact", { kind: "report", content: "x", path: "docs/r.md" }); + assert.equal(artifact.op, "write"); + assert.equal(artifact.path, "/repo/docs/r.md"); +}); + +test("path index: bash takes its cwd argument and parses only listing commands", () => { + const listing = indexOne("bash", { command: "ls -1", cwd: "src" }, ["a.ts", "b.ts"].join("\n")); + assert.equal(listing.op, "bash"); + assert.equal(listing.path, "/repo/src"); + assert.deepEqual(listing.surfaced, ["/repo/src/a.ts", "/repo/src/b.ts"]); + + const rg = indexOne("bash", { command: "rg const src", cwd: "/repo" }, "src/a.ts:3: const x = 1;"); + assert.deepEqual(rg.surfaced, ["/repo/src/a.ts"]); + + // Not a listing verb: the output is prose as far as this index is concerned. + const build = indexOne("bash", { command: "npm run build" }, "dist/index.js"); + assert.deepEqual(build.surfaced, []); + assert.equal(build.path, "", "a bash call with no cwd argument names no path"); +}); + +test("path index: git and verify index as commands", () => { + assert.equal(indexOne("git", { op: "status" }, "clean").op, "bash"); + assert.equal(indexOne("verify", { check: "typecheck" }, "ok").op, "bash"); +}); + +test("path index: an error result keeps its observation and surfaces nothing", () => { + const observation = indexOne("find", { pattern: "**/*", path: "src" }, "find: path not found: src", { + isError: true, + }); + assert.equal(observation.isError, true); + assert.deepEqual(observation.surfaced, []); +}); + +test("path index: a fileEntry is write evidence with no tool call", () => { + const entries: SessionEntry[] = [ + HEADER, + user("go"), + { kind: "fileEntry", turnId: "f1", parentTurnId: null, timestamp: TS, path: "src/a.ts", operation: "create" }, + { kind: "fileEntry", turnId: "f2", parentTurnId: null, timestamp: TS, path: "src/b.ts", operation: "edit" }, + { kind: "fileEntry", turnId: "f3", parentTurnId: null, timestamp: TS, path: "src/c.ts", operation: "read" }, + ]; + const index = buildPathIndex(entries); + assert.deepEqual( + index.observations.map((observation) => [observation.ref.entry, observation.op, observation.path]), + [ + ["f1", "write", "/repo/src/a.ts"], + ["f2", "edit", "/repo/src/b.ts"], + ["f3", "read", "/repo/src/c.ts"], + ], + ); + assert.equal(index.byRef.get("f1")?.toolCallId, null); + assert.equal(index.byRef.get("f1")?.argsKey, ""); +}); + +test("path index: argsKey is order-independent and distinguishes different arguments", () => { + const a = indexOne("read", { path: "src/a.ts", limit: 10, offset: 2 }); + const b = indexOne("read", { offset: 2, path: "src/a.ts", limit: 10 }); + const c = indexOne("read", { path: "src/a.ts", limit: 11, offset: 2 }); + assert.equal(a.argsKey, b.argsKey); + assert.notEqual(a.argsKey, c.argsKey); + assert.equal(a.argsKey, '{"limit":10,"offset":2,"path":"src/a.ts"}'); +}); + +test("path index: an unpaired result carries an empty argsKey rather than a guess", () => { + const entries: SessionEntry[] = [HEADER, user("go"), result("read", "call-missing", "body")]; + const observation = buildPathIndex(entries).observations[0]; + assert.equal(observation?.argsKey, ""); + assert.equal(observation?.path, ""); + assert.equal(observation?.toolCallId, "call-missing"); +}); + +test("path index: a call streamed as an assistant content block still pairs", () => { + const entries: SessionEntry[] = [ + HEADER, + user("go"), + { + kind: "message", + turnId: "a1", + parentTurnId: null, + timestamp: TS, + role: "assistant", + payload: { + content: [{ type: "toolCall", id: "call-block", name: "read", arguments: { path: "src/a.ts" } }], + }, + }, + result("read", "call-block", "body"), + ]; + const observation = buildPathIndex(entries).observations[0]; + assert.equal(observation?.path, "/repo/src/a.ts"); + assert.equal(observation?.argsKey, '{"path":"src/a.ts"}'); +}); + +test("path index: turn positions count turn starts strictly before an entry", () => { + const first = call("read", { path: "a.ts" }); + const second = call("read", { path: "b.ts" }); + const entries: SessionEntry[] = [ + HEADER, + user("one"), + first.entry, + result("read", first.id, "body", { turnId: "r1" }), + { + kind: "bashExecution", + turnId: "b1", + parentTurnId: null, + timestamp: TS, + command: "ls", + output: "a.ts", + exitCode: 0, + cancelled: false, + truncated: false, + }, + second.entry, + result("read", second.id, "body", { turnId: "r2" }), + ]; + const index = buildPathIndex(entries); + assert.equal(index.turnCount, 2); + assert.equal(index.byRef.get("r1")?.turnIndex, 1); + assert.equal(index.byRef.get("r2")?.turnIndex, 2); + // A turn start is not before itself. + assert.equal(index.turnIndexOf.get("b1"), 1); + assert.equal(index.turnIndexOf.get("r1"), 1); +}); + +test("path index: byPath groups every observation of one file in ledger order", () => { + const read = call("read", { path: "src/a.ts" }); + const edit = call("edit", { path: "src/a.ts", edits: [] }); + const other = call("read", { path: "src/b.ts" }); + const entries: SessionEntry[] = [ + HEADER, + user("go"), + read.entry, + result("read", read.id, "body", { turnId: "r1" }), + edit.entry, + result("edit", edit.id, "edited", { turnId: "e1" }), + other.entry, + result("read", other.id, "body", { turnId: "r2" }), + ]; + const index = buildPathIndex(entries); + assert.deepEqual( + index.byPath.get("/repo/src/a.ts")?.map((observation) => observation.ref.entry), + ["r1", "e1"], + ); + assert.deepEqual( + index.byPath.get("/repo/src/b.ts")?.map((observation) => observation.ref.entry), + ["r2"], + ); + // A pathless observation never lands in byPath. + assert.equal(index.byPath.has(""), false); + assert.equal( + index.observations.every((observation) => observation.entryIndex > 0), + true, + ); +}); + +test("path index: unobserved tools produce no observation", () => { + const made = call("web_fetch", { url: "https://example.com" }); + const entries: SessionEntry[] = [HEADER, user("go"), made.entry, result("web_fetch", made.id, "page")]; + assert.deepEqual(buildPathIndex(entries).observations, []); +}); + +test("path index: the same ledger indexes identically twice", () => { + const made = call("grep", { pattern: "x", path: "src" }); + const entries: SessionEntry[] = [HEADER, user("go"), made.entry, result("grep", made.id, "src/a.ts:1: x")]; + assert.deepEqual(buildPathIndex(entries).observations, buildPathIndex(entries).observations); +}); From f551ec40c6e34cb23b32ebc24fde2c7a95b77626 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:33:50 -0500 Subject: [PATCH 12/45] test(tools): update context tool pins for scope=recall and the marker hint --- tests/contracts/tools.test.ts | 2 +- tests/harness/tool-module-graph.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/contracts/tools.test.ts b/tests/contracts/tools.test.ts index 8322dfc2a..5bb5fadaf 100644 --- a/tests/contracts/tools.test.ts +++ b/tests/contracts/tools.test.ts @@ -2501,7 +2501,7 @@ describe("contracts/tools", () => { ); strictEqual( hinted.get("context"), - 'Call context with scope="skills" to list installed and marketplace skills; when one matches the task, or the operator names a skill or asks how one works, suggest the operator run /skill (a marketplace skill is offered for install) and never load it uninvited. When the user message carries a skill request, first load that skill via context (scope="skills", name=) before doing anything else.', + 'Call context with scope="skills" to list installed and marketplace skills; when one matches the task, or the operator names a skill or asks how one works, suggest the operator run /skill (a marketplace skill is offered for install) and never load it uninvited. When the user message carries a skill request, first load that skill via context (scope="skills", name=) before doing anything else. When an [evicted ...] marker names content you need, recall it with context(scope="recall", ref=...); re-read the file only when the marker says it changed.', ); strictEqual( hinted.get("dispatch"), diff --git a/tests/harness/tool-module-graph.ts b/tests/harness/tool-module-graph.ts index 57a949944..37b4fb471 100644 --- a/tests/harness/tool-module-graph.ts +++ b/tests/harness/tool-module-graph.ts @@ -14,7 +14,7 @@ const IMPLEMENTATION_MARKERS = { web_fetch: "web_fetch: binary or unsupported content type", verify: "frontend validation:", code_nav: "code_nav: no wiki page matches", - context: "context: scope must be workspace, docs, or skills", + context: "context: scope must be workspace, docs, skills, or recall", } as const; const ORCHESTRATOR_RUNNER_SURFACE_MARKERS = { From bd1293d4f5fbd279203b9df894bdca413f9fb7cf Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:40:16 -0500 Subject: [PATCH 13/45] feat(context): add the structural-v1 working-set policy The age rule asks how old a result is. This one asks what happened to it since: the file was rewritten, a later read covered the same lines, the failure was resolved, the listing was walked. Six rungs in charter 4.5 order, each emitting newest-first, every candidate through the protection predicates, no unit claimed twice. Rungs 1 to 5 run whatever the pressure, because redundant content is free to drop; rung 6 is the age rule as the last resort, gated on being over threshold and stopping the moment the projection reaches target. protect.ts is the absolute list: only tool-result bodies and thinking ever leave, never inside the protection horizon, never under the floor, never a blocked row, never a mutation the active turn stands on, and never a failure nothing has resolved. The rule that evicts a resolved failure and the predicate that protects an unresolved one call the same lookup, so a failure can never be both. horizon.ts holds the cutoff arithmetic both policies share. engine.ts exports tokensFreedByEviction so rung 6 does its headroom arithmetic against the same numbers planEviction records; planEviction now calls it too and its behavior is unchanged. A failure_resolved marker renders first_line instead of preview: failures are evidence, and the line that says what failed is the part worth a marker's tokens. The default policy stays age-horizon until the replay table reports. --- src/domains/context/working-set/engine.ts | 26 +- src/domains/context/working-set/horizon.ts | 42 ++ src/domains/context/working-set/marker.ts | 29 +- .../working-set/policies/age-horizon.ts | 31 +- .../context/working-set/policies/index.ts | 11 +- .../working-set/policies/structural.ts | 174 +++++ src/domains/context/working-set/protect.ts | 104 +++ .../contracts/working-set-age-horizon.test.ts | 5 +- tests/contracts/working-set-marker.test.ts | 23 + .../contracts/working-set-structural.test.ts | 593 ++++++++++++++++++ 10 files changed, 994 insertions(+), 44 deletions(-) create mode 100644 src/domains/context/working-set/horizon.ts create mode 100644 src/domains/context/working-set/policies/structural.ts create mode 100644 src/domains/context/working-set/protect.ts create mode 100644 tests/contracts/working-set-structural.test.ts diff --git a/src/domains/context/working-set/engine.ts b/src/domains/context/working-set/engine.ts index eda2ea1a9..59d68bdae 100644 --- a/src/domains/context/working-set/engine.ts +++ b/src/domains/context/working-set/engine.ts @@ -108,6 +108,29 @@ function sumTokens(entries: ReadonlyArray, estimate: (entry: Sessi return total; } +/** + * What one candidate takes out of the working set: the entry as it stands now + * minus the entry as the projection would render it. Zero when the candidate + * does not apply to the entry, and never negative, because a marker longer than + * the body it replaces is a bad trade, not a negative saving. + * + * Exported so a policy can do headroom arithmetic (`structural-v1` rung 6 needs + * to know when to stop) against the same numbers `planEviction` will record. + * A policy that priced evictions its own way would report headroom the ledger + * then contradicts. + */ +export function tokensFreedByEviction( + estimateTokens: (entry: SessionEntry) => number, + entry: SessionEntry, + candidate: EvictionCandidate, +): number { + const marker = markerFor(entry, candidate); + if (marker === null) return 0; + const key = refKey(candidate.ref); + const projected = projectWorkingSet([entry], soloView(key, pendingState(candidate, marker, "")))[0] ?? entry; + return Math.max(0, estimateTokens(entry) - estimateTokens(projected)); +} + export function planEviction(policy: WorkingSetPolicy, input: PolicyInput): EvictionPlan | null { const candidates = policy.select(input); if (candidates.length === 0) return null; @@ -128,11 +151,10 @@ export function planEviction(policy: WorkingSetPolicy, input: PolicyInput): Evic const marker = markerFor(entry, candidate); if (marker === null) continue; claimed.add(key); - const projected = projectWorkingSet([entry], soloView(key, pendingState(candidate, marker, policy.id)))[0] ?? entry; items.push({ ref: candidate.ref, reason: candidate.reason, - tokensFreed: Math.max(0, input.estimateTokens(entry) - input.estimateTokens(projected)), + tokensFreed: tokensFreedByEviction(input.estimateTokens, entry, candidate), marker, ...(candidate.by === undefined ? {} : { by: candidate.by }), }); diff --git a/src/domains/context/working-set/horizon.ts b/src/domains/context/working-set/horizon.ts new file mode 100644 index 000000000..8ff54e07b --- /dev/null +++ b/src/domains/context/working-set/horizon.ts @@ -0,0 +1,42 @@ +/** + * The protection horizon: where the recent, untouchable window begins. + * + * Both policies and every protection predicate answer "is this entry inside the + * last `protectLastTurns` turns" the same way, so the arithmetic lives here + * once. It is the same cutoff `maskStaleObservations` used before this layer + * existed, which is what keeps `age-horizon` selection-identical to the + * destructive stage it replaced. + * + * `path-index.ts` keeps its own copy of `isTurnStart` on purpose: it is + * cherry-picked on its own for the replay reference graph, so it stays free of + * intra-layer imports beyond the payload readers. + */ + +import type { SessionEntry } from "../../session/entries.js"; + +/** + * What starts a turn, in the sense the protection horizon counts. A local `!` + * bash execution and a branch summary both open a new stretch of work the same + * way an operator message does. + */ +export function isTurnStart(entry: SessionEntry): boolean { + if (entry.kind === "bashExecution" || entry.kind === "branchSummary") return true; + return entry.kind === "message" && entry.role === "user"; +} + +/** + * Index of the first protected entry: walk back until `protectLastTurns` turn + * starts have been seen. Entries before it are candidates, entries from it on + * are the recent window nothing touches. + */ +export function protectionCutoffIndex(entries: ReadonlyArray, protectLastTurns: number): number { + const horizon = Math.max(1, Math.floor(protectLastTurns)); + let seen = 0; + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (!entry || !isTurnStart(entry)) continue; + seen += 1; + if (seen >= horizon) return i; + } + return 0; +} diff --git a/src/domains/context/working-set/marker.ts b/src/domains/context/working-set/marker.ts index c84c98aad..68b5a9e1a 100644 --- a/src/domains/context/working-set/marker.ts +++ b/src/domains/context/working-set/marker.ts @@ -11,11 +11,16 @@ * the same trace disagree. * * The field order is fixed (ref, reason, by, tool, path, size, offload, - * recall, preview) so a diff between two markers is readable and so a model - * reading many of them sees the same shape every time. Undefined fields are - * omitted rather than rendered empty. `recall` spells out the exact tool call - * that brings the body back, which is the only affordance the model has once - * the body is gone. + * recall, then the body tail) so a diff between two markers is readable and so + * a model reading many of them sees the same shape every time. Undefined + * fields are omitted rather than rendered empty. `recall` spells out the exact + * tool call that brings the body back, which is the only affordance the model + * has once the body is gone. + * + * The body tail is `preview` for every reason but one. A `failure_resolved` + * eviction renders `first_line` instead: failures are evidence, and the line + * that says what failed is the part worth a marker's tokens, where a preview + * of a stack trace is not. */ import { formatSize } from "../../../engine/truncate.js"; @@ -52,6 +57,15 @@ function preview(text: string): string { return text.trim().replace(/\s+/g, " ").slice(0, PREVIEW_LIMIT).replace(/"/g, '\\"'); } +/** The first line that says anything, bounded and escaped like a preview. */ +function firstLine(text: string): string { + for (const line of text.split(/\r\n|\r|\n/)) { + const trimmed = line.trim(); + if (trimmed.length > 0) return trimmed.slice(0, PREVIEW_LIMIT).replace(/"/g, '\\"'); + } + return ""; +} + export function renderMarker(input: MarkerInput): string { const ref = input.ref.entry; const fields: string[] = [`ref=${ref}`, `reason=${input.reason}`]; @@ -64,8 +78,9 @@ export function renderMarker(input: MarkerInput): string { // An offloaded body is one `read` away at a stable path; a preview of it // would spend tokens repeating what the pointer already promises. if (input.offloadPath === undefined) { - const head = preview(input.text); - if (head.length > 0) fields.push(`preview="${head}"`); + const failed = input.reason === "failure_resolved"; + const tail = failed ? firstLine(input.text) : preview(input.text); + if (tail.length > 0) fields.push(`${failed ? "first_line" : "preview"}="${tail}"`); } return `[evicted ${fields.join(" ")}]`; } diff --git a/src/domains/context/working-set/policies/age-horizon.ts b/src/domains/context/working-set/policies/age-horizon.ts index 49c7382f8..7c0fd622a 100644 --- a/src/domains/context/working-set/policies/age-horizon.ts +++ b/src/domains/context/working-set/policies/age-horizon.ts @@ -20,42 +20,15 @@ * `minEvictableTokens` floor, below which the marker costs more than the body. */ -import type { SessionEntry } from "../../../session/entries.js"; import type { EvictionCandidate, PolicyInput, WorkingSetPolicy } from "../contract.js"; +import { protectionCutoffIndex } from "../horizon.js"; import { hasLegacyCompactionMarker, hasThinking } from "../payload.js"; -/** - * What starts a turn, in the sense the protection horizon counts. A local `!` - * bash execution and a branch summary both open a new stretch of work the same - * way an operator message does. - */ -function isTurnStart(entry: SessionEntry): boolean { - if (entry.kind === "bashExecution" || entry.kind === "branchSummary") return true; - return entry.kind === "message" && entry.role === "user"; -} - -/** - * Index of the first protected entry: walk back until `protectLastTurns` turn - * starts have been seen. Entries before it are candidates, entries from it on - * are the recent window nothing touches. - */ -function recentTurnCutoff(entries: ReadonlyArray, protectLastTurns: number): number { - const horizon = Math.max(1, Math.floor(protectLastTurns)); - let seen = 0; - for (let i = entries.length - 1; i >= 0; i -= 1) { - const entry = entries[i]; - if (!entry || !isTurnStart(entry)) continue; - seen += 1; - if (seen >= horizon) return i; - } - return 0; -} - export const ageHorizonPolicy: WorkingSetPolicy = { id: "age-horizon", select(input: PolicyInput): ReadonlyArray { const { entries, view, settings, estimateTokens } = input; - const cutoff = recentTurnCutoff(entries, settings.protectLastTurns); + const cutoff = protectionCutoffIndex(entries, settings.protectLastTurns); const candidates: EvictionCandidate[] = []; // Newest-safe-first: the entry closest to the protection horizon is the // least likely to be re-read, and a caller that stops early has then diff --git a/src/domains/context/working-set/policies/index.ts b/src/domains/context/working-set/policies/index.ts index adc6d63b7..7c0e4e986 100644 --- a/src/domains/context/working-set/policies/index.ts +++ b/src/domains/context/working-set/policies/index.ts @@ -2,16 +2,19 @@ * Policy registry. One id in, one pure policy out, so the live engine and the * replay-lite runner resolve the same object from the same settings value and * cannot drift into running different selections. + * + * `age-horizon` stays the default until the replay table says `structural-v1` + * is ahead on retention and ahead of the random control on precision. Both are + * resolvable now so the table can be produced. */ import type { WorkingSetPolicy, WorkingSetPolicyId } from "../contract.js"; import { ageHorizonPolicy } from "./age-horizon.js"; +import { structuralPolicy } from "./structural.js"; -export { ageHorizonPolicy }; +export { ageHorizonPolicy, structuralPolicy }; export function resolveWorkingSetPolicy(id: WorkingSetPolicyId): WorkingSetPolicy { if (id === "age-horizon") return ageHorizonPolicy; - throw new Error( - `working-set policy "${id}" is not implemented in this slice; set context.workingSet.policy to "age-horizon"`, - ); + return structuralPolicy; } diff --git a/src/domains/context/working-set/policies/structural.ts b/src/domains/context/working-set/policies/structural.ts new file mode 100644 index 000000000..6b13d5be8 --- /dev/null +++ b/src/domains/context/working-set/policies/structural.ts @@ -0,0 +1,174 @@ +/** + * `structural-v1`: evict what the session has structurally finished with. + * + * The age rule asks how old a result is. This one asks what happened to it + * since: was the file rewritten, did a later read cover the same lines, did the + * failure get resolved, has the listing been walked. Those are facts the ledger + * already records, and they are the facts a human would use to decide what is + * still worth carrying. Age is the last rung, not the first, and it only runs + * when the structural rungs did not free enough. + * + * Rule order is the policy. Each rung emits candidates newest-first, every + * candidate passes `isProtected`, and no unit is claimed twice, so a read that + * is both stale and superseded is evicted for the reason that came first and + * carries the ref that explains it. Rungs 1 to 5 are unconditional: redundant + * content is free to drop, whatever the pressure. Rung 6 is the only one that + * looks at token counts, and it stops the moment the projection reaches + * `target`. + * + * Deterministic by construction: the index is a pure function of the entries, + * every loop runs in index order, and nothing here reads a clock, a size + * ranking, or a recency score. + */ + +import type { EvictionCandidate, EvictionReason, PolicyInput, WorkingSetPolicy } from "../contract.js"; +import { tokensFreedByEviction } from "../engine.js"; +import { protectionCutoffIndex } from "../horizon.js"; +import { buildPathIndex, type PathIndex, type PathObservation, type PathRange } from "../path-index.js"; +import { hasThinking } from "../payload.js"; +import { findLaterSuccess, isProtected } from "../protect.js"; + +/** Ops that observe content rather than change it. */ +const READ_CLASS = new Set(["read", "grep", "find", "ls", "code_nav"]); +const MUTATING = new Set(["write", "edit"]); + +function rangeEnd(range: PathRange): number { + return range.limit === null ? Number.POSITIVE_INFINITY : range.offset + range.limit; +} + +function isFullRead(range: PathRange | null): boolean { + return range !== null && range.offset === 0 && range.limit === null; +} + +/** + * Does the later read make the earlier one redundant? A full read covers + * everything, including a `tail` read whose coverage is unknown. Any other read + * covers only an identical or containing range, and an unknown range covers + * nothing, which is what keeps partial reads from evicting each other. + */ +function covers(later: PathRange | null, earlier: PathRange | null): boolean { + if (isFullRead(later)) return true; + if (later === null || earlier === null) return false; + return later.offset <= earlier.offset && rangeEnd(later) >= rangeEnd(earlier); +} + +/** The mutation that invalidated this observation: the first one after it. */ +function firstMutationAfter(observation: PathObservation, index: PathIndex): PathObservation | null { + for (const other of index.byPath.get(observation.path) ?? []) { + if (other.entryIndex > observation.entryIndex && MUTATING.has(other.op)) return other; + } + return null; +} + +/** The most recent later read of the same file that covers this one's lines. */ +function lastCoveringRead(observation: PathObservation, index: PathIndex): PathObservation | null { + let found: PathObservation | null = null; + for (const other of index.byPath.get(observation.path) ?? []) { + if (other.entryIndex <= observation.entryIndex || other.op !== "read" || other.isError) continue; + if (covers(other.range, observation.range)) found = other; + } + return found; +} + +/** Every surfaced path went on to be read. A path nobody read is an unread path. */ +function isListingConsumed(observation: PathObservation, index: PathIndex): boolean { + if (observation.surfaced.length === 0) return false; + for (const path of observation.surfaced) { + const readLater = (index.byPath.get(path) ?? []).some( + (other) => other.op === "read" && !other.isError && other.entryIndex > observation.entryIndex, + ); + if (!readLater) return false; + } + return true; +} + +export const structuralPolicy: WorkingSetPolicy = { + id: "structural-v1", + select(input: PolicyInput): ReadonlyArray { + const { entries, view, settings, pressure, estimateTokens } = input; + const index = buildPathIndex(entries); + const cutoffIndex = protectionCutoffIndex(entries, settings.protectLastTurns); + const candidates: EvictionCandidate[] = []; + const claimed = new Set(); + let freed = 0; + + const entryIndexOf = new Map(); + for (let i = 0; i < entries.length; i += 1) { + const entry = entries[i]; + if (entry !== undefined) entryIndexOf.set(entry.turnId, i); + } + + const emit = (turnId: string, reason: EvictionReason, by?: string): boolean => { + if (claimed.has(turnId) || view.evicted.has(turnId)) return false; + const entryIndex = entryIndexOf.get(turnId); + if (entryIndex === undefined) return false; + const entry = entries[entryIndex]; + if (entry === undefined) return false; + if (isProtected(entry, { entryIndex, cutoffIndex, input, index })) return false; + const candidate: EvictionCandidate = { ref: { entry: turnId }, reason, ...(by === undefined ? {} : { by }) }; + claimed.add(turnId); + candidates.push(candidate); + freed += tokensFreedByEviction(estimateTokens, entry, candidate); + return true; + }; + + // Newest-first within every rung, for the cost reason in charter 4.6: + // evicting the youngest safe unit keeps the cold region after the + // eviction point small, so the turn that pays for the event pays least. + const newestFirst = [...index.observations].reverse(); + + // 1. The file changed under it. Whatever the body said is now a claim + // about a file that no longer exists in that form. + for (const observation of newestFirst) { + if (!READ_CLASS.has(observation.op) || observation.path.length === 0) continue; + const mutation = firstMutationAfter(observation, index); + if (mutation !== null) emit(observation.ref.entry, "stale_after_mutation", mutation.ref.entry); + } + + // 2. The agent asked for the same lines again. It already decided this + // content was worth re-fetching, and the newer copy is the live one. + for (const observation of newestFirst) { + if (observation.op !== "read" || observation.path.length === 0) continue; + const superseding = lastCoveringRead(observation, index); + if (superseding !== null) emit(observation.ref.entry, "superseded_read", superseding.ref.entry); + } + + // 3. The failure was resolved. The marker keeps its first line, because + // a failure that happened is evidence even once it is fixed. + for (const observation of newestFirst) { + if (!observation.isError) continue; + const success = findLaterSuccess(observation, index); + if (success !== null) emit(observation.ref.entry, "failure_resolved", success.ref.entry); + } + + // 4. The listing has been walked. One surfaced path still unread and it + // stays: that is the path the agent comes back to. + for (const observation of newestFirst) { + if (isListingConsumed(observation, index)) emit(observation.ref.entry, "listing_consumed"); + } + + // 5. Reasoning from a closed turn, the same rule age-horizon applies. + for (let i = cutoffIndex - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (entry?.kind !== "message" || entry.role !== "assistant") continue; + if (hasThinking(entry.payload)) emit(entry.turnId, "thinking_turn_closed"); + } + + // 6. Age, and only under pressure. Everything above is redundancy the + // session can lose for free; this rung loses content that is still + // good, so it runs only when the projection is still over threshold + // and stops the moment it reaches target. + const window = pressure.contextWindow; + if (window <= 0) return candidates; + let projected = pressure.tokens - freed; + if (projected <= pressure.threshold * window) return candidates; + const targetTokens = pressure.target * window; + for (let i = cutoffIndex - 1; i >= 0 && projected > targetTokens; i -= 1) { + const entry = entries[i]; + if (entry?.kind !== "message" || entry.role !== "tool_result") continue; + const before = freed; + if (emit(entry.turnId, "age_horizon")) projected -= freed - before; + } + return candidates; + }, +}; diff --git a/src/domains/context/working-set/protect.ts b/src/domains/context/working-set/protect.ts new file mode 100644 index 000000000..91135cfe2 --- /dev/null +++ b/src/domains/context/working-set/protect.ts @@ -0,0 +1,104 @@ +/** + * Protection predicates: what the working set never gives up, whatever a rule + * concludes. + * + * These run before every rule and are absolute. A policy is allowed to be + * wrong about relevance (that is what the replay table measures); it is not + * allowed to drop the operator's words, the last few turns of work, a failure + * nobody has resolved, or a mutation the current turn is still standing on. + * Charter 4.5 lists them; this module is that list, and `structural.ts` calls + * it on every candidate rather than reimplementing any of it. + * + * Pure over the entry, the index, and the policy input. + */ + +import type { SessionEntry } from "../../session/entries.js"; +import type { PolicyInput } from "./contract.js"; +import type { PathIndex, PathObservation } from "./path-index.js"; +import { hasLegacyCompactionMarker, isRecord } from "./payload.js"; + +export interface ProtectionContext { + entryIndex: number; + /** First entry of the protected recent window, from `protectionCutoffIndex`. */ + cutoffIndex: number; + input: PolicyInput; + index: PathIndex; +} + +/** Ops whose identity is the file they touched, so a retry on the same path counts as the same call. */ +const PATH_IDENTIFIED_OPS = new Set(["read", "grep", "find"]); + +function isBlockedResult(payload: unknown): boolean { + if (!isRecord(payload)) return false; + // The registry's admission verdict, persisted by turn-persistence. A call + // the safety rails refused is a decision the session made, not an + // observation it can re-fetch. + return payload.outcome === "blocked" || typeof payload.blockReason === "string"; +} + +function isErrorResult(payload: unknown): boolean { + if (!isRecord(payload)) return false; + return payload.isError === true || payload.error === true; +} + +/** + * The later call that resolved this failure: same tool with byte-identical + * arguments, or, for the path-identified ops, the same file by any route. Null + * when nothing after it succeeded, which is what keeps the failure protected. + * + * Shared with `structural.ts` rung 3 on purpose: the rule that evicts a + * resolved failure and the predicate that protects an unresolved one must + * answer the same question, or a failure could be both. + */ +export function findLaterSuccess(observation: PathObservation, index: PathIndex): PathObservation | null { + for (const candidate of index.observations) { + if (candidate.entryIndex <= observation.entryIndex || candidate.isError) continue; + if (candidate.toolName === observation.toolName && observation.argsKey.length > 0) { + if (candidate.argsKey === observation.argsKey) return candidate; + } + if ( + PATH_IDENTIFIED_OPS.has(observation.op) && + candidate.op === observation.op && + observation.path.length > 0 && + candidate.path === observation.path + ) { + return candidate; + } + } + return null; +} + +/** A write or edit the turn in flight is still standing on. */ +function isActiveTurnMutation(observation: PathObservation, index: PathIndex): boolean { + if (observation.op !== "write" && observation.op !== "edit") return false; + return observation.turnIndex >= index.turnCount; +} + +export function isProtected(entry: SessionEntry, ctx: ProtectionContext): boolean { + // Only two things ever leave the working set: a tool result's body and an + // assistant turn's thinking. Everything else (operator words, summaries, + // skill activations, ledgers, worker runs, bash executions) is the session's + // own record of itself. + if (entry.kind !== "message") return true; + if (entry.role !== "tool_result" && entry.role !== "assistant") return true; + + // The recent window is untouchable for both kinds. + if (ctx.entryIndex >= ctx.cutoffIndex) return true; + if (entry.role === "assistant") return false; + + // Below the floor the marker costs more than the body it replaces. + if (ctx.input.estimateTokens(entry) < ctx.input.settings.minEvictableTokens) return true; + // A body the legacy destructive stage already replaced has nothing left to evict. + if (hasLegacyCompactionMarker(entry.payload)) return true; + if (isBlockedResult(entry.payload)) return true; + + const observation = ctx.index.byRef.get(entry.turnId); + // No observation means no way to ask whether a failure was resolved, so an + // unindexed failure stays. Everything else unindexed is an ordinary result + // the age rung may still take under pressure. + if (observation === undefined) return isErrorResult(entry.payload); + + if (isActiveTurnMutation(observation, ctx.index)) return true; + if (observation.isError && findLaterSuccess(observation, ctx.index) === null) return true; + return false; +} diff --git a/tests/contracts/working-set-age-horizon.test.ts b/tests/contracts/working-set-age-horizon.test.ts index 69aa3591c..f7cfac5e2 100644 --- a/tests/contracts/working-set-age-horizon.test.ts +++ b/tests/contracts/working-set-age-horizon.test.ts @@ -199,8 +199,9 @@ test("age-horizon: an all-protected ledger selects nothing and plans nothing", ( assert.equal(planEviction(agePolicy, input), null); }); -test("policies: structural-v1 is not implemented in this slice", () => { - assert.throws(() => resolveWorkingSetPolicy("structural-v1"), /not implemented in this slice/); +test("policies: every settings id resolves to the policy that owns it", () => { + assert.equal(resolveWorkingSetPolicy("age-horizon").id, "age-horizon"); + assert.equal(resolveWorkingSetPolicy("structural-v1").id, "structural-v1"); }); test("planEviction: materializes markers, prices them, and shrinks the working set", () => { diff --git a/tests/contracts/working-set-marker.test.ts b/tests/contracts/working-set-marker.test.ts index 21924ed34..a229bfcdd 100644 --- a/tests/contracts/working-set-marker.test.ts +++ b/tests/contracts/working-set-marker.test.ts @@ -90,3 +90,26 @@ test("marker: preview collapses whitespace, escapes quotes, and stops at 120 cha false, ); }); + +test("marker: a resolved failure keeps its first line instead of a preview", () => { + const marker = renderMarker({ + ref: { entry: "01J8" }, + reason: "failure_resolved", + by: "01JC", + toolName: "bash", + text: "\n\nmake: *** No rule to make target\n at build.mk:12\n at Makefile:3\n", + }); + assert.equal( + marker, + '[evicted ref=01J8 reason=failure_resolved by=01JC tool=bash size=6 lines/68B recall=context(scope="recall", ref="01J8") first_line="make: *** No rule to make target"]', + ); + assert.equal(marker.includes("preview="), false); + // Same slot, same bounds, same escaping as a preview. + const long = renderMarker({ + ref: { entry: "01J8" }, + reason: "failure_resolved", + toolName: "bash", + text: `he said "no": ${"x".repeat(500)}\nrest`, + }); + assert.equal(long.includes(`first_line="he said \\"no\\": ${"x".repeat(106)}"`), true, long); +}); diff --git a/tests/contracts/working-set-structural.test.ts b/tests/contracts/working-set-structural.test.ts new file mode 100644 index 000000000..42f8253ca --- /dev/null +++ b/tests/contracts/working-set-structural.test.ts @@ -0,0 +1,593 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { + EvictionCandidate, + PolicyInput, + WorkingSetSettings, +} from "../../src/domains/context/working-set/contract.js"; +import { EMPTY_WORKING_SET_VIEW } from "../../src/domains/context/working-set/contract.js"; +import { DEFAULT_WORKING_SET_SETTINGS } from "../../src/domains/context/working-set/defaults.js"; +import { buildEvictionFields, planEviction } from "../../src/domains/context/working-set/engine.js"; +import { protectionCutoffIndex } from "../../src/domains/context/working-set/horizon.js"; +import { buildPathIndex } from "../../src/domains/context/working-set/path-index.js"; +import { structuralPolicy } from "../../src/domains/context/working-set/policies/index.js"; +import { isProtected } from "../../src/domains/context/working-set/protect.js"; +import { estimateTokens } from "../../src/domains/session/compaction/tokens.js"; +import { isSessionEntry, type SessionEntry } from "../../src/domains/session/entries.js"; + +const CWD = "/repo"; +const TS = "2026-08-21T00:00:00.000Z"; +const HEADER = { type: "session", version: 4, id: "s1", timestamp: TS, cwd: CWD } as unknown as SessionEntry; + +/** Big enough to clear the default 200-token floor. */ +function body(label: string, lines = 100): string { + return Array.from({ length: lines }, (_, i) => `${label} observation line ${i}`).join("\n"); +} + +/** + * A ledger under construction. Every call appends the tool_call/tool_result + * pair the session would have written and hands back the result's turnId, which + * is the ref a policy names. + */ +class Ledger { + readonly entries: SessionEntry[] = [HEADER]; + private seq = 0; + + private id(prefix: string): string { + this.seq += 1; + return `${prefix}${this.seq}`; + } + + user(text = "go"): this { + this.entries.push({ + kind: "message", + turnId: this.id("u"), + parentTurnId: null, + timestamp: TS, + role: "user", + payload: { text }, + }); + return this; + } + + thinking(text = "reasoning"): string { + const turnId = this.id("a"); + this.entries.push({ + kind: "message", + turnId, + parentTurnId: null, + timestamp: TS, + role: "assistant", + payload: { + content: [ + { type: "thinking", thinking: text }, + { type: "text", text: "answer" }, + ], + }, + }); + return turnId; + } + + call(toolName: string, args: unknown, text: string, options: { isError?: boolean; blocked?: boolean } = {}): string { + const callId = this.id("call-"); + this.entries.push({ + kind: "message", + turnId: this.id("c"), + parentTurnId: null, + timestamp: TS, + role: "tool_call", + payload: { toolCallId: callId, name: toolName, args }, + }); + const turnId = this.id("r"); + this.entries.push({ + kind: "message", + turnId, + parentTurnId: null, + timestamp: TS, + role: "tool_result", + payload: { + toolCallId: callId, + toolName, + result: { content: [{ type: "text", text }] }, + isError: options.isError === true, + ...(options.blocked === true ? { outcome: "blocked", blockReason: "safety net" } : {}), + }, + }); + return turnId; + } + + read(path: string, range: { offset?: number; limit?: number; tail?: number } = {}, text = body(path)): string { + return this.call("read", { path, ...range }, text); + } + + edit(path: string): string { + return this.call("edit", { path, edits: [{ oldText: "a", newText: "b" }] }, body(`edited ${path}`)); + } + + find(path: string, surfaced: ReadonlyArray): string { + return this.call( + "find", + { pattern: "**/*.ts", path }, + [...surfaced, `[find: ${surfaced.length} paths shown]`].join("\n"), + ); + } + + bash(command: string, text: string, options: { isError?: boolean } = {}): string { + return this.call("bash", { command }, text, options); + } + + /** Turn starts that push everything before them past the protection horizon. */ + pad(turns = 3): this { + for (let i = 0; i < turns; i += 1) this.user(`pad ${i}`); + return this; + } +} + +function policyInput(entries: ReadonlyArray, overrides: Partial = {}): PolicyInput { + const settings: WorkingSetSettings = { ...DEFAULT_WORKING_SET_SETTINGS, protectLastTurns: 2 }; + return { + entries, + view: EMPTY_WORKING_SET_VIEW, + settings, + // Far below threshold: rungs 1-5 run, rung 6 does not. + pressure: { tokens: 1_000, contextWindow: 100_000, threshold: 0.8, target: 0.6 }, + // The fixtures keep the JSONL header so paths resolve against the session + // cwd; it is not a ledger entry and carries no context tokens. + estimateTokens: (entry) => (isSessionEntry(entry) ? estimateTokens(entry) : 0), + ...overrides, + }; +} + +function select(entries: ReadonlyArray, overrides: Partial = {}): EvictionCandidate[] { + return [...structuralPolicy.select(policyInput(entries, overrides))]; +} + +function byRef(candidates: ReadonlyArray): Map { + return new Map(candidates.map((candidate) => [candidate.ref.entry, candidate])); +} + +test("structural: a later full read supersedes the earlier one (charter scenario 3)", () => { + const ledger = new Ledger(); + ledger.user(); + const first = ledger.read("src/a.ts"); + ledger.user(); + const second = ledger.read("src/a.ts"); + ledger.pad(); + + const candidates = byRef(select(ledger.entries)); + assert.equal(candidates.get(first)?.reason, "superseded_read"); + assert.equal(candidates.get(first)?.by, second); + assert.equal(candidates.has(second), false, "the live copy stays"); +}); + +test("structural: an edit makes the earlier read stale and names the mutation (charter scenario 4)", () => { + const ledger = new Ledger(); + ledger.user(); + const stale = ledger.read("src/b.ts"); + ledger.user(); + const edit = ledger.edit("src/b.ts"); + ledger.user(); + const fresh = ledger.read("src/b.ts"); + ledger.pad(); + + const candidates = byRef(select(ledger.entries)); + assert.equal(candidates.get(stale)?.reason, "stale_after_mutation"); + assert.equal(candidates.get(stale)?.by, edit); + // The read after the edit is a fresh observation of the current file. + assert.equal(candidates.has(fresh), false); + // The mutation itself is not redundant: nothing rewrote the file after it. + assert.equal(candidates.has(edit), false); +}); + +test("structural: staleness outranks supersession when both apply", () => { + const ledger = new Ledger(); + ledger.user(); + const first = ledger.read("src/c.ts"); + ledger.user(); + ledger.edit("src/c.ts"); + ledger.user(); + ledger.read("src/c.ts"); + ledger.pad(); + + assert.equal(byRef(select(ledger.entries)).get(first)?.reason, "stale_after_mutation"); +}); + +test("structural: a listing with unread surfaced paths stays (charter scenario 5)", () => { + // Long enough that the listing itself clears the minEvictableTokens floor. + const surfaced = Array.from({ length: 12 }, (_, i) => `domains/context/working-set/generated/component_${i}/index.ts`); + const ledger = new Ledger(); + ledger.user(); + const listing = ledger.find("src", surfaced); + for (const path of surfaced.slice(0, 5)) { + ledger.user(); + ledger.read(`src/${path}`); + } + ledger.pad(); + + assert.equal(byRef(select(ledger.entries)).has(listing), false, "7 surfaced paths are still unread"); +}); + +test("structural: a listing whose surfaced paths were all read is consumed", () => { + // Long enough that the listing itself clears the minEvictableTokens floor. + const surfaced = Array.from({ length: 12 }, (_, i) => `domains/context/working-set/generated/component_${i}/index.ts`); + const ledger = new Ledger(); + ledger.user(); + const listing = ledger.find("src", surfaced); + for (const path of surfaced) { + ledger.user(); + ledger.read(`src/${path}`); + } + ledger.pad(); + + assert.equal(byRef(select(ledger.entries)).get(listing)?.reason, "listing_consumed"); +}); + +test("structural: a listing that surfaced nothing is never consumed", () => { + const ledger = new Ledger(); + ledger.user(); + const empty = ledger.find("src", []); + ledger.pad(); + assert.equal(byRef(select(ledger.entries)).has(empty), false); +}); + +test("structural: a resolved failure is evicted and keeps its first line in the marker", () => { + const ledger = new Ledger(); + ledger.user(); + const failure = ledger.bash("npm test", `make: *** No rule to make target\n${body("stack")}`, { isError: true }); + ledger.user(); + const success = ledger.bash("npm test", body("passing")); + ledger.pad(); + + const candidates = select(ledger.entries); + const resolved = byRef(candidates).get(failure); + assert.equal(resolved?.reason, "failure_resolved"); + assert.equal(resolved?.by, success); + + const plan = planEviction(structuralPolicy, policyInput(ledger.entries)); + assert.ok(plan); + const item = plan.items.find((entry) => entry.ref.entry === failure); + assert.ok(item); + assert.equal(item.marker.includes('first_line="make: *** No rule to make target"'), true, item.marker); + assert.equal(item.marker.includes("preview="), false, "a failure marker carries evidence, not a preview"); + assert.equal(item.marker.includes(`by=${success}`), true, item.marker); +}); + +test("structural: an unresolved failure is protected", () => { + const ledger = new Ledger(); + ledger.user(); + const failure = ledger.bash("npm test", `make: *** No rule to make target\n${body("stack")}`, { isError: true }); + ledger.user(); + // A different command succeeding does not resolve this one. + ledger.bash("npm run lint", body("lint ok")); + ledger.pad(); + + assert.equal(byRef(select(ledger.entries)).has(failure), false); +}); + +test("structural: range reads only supersede ranges they contain", () => { + // A property sweep over deterministic offset/limit pairs: an earlier read is + // evicted only when the later read's lines contain it. + const spans: ReadonlyArray<[number, number | null]> = [ + [1, 50], + [20, 50], + [40, 10], + [1, null], + [100, 20], + [45, 5], + ]; + for (const [earlyOffset, earlyLimit] of spans) { + for (const [lateOffset, lateLimit] of spans) { + const ledger = new Ledger(); + ledger.user(); + const early = ledger.read("src/r.ts", { + offset: earlyOffset, + ...(earlyLimit === null ? {} : { limit: earlyLimit }), + }); + ledger.user(); + ledger.read("src/r.ts", { offset: lateOffset, ...(lateLimit === null ? {} : { limit: lateLimit }) }); + ledger.pad(); + + const earlyStart = earlyOffset - 1; + const lateStart = lateOffset - 1; + const earlyEnd = earlyLimit === null ? Number.POSITIVE_INFINITY : earlyStart + earlyLimit; + const lateEnd = lateLimit === null ? Number.POSITIVE_INFINITY : lateStart + lateLimit; + const contains = lateStart <= earlyStart && lateEnd >= earlyEnd; + assert.equal( + byRef(select(ledger.entries)).has(early), + contains, + `later [${lateOffset},${lateLimit}] vs earlier [${earlyOffset},${earlyLimit}]`, + ); + } + } +}); + +test("structural: a tail read supersedes nothing and only a full read supersedes it", () => { + const tailThenRange = new Ledger(); + tailThenRange.user(); + const tail = tailThenRange.read("src/t.ts", { tail: 40 }); + tailThenRange.user(); + tailThenRange.read("src/t.ts", { offset: 1, limit: 500 }); + tailThenRange.pad(); + assert.equal( + byRef(select(tailThenRange.entries)).has(tail), + false, + "an unknown range is not covered by a bounded read", + ); + + const tailThenFull = new Ledger(); + tailThenFull.user(); + const covered = tailThenFull.read("src/t.ts", { tail: 40 }); + tailThenFull.user(); + tailThenFull.read("src/t.ts"); + tailThenFull.pad(); + assert.equal(byRef(select(tailThenFull.entries)).get(covered)?.reason, "superseded_read"); + + const rangeThenTail = new Ledger(); + rangeThenTail.user(); + const ranged = rangeThenTail.read("src/t.ts", { offset: 10, limit: 5 }); + rangeThenTail.user(); + rangeThenTail.read("src/t.ts", { tail: 500 }); + rangeThenTail.pad(); + assert.equal(byRef(select(rangeThenTail.entries)).has(ranged), false, "a tail read covers nothing"); +}); + +test("structural: closed thinking goes, open thinking stays", () => { + const ledger = new Ledger(); + ledger.user(); + const closed = ledger.thinking("old reasoning"); + ledger.pad(); + const open = ledger.thinking("current reasoning"); + + const candidates = byRef(select(ledger.entries)); + assert.equal(candidates.get(closed)?.reason, "thinking_turn_closed"); + assert.equal(candidates.has(open), false); +}); + +test("structural: protection keeps the recent window, small results, and blocked rows", () => { + const ledger = new Ledger(); + ledger.user(); + const tiny = ledger.read("src/tiny.ts", {}, "ok"); + ledger.user(); + ledger.read("src/tiny.ts"); + ledger.user(); + const blocked = ledger.call("bash", { command: "rm -rf /" }, body("refused"), { blocked: true }); + ledger.user(); + ledger.bash("rm -rf /", body("refused again")); + ledger.user(); + const recent = ledger.read("src/recent.ts"); + ledger.user(); + ledger.read("src/recent.ts"); + + const candidates = byRef(select(ledger.entries)); + assert.equal(candidates.has(tiny), false, "below the floor the marker costs more than the body"); + assert.equal(candidates.has(blocked), false, "an admission verdict is a decision, not an observation"); + assert.equal(candidates.has(recent), false, "inside the protection horizon"); +}); + +test("structural: a mutation in the active turn is protected even without the horizon", () => { + const ledger = new Ledger(); + ledger.user(); + const edit = ledger.edit("src/live.ts"); + const entries = ledger.entries; + const entryIndex = entries.findIndex((entry) => entry.turnId === edit); + const entry = entries[entryIndex]; + assert.ok(entry); + + const input = policyInput(entries); + const index = buildPathIndex(entries); + // cutoffIndex past the end takes the horizon out of the answer, leaving the + // active-turn predicate as the only thing that can protect this write. + assert.equal(isProtected(entry, { entryIndex, cutoffIndex: entries.length, input, index }), true); + assert.equal(protectionCutoffIndex(entries, input.settings.protectLastTurns) <= entryIndex, true); +}); + +test("structural: nothing but a message body ever becomes a candidate", () => { + const ledger = new Ledger(); + ledger.user("operator words"); + ledger.entries.push({ + kind: "bashExecution", + turnId: "b1", + parentTurnId: null, + timestamp: TS, + command: "ls", + output: body("local bash"), + exitCode: 0, + cancelled: false, + truncated: false, + }); + ledger.entries.push({ + kind: "compactionSummary", + turnId: "s1", + parentTurnId: null, + timestamp: TS, + summary: body("summary"), + firstKeptTurnId: "", + trigger: "auto", + tokensBefore: 1_000, + }); + ledger.pad(); + + for (const candidate of select(ledger.entries)) { + const entry = ledger.entries.find((item) => item.turnId === candidate.ref.entry); + assert.equal(entry?.kind, "message"); + } +}); + +test("structural: rung 6 never runs below threshold", () => { + const ledger = new Ledger(); + ledger.user(); + ledger.read("src/keep.ts"); + ledger.user(); + ledger.read("src/other.ts"); + ledger.pad(); + + // Nothing is redundant here, so a policy that fired the age rung would be + // the only source of candidates. + assert.deepEqual(select(ledger.entries), []); +}); + +test("structural: rung 6 fires above threshold and stops at target", () => { + const ledger = new Ledger(); + for (let i = 0; i < 5; i += 1) { + ledger.user(); + ledger.read(`src/f${i}.ts`); + } + ledger.pad(); + + const perResult = estimateTokens( + ledger.entries.find((entry) => entry.kind === "message" && entry.role === "tool_result") as SessionEntry, + ); + const window = 1_000; + const candidates = select(ledger.entries, { + pressure: { tokens: 900, contextWindow: window, threshold: 0.8, target: 0.6 }, + }); + assert.ok(candidates.length > 0, "above threshold the age rung has to run"); + assert.equal( + candidates.every((candidate) => candidate.reason === "age_horizon"), + true, + ); + // Enough to cross 600, and not one more than that. + const needed = Math.ceil((900 - 600) / perResult); + assert.equal(candidates.length, needed, `freed ${perResult} per result`); + + // Newest-safe-first: the youngest evictable result goes first. + const evictableResults = ledger.entries + .filter((entry) => entry.kind === "message" && entry.role === "tool_result") + .map((entry) => entry.turnId); + assert.equal(candidates[0]?.ref.entry, evictableResults[evictableResults.length - 1]); +}); + +test("structural: rung 6 leaves protected units alone", () => { + const ledger = new Ledger(); + ledger.user(); + const failure = ledger.bash("npm test", body("boom"), { isError: true }); + ledger.user(); + const tiny = ledger.read("src/tiny.ts", {}, "ok"); + ledger.user(); + ledger.read("src/big.ts"); + ledger.pad(); + + const candidates = byRef( + select(ledger.entries, { pressure: { tokens: 100_000, contextWindow: 1_000, threshold: 0.8, target: 0.6 } }), + ); + assert.equal(candidates.has(failure), false); + assert.equal(candidates.has(tiny), false); +}); + +test("structural: units already out of the working set are never re-selected", () => { + const ledger = new Ledger(); + ledger.user(); + const first = ledger.read("src/a.ts"); + ledger.user(); + ledger.read("src/a.ts"); + ledger.pad(); + ledger.entries.push({ + kind: "contextEviction", + turnId: "e1", + parentTurnId: null, + timestamp: TS, + policyId: "structural-v1", + trigger: "pressure", + evicted: [{ ref: { entry: first }, reason: "superseded_read", tokensFreed: 100, marker: "[evicted]" }], + tokensBefore: 1_000, + tokensAfter: 900, + pressureBefore: 0.9, + snapshotIdBefore: null, + }); + + const entries = ledger.entries; + const view = { + evicted: new Map([ + [ + first, + { + reason: "superseded_read" as const, + marker: "[evicted]", + tokensFreed: 100, + evictedAtTurnId: "e1", + policyId: "structural-v1", + }, + ], + ]), + evictionEvents: 1, + itemsEvicted: 1, + recalls: 0, + lastPolicyId: "structural-v1", + lastEvictionTurnId: "e1", + }; + assert.equal( + select(entries, { view }).some((candidate) => candidate.ref.entry === first), + false, + ); +}); + +test("structural: the same ledger selects identically twice", () => { + const ledger = new Ledger(); + ledger.user(); + const read = ledger.read("src/a.ts"); + ledger.user(); + ledger.edit("src/a.ts"); + ledger.user(); + ledger.thinking(); + ledger.user(); + ledger.bash("npm test", body("boom"), { isError: true }); + ledger.user(); + ledger.bash("npm test", body("ok")); + ledger.pad(); + + const first = select(ledger.entries); + const second = select(ledger.entries); + assert.deepEqual(second, first); + assert.ok(first.some((candidate) => candidate.ref.entry === read)); +}); + +test("structural: planEviction turns a mixed selection into a valid ledger entry", () => { + const surfaced = Array.from({ length: 12 }, (_, i) => `domains/context/working-set/generated/surfaced_${i}/index.ts`); + const ledger = new Ledger(); + ledger.user(); + const staleRead = ledger.read("src/m.ts"); + ledger.user(); + ledger.edit("src/m.ts"); + ledger.user(); + const listing = ledger.find("src", surfaced); + for (const path of surfaced) { + ledger.user(); + ledger.read(`src/${path}`); + } + ledger.user(); + const failure = ledger.bash("npm test", `boom: it broke\n${body("stack")}`, { isError: true }); + ledger.user(); + ledger.bash("npm test", body("ok")); + ledger.user(); + const closedThinking = ledger.thinking("old reasoning"); + ledger.pad(); + + const plan = planEviction(structuralPolicy, policyInput(ledger.entries)); + assert.ok(plan); + assert.equal(plan.policyId, "structural-v1"); + assert.ok(plan.tokensAfter < plan.tokensBefore); + + const reasons = new Map(plan.items.map((item) => [item.ref.entry, item.reason])); + assert.equal(reasons.get(staleRead), "stale_after_mutation"); + assert.equal(reasons.get(listing), "listing_consumed"); + assert.equal(reasons.get(failure), "failure_resolved"); + assert.equal(reasons.get(closedThinking), "thinking_turn_closed"); + + for (const item of plan.items) { + if (item.reason === "thinking_turn_closed") { + assert.equal(item.marker, "", "thinking leaves without a marker"); + continue; + } + assert.match(item.marker, /^\[evicted ref=/); + assert.equal(item.marker.split("\n").length, 1); + assert.ok(item.tokensFreed > 0); + } + + const fields = buildEvictionFields(plan, { trigger: "pressure", pressureBefore: 0.91, snapshotIdBefore: "snap-1" }); + const entry = { ...fields, turnId: "e1", parentTurnId: "u1", timestamp: TS }; + assert.equal(isSessionEntry(entry), true, "the plan must round-trip through the ledger validator"); + assert.equal( + plan.tokensBefore - plan.tokensAfter, + plan.items.reduce((sum, item) => sum + item.tokensFreed, 0), + ); +}); From fe0259822c61c6e827c30d4f9223c6343c25e294 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:43:41 -0500 Subject: [PATCH 14/45] feat(context): add replay-lite harness --- docs/commands-and-modes.md | 18 + src/cli/context-working-set.ts | 425 ++++++++++++++++++ src/cli/context.ts | 8 + src/cli/index.ts | 2 + .../context/working-set/replay/controls.ts | 99 ++++ .../context/working-set/replay/load-clio.ts | 194 ++++++++ .../context/working-set/replay/metrics.ts | 125 ++++++ .../working-set/replay/reference-graph.ts | 79 ++++ .../context/working-set/replay/report.ts | 130 ++++++ .../context/working-set/replay/runner.ts | 131 ++++++ .../context/working-set/replay/trace.ts | 23 + .../fixtures/context-replay/fixture-01.jsonl | 46 ++ .../context-replay/generate-fixture.mjs | 112 +++++ 13 files changed, 1392 insertions(+) create mode 100644 src/cli/context-working-set.ts create mode 100644 src/domains/context/working-set/replay/controls.ts create mode 100644 src/domains/context/working-set/replay/load-clio.ts create mode 100644 src/domains/context/working-set/replay/metrics.ts create mode 100644 src/domains/context/working-set/replay/reference-graph.ts create mode 100644 src/domains/context/working-set/replay/report.ts create mode 100644 src/domains/context/working-set/replay/runner.ts create mode 100644 src/domains/context/working-set/replay/trace.ts create mode 100644 tests/fixtures/context-replay/fixture-01.jsonl create mode 100644 tests/fixtures/context-replay/generate-fixture.mjs diff --git a/docs/commands-and-modes.md b/docs/commands-and-modes.md index d907a48e5..92b92a382 100644 --- a/docs/commands-and-modes.md +++ b/docs/commands-and-modes.md @@ -78,6 +78,8 @@ For process exit codes, stdout deliverable guarantees, and machine-readable JSON | `clio-coder context wiki [--update] [--status] [--depth auto\|simple\|medium\|detailed] [--target ] [--model ] [--thinking off\|low\|medium\|high]` | Generate, update, or inspect the agent-authored Markdown wiki under `.clio-coder/wiki/`. | | `clio-coder context reset [--all] [--yes]` | Clear accumulated project context artifacts; `--all` also removes `CLIO-CODER.md`. `--yes` (or `-y`) answers every confirmation and is required when stdin is not a terminal. | | `clio-coder context index [--json]` | Build the structural codewiki index without model calls; writes `.clio-coder/codewiki.json` and `.clio-coder/state.json` and prints coverage plus a structural hash. | +| `clio-coder context replay --sessions ... [--policies ] [--budgets ] [--threshold ] [--target ] [--seed ] [--no-filter] [--json ] [--md ]` | Replay working-set policies over Clio session ledgers and report retention, precision, token savings, churn, and summary headroom. | +| `clio-coder context working-set --session ` | Inspect one session's durable working-set fold and path-index summary without modifying the ledger. | ## Headless Run Flags @@ -519,6 +521,22 @@ structural hash. The same builder is used by `clio-coder context init`, `clio-co refresh`, session freshness checks, tool-demand backfill, and in-session incremental updates. +### Working-set replay + +`clio-coder context replay --sessions ...` accepts individual session directories, +Clio sessions roots, and `current.jsonl` files. It removes prior eviction/recall sidecars, +selects the active branch, and drives the live fold, projection, policy, and eviction planner +at deterministic turn boundaries. The default inclusion cascade requires at least eight +turns, eight tool results, and one file re-read; `--no-filter` retains every readable trace. +Markdown goes to stdout unless `--md` names a file, while `--json` writes a stable report +including the configuration, git revision when available, and exact command line. + +`clio-coder context working-set --session ` is a read-only inspection command for +one ledger. It prints evicted refs with reason, superseding ref, and token count; aggregate +event, recall, and churn facts; path-observation counts by operation; and paths whose earlier +reads were followed by writes or edits. A persisted `/tree` pin is honored when the session +metadata is available, so the report does not resurrect an abandoned branch. + The current artifact is schema v5. It records files with path, language, line count, role, content hash, imports, and optional summary; declaration-only symbols with name, kind, file id, line, and optional signature; and import edges diff --git a/src/cli/context-working-set.ts b/src/cli/context-working-set.ts new file mode 100644 index 000000000..85b6a4b34 --- /dev/null +++ b/src/cli/context-working-set.ts @@ -0,0 +1,425 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { assertSafeId } from "../core/safe-id.js"; +import { clioStatePath } from "../core/xdg.js"; +import { DEFAULT_WORKING_SET_SETTINGS } from "../domains/context/working-set/defaults.js"; +import { foldWorkingSet } from "../domains/context/working-set/fold.js"; +import { buildPathIndex } from "../domains/context/working-set/path-index.js"; +import { resolveWorkingSetPolicy } from "../domains/context/working-set/policies/index.js"; +import { makeOraclePolicy, makeRandomPolicy, nonePolicy } from "../domains/context/working-set/replay/controls.js"; +import { loadClioTraces, type ReplayLoadCascade } from "../domains/context/working-set/replay/load-clio.js"; +import { aggregateReplayMetrics, type ReplayMeasurement } from "../domains/context/working-set/replay/metrics.js"; +import { buildReferenceGraph, type ReferenceGraph } from "../domains/context/working-set/replay/reference-graph.js"; +import { + type ReplayPolicyResult, + type ReplayReportInput, + renderReplayJson, + renderReplayMarkdown, +} from "../domains/context/working-set/replay/report.js"; +import { replayTrace } from "../domains/context/working-set/replay/runner.js"; +import type { Trace } from "../domains/context/working-set/replay/trace.js"; +import { parseSessionEntries } from "../domains/session/archive-readers.js"; +import { filterEntriesToActivePath } from "../domains/session/tree/active-path.js"; + +const REPLAY_HELP = `Usage: + clio-coder context replay --sessions ... [options] + +Options: + --policies comma-separated none,random,age-horizon,structural-v1,oracle + --budgets comma-separated budgets (default: 16000,32000,64000) + --threshold pressure threshold (default: 0.8) + --target post-eviction pressure target (default: 0.6) + --seed deterministic random-policy seed (default: 0) + --no-filter include every readable Clio ledger + --json write the stable JSON report + --md write Markdown instead of printing it +`; + +const WORKING_SET_HELP = `Usage: + clio-coder context working-set --session + +Print the durable working-set fold and path-index summary for one Clio session. +`; + +const POLICY_IDS = ["none", "random", "age-horizon", "structural-v1", "oracle"] as const; +type ReplayPolicyId = (typeof POLICY_IDS)[number]; + +class CliUsageError extends Error {} + +interface ReplayArgs { + sessions: string[]; + policies: ReplayPolicyId[]; + budgets: number[]; + threshold: number; + target: number; + seed: number; + noFilter: boolean; + jsonPath?: string; + markdownPath?: string; +} + +function commaValues(value: string, flag: string): string[] { + const values = value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (values.length === 0) throw new CliUsageError(`${flag} requires at least one value`); + return [...new Set(values)]; +} + +function numberValue(value: string, flag: string): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) throw new CliUsageError(`${flag} requires a finite number`); + return parsed; +} + +function requiredValue(args: ReadonlyArray, index: number, flag: string): string { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new CliUsageError(`${flag} requires a value`); + return value; +} + +function policyResolves(id: ReplayPolicyId): boolean { + if (id === "none" || id === "random" || id === "oracle") return true; + try { + resolveWorkingSetPolicy(id); + return true; + } catch { + return false; + } +} + +function defaultPolicies(): ReplayPolicyId[] { + return POLICY_IDS.filter(policyResolves); +} + +function parseReplayArgs(args: ReadonlyArray): ReplayArgs { + const parsed: ReplayArgs = { + sessions: [], + policies: defaultPolicies(), + budgets: [16_000, 32_000, 64_000], + threshold: 0.8, + target: 0.6, + seed: 0, + noFilter: false, + }; + let policiesExplicit = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--no-filter") { + parsed.noFilter = true; + continue; + } + if (arg === "--sessions") { + let consumed = 0; + while (index + 1 < args.length && !args[index + 1]?.startsWith("--")) { + parsed.sessions.push(args[index + 1] as string); + index += 1; + consumed += 1; + } + if (consumed === 0) throw new CliUsageError("--sessions requires at least one path"); + continue; + } + if (arg === "--policies" || arg === "--budgets" || arg === "--threshold" || arg === "--target" || arg === "--seed") { + const value = requiredValue(args, index, arg); + index += 1; + if (arg === "--policies") { + const values = commaValues(value, arg); + const unknown = values.filter((entry) => !(POLICY_IDS as ReadonlyArray).includes(entry)); + if (unknown.length > 0) throw new CliUsageError(`unknown replay policy: ${unknown.join(", ")}`); + parsed.policies = values as ReplayPolicyId[]; + policiesExplicit = true; + } else if (arg === "--budgets") { + parsed.budgets = commaValues(value, arg).map((entry) => { + const budget = numberValue(entry, arg); + if (!Number.isInteger(budget) || budget <= 0) { + throw new CliUsageError("--budgets values must be positive integers"); + } + return budget; + }); + } else if (arg === "--threshold") { + parsed.threshold = numberValue(value, arg); + if (parsed.threshold <= 0 || parsed.threshold > 1) { + throw new CliUsageError("--threshold must be greater than 0 and at most 1"); + } + } else if (arg === "--target") { + parsed.target = numberValue(value, arg); + if (parsed.target <= 0 || parsed.target >= 1) { + throw new CliUsageError("--target must be greater than 0 and less than 1"); + } + } else { + parsed.seed = numberValue(value, arg); + if (!Number.isInteger(parsed.seed)) throw new CliUsageError("--seed must be an integer"); + } + continue; + } + if (arg === "--json" || arg === "--md") { + const value = requiredValue(args, index, arg); + index += 1; + if (arg === "--json") parsed.jsonPath = value; + else parsed.markdownPath = value; + continue; + } + throw new CliUsageError(`unknown flag ${arg}`); + } + if (parsed.sessions.length === 0) throw new CliUsageError("--sessions is required"); + if (parsed.target >= parsed.threshold) throw new CliUsageError("--target must be less than --threshold"); + if (policiesExplicit) { + const unavailable = parsed.policies.filter((id) => !policyResolves(id)); + if (unavailable.length > 0) { + throw new CliUsageError(`replay policy is not available in this build: ${unavailable.join(", ")}`); + } + } + return parsed; +} + +function policyForTrace(id: ReplayPolicyId, graph: ReferenceGraph, seed: number) { + if (id === "none") return nonePolicy; + if (id === "random") return makeRandomPolicy(seed); + if (id === "oracle") return makeOraclePolicy(graph); + return resolveWorkingSetPolicy(id); +} + +function gitSha(): string | null { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return null; + } +} + +function exactCommandLine(): string[] { + return [process.execPath, ...process.execArgv, ...process.argv.slice(1)]; +} + +async function writeOutput(path: string, contents: string): Promise { + const output = resolve(path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents, "utf8"); + return output; +} + +function cascadeLine(cascade: ReplayLoadCascade): string { + const filtered = Object.entries(cascade.filtered) + .map(([stage, count]) => `${stage}=${count}`) + .join(" "); + return `cascade found=${cascade.found} unreadable=${cascade.unreadable} ${filtered} kept=${cascade.kept}`; +} + +export async function runContextReplayCommand(args: string[]): Promise { + if (args.includes("--help") || args.includes("-h")) { + process.stdout.write(REPLAY_HELP); + return 0; + } + let parsed: ReplayArgs; + try { + parsed = parseReplayArgs(args); + } catch (error) { + process.stderr.write( + `clio-coder context replay: ${error instanceof Error ? error.message : String(error)}\n${REPLAY_HELP}`, + ); + return 2; + } + try { + const loaded = await loadClioTraces(parsed.sessions, { filter: parsed.noFilter ? false : {} }); + const indexed = loaded.traces.map((trace) => { + const index = buildPathIndex(trace.entries); + return { trace, index, graph: buildReferenceGraph(trace, index) }; + }); + const settings = { ...DEFAULT_WORKING_SET_SETTINGS, target: parsed.target }; + const results: ReplayPolicyResult[] = []; + for (const budgetTokens of parsed.budgets) { + for (const policyId of parsed.policies) { + const measurements: ReplayMeasurement[] = indexed.map(({ trace, index, graph }) => ({ + trace, + index, + graph, + replay: replayTrace(trace, policyForTrace(policyId, graph, parsed.seed), { + policyId, + budgetTokens, + threshold: parsed.threshold, + target: parsed.target, + settings, + seed: parsed.seed, + }), + })); + results.push({ budgetTokens, policyId, metrics: aggregateReplayMetrics(measurements) }); + } + } + const report: ReplayReportInput = { + config: { + policies: parsed.policies, + budgets: parsed.budgets, + threshold: parsed.threshold, + target: parsed.target, + seed: parsed.seed, + filter: parsed.noFilter ? "none" : "default", + settings, + }, + cascade: loaded.cascade, + results, + gitSha: gitSha(), + commandLine: exactCommandLine(), + }; + const markdown = renderReplayMarkdown(report); + if (parsed.markdownPath === undefined) process.stdout.write(markdown); + else { + const path = await writeOutput(parsed.markdownPath, markdown); + process.stdout.write(`${cascadeLine(loaded.cascade)}\nmarkdown ${path}\n`); + } + if (parsed.jsonPath !== undefined) { + const path = await writeOutput(parsed.jsonPath, renderReplayJson(report)); + process.stdout.write(`json ${path}\n`); + } + return 0; + } catch (error) { + process.stderr.write(`clio-coder context replay failed: ${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } +} + +async function sessionPath(value: string): Promise { + const candidate = resolve(value); + try { + const facts = await stat(candidate); + if (facts.isDirectory()) return join(candidate, "current.jsonl"); + if (facts.isFile()) return candidate; + } catch { + // A non-path value is resolved as a validated session id below. + } + try { + assertSafeId(value, "session"); + } catch (error) { + throw new CliUsageError(error instanceof Error ? error.message : String(error)); + } + const sessionsRoot = join(clioStatePath(), "sessions"); + let cwdHashes: string[]; + try { + cwdHashes = await readdir(sessionsRoot); + } catch { + throw new Error(`session not found: ${value}`); + } + for (const cwdHash of cwdHashes.sort()) { + const current = join(sessionsRoot, cwdHash, value, "current.jsonl"); + try { + if ((await stat(current)).isFile()) return current; + } catch { + // Continue across repositories until the id is found. + } + } + throw new Error(`session not found: ${value}`); +} + +async function pinnedLeafForSession(source: string): Promise { + try { + const raw = await readFile(join(dirname(source), "meta.json"), "utf8"); + const value = JSON.parse(raw) as unknown; + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const pinned = (value as Record).pinnedLeafTurnId; + return typeof pinned === "string" && pinned.length > 0 ? pinned : undefined; + } catch { + return undefined; + } +} + +function formatWorkingSet(trace: Trace): string { + const view = foldWorkingSet(trace.entries); + const index = buildPathIndex(trace.entries); + const graph = buildReferenceGraph(trace, index); + const evictedTokens = [...view.evicted.values()].reduce((sum, state) => sum + state.tokensFreed, 0); + const churn = view.itemsEvicted === 0 ? "n/a" : (view.recalls / view.itemsEvicted).toFixed(3); + const opCounts = new Map(); + for (const observation of index.observations) { + opCounts.set(observation.op, (opCounts.get(observation.op) ?? 0) + 1); + } + const rewrittenPaths = new Set(); + for (const edge of graph.edges) { + if (edge.kind !== "file_rewrite") continue; + const path = index.byRef.get(edge.from)?.path; + if (path) rewrittenPaths.add(path); + } + const lines = [ + `session: ${trace.id}`, + `source: ${trace.source}`, + "working set:", + ` policy: ${view.lastPolicyId ?? "none"}`, + ` eviction events: ${view.evictionEvents}`, + ` evicted refs: ${view.evicted.size}`, + ` items evicted: ${view.itemsEvicted}`, + ` evicted tokens: ${evictedTokens}`, + ` recalls: ${view.recalls}`, + ` churn: ${churn}`, + " refs:", + ...([...view.evicted.entries()].length === 0 + ? [" none"] + : [...view.evicted.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([ref, state]) => ` ${ref} reason=${state.reason} by=${state.by ?? "-"} tokens=${state.tokensFreed}`)), + "path index:", + ` observations: ${index.observations.length}`, + ` ops: ${ + [...opCounts.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([op, count]) => `${op}=${count}`) + .join(", ") || "none" + }`, + ` paths with rewrites: ${rewrittenPaths.size}`, + ...[...rewrittenPaths].sort().map((path) => ` ${path}`), + "", + ]; + return lines.join("\n"); +} + +export async function runContextWorkingSetCommand(args: string[]): Promise { + if (args.includes("--help") || args.includes("-h")) { + process.stdout.write(WORKING_SET_HELP); + return 0; + } + let session: string | undefined; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--session") { + try { + session = requiredValue(args, index, arg); + } catch (error) { + process.stderr.write( + `clio-coder context working-set: ${error instanceof Error ? error.message : String(error)}\n${WORKING_SET_HELP}`, + ); + return 2; + } + index += 1; + continue; + } + process.stderr.write(`clio-coder context working-set: unknown flag ${arg}\n${WORKING_SET_HELP}`); + return 2; + } + if (session === undefined) { + process.stderr.write(`clio-coder context working-set: --session is required\n${WORKING_SET_HELP}`); + return 2; + } + try { + const source = await sessionPath(session); + const raw = await readFile(source, "utf8"); + const parsed = parseSessionEntries(raw, source); + if (parsed.errors.length > 0) throw new Error(parsed.errors.join("; ")); + const entries = filterEntriesToActivePath(parsed.entries, await pinnedLeafForSession(source)); + process.stdout.write( + formatWorkingSet({ id: basename(dirname(source)), source, entries, turnCount: buildPathIndex(entries).turnCount }), + ); + return 0; + } catch (error) { + if (error instanceof CliUsageError) { + process.stderr.write(`clio-coder context working-set: ${error.message}\n${WORKING_SET_HELP}`); + return 2; + } + process.stderr.write( + `clio-coder context working-set failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + return 1; + } +} diff --git a/src/cli/context.ts b/src/cli/context.ts index e6a1dce2d..c7ec9cac2 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -10,6 +10,8 @@ const HELP = `Usage: [--target ] [--model ] [--thinking off|low|medium|high] clio-coder context reset [--all] [--yes] clio-coder context index [--json] + clio-coder context replay --sessions ... [options] + clio-coder context working-set --session Project context commands: clio-coder context show project context status (CLIO-CODER.md, preload, codewiki) @@ -18,6 +20,8 @@ Project context commands: clio-coder context wiki generate or inspect the agent-authored Markdown wiki clio-coder context reset clear accumulated project context artifacts clio-coder context index build the codewiki index without model calls + clio-coder context replay compare working-set policies over Clio ledgers + clio-coder context working-set inspect one session's working-set fold and path index `; function printWikiProgress(event: BootstrapProgressEvent): void { @@ -313,6 +317,10 @@ export async function runContextCommand(args: string[]): Promise { return (await import("./context-clear.js")).runContextClearCommand(rest); case "index": return (await import("./context-index.js")).runContextIndexCommand(rest); + case "replay": + return (await import("./context-working-set.js")).runContextReplayCommand(rest); + case "working-set": + return (await import("./context-working-set.js")).runContextWorkingSetCommand(rest); default: process.stderr.write(`clio-coder context: unknown subcommand ${verb}\n`); process.stdout.write(HELP); diff --git a/src/cli/index.ts b/src/cli/index.ts index 031661d1a..3922ff4b6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -52,6 +52,8 @@ Usage: clio-coder context wiki [--update] [--status] [--depth auto|simple|medium|detailed] [--target ] [--model ] clio-coder context reset [--all] [--yes] clear accumulated project context artifacts clio-coder context index [--json] build the codewiki index without model calls + clio-coder context replay --sessions ... compare working-set policies over Clio ledgers + clio-coder context working-set --session inspect one session's working-set state clio-coder uninstall remove all Clio Coder state; --remove-binary also unlinks the launcher clio-coder upgrade upgrade Clio Coder and run pending migrations clio-coder agents list discovered agent recipes diff --git a/src/domains/context/working-set/replay/controls.ts b/src/domains/context/working-set/replay/controls.ts new file mode 100644 index 000000000..4e77c611e --- /dev/null +++ b/src/domains/context/working-set/replay/controls.ts @@ -0,0 +1,99 @@ +import type { SessionEntry } from "../../../session/entries.js"; +import type { EvictionCandidate, PolicyInput, WorkingSetPolicy, WorkingSetPolicyId } from "../contract.js"; +import { hasLegacyCompactionMarker } from "../payload.js"; +import type { ReferenceGraph } from "./reference-graph.js"; +import { countReplayTurns, isReplayTurnStart } from "./trace.js"; + +function controlId(id: string): WorkingSetPolicyId { + return id as WorkingSetPolicyId; +} + +function recentTurnCutoff(entries: ReadonlyArray, protectLastTurns: number): number { + const horizon = Math.max(1, Math.floor(protectLastTurns)); + let seen = 0; + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry === undefined || !isReplayTurnStart(entry)) continue; + seen += 1; + if (seen >= horizon) return index; + } + return 0; +} + +function eligibleToolResults(input: PolicyInput): SessionEntry[] { + const cutoff = recentTurnCutoff(input.entries, input.settings.protectLastTurns); + const out: SessionEntry[] = []; + for (let index = cutoff - 1; index >= 0; index -= 1) { + const entry = input.entries[index]; + if (entry?.kind !== "message" || entry.role !== "tool_result") continue; + if (input.view.evicted.has(entry.turnId)) continue; + if (hasLegacyCompactionMarker(entry.payload)) continue; + if (input.estimateTokens(entry) < input.settings.minEvictableTokens) continue; + out.push(entry); + } + return out; +} + +function takeToTarget(input: PolicyInput, entries: ReadonlyArray): EvictionCandidate[] { + let tokensNeeded = Math.max(0, input.pressure.tokens - input.pressure.target * input.pressure.contextWindow); + if (tokensNeeded <= 0) return []; + const selected: EvictionCandidate[] = []; + for (const entry of entries) { + selected.push({ ref: { entry: entry.turnId }, reason: "age_horizon" }); + tokensNeeded -= input.estimateTokens(entry); + if (tokensNeeded <= 0) break; + } + return selected; +} + +export function makeOraclePolicy(graph: ReferenceGraph): WorkingSetPolicy { + return { + id: controlId("oracle"), + select(input): ReadonlyArray { + // Replay calls before the next turn-start entry is appended. A reference + // in that next turn is therefore still future from the model's view. + const currentTurn = countReplayTurns(input.entries) + 1; + const safe = eligibleToolResults(input).filter((entry) => { + const futureTurns = graph.futureTurnsOf.get(entry.turnId) ?? []; + return futureTurns.every((turn) => turn < currentTurn); + }); + return takeToTarget(input, safe); + }, + }; +} + +/** Graph-free export for callers that need a registry-shaped control. */ +export const oraclePolicy: WorkingSetPolicy = makeOraclePolicy({ edges: [], futureTurnsOf: new Map() }); + +function mulberry32(seed: number): () => number { + let value = seed >>> 0; + return () => { + value = (value + 0x6d2b79f5) >>> 0; + let next = value; + next = Math.imul(next ^ (next >>> 15), next | 1); + next ^= next + Math.imul(next ^ (next >>> 7), next | 61); + return ((next ^ (next >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +export function makeRandomPolicy(seed: number): WorkingSetPolicy { + return { + id: controlId("random"), + select(input): ReadonlyArray { + const entries = [...eligibleToolResults(input)]; + const random = mulberry32(seed); + for (let index = entries.length - 1; index > 0; index -= 1) { + const swap = Math.floor(random() * (index + 1)); + const value = entries[index]; + entries[index] = entries[swap] as SessionEntry; + entries[swap] = value as SessionEntry; + } + return takeToTarget(input, entries); + }, + }; +} + +export const nonePolicy: WorkingSetPolicy = { + id: controlId("none"), + select: () => [], +}; diff --git a/src/domains/context/working-set/replay/load-clio.ts b/src/domains/context/working-set/replay/load-clio.ts new file mode 100644 index 000000000..c6eb5b2d5 --- /dev/null +++ b/src/domains/context/working-set/replay/load-clio.ts @@ -0,0 +1,194 @@ +import type { Dirent } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { parseSessionEntries } from "../../../session/archive-readers.js"; +import { isSessionHeader, type SessionEntry } from "../../../session/entries.js"; +import { filterEntriesToActivePath } from "../../../session/tree/active-path.js"; +import { buildPathIndex } from "../path-index.js"; +import { buildReferenceGraph } from "./reference-graph.js"; +import { countReplayTurns, type Trace } from "./trace.js"; + +export interface ReplayFilterOptions { + minTurns?: number; + minToolResults?: number; + requireFileReread?: boolean; +} + +export interface LoadClioTraceOptions { + /** False is the CLI's --no-filter behavior. */ + filter?: false | ReplayFilterOptions; +} + +export interface ReplayLoadCascade { + found: number; + unreadable: number; + filtered: Record; + kept: number; +} + +const DEFAULT_FILTER = { + minTurns: 8, + minToolResults: 8, + requireFileReread: true, +} as const; + +async function collectLedgerFiles(input: string, out: Set): Promise { + const path = resolve(input); + let facts: Awaited>; + try { + facts = await stat(path); + } catch { + return; + } + if (facts.isFile()) { + if (basename(path) === "current.jsonl") out.add(path); + return; + } + if (!facts.isDirectory()) return; + + const direct = join(path, "current.jsonl"); + try { + if ((await stat(direct)).isFile()) { + out.add(direct); + return; + } + } catch { + // A sessions root or cwd-hash directory has no direct ledger. + } + + let children: Dirent[]; + try { + children = await readdir(path, { withFileTypes: true }); + } catch { + return; + } + for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) { + if (!child.isDirectory()) continue; + await collectLedgerFiles(join(path, child.name), out); + } +} + +function sessionId(raw: string, source: string): string { + for (const line of raw.split("\n")) { + if (line.trim().length === 0) continue; + try { + const value = JSON.parse(line) as unknown; + if (isSessionHeader(value)) return value.id; + } catch { + return basename(dirname(source)); + } + } + return basename(dirname(source)); +} + +async function pinnedLeafTurnId(source: string): Promise { + try { + const raw = await readFile(join(dirname(source), "meta.json"), "utf8"); + const value = JSON.parse(raw) as unknown; + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const pinned = (value as Record).pinnedLeafTurnId; + return typeof pinned === "string" && pinned.length > 0 ? pinned : undefined; + } catch { + return undefined; + } +} + +function cleanActiveEntries(entries: ReadonlyArray, activeLeafTurnId?: string): SessionEntry[] { + return filterEntriesToActivePath(entries, activeLeafTurnId).filter( + (entry) => entry.kind !== "contextEviction" && entry.kind !== "contextRecall", + ); +} + +function toolResultCount(entries: ReadonlyArray): number { + let count = 0; + for (const entry of entries) { + if (entry.kind === "message" && entry.role === "tool_result") count += 1; + } + return count; +} + +/** + * Load only Clio ledgers. Inputs may be a current.jsonl file, one session + * directory, a cwd-hash directory, or the sessions root. + */ +export async function loadClioTraces( + paths: ReadonlyArray, + options: LoadClioTraceOptions = {}, +): Promise<{ traces: Trace[]; cascade: ReplayLoadCascade }> { + const discovered = new Set(); + let missingInputs = 0; + for (const path of paths) { + const before = discovered.size; + await collectLedgerFiles(path, discovered); + if (discovered.size === before) { + try { + await stat(resolve(path)); + } catch { + missingInputs += 1; + } + } + } + + const filtered: Record = { + turns_lt_8: 0, + tool_results_lt_8: 0, + no_file_reread: 0, + }; + const cascade: ReplayLoadCascade = { + found: discovered.size, + unreadable: missingInputs, + filtered, + kept: 0, + }; + const traces: Trace[] = []; + const filter = + options.filter === false + ? null + : { + minTurns: options.filter?.minTurns ?? DEFAULT_FILTER.minTurns, + minToolResults: options.filter?.minToolResults ?? DEFAULT_FILTER.minToolResults, + requireFileReread: options.filter?.requireFileReread ?? DEFAULT_FILTER.requireFileReread, + }; + + for (const source of [...discovered].sort((a, b) => a.localeCompare(b))) { + let raw: string; + try { + raw = await readFile(source, "utf8"); + } catch { + cascade.unreadable += 1; + continue; + } + const parsed = parseSessionEntries(raw, source); + if (parsed.errors.length > 0) { + cascade.unreadable += 1; + continue; + } + const entries = cleanActiveEntries(parsed.entries, await pinnedLeafTurnId(source)); + const trace: Trace = { + id: sessionId(raw, source), + source, + entries, + turnCount: countReplayTurns(entries), + }; + if (filter !== null) { + if (trace.turnCount < filter.minTurns) { + filtered.turns_lt_8 = (filtered.turns_lt_8 ?? 0) + 1; + continue; + } + if (toolResultCount(entries) < filter.minToolResults) { + filtered.tool_results_lt_8 = (filtered.tool_results_lt_8 ?? 0) + 1; + continue; + } + if (filter.requireFileReread) { + const graph = buildReferenceGraph(trace, buildPathIndex(entries)); + if (!graph.edges.some((edge) => edge.kind === "file_reread")) { + filtered.no_file_reread = (filtered.no_file_reread ?? 0) + 1; + continue; + } + } + } + traces.push(trace); + } + cascade.kept = traces.length; + return { traces, cascade }; +} diff --git a/src/domains/context/working-set/replay/metrics.ts b/src/domains/context/working-set/replay/metrics.ts new file mode 100644 index 000000000..0bc263878 --- /dev/null +++ b/src/domains/context/working-set/replay/metrics.ts @@ -0,0 +1,125 @@ +import type { PathIndex } from "../path-index.js"; +import type { ReferenceGraph } from "./reference-graph.js"; +import type { ReplayTraceResult } from "./runner.js"; +import type { Trace } from "./trace.js"; + +export interface ReplayMetrics { + traces: number; + retention: number; + retentionAt10: number; + evictionPrecision: number; + tokensEvicted: number; + evictionEvents: number; + churn: number; + turnsToFirstSummary: number | null; +} + +export interface ReplayMeasurement { + trace: Trace; + index: PathIndex; + graph: ReferenceGraph; + replay: ReplayTraceResult; +} + +export interface ReplayMetricAggregate { + /** Arithmetic mean of the per-trace metrics; `traces` is the sample size. */ + mean: ReplayMetrics; + /** Headline pair-level retention pooled across every critical future reference. */ + pooledRetention: number; + pooledRetentionAt10: number; +} + +interface MeasuredTrace { + metrics: ReplayMetrics; + pairs: number; + retainedPairs: number; + pairsAt10: number; + retainedPairsAt10: number; +} + +function safeFraction(numerator: number, denominator: number, empty: number): number { + return denominator === 0 ? empty : numerator / denominator; +} + +function measure(input: ReplayMeasurement): MeasuredTrace { + let pairs = 0; + let retainedPairs = 0; + let pairsAt10 = 0; + let retainedPairsAt10 = 0; + for (const [ref, futureTurns] of input.graph.futureTurnsOf) { + const observationTurn = input.index.byRef.get(ref)?.turnIndex; + const evictedAt = input.replay.evictedAtTurn.get(ref); + for (const referenceTurn of futureTurns) { + pairs += 1; + const retained = evictedAt === undefined || evictedAt > referenceTurn; + if (retained) retainedPairs += 1; + if (observationTurn !== undefined && referenceTurn - observationTurn <= 10) { + pairsAt10 += 1; + if (retained) retainedPairsAt10 += 1; + } + } + } + + let evictedItems = 0; + let safelyEvictedItems = 0; + let churnedItems = 0; + let tokensEvicted = 0; + for (const event of input.replay.events) { + for (const item of event.items) { + evictedItems += 1; + tokensEvicted += item.tokensFreed; + const future = input.graph.futureTurnsOf.get(item.ref.entry) ?? []; + const referencedAfter = future.some((turn) => turn > event.turnIndex); + if (referencedAfter) churnedItems += 1; + else safelyEvictedItems += 1; + } + } + + return { + metrics: { + traces: 1, + retention: safeFraction(retainedPairs, pairs, 1), + retentionAt10: safeFraction(retainedPairsAt10, pairsAt10, 1), + evictionPrecision: safeFraction(safelyEvictedItems, evictedItems, 1), + tokensEvicted, + evictionEvents: input.replay.events.length, + churn: safeFraction(churnedItems, evictedItems, 0), + turnsToFirstSummary: input.replay.turnsToFirstSummary, + }, + pairs, + retainedPairs, + pairsAt10, + retainedPairsAt10, + }; +} + +export function measureReplayTrace(input: ReplayMeasurement): ReplayMetrics { + return measure(input).metrics; +} + +function mean(values: ReadonlyArray): number { + return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length; +} + +export function aggregateReplayMetrics(inputs: ReadonlyArray): ReplayMetricAggregate { + const measured = inputs.map(measure); + const summaries = measured + .map((entry) => entry.metrics.turnsToFirstSummary) + .filter((value): value is number => value !== null); + const sum = (field: "pairs" | "retainedPairs" | "pairsAt10" | "retainedPairsAt10"): number => + measured.reduce((total, entry) => total + entry[field], 0); + return { + mean: { + traces: measured.length, + retention: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.retention)), + retentionAt10: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.retentionAt10)), + evictionPrecision: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.evictionPrecision)), + tokensEvicted: mean(measured.map((entry) => entry.metrics.tokensEvicted)), + evictionEvents: mean(measured.map((entry) => entry.metrics.evictionEvents)), + churn: mean(measured.map((entry) => entry.metrics.churn)), + turnsToFirstSummary: summaries.length === 0 ? null : mean(summaries), + }, + pooledRetention: safeFraction(sum("retainedPairs"), sum("pairs"), 1), + pooledRetentionAt10: safeFraction(sum("retainedPairsAt10"), sum("pairsAt10"), 1), + }; +} diff --git a/src/domains/context/working-set/replay/reference-graph.ts b/src/domains/context/working-set/replay/reference-graph.ts new file mode 100644 index 000000000..bf1ec0b34 --- /dev/null +++ b/src/domains/context/working-set/replay/reference-graph.ts @@ -0,0 +1,79 @@ +import type { SessionEntry } from "../../../session/entries.js"; +import type { PathIndex, PathObservation } from "../path-index.js"; +import type { Trace } from "./trace.js"; + +export interface ReferenceEdge { + /** Ref key of the earlier tool_result. */ + from: string; + toTurnIndex: number; + kind: "file_reread" | "file_discovery" | "file_rewrite"; +} + +export interface ReferenceGraph { + edges: ReadonlyArray; + /** Critical future references only: file_reread and file_discovery. */ + futureTurnsOf: ReadonlyMap>; +} + +const READ_CLASS_OPS = new Set(["read", "grep", "find", "ls", "code_nav"]); +const MUTATION_OPS = new Set(["write", "edit"]); + +function isToolResult(entry: SessionEntry | undefined): boolean { + return entry?.kind === "message" && entry.role === "tool_result"; +} + +function isReadableObservation(observation: PathObservation): boolean { + return !observation.isError && observation.path.length > 0 && READ_CLASS_OPS.has(observation.op); +} + +function edgeKey(edge: ReferenceEdge): string { + return `${edge.from}\u0000${edge.toTurnIndex}\u0000${edge.kind}`; +} + +/** + * Label future path use without inspecting result prose. Rewrites are emitted + * for diagnosis but deliberately stay out of `futureTurnsOf`: a mutation makes + * the earlier read stale rather than critical to retain. + */ +export function buildReferenceGraph(trace: Trace, index: PathIndex): ReferenceGraph { + const entryById = new Map(trace.entries.map((entry) => [entry.turnId, entry])); + const edges: ReferenceEdge[] = []; + const seenEdges = new Set(); + const future = new Map>(); + + const add = (edge: ReferenceEdge): void => { + const key = edgeKey(edge); + if (seenEdges.has(key)) return; + seenEdges.add(key); + edges.push(edge); + if (edge.kind === "file_rewrite") return; + const turns = future.get(edge.from); + if (turns === undefined) future.set(edge.from, new Set([edge.toTurnIndex])); + else turns.add(edge.toTurnIndex); + }; + + for (const earlier of index.observations) { + if (!isToolResult(entryById.get(earlier.ref.entry)) || !isReadableObservation(earlier)) continue; + const surfaced = new Set(earlier.surfaced); + for (const later of index.observations) { + if (later.entryIndex <= earlier.entryIndex) continue; + if (isReadableObservation(later) && later.path === earlier.path) { + add({ from: earlier.ref.entry, toTurnIndex: later.turnIndex, kind: "file_reread" }); + } + if (isReadableObservation(later) && surfaced.has(later.path)) { + add({ from: earlier.ref.entry, toTurnIndex: later.turnIndex, kind: "file_discovery" }); + } + if (later.path === earlier.path && MUTATION_OPS.has(later.op)) { + add({ from: earlier.ref.entry, toTurnIndex: later.turnIndex, kind: "file_rewrite" }); + } + } + } + + const futureTurnsOf = new Map>(); + for (const [ref, turns] of future) + futureTurnsOf.set( + ref, + [...turns].sort((a, b) => a - b), + ); + return { edges, futureTurnsOf }; +} diff --git a/src/domains/context/working-set/replay/report.ts b/src/domains/context/working-set/replay/report.ts new file mode 100644 index 000000000..c3172170f --- /dev/null +++ b/src/domains/context/working-set/replay/report.ts @@ -0,0 +1,130 @@ +import type { WorkingSetSettings } from "../../../../core/defaults.js"; +import type { ReplayLoadCascade } from "./load-clio.js"; +import type { ReplayMetricAggregate, ReplayMetrics } from "./metrics.js"; + +export interface ReplayReportConfig { + policies: ReadonlyArray; + budgets: ReadonlyArray; + threshold: number; + target: number; + seed: number; + filter: "default" | "none"; + settings: WorkingSetSettings; +} + +export interface ReplayPolicyResult { + budgetTokens: number; + policyId: string; + metrics: ReplayMetricAggregate; +} + +export interface ReplayReportInput { + config: ReplayReportConfig; + cascade: ReplayLoadCascade; + results: ReadonlyArray; + gitSha: string | null; + commandLine: ReadonlyArray; +} + +function metricObject(metrics: ReplayMetrics): Record { + return { + traces: metrics.traces, + retention: metrics.retention, + retentionAt10: metrics.retentionAt10, + evictionPrecision: metrics.evictionPrecision, + tokensEvicted: metrics.tokensEvicted, + evictionEvents: metrics.evictionEvents, + churn: metrics.churn, + turnsToFirstSummary: metrics.turnsToFirstSummary, + }; +} + +export function renderReplayJson(input: ReplayReportInput): string { + const filtered = Object.fromEntries(Object.entries(input.cascade.filtered).sort(([a], [b]) => a.localeCompare(b))); + const artifact = { + schema: "clio-context-replay-v1", + config: { + policies: [...input.config.policies], + budgets: [...input.config.budgets], + threshold: input.config.threshold, + target: input.config.target, + seed: input.config.seed, + filter: input.config.filter, + settings: { + enabled: input.config.settings.enabled, + policy: input.config.settings.policy, + target: input.config.settings.target, + protectLastTurns: input.config.settings.protectLastTurns, + minEvictableTokens: input.config.settings.minEvictableTokens, + }, + }, + provenance: { + gitSha: input.gitSha, + commandLine: [...input.commandLine], + }, + cascade: { + found: input.cascade.found, + unreadable: input.cascade.unreadable, + filtered, + kept: input.cascade.kept, + }, + results: input.results.map((result) => ({ + budgetTokens: result.budgetTokens, + policyId: result.policyId, + metrics: { + mean: metricObject(result.metrics.mean), + pooledRetention: result.metrics.pooledRetention, + pooledRetentionAt10: result.metrics.pooledRetentionAt10, + }, + })), + }; + return `${JSON.stringify(artifact, null, 2)}\n`; +} + +function ratio(value: number): string { + return value.toFixed(3); +} + +function quantity(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1); +} + +function cascadeRows(cascade: ReplayLoadCascade): string[] { + return [ + `| found | ${cascade.found} |`, + `| unreadable | ${cascade.unreadable} |`, + ...Object.entries(cascade.filtered).map(([stage, count]) => `| ${stage} | ${count} |`), + `| kept | ${cascade.kept} |`, + ]; +} + +export function renderReplayMarkdown(input: ReplayReportInput): string { + const lines = [ + "# Clio working-set replay", + "", + "## Inclusion cascade", + "", + "| stage | traces |", + "| --- | ---: |", + ...cascadeRows(input.cascade), + ]; + for (const budget of input.config.budgets) { + lines.push( + "", + `## Budget ${budget}`, + "", + "| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | churn (mean) | turns to first summary (mean) |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + ); + for (const policy of input.config.policies) { + const result = input.results.find((entry) => entry.budgetTokens === budget && entry.policyId === policy); + if (result === undefined) continue; + const metrics = result.metrics.mean; + lines.push( + `| ${policy} | ${metrics.traces} | ${ratio(metrics.retention)} | ${ratio(result.metrics.pooledRetention)} | ${ratio(metrics.retentionAt10)} | ${ratio(metrics.evictionPrecision)} | ${quantity(metrics.tokensEvicted)} | ${quantity(metrics.evictionEvents)} | ${ratio(metrics.churn)} | ${metrics.turnsToFirstSummary === null ? "—" : quantity(metrics.turnsToFirstSummary)} |`, + ); + } + } + lines.push(""); + return lines.join("\n"); +} diff --git a/src/domains/context/working-set/replay/runner.ts b/src/domains/context/working-set/replay/runner.ts new file mode 100644 index 000000000..0687b64ac --- /dev/null +++ b/src/domains/context/working-set/replay/runner.ts @@ -0,0 +1,131 @@ +import type { WorkingSetSettings } from "../../../../core/defaults.js"; +import { estimateTokens } from "../../../session/compaction/tokens.js"; +import type { ContextEvictionEntry, EvictedItem, SessionEntry } from "../../../session/entries.js"; +import type { WorkingSetPolicy } from "../contract.js"; +import { buildEvictionFields, planEviction } from "../engine.js"; +import { foldWorkingSet } from "../fold.js"; +import { projectWorkingSet } from "../project.js"; +import { isReplayTurnStart, type Trace } from "./trace.js"; + +export interface ReplayConfig { + policyId: string; + budgetTokens: number; + threshold: number; + target: number; + settings: WorkingSetSettings; + seed: number; +} + +export interface ReplayEvictionEvent { + turnIndex: number; + items: ReadonlyArray; + tokensBefore: number; + tokensAfter: number; +} + +export interface ReplayTraceResult { + traceId: string; + policyId: string; + budgetTokens: number; + turnCount: number; + events: ReadonlyArray; + /** Tool-result refs only; thinking-unit evictions are intentionally absent. */ + evictedAtTurn: ReadonlyMap; + turnsToFirstSummary: number | null; + /** Original entries plus synthetic append-only contextEviction sidecars. */ + entries: ReadonlyArray; +} + +function sumProjectedTokens(entries: ReadonlyArray, activeLeafTurnId?: string): number { + const view = foldWorkingSet(entries, activeLeafTurnId); + let tokens = 0; + for (const entry of projectWorkingSet(entries, view)) tokens += estimateTokens(entry); + return tokens; +} + +function lastMessageTurnId(entries: ReadonlyArray): string | null { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry?.kind === "message") return entry.turnId; + } + return null; +} + +/** Live plan/fold/project code driven at deterministic ledger turn boundaries. */ +export function replayTrace(trace: Trace, policy: WorkingSetPolicy, config: ReplayConfig): ReplayTraceResult { + const soFar: SessionEntry[] = []; + const events: ReplayEvictionEvent[] = []; + const evictedAtTurn = new Map(); + const toolResults = new Set( + trace.entries + .filter((entry) => entry.kind === "message" && entry.role === "tool_result") + .map((entry) => entry.turnId), + ); + let evictionSequence = 0; + let turnIndex = 0; + let turnsToFirstSummary: number | null = null; + const pressureLimit = config.threshold * config.budgetTokens; + + for (const entry of trace.entries) { + if (isReplayTurnStart(entry)) { + turnIndex += 1; + const leaf = lastMessageTurnId(soFar); + const tokens = sumProjectedTokens(soFar, leaf ?? undefined); + if (tokens > pressureLimit) { + const view = foldWorkingSet(soFar, leaf ?? undefined); + const plan = planEviction(policy, { + entries: soFar, + view, + settings: config.settings, + pressure: { + tokens, + contextWindow: config.budgetTokens, + threshold: config.threshold, + target: config.target, + }, + estimateTokens, + }); + if (plan !== null) { + evictionSequence += 1; + const previous = soFar[soFar.length - 1]; + const synthetic: ContextEvictionEntry = { + ...buildEvictionFields(plan, { + trigger: "pressure", + pressureBefore: tokens / config.budgetTokens, + snapshotIdBefore: null, + }), + turnId: `replay-evict-${evictionSequence}`, + parentTurnId: leaf, + timestamp: previous?.timestamp ?? entry.timestamp, + }; + soFar.push(synthetic); + events.push({ + turnIndex, + items: plan.items, + tokensBefore: plan.tokensBefore, + tokensAfter: plan.tokensAfter, + }); + for (const item of plan.items) { + if (toolResults.has(item.ref.entry) && !evictedAtTurn.has(item.ref.entry)) { + evictedAtTurn.set(item.ref.entry, turnIndex); + } + } + } + const postTokens = sumProjectedTokens(soFar, leaf ?? undefined); + if (turnsToFirstSummary === null && postTokens > pressureLimit) turnsToFirstSummary = turnIndex; + } + } + soFar.push(entry); + } + + return { + traceId: trace.id, + policyId: config.policyId, + budgetTokens: config.budgetTokens, + turnCount: trace.turnCount, + events, + evictedAtTurn, + turnsToFirstSummary, + entries: soFar, + }; +} diff --git a/src/domains/context/working-set/replay/trace.ts b/src/domains/context/working-set/replay/trace.ts new file mode 100644 index 000000000..9abdfb114 --- /dev/null +++ b/src/domains/context/working-set/replay/trace.ts @@ -0,0 +1,23 @@ +import type { SessionEntry } from "../../../session/entries.js"; + +/** One active-path Clio ledger prepared for deterministic replay. */ +export interface Trace { + id: string; + source: string; + entries: ReadonlyArray; + turnCount: number; +} + +/** Turn boundaries shared by the live age horizon and replay runner. */ +export function isReplayTurnStart(entry: SessionEntry): boolean { + if (entry.kind === "bashExecution" || entry.kind === "branchSummary") return true; + return entry.kind === "message" && entry.role === "user"; +} + +export function countReplayTurns(entries: ReadonlyArray): number { + let count = 0; + for (const entry of entries) { + if (isReplayTurnStart(entry)) count += 1; + } + return count; +} diff --git a/tests/fixtures/context-replay/fixture-01.jsonl b/tests/fixtures/context-replay/fixture-01.jsonl new file mode 100644 index 000000000..a11c014f3 --- /dev/null +++ b/tests/fixtures/context-replay/fixture-01.jsonl @@ -0,0 +1,46 @@ +{"type":"session","version":4,"id":"context-replay-fixture-01","timestamp":"2026-08-21T00:00:00.000Z","cwd":"/fixture/repo"} +{"kind":"message","turnId":"user-01","parentTurnId":null,"timestamp":"2026-08-21T00:00:01.000Z","role":"user","payload":{"text":"initial a read"}} +{"kind":"message","turnId":"call-entry-01","parentTurnId":"user-01","timestamp":"2026-08-21T00:00:02.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-01","name":"read","args":{"path":"src/a.ts"}}} +{"kind":"message","turnId":"result-01","parentTurnId":"call-entry-01","timestamp":"2026-08-21T00:00:03.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-01","toolName":"read","result":{"content":[{"type":"text","text":"a-v1 fixture line 001 contains deterministic replay evidence.\na-v1 fixture line 002 contains deterministic replay evidence.\na-v1 fixture line 003 contains deterministic replay evidence.\na-v1 fixture line 004 contains deterministic replay evidence.\na-v1 fixture line 005 contains deterministic replay evidence.\na-v1 fixture line 006 contains deterministic replay evidence.\na-v1 fixture line 007 contains deterministic replay evidence.\na-v1 fixture line 008 contains deterministic replay evidence.\na-v1 fixture line 009 contains deterministic replay evidence.\na-v1 fixture line 010 contains deterministic replay evidence.\na-v1 fixture line 011 contains deterministic replay evidence.\na-v1 fixture line 012 contains deterministic replay evidence.\na-v1 fixture line 013 contains deterministic replay evidence.\na-v1 fixture line 014 contains deterministic replay evidence.\na-v1 fixture line 015 contains deterministic replay evidence.\na-v1 fixture line 016 contains deterministic replay evidence.\na-v1 fixture line 017 contains deterministic replay evidence.\na-v1 fixture line 018 contains deterministic replay evidence.\na-v1 fixture line 019 contains deterministic replay evidence.\na-v1 fixture line 020 contains deterministic replay evidence.\na-v1 fixture line 021 contains deterministic replay evidence.\na-v1 fixture line 022 contains deterministic replay evidence.\na-v1 fixture line 023 contains deterministic replay evidence.\na-v1 fixture line 024 contains deterministic replay evidence.\na-v1 fixture line 025 contains deterministic replay evidence.\na-v1 fixture line 026 contains deterministic replay evidence.\na-v1 fixture line 027 contains deterministic replay evidence.\na-v1 fixture line 028 contains deterministic replay evidence.\na-v1 fixture line 029 contains deterministic replay evidence.\na-v1 fixture line 030 contains deterministic replay evidence.\na-v1 fixture line 031 contains deterministic replay evidence.\na-v1 fixture line 032 contains deterministic replay evidence.\na-v1 fixture line 033 contains deterministic replay evidence.\na-v1 fixture line 034 contains deterministic replay evidence.\na-v1 fixture line 035 contains deterministic replay evidence.\na-v1 fixture line 036 contains deterministic replay evidence.\na-v1 fixture line 037 contains deterministic replay evidence.\na-v1 fixture line 038 contains deterministic replay evidence.\na-v1 fixture line 039 contains deterministic replay evidence.\na-v1 fixture line 040 contains deterministic replay evidence.\na-v1 fixture line 041 contains deterministic replay evidence.\na-v1 fixture line 042 contains deterministic replay evidence.\na-v1 fixture line 043 contains deterministic replay evidence.\na-v1 fixture line 044 contains deterministic replay evidence.\na-v1 fixture line 045 contains deterministic replay evidence.\na-v1 fixture line 046 contains deterministic replay evidence.\na-v1 fixture line 047 contains deterministic replay evidence.\na-v1 fixture line 048 contains deterministic replay evidence.\na-v1 fixture line 049 contains deterministic replay evidence.\na-v1 fixture line 050 contains deterministic replay evidence.\na-v1 fixture line 051 contains deterministic replay evidence.\na-v1 fixture line 052 contains deterministic replay evidence.\na-v1 fixture line 053 contains deterministic replay evidence.\na-v1 fixture line 054 contains deterministic replay evidence.\na-v1 fixture line 055 contains deterministic replay evidence.\na-v1 fixture line 056 contains deterministic replay evidence.\na-v1 fixture line 057 contains deterministic replay evidence.\na-v1 fixture line 058 contains deterministic replay evidence.\na-v1 fixture line 059 contains deterministic replay evidence.\na-v1 fixture line 060 contains deterministic replay evidence.\na-v1 fixture line 061 contains deterministic replay evidence.\na-v1 fixture line 062 contains deterministic replay evidence.\na-v1 fixture line 063 contains deterministic replay evidence.\na-v1 fixture line 064 contains deterministic replay evidence.\na-v1 fixture line 065 contains deterministic replay evidence.\na-v1 fixture line 066 contains deterministic replay evidence.\na-v1 fixture line 067 contains deterministic replay evidence.\na-v1 fixture line 068 contains deterministic replay evidence.\na-v1 fixture line 069 contains deterministic replay evidence.\na-v1 fixture line 070 contains deterministic replay evidence.\na-v1 fixture line 071 contains deterministic replay evidence.\na-v1 fixture line 072 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-02","parentTurnId":"result-01","timestamp":"2026-08-21T00:00:04.000Z","role":"user","payload":{"text":"initial b read"}} +{"kind":"message","turnId":"call-entry-02","parentTurnId":"user-02","timestamp":"2026-08-21T00:00:05.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-02","name":"read","args":{"path":"src/b.ts"}}} +{"kind":"message","turnId":"result-02","parentTurnId":"call-entry-02","timestamp":"2026-08-21T00:00:06.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-02","toolName":"read","result":{"content":[{"type":"text","text":"b-v1 fixture line 001 contains deterministic replay evidence.\nb-v1 fixture line 002 contains deterministic replay evidence.\nb-v1 fixture line 003 contains deterministic replay evidence.\nb-v1 fixture line 004 contains deterministic replay evidence.\nb-v1 fixture line 005 contains deterministic replay evidence.\nb-v1 fixture line 006 contains deterministic replay evidence.\nb-v1 fixture line 007 contains deterministic replay evidence.\nb-v1 fixture line 008 contains deterministic replay evidence.\nb-v1 fixture line 009 contains deterministic replay evidence.\nb-v1 fixture line 010 contains deterministic replay evidence.\nb-v1 fixture line 011 contains deterministic replay evidence.\nb-v1 fixture line 012 contains deterministic replay evidence.\nb-v1 fixture line 013 contains deterministic replay evidence.\nb-v1 fixture line 014 contains deterministic replay evidence.\nb-v1 fixture line 015 contains deterministic replay evidence.\nb-v1 fixture line 016 contains deterministic replay evidence.\nb-v1 fixture line 017 contains deterministic replay evidence.\nb-v1 fixture line 018 contains deterministic replay evidence.\nb-v1 fixture line 019 contains deterministic replay evidence.\nb-v1 fixture line 020 contains deterministic replay evidence.\nb-v1 fixture line 021 contains deterministic replay evidence.\nb-v1 fixture line 022 contains deterministic replay evidence.\nb-v1 fixture line 023 contains deterministic replay evidence.\nb-v1 fixture line 024 contains deterministic replay evidence.\nb-v1 fixture line 025 contains deterministic replay evidence.\nb-v1 fixture line 026 contains deterministic replay evidence.\nb-v1 fixture line 027 contains deterministic replay evidence.\nb-v1 fixture line 028 contains deterministic replay evidence.\nb-v1 fixture line 029 contains deterministic replay evidence.\nb-v1 fixture line 030 contains deterministic replay evidence.\nb-v1 fixture line 031 contains deterministic replay evidence.\nb-v1 fixture line 032 contains deterministic replay evidence.\nb-v1 fixture line 033 contains deterministic replay evidence.\nb-v1 fixture line 034 contains deterministic replay evidence.\nb-v1 fixture line 035 contains deterministic replay evidence.\nb-v1 fixture line 036 contains deterministic replay evidence.\nb-v1 fixture line 037 contains deterministic replay evidence.\nb-v1 fixture line 038 contains deterministic replay evidence.\nb-v1 fixture line 039 contains deterministic replay evidence.\nb-v1 fixture line 040 contains deterministic replay evidence.\nb-v1 fixture line 041 contains deterministic replay evidence.\nb-v1 fixture line 042 contains deterministic replay evidence.\nb-v1 fixture line 043 contains deterministic replay evidence.\nb-v1 fixture line 044 contains deterministic replay evidence.\nb-v1 fixture line 045 contains deterministic replay evidence.\nb-v1 fixture line 046 contains deterministic replay evidence.\nb-v1 fixture line 047 contains deterministic replay evidence.\nb-v1 fixture line 048 contains deterministic replay evidence.\nb-v1 fixture line 049 contains deterministic replay evidence.\nb-v1 fixture line 050 contains deterministic replay evidence.\nb-v1 fixture line 051 contains deterministic replay evidence.\nb-v1 fixture line 052 contains deterministic replay evidence.\nb-v1 fixture line 053 contains deterministic replay evidence.\nb-v1 fixture line 054 contains deterministic replay evidence.\nb-v1 fixture line 055 contains deterministic replay evidence.\nb-v1 fixture line 056 contains deterministic replay evidence.\nb-v1 fixture line 057 contains deterministic replay evidence.\nb-v1 fixture line 058 contains deterministic replay evidence.\nb-v1 fixture line 059 contains deterministic replay evidence.\nb-v1 fixture line 060 contains deterministic replay evidence.\nb-v1 fixture line 061 contains deterministic replay evidence.\nb-v1 fixture line 062 contains deterministic replay evidence.\nb-v1 fixture line 063 contains deterministic replay evidence.\nb-v1 fixture line 064 contains deterministic replay evidence.\nb-v1 fixture line 065 contains deterministic replay evidence.\nb-v1 fixture line 066 contains deterministic replay evidence.\nb-v1 fixture line 067 contains deterministic replay evidence.\nb-v1 fixture line 068 contains deterministic replay evidence.\nb-v1 fixture line 069 contains deterministic replay evidence.\nb-v1 fixture line 070 contains deterministic replay evidence.\nb-v1 fixture line 071 contains deterministic replay evidence.\nb-v1 fixture line 072 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-03","parentTurnId":"result-02","timestamp":"2026-08-21T00:00:07.000Z","role":"user","payload":{"text":"discover files"}} +{"kind":"message","turnId":"call-entry-03","parentTurnId":"user-03","timestamp":"2026-08-21T00:00:08.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-03","name":"find","args":{"path":".","pattern":"src/*.ts"}}} +{"kind":"message","turnId":"result-03","parentTurnId":"call-entry-03","timestamp":"2026-08-21T00:00:09.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-03","toolName":"find","result":{"content":[{"type":"text","text":"src/c.ts\nsrc/d.ts"}]},"isError":false}} +{"kind":"message","turnId":"user-04","parentTurnId":"result-03","timestamp":"2026-08-21T00:00:10.000Z","role":"user","payload":{"text":"consume c"}} +{"kind":"message","turnId":"call-entry-04","parentTurnId":"user-04","timestamp":"2026-08-21T00:00:11.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-04","name":"read","args":{"path":"src/c.ts"}}} +{"kind":"message","turnId":"result-04","parentTurnId":"call-entry-04","timestamp":"2026-08-21T00:00:12.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-04","toolName":"read","result":{"content":[{"type":"text","text":"c-v1 fixture line 001 contains deterministic replay evidence.\nc-v1 fixture line 002 contains deterministic replay evidence.\nc-v1 fixture line 003 contains deterministic replay evidence.\nc-v1 fixture line 004 contains deterministic replay evidence.\nc-v1 fixture line 005 contains deterministic replay evidence.\nc-v1 fixture line 006 contains deterministic replay evidence.\nc-v1 fixture line 007 contains deterministic replay evidence.\nc-v1 fixture line 008 contains deterministic replay evidence.\nc-v1 fixture line 009 contains deterministic replay evidence.\nc-v1 fixture line 010 contains deterministic replay evidence.\nc-v1 fixture line 011 contains deterministic replay evidence.\nc-v1 fixture line 012 contains deterministic replay evidence.\nc-v1 fixture line 013 contains deterministic replay evidence.\nc-v1 fixture line 014 contains deterministic replay evidence.\nc-v1 fixture line 015 contains deterministic replay evidence.\nc-v1 fixture line 016 contains deterministic replay evidence.\nc-v1 fixture line 017 contains deterministic replay evidence.\nc-v1 fixture line 018 contains deterministic replay evidence.\nc-v1 fixture line 019 contains deterministic replay evidence.\nc-v1 fixture line 020 contains deterministic replay evidence.\nc-v1 fixture line 021 contains deterministic replay evidence.\nc-v1 fixture line 022 contains deterministic replay evidence.\nc-v1 fixture line 023 contains deterministic replay evidence.\nc-v1 fixture line 024 contains deterministic replay evidence.\nc-v1 fixture line 025 contains deterministic replay evidence.\nc-v1 fixture line 026 contains deterministic replay evidence.\nc-v1 fixture line 027 contains deterministic replay evidence.\nc-v1 fixture line 028 contains deterministic replay evidence.\nc-v1 fixture line 029 contains deterministic replay evidence.\nc-v1 fixture line 030 contains deterministic replay evidence.\nc-v1 fixture line 031 contains deterministic replay evidence.\nc-v1 fixture line 032 contains deterministic replay evidence.\nc-v1 fixture line 033 contains deterministic replay evidence.\nc-v1 fixture line 034 contains deterministic replay evidence.\nc-v1 fixture line 035 contains deterministic replay evidence.\nc-v1 fixture line 036 contains deterministic replay evidence.\nc-v1 fixture line 037 contains deterministic replay evidence.\nc-v1 fixture line 038 contains deterministic replay evidence.\nc-v1 fixture line 039 contains deterministic replay evidence.\nc-v1 fixture line 040 contains deterministic replay evidence.\nc-v1 fixture line 041 contains deterministic replay evidence.\nc-v1 fixture line 042 contains deterministic replay evidence.\nc-v1 fixture line 043 contains deterministic replay evidence.\nc-v1 fixture line 044 contains deterministic replay evidence.\nc-v1 fixture line 045 contains deterministic replay evidence.\nc-v1 fixture line 046 contains deterministic replay evidence.\nc-v1 fixture line 047 contains deterministic replay evidence.\nc-v1 fixture line 048 contains deterministic replay evidence.\nc-v1 fixture line 049 contains deterministic replay evidence.\nc-v1 fixture line 050 contains deterministic replay evidence.\nc-v1 fixture line 051 contains deterministic replay evidence.\nc-v1 fixture line 052 contains deterministic replay evidence.\nc-v1 fixture line 053 contains deterministic replay evidence.\nc-v1 fixture line 054 contains deterministic replay evidence.\nc-v1 fixture line 055 contains deterministic replay evidence.\nc-v1 fixture line 056 contains deterministic replay evidence.\nc-v1 fixture line 057 contains deterministic replay evidence.\nc-v1 fixture line 058 contains deterministic replay evidence.\nc-v1 fixture line 059 contains deterministic replay evidence.\nc-v1 fixture line 060 contains deterministic replay evidence.\nc-v1 fixture line 061 contains deterministic replay evidence.\nc-v1 fixture line 062 contains deterministic replay evidence.\nc-v1 fixture line 063 contains deterministic replay evidence.\nc-v1 fixture line 064 contains deterministic replay evidence.\nc-v1 fixture line 065 contains deterministic replay evidence.\nc-v1 fixture line 066 contains deterministic replay evidence.\nc-v1 fixture line 067 contains deterministic replay evidence.\nc-v1 fixture line 068 contains deterministic replay evidence.\nc-v1 fixture line 069 contains deterministic replay evidence.\nc-v1 fixture line 070 contains deterministic replay evidence.\nc-v1 fixture line 071 contains deterministic replay evidence.\nc-v1 fixture line 072 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-05","parentTurnId":"result-04","timestamp":"2026-08-21T00:00:13.000Z","role":"user","payload":{"text":"re-read a"}} +{"kind":"message","turnId":"call-entry-05","parentTurnId":"user-05","timestamp":"2026-08-21T00:00:14.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-05","name":"read","args":{"path":"src/a.ts"}}} +{"kind":"message","turnId":"result-05","parentTurnId":"call-entry-05","timestamp":"2026-08-21T00:00:15.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-05","toolName":"read","result":{"content":[{"type":"text","text":"a-v1-again fixture line 001 contains deterministic replay evidence.\na-v1-again fixture line 002 contains deterministic replay evidence.\na-v1-again fixture line 003 contains deterministic replay evidence.\na-v1-again fixture line 004 contains deterministic replay evidence.\na-v1-again fixture line 005 contains deterministic replay evidence.\na-v1-again fixture line 006 contains deterministic replay evidence.\na-v1-again fixture line 007 contains deterministic replay evidence.\na-v1-again fixture line 008 contains deterministic replay evidence.\na-v1-again fixture line 009 contains deterministic replay evidence.\na-v1-again fixture line 010 contains deterministic replay evidence.\na-v1-again fixture line 011 contains deterministic replay evidence.\na-v1-again fixture line 012 contains deterministic replay evidence.\na-v1-again fixture line 013 contains deterministic replay evidence.\na-v1-again fixture line 014 contains deterministic replay evidence.\na-v1-again fixture line 015 contains deterministic replay evidence.\na-v1-again fixture line 016 contains deterministic replay evidence.\na-v1-again fixture line 017 contains deterministic replay evidence.\na-v1-again fixture line 018 contains deterministic replay evidence.\na-v1-again fixture line 019 contains deterministic replay evidence.\na-v1-again fixture line 020 contains deterministic replay evidence.\na-v1-again fixture line 021 contains deterministic replay evidence.\na-v1-again fixture line 022 contains deterministic replay evidence.\na-v1-again fixture line 023 contains deterministic replay evidence.\na-v1-again fixture line 024 contains deterministic replay evidence.\na-v1-again fixture line 025 contains deterministic replay evidence.\na-v1-again fixture line 026 contains deterministic replay evidence.\na-v1-again fixture line 027 contains deterministic replay evidence.\na-v1-again fixture line 028 contains deterministic replay evidence.\na-v1-again fixture line 029 contains deterministic replay evidence.\na-v1-again fixture line 030 contains deterministic replay evidence.\na-v1-again fixture line 031 contains deterministic replay evidence.\na-v1-again fixture line 032 contains deterministic replay evidence.\na-v1-again fixture line 033 contains deterministic replay evidence.\na-v1-again fixture line 034 contains deterministic replay evidence.\na-v1-again fixture line 035 contains deterministic replay evidence.\na-v1-again fixture line 036 contains deterministic replay evidence.\na-v1-again fixture line 037 contains deterministic replay evidence.\na-v1-again fixture line 038 contains deterministic replay evidence.\na-v1-again fixture line 039 contains deterministic replay evidence.\na-v1-again fixture line 040 contains deterministic replay evidence.\na-v1-again fixture line 041 contains deterministic replay evidence.\na-v1-again fixture line 042 contains deterministic replay evidence.\na-v1-again fixture line 043 contains deterministic replay evidence.\na-v1-again fixture line 044 contains deterministic replay evidence.\na-v1-again fixture line 045 contains deterministic replay evidence.\na-v1-again fixture line 046 contains deterministic replay evidence.\na-v1-again fixture line 047 contains deterministic replay evidence.\na-v1-again fixture line 048 contains deterministic replay evidence.\na-v1-again fixture line 049 contains deterministic replay evidence.\na-v1-again fixture line 050 contains deterministic replay evidence.\na-v1-again fixture line 051 contains deterministic replay evidence.\na-v1-again fixture line 052 contains deterministic replay evidence.\na-v1-again fixture line 053 contains deterministic replay evidence.\na-v1-again fixture line 054 contains deterministic replay evidence.\na-v1-again fixture line 055 contains deterministic replay evidence.\na-v1-again fixture line 056 contains deterministic replay evidence.\na-v1-again fixture line 057 contains deterministic replay evidence.\na-v1-again fixture line 058 contains deterministic replay evidence.\na-v1-again fixture line 059 contains deterministic replay evidence.\na-v1-again fixture line 060 contains deterministic replay evidence.\na-v1-again fixture line 061 contains deterministic replay evidence.\na-v1-again fixture line 062 contains deterministic replay evidence.\na-v1-again fixture line 063 contains deterministic replay evidence.\na-v1-again fixture line 064 contains deterministic replay evidence.\na-v1-again fixture line 065 contains deterministic replay evidence.\na-v1-again fixture line 066 contains deterministic replay evidence.\na-v1-again fixture line 067 contains deterministic replay evidence.\na-v1-again fixture line 068 contains deterministic replay evidence.\na-v1-again fixture line 069 contains deterministic replay evidence.\na-v1-again fixture line 070 contains deterministic replay evidence.\na-v1-again fixture line 071 contains deterministic replay evidence.\na-v1-again fixture line 072 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-06","parentTurnId":"result-05","timestamp":"2026-08-21T00:00:16.000Z","role":"user","payload":{"text":"edit b"}} +{"kind":"message","turnId":"call-entry-06","parentTurnId":"user-06","timestamp":"2026-08-21T00:00:17.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-06","name":"edit","args":{"path":"src/b.ts","edits":[{"oldText":"x","newText":"y"}]}}} +{"kind":"message","turnId":"result-06","parentTurnId":"call-entry-06","timestamp":"2026-08-21T00:00:18.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-06","toolName":"edit","result":{"content":[{"type":"text","text":"b-edited fixture line 001 contains deterministic replay evidence.\nb-edited fixture line 002 contains deterministic replay evidence.\nb-edited fixture line 003 contains deterministic replay evidence.\nb-edited fixture line 004 contains deterministic replay evidence.\nb-edited fixture line 005 contains deterministic replay evidence.\nb-edited fixture line 006 contains deterministic replay evidence.\nb-edited fixture line 007 contains deterministic replay evidence.\nb-edited fixture line 008 contains deterministic replay evidence.\nb-edited fixture line 009 contains deterministic replay evidence.\nb-edited fixture line 010 contains deterministic replay evidence.\nb-edited fixture line 011 contains deterministic replay evidence.\nb-edited fixture line 012 contains deterministic replay evidence.\nb-edited fixture line 013 contains deterministic replay evidence.\nb-edited fixture line 014 contains deterministic replay evidence.\nb-edited fixture line 015 contains deterministic replay evidence.\nb-edited fixture line 016 contains deterministic replay evidence.\nb-edited fixture line 017 contains deterministic replay evidence.\nb-edited fixture line 018 contains deterministic replay evidence.\nb-edited fixture line 019 contains deterministic replay evidence.\nb-edited fixture line 020 contains deterministic replay evidence.\nb-edited fixture line 021 contains deterministic replay evidence.\nb-edited fixture line 022 contains deterministic replay evidence.\nb-edited fixture line 023 contains deterministic replay evidence.\nb-edited fixture line 024 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-07","parentTurnId":"result-06","timestamp":"2026-08-21T00:00:19.000Z","role":"user","payload":{"text":"missing read fails"}} +{"kind":"message","turnId":"call-entry-07","parentTurnId":"user-07","timestamp":"2026-08-21T00:00:20.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-07","name":"read","args":{"path":"src/missing.ts"}}} +{"kind":"message","turnId":"result-07","parentTurnId":"call-entry-07","timestamp":"2026-08-21T00:00:21.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-07","toolName":"read","result":{"content":[{"type":"text","text":"ENOENT: src/missing.ts was not generated yet"}]},"isError":true}} +{"kind":"message","turnId":"user-08","parentTurnId":"result-07","timestamp":"2026-08-21T00:00:22.000Z","role":"user","payload":{"text":"missing read succeeds"}} +{"kind":"message","turnId":"call-entry-08","parentTurnId":"user-08","timestamp":"2026-08-21T00:00:23.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-08","name":"read","args":{"path":"src/missing.ts"}}} +{"kind":"message","turnId":"result-08","parentTurnId":"call-entry-08","timestamp":"2026-08-21T00:00:24.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-08","toolName":"read","result":{"content":[{"type":"text","text":"missing-now-present fixture line 001 contains deterministic replay evidence.\nmissing-now-present fixture line 002 contains deterministic replay evidence.\nmissing-now-present fixture line 003 contains deterministic replay evidence.\nmissing-now-present fixture line 004 contains deterministic replay evidence.\nmissing-now-present fixture line 005 contains deterministic replay evidence.\nmissing-now-present fixture line 006 contains deterministic replay evidence.\nmissing-now-present fixture line 007 contains deterministic replay evidence.\nmissing-now-present fixture line 008 contains deterministic replay evidence.\nmissing-now-present fixture line 009 contains deterministic replay evidence.\nmissing-now-present fixture line 010 contains deterministic replay evidence.\nmissing-now-present fixture line 011 contains deterministic replay evidence.\nmissing-now-present fixture line 012 contains deterministic replay evidence.\nmissing-now-present fixture line 013 contains deterministic replay evidence.\nmissing-now-present fixture line 014 contains deterministic replay evidence.\nmissing-now-present fixture line 015 contains deterministic replay evidence.\nmissing-now-present fixture line 016 contains deterministic replay evidence.\nmissing-now-present fixture line 017 contains deterministic replay evidence.\nmissing-now-present fixture line 018 contains deterministic replay evidence.\nmissing-now-present fixture line 019 contains deterministic replay evidence.\nmissing-now-present fixture line 020 contains deterministic replay evidence.\nmissing-now-present fixture line 021 contains deterministic replay evidence.\nmissing-now-present fixture line 022 contains deterministic replay evidence.\nmissing-now-present fixture line 023 contains deterministic replay evidence.\nmissing-now-present fixture line 024 contains deterministic replay evidence.\nmissing-now-present fixture line 025 contains deterministic replay evidence.\nmissing-now-present fixture line 026 contains deterministic replay evidence.\nmissing-now-present fixture line 027 contains deterministic replay evidence.\nmissing-now-present fixture line 028 contains deterministic replay evidence.\nmissing-now-present fixture line 029 contains deterministic replay evidence.\nmissing-now-present fixture line 030 contains deterministic replay evidence.\nmissing-now-present fixture line 031 contains deterministic replay evidence.\nmissing-now-present fixture line 032 contains deterministic replay evidence.\nmissing-now-present fixture line 033 contains deterministic replay evidence.\nmissing-now-present fixture line 034 contains deterministic replay evidence.\nmissing-now-present fixture line 035 contains deterministic replay evidence.\nmissing-now-present fixture line 036 contains deterministic replay evidence.\nmissing-now-present fixture line 037 contains deterministic replay evidence.\nmissing-now-present fixture line 038 contains deterministic replay evidence.\nmissing-now-present fixture line 039 contains deterministic replay evidence.\nmissing-now-present fixture line 040 contains deterministic replay evidence.\nmissing-now-present fixture line 041 contains deterministic replay evidence.\nmissing-now-present fixture line 042 contains deterministic replay evidence.\nmissing-now-present fixture line 043 contains deterministic replay evidence.\nmissing-now-present fixture line 044 contains deterministic replay evidence.\nmissing-now-present fixture line 045 contains deterministic replay evidence.\nmissing-now-present fixture line 046 contains deterministic replay evidence.\nmissing-now-present fixture line 047 contains deterministic replay evidence.\nmissing-now-present fixture line 048 contains deterministic replay evidence.\nmissing-now-present fixture line 049 contains deterministic replay evidence.\nmissing-now-present fixture line 050 contains deterministic replay evidence.\nmissing-now-present fixture line 051 contains deterministic replay evidence.\nmissing-now-present fixture line 052 contains deterministic replay evidence.\nmissing-now-present fixture line 053 contains deterministic replay evidence.\nmissing-now-present fixture line 054 contains deterministic replay evidence.\nmissing-now-present fixture line 055 contains deterministic replay evidence.\nmissing-now-present fixture line 056 contains deterministic replay evidence.\nmissing-now-present fixture line 057 contains deterministic replay evidence.\nmissing-now-present fixture line 058 contains deterministic replay evidence.\nmissing-now-present fixture line 059 contains deterministic replay evidence.\nmissing-now-present fixture line 060 contains deterministic replay evidence.\nmissing-now-present fixture line 061 contains deterministic replay evidence.\nmissing-now-present fixture line 062 contains deterministic replay evidence.\nmissing-now-present fixture line 063 contains deterministic replay evidence.\nmissing-now-present fixture line 064 contains deterministic replay evidence.\nmissing-now-present fixture line 065 contains deterministic replay evidence.\nmissing-now-present fixture line 066 contains deterministic replay evidence.\nmissing-now-present fixture line 067 contains deterministic replay evidence.\nmissing-now-present fixture line 068 contains deterministic replay evidence.\nmissing-now-present fixture line 069 contains deterministic replay evidence.\nmissing-now-present fixture line 070 contains deterministic replay evidence.\nmissing-now-present fixture line 071 contains deterministic replay evidence.\nmissing-now-present fixture line 072 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-09","parentTurnId":"result-08","timestamp":"2026-08-21T00:00:25.000Z","role":"user","payload":{"text":"read e"}} +{"kind":"message","turnId":"call-entry-09","parentTurnId":"user-09","timestamp":"2026-08-21T00:00:26.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-09","name":"read","args":{"path":"src/e.ts"}}} +{"kind":"message","turnId":"result-09","parentTurnId":"call-entry-09","timestamp":"2026-08-21T00:00:27.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-09","toolName":"read","result":{"content":[{"type":"text","text":"e-v1 fixture line 001 contains deterministic replay evidence.\ne-v1 fixture line 002 contains deterministic replay evidence.\ne-v1 fixture line 003 contains deterministic replay evidence.\ne-v1 fixture line 004 contains deterministic replay evidence.\ne-v1 fixture line 005 contains deterministic replay evidence.\ne-v1 fixture line 006 contains deterministic replay evidence.\ne-v1 fixture line 007 contains deterministic replay evidence.\ne-v1 fixture line 008 contains deterministic replay evidence.\ne-v1 fixture line 009 contains deterministic replay evidence.\ne-v1 fixture line 010 contains deterministic replay evidence.\ne-v1 fixture line 011 contains deterministic replay evidence.\ne-v1 fixture line 012 contains deterministic replay evidence.\ne-v1 fixture line 013 contains deterministic replay evidence.\ne-v1 fixture line 014 contains deterministic replay evidence.\ne-v1 fixture line 015 contains deterministic replay evidence.\ne-v1 fixture line 016 contains deterministic replay evidence.\ne-v1 fixture line 017 contains deterministic replay evidence.\ne-v1 fixture line 018 contains deterministic replay evidence.\ne-v1 fixture line 019 contains deterministic replay evidence.\ne-v1 fixture line 020 contains deterministic replay evidence.\ne-v1 fixture line 021 contains deterministic replay evidence.\ne-v1 fixture line 022 contains deterministic replay evidence.\ne-v1 fixture line 023 contains deterministic replay evidence.\ne-v1 fixture line 024 contains deterministic replay evidence.\ne-v1 fixture line 025 contains deterministic replay evidence.\ne-v1 fixture line 026 contains deterministic replay evidence.\ne-v1 fixture line 027 contains deterministic replay evidence.\ne-v1 fixture line 028 contains deterministic replay evidence.\ne-v1 fixture line 029 contains deterministic replay evidence.\ne-v1 fixture line 030 contains deterministic replay evidence.\ne-v1 fixture line 031 contains deterministic replay evidence.\ne-v1 fixture line 032 contains deterministic replay evidence.\ne-v1 fixture line 033 contains deterministic replay evidence.\ne-v1 fixture line 034 contains deterministic replay evidence.\ne-v1 fixture line 035 contains deterministic replay evidence.\ne-v1 fixture line 036 contains deterministic replay evidence.\ne-v1 fixture line 037 contains deterministic replay evidence.\ne-v1 fixture line 038 contains deterministic replay evidence.\ne-v1 fixture line 039 contains deterministic replay evidence.\ne-v1 fixture line 040 contains deterministic replay evidence.\ne-v1 fixture line 041 contains deterministic replay evidence.\ne-v1 fixture line 042 contains deterministic replay evidence.\ne-v1 fixture line 043 contains deterministic replay evidence.\ne-v1 fixture line 044 contains deterministic replay evidence.\ne-v1 fixture line 045 contains deterministic replay evidence.\ne-v1 fixture line 046 contains deterministic replay evidence.\ne-v1 fixture line 047 contains deterministic replay evidence.\ne-v1 fixture line 048 contains deterministic replay evidence.\ne-v1 fixture line 049 contains deterministic replay evidence.\ne-v1 fixture line 050 contains deterministic replay evidence.\ne-v1 fixture line 051 contains deterministic replay evidence.\ne-v1 fixture line 052 contains deterministic replay evidence.\ne-v1 fixture line 053 contains deterministic replay evidence.\ne-v1 fixture line 054 contains deterministic replay evidence.\ne-v1 fixture line 055 contains deterministic replay evidence.\ne-v1 fixture line 056 contains deterministic replay evidence.\ne-v1 fixture line 057 contains deterministic replay evidence.\ne-v1 fixture line 058 contains deterministic replay evidence.\ne-v1 fixture line 059 contains deterministic replay evidence.\ne-v1 fixture line 060 contains deterministic replay evidence.\ne-v1 fixture line 061 contains deterministic replay evidence.\ne-v1 fixture line 062 contains deterministic replay evidence.\ne-v1 fixture line 063 contains deterministic replay evidence.\ne-v1 fixture line 064 contains deterministic replay evidence.\ne-v1 fixture line 065 contains deterministic replay evidence.\ne-v1 fixture line 066 contains deterministic replay evidence.\ne-v1 fixture line 067 contains deterministic replay evidence.\ne-v1 fixture line 068 contains deterministic replay evidence.\ne-v1 fixture line 069 contains deterministic replay evidence.\ne-v1 fixture line 070 contains deterministic replay evidence.\ne-v1 fixture line 071 contains deterministic replay evidence.\ne-v1 fixture line 072 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-10","parentTurnId":"result-09","timestamp":"2026-08-21T00:00:28.000Z","role":"user","payload":{"text":"consume d"}} +{"kind":"message","turnId":"call-entry-10","parentTurnId":"user-10","timestamp":"2026-08-21T00:00:29.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-10","name":"read","args":{"path":"src/d.ts"}}} +{"kind":"message","turnId":"result-10","parentTurnId":"call-entry-10","timestamp":"2026-08-21T00:00:30.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-10","toolName":"read","result":{"content":[{"type":"text","text":"d-v1 fixture line 001 contains deterministic replay evidence.\nd-v1 fixture line 002 contains deterministic replay evidence.\nd-v1 fixture line 003 contains deterministic replay evidence.\nd-v1 fixture line 004 contains deterministic replay evidence.\nd-v1 fixture line 005 contains deterministic replay evidence.\nd-v1 fixture line 006 contains deterministic replay evidence.\nd-v1 fixture line 007 contains deterministic replay evidence.\nd-v1 fixture line 008 contains deterministic replay evidence.\nd-v1 fixture line 009 contains deterministic replay evidence.\nd-v1 fixture line 010 contains deterministic replay evidence.\nd-v1 fixture line 011 contains deterministic replay evidence.\nd-v1 fixture line 012 contains deterministic replay evidence.\nd-v1 fixture line 013 contains deterministic replay evidence.\nd-v1 fixture line 014 contains deterministic replay evidence.\nd-v1 fixture line 015 contains deterministic replay evidence.\nd-v1 fixture line 016 contains deterministic replay evidence.\nd-v1 fixture line 017 contains deterministic replay evidence.\nd-v1 fixture line 018 contains deterministic replay evidence.\nd-v1 fixture line 019 contains deterministic replay evidence.\nd-v1 fixture line 020 contains deterministic replay evidence.\nd-v1 fixture line 021 contains deterministic replay evidence.\nd-v1 fixture line 022 contains deterministic replay evidence.\nd-v1 fixture line 023 contains deterministic replay evidence.\nd-v1 fixture line 024 contains deterministic replay evidence.\nd-v1 fixture line 025 contains deterministic replay evidence.\nd-v1 fixture line 026 contains deterministic replay evidence.\nd-v1 fixture line 027 contains deterministic replay evidence.\nd-v1 fixture line 028 contains deterministic replay evidence.\nd-v1 fixture line 029 contains deterministic replay evidence.\nd-v1 fixture line 030 contains deterministic replay evidence.\nd-v1 fixture line 031 contains deterministic replay evidence.\nd-v1 fixture line 032 contains deterministic replay evidence.\nd-v1 fixture line 033 contains deterministic replay evidence.\nd-v1 fixture line 034 contains deterministic replay evidence.\nd-v1 fixture line 035 contains deterministic replay evidence.\nd-v1 fixture line 036 contains deterministic replay evidence.\nd-v1 fixture line 037 contains deterministic replay evidence.\nd-v1 fixture line 038 contains deterministic replay evidence.\nd-v1 fixture line 039 contains deterministic replay evidence.\nd-v1 fixture line 040 contains deterministic replay evidence.\nd-v1 fixture line 041 contains deterministic replay evidence.\nd-v1 fixture line 042 contains deterministic replay evidence.\nd-v1 fixture line 043 contains deterministic replay evidence.\nd-v1 fixture line 044 contains deterministic replay evidence.\nd-v1 fixture line 045 contains deterministic replay evidence.\nd-v1 fixture line 046 contains deterministic replay evidence.\nd-v1 fixture line 047 contains deterministic replay evidence.\nd-v1 fixture line 048 contains deterministic replay evidence.\nd-v1 fixture line 049 contains deterministic replay evidence.\nd-v1 fixture line 050 contains deterministic replay evidence.\nd-v1 fixture line 051 contains deterministic replay evidence.\nd-v1 fixture line 052 contains deterministic replay evidence.\nd-v1 fixture line 053 contains deterministic replay evidence.\nd-v1 fixture line 054 contains deterministic replay evidence.\nd-v1 fixture line 055 contains deterministic replay evidence.\nd-v1 fixture line 056 contains deterministic replay evidence.\nd-v1 fixture line 057 contains deterministic replay evidence.\nd-v1 fixture line 058 contains deterministic replay evidence.\nd-v1 fixture line 059 contains deterministic replay evidence.\nd-v1 fixture line 060 contains deterministic replay evidence.\nd-v1 fixture line 061 contains deterministic replay evidence.\nd-v1 fixture line 062 contains deterministic replay evidence.\nd-v1 fixture line 063 contains deterministic replay evidence.\nd-v1 fixture line 064 contains deterministic replay evidence.\nd-v1 fixture line 065 contains deterministic replay evidence.\nd-v1 fixture line 066 contains deterministic replay evidence.\nd-v1 fixture line 067 contains deterministic replay evidence.\nd-v1 fixture line 068 contains deterministic replay evidence.\nd-v1 fixture line 069 contains deterministic replay evidence.\nd-v1 fixture line 070 contains deterministic replay evidence.\nd-v1 fixture line 071 contains deterministic replay evidence.\nd-v1 fixture line 072 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-11","parentTurnId":"result-10","timestamp":"2026-08-21T00:00:31.000Z","role":"user","payload":{"text":"read f"}} +{"kind":"message","turnId":"call-entry-11","parentTurnId":"user-11","timestamp":"2026-08-21T00:00:32.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-11","name":"read","args":{"path":"src/f.ts"}}} +{"kind":"message","turnId":"result-11","parentTurnId":"call-entry-11","timestamp":"2026-08-21T00:00:33.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-11","toolName":"read","result":{"content":[{"type":"text","text":"f-v1 fixture line 001 contains deterministic replay evidence.\nf-v1 fixture line 002 contains deterministic replay evidence.\nf-v1 fixture line 003 contains deterministic replay evidence.\nf-v1 fixture line 004 contains deterministic replay evidence.\nf-v1 fixture line 005 contains deterministic replay evidence.\nf-v1 fixture line 006 contains deterministic replay evidence.\nf-v1 fixture line 007 contains deterministic replay evidence.\nf-v1 fixture line 008 contains deterministic replay evidence.\nf-v1 fixture line 009 contains deterministic replay evidence.\nf-v1 fixture line 010 contains deterministic replay evidence.\nf-v1 fixture line 011 contains deterministic replay evidence.\nf-v1 fixture line 012 contains deterministic replay evidence.\nf-v1 fixture line 013 contains deterministic replay evidence.\nf-v1 fixture line 014 contains deterministic replay evidence.\nf-v1 fixture line 015 contains deterministic replay evidence.\nf-v1 fixture line 016 contains deterministic replay evidence.\nf-v1 fixture line 017 contains deterministic replay evidence.\nf-v1 fixture line 018 contains deterministic replay evidence.\nf-v1 fixture line 019 contains deterministic replay evidence.\nf-v1 fixture line 020 contains deterministic replay evidence.\nf-v1 fixture line 021 contains deterministic replay evidence.\nf-v1 fixture line 022 contains deterministic replay evidence.\nf-v1 fixture line 023 contains deterministic replay evidence.\nf-v1 fixture line 024 contains deterministic replay evidence.\nf-v1 fixture line 025 contains deterministic replay evidence.\nf-v1 fixture line 026 contains deterministic replay evidence.\nf-v1 fixture line 027 contains deterministic replay evidence.\nf-v1 fixture line 028 contains deterministic replay evidence.\nf-v1 fixture line 029 contains deterministic replay evidence.\nf-v1 fixture line 030 contains deterministic replay evidence.\nf-v1 fixture line 031 contains deterministic replay evidence.\nf-v1 fixture line 032 contains deterministic replay evidence.\nf-v1 fixture line 033 contains deterministic replay evidence.\nf-v1 fixture line 034 contains deterministic replay evidence.\nf-v1 fixture line 035 contains deterministic replay evidence.\nf-v1 fixture line 036 contains deterministic replay evidence.\nf-v1 fixture line 037 contains deterministic replay evidence.\nf-v1 fixture line 038 contains deterministic replay evidence.\nf-v1 fixture line 039 contains deterministic replay evidence.\nf-v1 fixture line 040 contains deterministic replay evidence.\nf-v1 fixture line 041 contains deterministic replay evidence.\nf-v1 fixture line 042 contains deterministic replay evidence.\nf-v1 fixture line 043 contains deterministic replay evidence.\nf-v1 fixture line 044 contains deterministic replay evidence.\nf-v1 fixture line 045 contains deterministic replay evidence.\nf-v1 fixture line 046 contains deterministic replay evidence.\nf-v1 fixture line 047 contains deterministic replay evidence.\nf-v1 fixture line 048 contains deterministic replay evidence.\nf-v1 fixture line 049 contains deterministic replay evidence.\nf-v1 fixture line 050 contains deterministic replay evidence.\nf-v1 fixture line 051 contains deterministic replay evidence.\nf-v1 fixture line 052 contains deterministic replay evidence.\nf-v1 fixture line 053 contains deterministic replay evidence.\nf-v1 fixture line 054 contains deterministic replay evidence.\nf-v1 fixture line 055 contains deterministic replay evidence.\nf-v1 fixture line 056 contains deterministic replay evidence.\nf-v1 fixture line 057 contains deterministic replay evidence.\nf-v1 fixture line 058 contains deterministic replay evidence.\nf-v1 fixture line 059 contains deterministic replay evidence.\nf-v1 fixture line 060 contains deterministic replay evidence.\nf-v1 fixture line 061 contains deterministic replay evidence.\nf-v1 fixture line 062 contains deterministic replay evidence.\nf-v1 fixture line 063 contains deterministic replay evidence.\nf-v1 fixture line 064 contains deterministic replay evidence.\nf-v1 fixture line 065 contains deterministic replay evidence.\nf-v1 fixture line 066 contains deterministic replay evidence.\nf-v1 fixture line 067 contains deterministic replay evidence.\nf-v1 fixture line 068 contains deterministic replay evidence.\nf-v1 fixture line 069 contains deterministic replay evidence.\nf-v1 fixture line 070 contains deterministic replay evidence.\nf-v1 fixture line 071 contains deterministic replay evidence.\nf-v1 fixture line 072 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-12","parentTurnId":"result-11","timestamp":"2026-08-21T00:00:34.000Z","role":"user","payload":{"text":"edit e"}} +{"kind":"message","turnId":"call-entry-12","parentTurnId":"user-12","timestamp":"2026-08-21T00:00:35.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-12","name":"edit","args":{"path":"src/e.ts","edits":[{"oldText":"before","newText":"after"}]}}} +{"kind":"message","turnId":"result-12","parentTurnId":"call-entry-12","timestamp":"2026-08-21T00:00:36.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-12","toolName":"edit","result":{"content":[{"type":"text","text":"e-edited fixture line 001 contains deterministic replay evidence.\ne-edited fixture line 002 contains deterministic replay evidence.\ne-edited fixture line 003 contains deterministic replay evidence.\ne-edited fixture line 004 contains deterministic replay evidence.\ne-edited fixture line 005 contains deterministic replay evidence.\ne-edited fixture line 006 contains deterministic replay evidence.\ne-edited fixture line 007 contains deterministic replay evidence.\ne-edited fixture line 008 contains deterministic replay evidence.\ne-edited fixture line 009 contains deterministic replay evidence.\ne-edited fixture line 010 contains deterministic replay evidence.\ne-edited fixture line 011 contains deterministic replay evidence.\ne-edited fixture line 012 contains deterministic replay evidence.\ne-edited fixture line 013 contains deterministic replay evidence.\ne-edited fixture line 014 contains deterministic replay evidence.\ne-edited fixture line 015 contains deterministic replay evidence.\ne-edited fixture line 016 contains deterministic replay evidence.\ne-edited fixture line 017 contains deterministic replay evidence.\ne-edited fixture line 018 contains deterministic replay evidence.\ne-edited fixture line 019 contains deterministic replay evidence.\ne-edited fixture line 020 contains deterministic replay evidence.\ne-edited fixture line 021 contains deterministic replay evidence.\ne-edited fixture line 022 contains deterministic replay evidence.\ne-edited fixture line 023 contains deterministic replay evidence.\ne-edited fixture line 024 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-13","parentTurnId":"result-12","timestamp":"2026-08-21T00:00:37.000Z","role":"user","payload":{"text":"read g"}} +{"kind":"message","turnId":"call-entry-13","parentTurnId":"user-13","timestamp":"2026-08-21T00:00:38.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-13","name":"read","args":{"path":"src/g.ts"}}} +{"kind":"message","turnId":"result-13","parentTurnId":"call-entry-13","timestamp":"2026-08-21T00:00:39.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-13","toolName":"read","result":{"content":[{"type":"text","text":"g-v1 fixture line 001 contains deterministic replay evidence.\ng-v1 fixture line 002 contains deterministic replay evidence.\ng-v1 fixture line 003 contains deterministic replay evidence.\ng-v1 fixture line 004 contains deterministic replay evidence.\ng-v1 fixture line 005 contains deterministic replay evidence.\ng-v1 fixture line 006 contains deterministic replay evidence.\ng-v1 fixture line 007 contains deterministic replay evidence.\ng-v1 fixture line 008 contains deterministic replay evidence.\ng-v1 fixture line 009 contains deterministic replay evidence.\ng-v1 fixture line 010 contains deterministic replay evidence.\ng-v1 fixture line 011 contains deterministic replay evidence.\ng-v1 fixture line 012 contains deterministic replay evidence.\ng-v1 fixture line 013 contains deterministic replay evidence.\ng-v1 fixture line 014 contains deterministic replay evidence.\ng-v1 fixture line 015 contains deterministic replay evidence.\ng-v1 fixture line 016 contains deterministic replay evidence.\ng-v1 fixture line 017 contains deterministic replay evidence.\ng-v1 fixture line 018 contains deterministic replay evidence.\ng-v1 fixture line 019 contains deterministic replay evidence.\ng-v1 fixture line 020 contains deterministic replay evidence.\ng-v1 fixture line 021 contains deterministic replay evidence.\ng-v1 fixture line 022 contains deterministic replay evidence.\ng-v1 fixture line 023 contains deterministic replay evidence.\ng-v1 fixture line 024 contains deterministic replay evidence.\ng-v1 fixture line 025 contains deterministic replay evidence.\ng-v1 fixture line 026 contains deterministic replay evidence.\ng-v1 fixture line 027 contains deterministic replay evidence.\ng-v1 fixture line 028 contains deterministic replay evidence.\ng-v1 fixture line 029 contains deterministic replay evidence.\ng-v1 fixture line 030 contains deterministic replay evidence.\ng-v1 fixture line 031 contains deterministic replay evidence.\ng-v1 fixture line 032 contains deterministic replay evidence.\ng-v1 fixture line 033 contains deterministic replay evidence.\ng-v1 fixture line 034 contains deterministic replay evidence.\ng-v1 fixture line 035 contains deterministic replay evidence.\ng-v1 fixture line 036 contains deterministic replay evidence.\ng-v1 fixture line 037 contains deterministic replay evidence.\ng-v1 fixture line 038 contains deterministic replay evidence.\ng-v1 fixture line 039 contains deterministic replay evidence.\ng-v1 fixture line 040 contains deterministic replay evidence.\ng-v1 fixture line 041 contains deterministic replay evidence.\ng-v1 fixture line 042 contains deterministic replay evidence.\ng-v1 fixture line 043 contains deterministic replay evidence.\ng-v1 fixture line 044 contains deterministic replay evidence.\ng-v1 fixture line 045 contains deterministic replay evidence.\ng-v1 fixture line 046 contains deterministic replay evidence.\ng-v1 fixture line 047 contains deterministic replay evidence.\ng-v1 fixture line 048 contains deterministic replay evidence.\ng-v1 fixture line 049 contains deterministic replay evidence.\ng-v1 fixture line 050 contains deterministic replay evidence.\ng-v1 fixture line 051 contains deterministic replay evidence.\ng-v1 fixture line 052 contains deterministic replay evidence.\ng-v1 fixture line 053 contains deterministic replay evidence.\ng-v1 fixture line 054 contains deterministic replay evidence.\ng-v1 fixture line 055 contains deterministic replay evidence.\ng-v1 fixture line 056 contains deterministic replay evidence.\ng-v1 fixture line 057 contains deterministic replay evidence.\ng-v1 fixture line 058 contains deterministic replay evidence.\ng-v1 fixture line 059 contains deterministic replay evidence.\ng-v1 fixture line 060 contains deterministic replay evidence.\ng-v1 fixture line 061 contains deterministic replay evidence.\ng-v1 fixture line 062 contains deterministic replay evidence.\ng-v1 fixture line 063 contains deterministic replay evidence.\ng-v1 fixture line 064 contains deterministic replay evidence.\ng-v1 fixture line 065 contains deterministic replay evidence.\ng-v1 fixture line 066 contains deterministic replay evidence.\ng-v1 fixture line 067 contains deterministic replay evidence.\ng-v1 fixture line 068 contains deterministic replay evidence.\ng-v1 fixture line 069 contains deterministic replay evidence.\ng-v1 fixture line 070 contains deterministic replay evidence.\ng-v1 fixture line 071 contains deterministic replay evidence.\ng-v1 fixture line 072 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-14","parentTurnId":"result-13","timestamp":"2026-08-21T00:00:40.000Z","role":"user","payload":{"text":"read h"}} +{"kind":"message","turnId":"call-entry-14","parentTurnId":"user-14","timestamp":"2026-08-21T00:00:41.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-14","name":"read","args":{"path":"src/h.ts"}}} +{"kind":"message","turnId":"result-14","parentTurnId":"call-entry-14","timestamp":"2026-08-21T00:00:42.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-14","toolName":"read","result":{"content":[{"type":"text","text":"h-v1 fixture line 001 contains deterministic replay evidence.\nh-v1 fixture line 002 contains deterministic replay evidence.\nh-v1 fixture line 003 contains deterministic replay evidence.\nh-v1 fixture line 004 contains deterministic replay evidence.\nh-v1 fixture line 005 contains deterministic replay evidence.\nh-v1 fixture line 006 contains deterministic replay evidence.\nh-v1 fixture line 007 contains deterministic replay evidence.\nh-v1 fixture line 008 contains deterministic replay evidence.\nh-v1 fixture line 009 contains deterministic replay evidence.\nh-v1 fixture line 010 contains deterministic replay evidence.\nh-v1 fixture line 011 contains deterministic replay evidence.\nh-v1 fixture line 012 contains deterministic replay evidence.\nh-v1 fixture line 013 contains deterministic replay evidence.\nh-v1 fixture line 014 contains deterministic replay evidence.\nh-v1 fixture line 015 contains deterministic replay evidence.\nh-v1 fixture line 016 contains deterministic replay evidence.\nh-v1 fixture line 017 contains deterministic replay evidence.\nh-v1 fixture line 018 contains deterministic replay evidence.\nh-v1 fixture line 019 contains deterministic replay evidence.\nh-v1 fixture line 020 contains deterministic replay evidence.\nh-v1 fixture line 021 contains deterministic replay evidence.\nh-v1 fixture line 022 contains deterministic replay evidence.\nh-v1 fixture line 023 contains deterministic replay evidence.\nh-v1 fixture line 024 contains deterministic replay evidence.\nh-v1 fixture line 025 contains deterministic replay evidence.\nh-v1 fixture line 026 contains deterministic replay evidence.\nh-v1 fixture line 027 contains deterministic replay evidence.\nh-v1 fixture line 028 contains deterministic replay evidence.\nh-v1 fixture line 029 contains deterministic replay evidence.\nh-v1 fixture line 030 contains deterministic replay evidence.\nh-v1 fixture line 031 contains deterministic replay evidence.\nh-v1 fixture line 032 contains deterministic replay evidence.\nh-v1 fixture line 033 contains deterministic replay evidence.\nh-v1 fixture line 034 contains deterministic replay evidence.\nh-v1 fixture line 035 contains deterministic replay evidence.\nh-v1 fixture line 036 contains deterministic replay evidence.\nh-v1 fixture line 037 contains deterministic replay evidence.\nh-v1 fixture line 038 contains deterministic replay evidence.\nh-v1 fixture line 039 contains deterministic replay evidence.\nh-v1 fixture line 040 contains deterministic replay evidence.\nh-v1 fixture line 041 contains deterministic replay evidence.\nh-v1 fixture line 042 contains deterministic replay evidence.\nh-v1 fixture line 043 contains deterministic replay evidence.\nh-v1 fixture line 044 contains deterministic replay evidence.\nh-v1 fixture line 045 contains deterministic replay evidence.\nh-v1 fixture line 046 contains deterministic replay evidence.\nh-v1 fixture line 047 contains deterministic replay evidence.\nh-v1 fixture line 048 contains deterministic replay evidence.\nh-v1 fixture line 049 contains deterministic replay evidence.\nh-v1 fixture line 050 contains deterministic replay evidence.\nh-v1 fixture line 051 contains deterministic replay evidence.\nh-v1 fixture line 052 contains deterministic replay evidence.\nh-v1 fixture line 053 contains deterministic replay evidence.\nh-v1 fixture line 054 contains deterministic replay evidence.\nh-v1 fixture line 055 contains deterministic replay evidence.\nh-v1 fixture line 056 contains deterministic replay evidence.\nh-v1 fixture line 057 contains deterministic replay evidence.\nh-v1 fixture line 058 contains deterministic replay evidence.\nh-v1 fixture line 059 contains deterministic replay evidence.\nh-v1 fixture line 060 contains deterministic replay evidence.\nh-v1 fixture line 061 contains deterministic replay evidence.\nh-v1 fixture line 062 contains deterministic replay evidence.\nh-v1 fixture line 063 contains deterministic replay evidence.\nh-v1 fixture line 064 contains deterministic replay evidence.\nh-v1 fixture line 065 contains deterministic replay evidence.\nh-v1 fixture line 066 contains deterministic replay evidence.\nh-v1 fixture line 067 contains deterministic replay evidence.\nh-v1 fixture line 068 contains deterministic replay evidence.\nh-v1 fixture line 069 contains deterministic replay evidence.\nh-v1 fixture line 070 contains deterministic replay evidence.\nh-v1 fixture line 071 contains deterministic replay evidence.\nh-v1 fixture line 072 contains deterministic replay evidence."}]},"isError":false}} +{"kind":"message","turnId":"user-15","parentTurnId":"result-14","timestamp":"2026-08-21T00:00:43.000Z","role":"user","payload":{"text":"read i"}} +{"kind":"message","turnId":"call-entry-15","parentTurnId":"user-15","timestamp":"2026-08-21T00:00:44.000Z","role":"tool_call","payload":{"toolCallId":"tool-call-15","name":"read","args":{"path":"src/i.ts"}}} +{"kind":"message","turnId":"result-15","parentTurnId":"call-entry-15","timestamp":"2026-08-21T00:00:45.000Z","role":"tool_result","payload":{"toolCallId":"tool-call-15","toolName":"read","result":{"content":[{"type":"text","text":"i-v1 fixture line 001 contains deterministic replay evidence.\ni-v1 fixture line 002 contains deterministic replay evidence.\ni-v1 fixture line 003 contains deterministic replay evidence.\ni-v1 fixture line 004 contains deterministic replay evidence.\ni-v1 fixture line 005 contains deterministic replay evidence.\ni-v1 fixture line 006 contains deterministic replay evidence.\ni-v1 fixture line 007 contains deterministic replay evidence.\ni-v1 fixture line 008 contains deterministic replay evidence.\ni-v1 fixture line 009 contains deterministic replay evidence.\ni-v1 fixture line 010 contains deterministic replay evidence.\ni-v1 fixture line 011 contains deterministic replay evidence.\ni-v1 fixture line 012 contains deterministic replay evidence.\ni-v1 fixture line 013 contains deterministic replay evidence.\ni-v1 fixture line 014 contains deterministic replay evidence.\ni-v1 fixture line 015 contains deterministic replay evidence.\ni-v1 fixture line 016 contains deterministic replay evidence.\ni-v1 fixture line 017 contains deterministic replay evidence.\ni-v1 fixture line 018 contains deterministic replay evidence.\ni-v1 fixture line 019 contains deterministic replay evidence.\ni-v1 fixture line 020 contains deterministic replay evidence.\ni-v1 fixture line 021 contains deterministic replay evidence.\ni-v1 fixture line 022 contains deterministic replay evidence.\ni-v1 fixture line 023 contains deterministic replay evidence.\ni-v1 fixture line 024 contains deterministic replay evidence.\ni-v1 fixture line 025 contains deterministic replay evidence.\ni-v1 fixture line 026 contains deterministic replay evidence.\ni-v1 fixture line 027 contains deterministic replay evidence.\ni-v1 fixture line 028 contains deterministic replay evidence.\ni-v1 fixture line 029 contains deterministic replay evidence.\ni-v1 fixture line 030 contains deterministic replay evidence.\ni-v1 fixture line 031 contains deterministic replay evidence.\ni-v1 fixture line 032 contains deterministic replay evidence.\ni-v1 fixture line 033 contains deterministic replay evidence.\ni-v1 fixture line 034 contains deterministic replay evidence.\ni-v1 fixture line 035 contains deterministic replay evidence.\ni-v1 fixture line 036 contains deterministic replay evidence.\ni-v1 fixture line 037 contains deterministic replay evidence.\ni-v1 fixture line 038 contains deterministic replay evidence.\ni-v1 fixture line 039 contains deterministic replay evidence.\ni-v1 fixture line 040 contains deterministic replay evidence.\ni-v1 fixture line 041 contains deterministic replay evidence.\ni-v1 fixture line 042 contains deterministic replay evidence.\ni-v1 fixture line 043 contains deterministic replay evidence.\ni-v1 fixture line 044 contains deterministic replay evidence.\ni-v1 fixture line 045 contains deterministic replay evidence.\ni-v1 fixture line 046 contains deterministic replay evidence.\ni-v1 fixture line 047 contains deterministic replay evidence.\ni-v1 fixture line 048 contains deterministic replay evidence.\ni-v1 fixture line 049 contains deterministic replay evidence.\ni-v1 fixture line 050 contains deterministic replay evidence.\ni-v1 fixture line 051 contains deterministic replay evidence.\ni-v1 fixture line 052 contains deterministic replay evidence.\ni-v1 fixture line 053 contains deterministic replay evidence.\ni-v1 fixture line 054 contains deterministic replay evidence.\ni-v1 fixture line 055 contains deterministic replay evidence.\ni-v1 fixture line 056 contains deterministic replay evidence.\ni-v1 fixture line 057 contains deterministic replay evidence.\ni-v1 fixture line 058 contains deterministic replay evidence.\ni-v1 fixture line 059 contains deterministic replay evidence.\ni-v1 fixture line 060 contains deterministic replay evidence.\ni-v1 fixture line 061 contains deterministic replay evidence.\ni-v1 fixture line 062 contains deterministic replay evidence.\ni-v1 fixture line 063 contains deterministic replay evidence.\ni-v1 fixture line 064 contains deterministic replay evidence.\ni-v1 fixture line 065 contains deterministic replay evidence.\ni-v1 fixture line 066 contains deterministic replay evidence.\ni-v1 fixture line 067 contains deterministic replay evidence.\ni-v1 fixture line 068 contains deterministic replay evidence.\ni-v1 fixture line 069 contains deterministic replay evidence.\ni-v1 fixture line 070 contains deterministic replay evidence.\ni-v1 fixture line 071 contains deterministic replay evidence.\ni-v1 fixture line 072 contains deterministic replay evidence."}]},"isError":false}} diff --git a/tests/fixtures/context-replay/generate-fixture.mjs b/tests/fixtures/context-replay/generate-fixture.mjs new file mode 100644 index 000000000..71b8b3476 --- /dev/null +++ b/tests/fixtures/context-replay/generate-fixture.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const output = join(dirname(fileURLToPath(import.meta.url)), "fixture-01.jsonl"); +const entries = [ + { + type: "session", + version: 4, + id: "context-replay-fixture-01", + timestamp: "2026-08-21T00:00:00.000Z", + cwd: "/fixture/repo", + }, +]; + +let parentTurnId = null; +let sequence = 0; + +function timestamp() { + sequence += 1; + return `2026-08-21T00:${String(Math.floor(sequence / 60)).padStart(2, "0")}:${String(sequence % 60).padStart(2, "0")}.000Z`; +} + +function body(label, lines = 72) { + return Array.from( + { length: lines }, + (_, index) => `${label} fixture line ${String(index + 1).padStart(3, "0")} contains deterministic replay evidence.`, + ).join("\n"); +} + +const turns = [ + { label: "initial a read", tool: "read", args: { path: "src/a.ts" }, text: body("a-v1") }, + { label: "initial b read", tool: "read", args: { path: "src/b.ts" }, text: body("b-v1") }, + { + label: "discover files", + tool: "find", + args: { path: ".", pattern: "src/*.ts" }, + text: "src/c.ts\nsrc/d.ts", + }, + { label: "consume c", tool: "read", args: { path: "src/c.ts" }, text: body("c-v1") }, + { label: "re-read a", tool: "read", args: { path: "src/a.ts" }, text: body("a-v1-again") }, + { + label: "edit b", + tool: "edit", + args: { path: "src/b.ts", edits: [{ oldText: "x", newText: "y" }] }, + text: body("b-edited", 24), + }, + { + label: "missing read fails", + tool: "read", + args: { path: "src/missing.ts" }, + text: "ENOENT: src/missing.ts was not generated yet", + isError: true, + }, + { label: "missing read succeeds", tool: "read", args: { path: "src/missing.ts" }, text: body("missing-now-present") }, + { label: "read e", tool: "read", args: { path: "src/e.ts" }, text: body("e-v1") }, + { label: "consume d", tool: "read", args: { path: "src/d.ts" }, text: body("d-v1") }, + { label: "read f", tool: "read", args: { path: "src/f.ts" }, text: body("f-v1") }, + { + label: "edit e", + tool: "edit", + args: { path: "src/e.ts", edits: [{ oldText: "before", newText: "after" }] }, + text: body("e-edited", 24), + }, + { label: "read g", tool: "read", args: { path: "src/g.ts" }, text: body("g-v1") }, + { label: "read h", tool: "read", args: { path: "src/h.ts" }, text: body("h-v1") }, + { label: "read i", tool: "read", args: { path: "src/i.ts" }, text: body("i-v1") }, +]; + +for (let index = 0; index < turns.length; index += 1) { + const turn = turns[index]; + const number = String(index + 1).padStart(2, "0"); + const userId = `user-${number}`; + const callEntryId = `call-entry-${number}`; + const callId = `tool-call-${number}`; + const resultId = `result-${number}`; + entries.push({ + kind: "message", + turnId: userId, + parentTurnId, + timestamp: timestamp(), + role: "user", + payload: { text: turn.label }, + }); + entries.push({ + kind: "message", + turnId: callEntryId, + parentTurnId: userId, + timestamp: timestamp(), + role: "tool_call", + payload: { toolCallId: callId, name: turn.tool, args: turn.args }, + }); + entries.push({ + kind: "message", + turnId: resultId, + parentTurnId: callEntryId, + timestamp: timestamp(), + role: "tool_result", + payload: { + toolCallId: callId, + toolName: turn.tool, + result: { content: [{ type: "text", text: turn.text }] }, + isError: turn.isError === true, + }, + }); + parentTurnId = resultId; +} + +writeFileSync(output, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8"); +process.stdout.write(`${output}\n`); From 67f76dcd895899d4e966a10e0f7aaa986131d223 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 10:55:12 -0500 Subject: [PATCH 15/45] feat(context): complete replay-lite contracts --- .../context/working-set/replay/controls.ts | 34 +-- .../context/working-set/replay/load-clio.ts | 4 +- .../context/working-set/replay/trace.ts | 4 +- tests/contracts/working-set-replay.test.ts | 271 ++++++++++++++++++ 4 files changed, 289 insertions(+), 24 deletions(-) create mode 100644 tests/contracts/working-set-replay.test.ts diff --git a/src/domains/context/working-set/replay/controls.ts b/src/domains/context/working-set/replay/controls.ts index 4e77c611e..528a1a10e 100644 --- a/src/domains/context/working-set/replay/controls.ts +++ b/src/domains/context/working-set/replay/controls.ts @@ -1,34 +1,25 @@ import type { SessionEntry } from "../../../session/entries.js"; import type { EvictionCandidate, PolicyInput, WorkingSetPolicy, WorkingSetPolicyId } from "../contract.js"; -import { hasLegacyCompactionMarker } from "../payload.js"; +import { tokensFreedByEviction } from "../engine.js"; +import { protectionCutoffIndex } from "../horizon.js"; +import { buildPathIndex } from "../path-index.js"; +import { isProtected } from "../protect.js"; import type { ReferenceGraph } from "./reference-graph.js"; -import { countReplayTurns, isReplayTurnStart } from "./trace.js"; +import { countReplayTurns } from "./trace.js"; function controlId(id: string): WorkingSetPolicyId { return id as WorkingSetPolicyId; } -function recentTurnCutoff(entries: ReadonlyArray, protectLastTurns: number): number { - const horizon = Math.max(1, Math.floor(protectLastTurns)); - let seen = 0; - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (entry === undefined || !isReplayTurnStart(entry)) continue; - seen += 1; - if (seen >= horizon) return index; - } - return 0; -} - function eligibleToolResults(input: PolicyInput): SessionEntry[] { - const cutoff = recentTurnCutoff(input.entries, input.settings.protectLastTurns); + const cutoff = protectionCutoffIndex(input.entries, input.settings.protectLastTurns); + const index = buildPathIndex(input.entries); const out: SessionEntry[] = []; - for (let index = cutoff - 1; index >= 0; index -= 1) { - const entry = input.entries[index]; + for (let entryIndex = cutoff - 1; entryIndex >= 0; entryIndex -= 1) { + const entry = input.entries[entryIndex]; if (entry?.kind !== "message" || entry.role !== "tool_result") continue; if (input.view.evicted.has(entry.turnId)) continue; - if (hasLegacyCompactionMarker(entry.payload)) continue; - if (input.estimateTokens(entry) < input.settings.minEvictableTokens) continue; + if (isProtected(entry, { entryIndex, cutoffIndex: cutoff, input, index })) continue; out.push(entry); } return out; @@ -39,8 +30,9 @@ function takeToTarget(input: PolicyInput, entries: ReadonlyArray): if (tokensNeeded <= 0) return []; const selected: EvictionCandidate[] = []; for (const entry of entries) { - selected.push({ ref: { entry: entry.turnId }, reason: "age_horizon" }); - tokensNeeded -= input.estimateTokens(entry); + const candidate: EvictionCandidate = { ref: { entry: entry.turnId }, reason: "age_horizon" }; + selected.push(candidate); + tokensNeeded -= tokensFreedByEviction(input.estimateTokens, entry, candidate); if (tokensNeeded <= 0) break; } return selected; diff --git a/src/domains/context/working-set/replay/load-clio.ts b/src/domains/context/working-set/replay/load-clio.ts index c6eb5b2d5..0d5d21247 100644 --- a/src/domains/context/working-set/replay/load-clio.ts +++ b/src/domains/context/working-set/replay/load-clio.ts @@ -41,7 +41,9 @@ async function collectLedgerFiles(input: string, out: Set): Promise): number { diff --git a/tests/contracts/working-set-replay.test.ts b/tests/contracts/working-set-replay.test.ts new file mode 100644 index 000000000..c0188bb56 --- /dev/null +++ b/tests/contracts/working-set-replay.test.ts @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import type { + PolicyInput, + WorkingSetPolicy, + WorkingSetSettings, +} from "../../src/domains/context/working-set/contract.js"; +import { DEFAULT_WORKING_SET_SETTINGS } from "../../src/domains/context/working-set/defaults.js"; +import { planEviction } from "../../src/domains/context/working-set/engine.js"; +import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; +import { buildPathIndex } from "../../src/domains/context/working-set/path-index.js"; +import { resolveWorkingSetPolicy } from "../../src/domains/context/working-set/policies/index.js"; +import { projectWorkingSet } from "../../src/domains/context/working-set/project.js"; +import { + makeOraclePolicy, + makeRandomPolicy, + nonePolicy, +} from "../../src/domains/context/working-set/replay/controls.js"; +import { loadClioTraces } from "../../src/domains/context/working-set/replay/load-clio.js"; +import { aggregateReplayMetrics, measureReplayTrace } from "../../src/domains/context/working-set/replay/metrics.js"; +import { + buildReferenceGraph, + type ReferenceGraph, +} from "../../src/domains/context/working-set/replay/reference-graph.js"; +import { + type ReplayPolicyResult, + renderReplayJson, + renderReplayMarkdown, +} from "../../src/domains/context/working-set/replay/report.js"; +import { + type ReplayConfig, + type ReplayTraceResult, + replayTrace, +} from "../../src/domains/context/working-set/replay/runner.js"; +import { isReplayTurnStart, type Trace } from "../../src/domains/context/working-set/replay/trace.js"; +import { estimateTokens } from "../../src/domains/session/compaction/tokens.js"; +import type { SessionEntry } from "../../src/domains/session/entries.js"; + +const FIXTURE = fileURLToPath(new URL("../fixtures/context-replay/fixture-01.jsonl", import.meta.url)); +const SETTINGS: WorkingSetSettings = { + ...DEFAULT_WORKING_SET_SETTINGS, + protectLastTurns: 3, + minEvictableTokens: 0, +}; + +async function fixture(): Promise<{ trace: Trace; graph: ReferenceGraph }> { + const loaded = await loadClioTraces([FIXTURE]); + assert.deepEqual(loaded.cascade, { + found: 1, + unreadable: 0, + filtered: { turns_lt_8: 0, tool_results_lt_8: 0, no_file_reread: 0 }, + kept: 1, + }); + const trace = loaded.traces[0]; + assert.ok(trace); + return { trace, graph: buildReferenceGraph(trace, buildPathIndex(trace.entries)) }; +} + +function config(policyId: string, budgetTokens = 12_000, settings = SETTINGS): ReplayConfig { + return { policyId, budgetTokens, threshold: 0.8, target: 0.6, settings, seed: 0 }; +} + +function prefixBeforeTurn(trace: Trace, wantedTurn: number): SessionEntry[] { + const prefix: SessionEntry[] = []; + let turn = 0; + for (const entry of trace.entries) { + if (isReplayTurnStart(entry)) { + turn += 1; + if (turn === wantedTurn) return prefix; + } + prefix.push(entry); + } + throw new Error(`fixture has no turn ${wantedTurn}`); +} + +function lastMessage(entries: ReadonlyArray): string | undefined { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry?.kind === "message") return entry.turnId; + } + return undefined; +} + +function projectedTokens(entries: ReadonlyArray, leaf?: string): number { + const view = foldWorkingSet(entries, leaf); + return projectWorkingSet(entries, view).reduce((sum, entry) => sum + estimateTokens(entry), 0); +} + +function refSet(items: ReadonlyArray<{ ref: { entry: string } }>): Set { + return new Set(items.map((item) => item.ref.entry)); +} + +describe("contracts/working-set replay-lite", () => { + it("drives age-horizon through the identical live planner at the same prefix", async () => { + const { trace } = await fixture(); + const policy = resolveWorkingSetPolicy("age-horizon"); + const replay = replayTrace(trace, policy, config("age-horizon")); + assert.equal(replay.events.length, 1, "fixture budget should isolate one append-only eviction event"); + const event = replay.events[0]; + assert.ok(event); + + const prefix = prefixBeforeTurn(trace, event.turnIndex); + const leaf = lastMessage(prefix); + const tokens = projectedTokens(prefix, leaf); + const input: PolicyInput = { + entries: prefix, + view: foldWorkingSet(prefix, leaf), + settings: SETTINGS, + pressure: { tokens, contextWindow: 12_000, threshold: 0.8, target: 0.6 }, + estimateTokens, + }; + const direct = planEviction(policy, input); + assert.ok(direct); + assert.deepEqual(refSet(event.items), refSet(direct.items)); + + const synthetic = replay.entries.filter((entry) => entry.kind === "contextEviction"); + assert.equal(synthetic.length, 1); + assert.deepEqual(refSet(synthetic[0]?.evicted ?? []), refSet(direct.items)); + assert.equal(synthetic[0]?.parentTurnId, leaf); + assert.equal(synthetic[0]?.timestamp, prefix[prefix.length - 1]?.timestamp); + }); + + it("labels the fixture's reread, discovery, and rewrite edges exactly", async () => { + const { graph } = await fixture(); + assert.deepEqual(graph.edges, [ + { from: "result-01", toTurnIndex: 5, kind: "file_reread" }, + { from: "result-02", toTurnIndex: 6, kind: "file_rewrite" }, + { from: "result-03", toTurnIndex: 4, kind: "file_discovery" }, + { from: "result-03", toTurnIndex: 10, kind: "file_discovery" }, + { from: "result-09", toTurnIndex: 12, kind: "file_rewrite" }, + ]); + assert.deepEqual( + [...graph.futureTurnsOf], + [ + ["result-01", [5]], + ["result-03", [4, 10]], + ], + ); + }); + + it("counts a ref evicted two turns before reuse as lost retention and full churn", async () => { + const { trace } = await fixture(); + const index = buildPathIndex(trace.entries); + const replay: ReplayTraceResult = { + traceId: trace.id, + policyId: "hand-computed", + budgetTokens: 1_000, + turnCount: trace.turnCount, + events: [ + { + turnIndex: 1, + items: [ + { + ref: { entry: "result-01" }, + reason: "age_horizon", + tokensFreed: 250, + marker: "[evicted ref=result-01]", + }, + ], + tokensBefore: 900, + tokensAfter: 650, + }, + ], + evictedAtTurn: new Map([["result-01", 1]]), + turnsToFirstSummary: null, + entries: trace.entries, + }; + const graph: ReferenceGraph = { + edges: [{ from: "result-01", toTurnIndex: 3, kind: "file_reread" }], + futureTurnsOf: new Map([["result-01", [3]]]), + }; + const metrics = measureReplayTrace({ trace, index, graph, replay }); + assert.equal(metrics.retention, 0); + assert.equal(metrics.retentionAt10, 0); + assert.equal(metrics.evictionPrecision, 0); + assert.equal(metrics.tokensEvicted, 250); + assert.equal(metrics.evictionEvents, 1); + assert.equal(metrics.churn, 1); + }); + + it("oracle never evicts a critical ref before its final reference", async () => { + const { trace, graph } = await fixture(); + const replay = replayTrace( + trace, + makeOraclePolicy(graph), + config("oracle", 7_000, { ...SETTINGS, protectLastTurns: 2 }), + ); + let checkedCritical = 0; + for (const event of replay.events) { + for (const item of event.items) { + const future = graph.futureTurnsOf.get(item.ref.entry); + if (future === undefined) continue; + checkedCritical += 1; + assert.equal( + future.every((turn) => turn < event.turnIndex), + true, + `${item.ref.entry} was evicted at ${event.turnIndex} before ${future.join(",")}`, + ); + } + } + assert.ok(checkedCritical > 0, "fixture must exercise an eventually-safe critical ref"); + }); + + it("random produces the identical event sequence for one seed", async () => { + const { trace } = await fixture(); + const replayConfig = config("random", 7_000, { ...SETTINGS, protectLastTurns: 2 }); + const run = (): ReadonlyArray> => + replayTrace(trace, makeRandomPolicy(17), replayConfig).events.map((event) => + event.items.map((item) => item.ref.entry), + ); + assert.deepEqual(run(), run()); + }); + + it("renders one policy row per budget and stable provenance JSON", async () => { + const { trace, graph } = await fixture(); + const index = buildPathIndex(trace.entries); + const policies: ReadonlyArray = [ + ["none", nonePolicy], + ["age-horizon", resolveWorkingSetPolicy("age-horizon")], + ["structural-v1", resolveWorkingSetPolicy("structural-v1")], + ]; + const budgets = [12_000, 16_000]; + const results: ReplayPolicyResult[] = []; + for (const budget of budgets) { + for (const [policyId, policy] of policies) { + const replay = replayTrace(trace, policy, config(policyId, budget)); + results.push({ + budgetTokens: budget, + policyId, + metrics: aggregateReplayMetrics([{ trace, index, graph, replay }]), + }); + } + } + const input = { + config: { + policies: policies.map(([id]) => id), + budgets, + threshold: 0.8, + target: 0.6, + seed: 0, + filter: "default" as const, + settings: SETTINGS, + }, + cascade: { + found: 1, + unreadable: 0, + filtered: { turns_lt_8: 0, tool_results_lt_8: 0, no_file_reread: 0 }, + kept: 1, + }, + results, + gitSha: "abc123", + commandLine: ["node", "src/cli/index.ts", "context", "replay"], + }; + const markdown = renderReplayMarkdown(input); + for (const [policyId] of policies) { + assert.equal(markdown.match(new RegExp(`^\\| ${policyId} \\|`, "gm"))?.length, budgets.length); + } + assert.equal(markdown.match(/^## Budget /gm)?.length, budgets.length); + + const json = renderReplayJson(input); + assert.equal(renderReplayJson(input), json, "stable input must render byte-identically"); + const parsed = JSON.parse(json) as { + provenance: { gitSha: string; commandLine: string[] }; + results: unknown[]; + }; + assert.equal(parsed.provenance.gitSha, "abc123"); + assert.deepEqual(parsed.provenance.commandLine, input.commandLine); + assert.equal(parsed.results.length, policies.length * budgets.length); + }); +}); From 5c3661981b1dfbd12f89948073e846a47c2a55ae Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:03:35 -0500 Subject: [PATCH 16/45] fix(context): feed the working-set policy only the entries the model can see Both policies were given the whole active path, so after a compaction summary every pressure crossing planned evictions of results already behind the cut. Those priced as real savings: the ledger recorded tokens it never freed, the structural age rung stopped early, and the LLM summary ran again for nothing. selectVisibleEntries applies the same active-path and firstKeptTurnId cuts the replay builder applies, and runAutoCompact and the replay runner both feed it to planEviction. The fold still runs over the full active path so evicted refs stay known. --- src/domains/context/working-set/contract.ts | 11 +- .../context/working-set/replay/runner.ts | 3 +- src/domains/context/working-set/visible.ts | 39 ++++ src/interactive/turn-context.ts | 4 +- tests/contracts/working-set-visible.test.ts | 182 ++++++++++++++++++ 5 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 src/domains/context/working-set/visible.ts create mode 100644 tests/contracts/working-set-visible.test.ts diff --git a/src/domains/context/working-set/contract.ts b/src/domains/context/working-set/contract.ts index 2127dfd76..370a9ffa4 100644 --- a/src/domains/context/working-set/contract.ts +++ b/src/domains/context/working-set/contract.ts @@ -95,10 +95,13 @@ export interface PressureInput { } /** - * Everything a policy may look at. Entries are the active path in ledger - * order and are NOT projected: a policy must consult `view.evicted` to skip - * units that are already out. Token counts enter selection only through - * `settings.minEvictableTokens` and the headroom arithmetic against + * Everything a policy may look at. `entries` are the active-path entries the + * model can currently see: after the latest `compactionSummary` cut, in ledger + * order, as `selectVisibleEntries` in visible.ts produces them. They are NOT + * projected: a policy must consult `view.evicted` to skip units that are + * already out. The view is folded over the full active path, so a ref evicted + * before a later compaction is still known. Token counts enter selection only + * through `settings.minEvictableTokens` and the headroom arithmetic against * `pressure.target`; no rule may rank candidates by size or recency score. */ export interface PolicyInput { diff --git a/src/domains/context/working-set/replay/runner.ts b/src/domains/context/working-set/replay/runner.ts index 0687b64ac..0a3b351ec 100644 --- a/src/domains/context/working-set/replay/runner.ts +++ b/src/domains/context/working-set/replay/runner.ts @@ -5,6 +5,7 @@ import type { WorkingSetPolicy } from "../contract.js"; import { buildEvictionFields, planEviction } from "../engine.js"; import { foldWorkingSet } from "../fold.js"; import { projectWorkingSet } from "../project.js"; +import { selectVisibleEntries } from "../visible.js"; import { isReplayTurnStart, type Trace } from "./trace.js"; export interface ReplayConfig { @@ -74,7 +75,7 @@ export function replayTrace(trace: Trace, policy: WorkingSetPolicy, config: Repl if (tokens > pressureLimit) { const view = foldWorkingSet(soFar, leaf ?? undefined); const plan = planEviction(policy, { - entries: soFar, + entries: selectVisibleEntries(soFar, leaf ?? undefined), view, settings: config.settings, pressure: { diff --git a/src/domains/context/working-set/visible.ts b/src/domains/context/working-set/visible.ts new file mode 100644 index 000000000..ec40e7aed --- /dev/null +++ b/src/domains/context/working-set/visible.ts @@ -0,0 +1,39 @@ +/** + * The entries the model can currently see. + * + * A policy that is shown the whole active path will happily "evict" results + * that a compaction summary already removed from the replay. Those items price + * as real savings, so the event records tokens it never freed, the structural + * age rung stops early believing it reached target, and the summary stage runs + * again for nothing. This helper applies the same two cuts the replay builder + * applies (`selectReplayEntries` in chat-renderer.ts): the active path, then + * everything from the latest compaction's `firstKeptTurnId` onward. The + * `compactionSummary` entry itself is left out; it is never a candidate and the + * policy has no use for it. + * + * The fold (`WorkingSetView`) deliberately keeps running over the full active + * path so refs evicted before a later compaction stay known as evicted. + */ + +import type { SessionEntry } from "../../session/entries.js"; +import { filterEntriesToActivePath } from "../../session/tree/active-path.js"; + +export function selectVisibleEntries(entries: ReadonlyArray, activeLeafTurnId?: string): SessionEntry[] { + const active = filterEntriesToActivePath(entries, activeLeafTurnId); + let compactionIndex = -1; + for (let i = active.length - 1; i >= 0; i -= 1) { + if (active[i]?.kind === "compactionSummary") { + compactionIndex = i; + break; + } + } + if (compactionIndex < 0) return active; + const compaction = active[compactionIndex]; + if (compaction?.kind !== "compactionSummary") return active; + const firstKeptIndex = + compaction.firstKeptTurnId.length > 0 + ? active.findIndex((entry) => entry.turnId === compaction.firstKeptTurnId) + : -1; + const kept = firstKeptIndex >= 0 && firstKeptIndex < compactionIndex ? active.slice(firstKeptIndex, compactionIndex) : []; + return [...kept, ...active.slice(compactionIndex + 1)]; +} diff --git a/src/interactive/turn-context.ts b/src/interactive/turn-context.ts index 31b7e55a1..ef543551a 100644 --- a/src/interactive/turn-context.ts +++ b/src/interactive/turn-context.ts @@ -19,6 +19,7 @@ import type { ToolName } from "../core/tool-names.js"; import { buildEvictionFields, planEviction } from "../domains/context/working-set/engine.js"; import { foldWorkingSet } from "../domains/context/working-set/fold.js"; import { resolveWorkingSetPolicy } from "../domains/context/working-set/policies/index.js"; +import { selectVisibleEntries } from "../domains/context/working-set/visible.js"; import type { ObservabilityContract } from "../domains/observability/contract.js"; import type { CompiledSessionPrompt, SessionPromptInputs } from "../domains/prompts/compiler.js"; import type { PromptsContract } from "../domains/prompts/contract.js"; @@ -52,7 +53,6 @@ import { buildContextLedger, type ContextLedger, type PromptCacheStats } from ". import type { SessionContract } from "../domains/session/contract.js"; import type { CompactionTrigger, SessionEntry } from "../domains/session/entries.js"; import { appendPromptCompileRecord, type SessionPromptCompileRecord } from "../domains/session/prompt-manifest.js"; -import { filterEntriesToActivePath } from "../domains/session/tree/active-path.js"; import type { AgentMessage, Usage } from "../engine/types.js"; import type { ToolRegistry } from "../tools/registry.js"; import { @@ -464,7 +464,7 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { const view = foldWorkingSet(entries, state.lastTurnId ?? undefined); const policy = resolveWorkingSetPolicy(settings.context.workingSet.policy); planned = (deps.planEviction ?? planEviction)(policy, { - entries: filterEntriesToActivePath(entries, state.lastTurnId ?? undefined), + entries: selectVisibleEntries(entries, state.lastTurnId ?? undefined), view, settings: settings.context.workingSet, pressure: { diff --git a/tests/contracts/working-set-visible.test.ts b/tests/contracts/working-set-visible.test.ts new file mode 100644 index 000000000..76134db79 --- /dev/null +++ b/tests/contracts/working-set-visible.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { PolicyInput } from "../../src/domains/context/working-set/contract.js"; +import { DEFAULT_WORKING_SET_SETTINGS } from "../../src/domains/context/working-set/defaults.js"; +import { planEviction } from "../../src/domains/context/working-set/engine.js"; +import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; +import { ageHorizonPolicy, structuralPolicy } from "../../src/domains/context/working-set/policies/index.js"; +import { selectVisibleEntries } from "../../src/domains/context/working-set/visible.js"; +import { estimateAgentMessageTokens } from "../../src/domains/session/context-accounting.js"; +import { estimateTokens } from "../../src/domains/session/compaction/tokens.js"; +import type { ContextEvictionEntry, SessionEntry } from "../../src/domains/session/entries.js"; +import { buildModelReplayAgentMessagesFromTurns } from "../../src/interactive/model-session-replay.js"; + +const TS = "2026-08-21T00:00:00.000Z"; + +function body(label: string, lines = 100): string { + return Array.from({ length: lines }, (_, i) => `${label} observation line ${i}`).join("\n"); +} + +/** Linear ledger builder: every message parents onto the previous one. */ +class Ledger { + readonly entries: SessionEntry[] = []; + private seq = 0; + private last: string | null = null; + + private id(prefix: string): string { + this.seq += 1; + return `${prefix}${this.seq}`; + } + + private push(entry: SessionEntry): string { + this.entries.push(entry); + if (entry.kind === "message") this.last = entry.turnId; + return entry.turnId; + } + + user(text = "go"): string { + return this.push({ + kind: "message", + turnId: this.id("u"), + parentTurnId: this.last, + timestamp: TS, + role: "user", + payload: { text }, + }); + } + + read(path: string): string { + const callId = this.id("call-"); + this.push({ + kind: "message", + turnId: this.id("c"), + parentTurnId: this.last, + timestamp: TS, + role: "tool_call", + payload: { toolCallId: callId, name: "read", args: { path } }, + }); + return this.push({ + kind: "message", + turnId: this.id("r"), + parentTurnId: this.last, + timestamp: TS, + role: "tool_result", + payload: { toolCallId: callId, toolName: "read", result: { content: [{ type: "text", text: body(path) }] }, isError: false }, + }); + } + + compaction(firstKeptTurnId: string): string { + return this.push({ + kind: "compactionSummary", + turnId: this.id("cs"), + parentTurnId: this.last, + timestamp: TS, + summary: "summary of everything before the kept turn", + firstKeptTurnId, + trigger: "auto", + tokensBefore: 50_000, + }); + } + + leaf(): string | undefined { + return this.last ?? undefined; + } +} + +function input(ledger: Ledger, protectLastTurns: number): PolicyInput { + return { + entries: selectVisibleEntries(ledger.entries, ledger.leaf()), + view: foldWorkingSet(ledger.entries, ledger.leaf()), + settings: { ...DEFAULT_WORKING_SET_SETTINGS, protectLastTurns }, + pressure: { tokens: 90_000, contextWindow: 100_000, threshold: 0.8, target: 0.6 }, + estimateTokens, + }; +} + +function replayTokens(entries: ReadonlyArray): number { + return buildModelReplayAgentMessagesFromTurns(entries).reduce((sum, message) => sum + estimateAgentMessageTokens(message), 0); +} + +function withEvent(ledger: Ledger, plan: NonNullable>): SessionEntry[] { + const event: ContextEvictionEntry = { + kind: "contextEviction", + turnId: "e1", + parentTurnId: ledger.leaf() ?? null, + timestamp: TS, + policyId: plan.policyId, + trigger: "pressure", + evicted: plan.items, + tokensBefore: plan.tokensBefore, + tokensAfter: plan.tokensAfter, + pressureBefore: 0.9, + snapshotIdBefore: null, + }; + return [...ledger.entries, event]; +} + +/** user, read(old1), user, read(old2), user K, compaction{firstKept: K}, read(new), user, user. */ +function compactedLedger(): { ledger: Ledger; old: string[]; fresh: string } { + const ledger = new Ledger(); + ledger.user("first"); + const old1 = ledger.read("src/old1.ts"); + ledger.user("second"); + const old2 = ledger.read("src/old2.ts"); + const kept = ledger.user("after summary"); + ledger.compaction(kept); + const fresh = ledger.read("src/new.ts"); + ledger.user("pad1"); + ledger.user("pad2"); + return { ledger, old: [old1, old2], fresh }; +} + +test("visible: the compaction cut removes everything before firstKeptTurnId and the summary itself", () => { + const { ledger, old, fresh } = compactedLedger(); + const visible = selectVisibleEntries(ledger.entries, ledger.leaf()); + const ids = new Set(visible.map((entry) => entry.turnId)); + for (const ref of old) assert.equal(ids.has(ref), false, `${ref} is behind the cut`); + assert.equal(ids.has(fresh), true); + assert.equal( + visible.some((entry) => entry.kind === "compactionSummary"), + false, + ); + assert.equal(visible[0]?.payload && (visible[0].payload as { text?: string }).text, "after summary"); +}); + +test("visible: a ledger without a compaction is the active path unchanged", () => { + const ledger = new Ledger(); + ledger.user(); + ledger.read("src/a.ts"); + ledger.user(); + assert.deepEqual(selectVisibleEntries(ledger.entries, ledger.leaf()), ledger.entries); +}); + +test("visible: both policies plan nothing when the only evictable results are behind the cut", () => { + const { ledger } = compactedLedger(); + // Horizon 3 protects the kept user turn and both pads, so `fresh` is inside + // the window and only old1/old2 could have been selected. + for (const policy of [ageHorizonPolicy, structuralPolicy]) { + assert.equal(planEviction(policy, input(ledger, 3)), null, policy.id); + } +}); + +test("visible: with one post-cut result past the horizon, it is the only item and the plan prices exactly what the replay loses", () => { + const { ledger, fresh } = compactedLedger(); + for (const policy of [ageHorizonPolicy, structuralPolicy]) { + const plan = planEviction(policy, input(ledger, 2)); + assert.ok(plan, policy.id); + assert.deepEqual( + plan.items.map((item) => item.ref.entry), + [fresh], + policy.id, + ); + const before = replayTokens(ledger.entries); + const after = replayTokens(withEvent(ledger, plan)); + assert.ok(before > after, policy.id); + // The plan prices ledger entries (payload JSON, including the + // `details.workingSet` stamp the projection adds); the replay estimator + // prices the message content the model receives. The two differ only by + // that stamp, a handful of tokens, never by a body behind the cut. + const claimed = plan.tokensBefore - plan.tokensAfter; + assert.ok(Math.abs(claimed - (before - after)) <= 8, `${policy.id}: claimed ${claimed}, replay lost ${before - after}`); + } +}); From 2461c51261765acb874ba49c0f1d1e5f8d981f46 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:03:49 -0500 Subject: [PATCH 17/45] fix(context): a failed edit does not make the earlier read stale firstMutationAfter skipped the isError check its sibling rules already apply, so a read followed by an edit that failed was evicted as stale_after_mutation and the model was told to re-read a file that had not changed. --- .../context/working-set/policies/structural.ts | 9 +++++++-- tests/contracts/working-set-structural.test.ts | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/domains/context/working-set/policies/structural.ts b/src/domains/context/working-set/policies/structural.ts index 6b13d5be8..338ff4fd2 100644 --- a/src/domains/context/working-set/policies/structural.ts +++ b/src/domains/context/working-set/policies/structural.ts @@ -52,10 +52,15 @@ function covers(later: PathRange | null, earlier: PathRange | null): boolean { return later.offset <= earlier.offset && rangeEnd(later) >= rangeEnd(earlier); } -/** The mutation that invalidated this observation: the first one after it. */ +/** + * The mutation that invalidated this observation: the first successful one + * after it. A failed edit (`oldText not found`, permission denied) changed + * nothing, and the read it was aimed at is exactly what the model needs to fix + * the edit. + */ function firstMutationAfter(observation: PathObservation, index: PathIndex): PathObservation | null { for (const other of index.byPath.get(observation.path) ?? []) { - if (other.entryIndex > observation.entryIndex && MUTATING.has(other.op)) return other; + if (other.entryIndex > observation.entryIndex && MUTATING.has(other.op) && !other.isError) return other; } return null; } diff --git a/tests/contracts/working-set-structural.test.ts b/tests/contracts/working-set-structural.test.ts index 42f8253ca..dad1303ae 100644 --- a/tests/contracts/working-set-structural.test.ts +++ b/tests/contracts/working-set-structural.test.ts @@ -179,6 +179,23 @@ test("structural: an edit makes the earlier read stale and names the mutation (c assert.equal(candidates.has(edit), false); }); +test("structural: a failed edit changes nothing, so the read it targeted is not stale", () => { + const ledger = new Ledger(); + ledger.user(); + const read = ledger.read("src/b.ts"); + ledger.user(); + ledger.call("edit", { path: "src/b.ts", edits: [{ oldText: "missing", newText: "b" }] }, "edit: oldText not found", { + isError: true, + }); + ledger.pad(); + assert.equal(byRef(select(ledger.entries)).has(read), false, "the failed edit is not a mutation"); + + ledger.user(); + const edit = ledger.edit("src/b.ts"); + ledger.pad(); + assert.equal(byRef(select(ledger.entries)).get(read)?.by, edit, "the first successful edit names the staleness"); +}); + test("structural: staleness outranks supersession when both apply", () => { const ledger = new Ledger(); ledger.user(); From 6a3d49cbcb8e3a0c3db2012f20572ec2049910f5 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:06:12 -0500 Subject: [PATCH 18/45] fix(context): thread the session cwd into the path index instead of sniffing a header that never arrives The live ledger readers strip the JSONL header before any entry reaches the policy, so the cwd sniff in buildPathIndex was dead in production and every relative path stayed exactly as the model spelled it. Listing output, which grep and find print relative to the search root, was never joined onto that root unless the root was absolute, so listing_consumed could only fire for a search of ".". buildPathIndex takes { cwd } explicitly; PolicyInput and Trace carry cwd; runAutoCompact passes the session's, the Clio loader reads the header's, and the CLI reads meta.json's. Listing output joins onto its root before canonicalizing, and relative spellings normalize so src/a.ts, ./src/a.ts and the absolute form key the same file. Test fixtures stop injecting a header and the header-less listing_consumed case is covered. --- src/cli/context-working-set.ts | 25 +++++++- src/domains/context/working-set/contract.ts | 2 + src/domains/context/working-set/path-index.ts | 59 ++++++++++--------- .../working-set/policies/structural.ts | 2 +- .../context/working-set/replay/controls.ts | 2 +- .../context/working-set/replay/load-clio.ts | 15 +++-- .../context/working-set/replay/runner.ts | 1 + .../context/working-set/replay/trace.ts | 2 + src/interactive/turn-context.ts | 1 + .../contracts/working-set-age-horizon.test.ts | 1 + .../contracts/working-set-path-index.test.ts | 51 +++++++++------- tests/contracts/working-set-replay.test.ts | 1 + .../contracts/working-set-structural.test.ts | 28 +++++++-- tests/contracts/working-set-visible.test.ts | 1 + 14 files changed, 125 insertions(+), 66 deletions(-) diff --git a/src/cli/context-working-set.ts b/src/cli/context-working-set.ts index 85b6a4b34..ba4209494 100644 --- a/src/cli/context-working-set.ts +++ b/src/cli/context-working-set.ts @@ -228,7 +228,7 @@ export async function runContextReplayCommand(args: string[]): Promise { try { const loaded = await loadClioTraces(parsed.sessions, { filter: parsed.noFilter ? false : {} }); const indexed = loaded.traces.map((trace) => { - const index = buildPathIndex(trace.entries); + const index = buildPathIndex(trace.entries, { cwd: trace.cwd }); return { trace, index, graph: buildReferenceGraph(trace, index) }; }); const settings = { ...DEFAULT_WORKING_SET_SETTINGS, target: parsed.target }; @@ -327,9 +327,21 @@ async function pinnedLeafForSession(source: string): Promise } } +async function sessionCwdForSession(source: string): Promise { + try { + const raw = await readFile(join(dirname(source), "meta.json"), "utf8"); + const value = JSON.parse(raw) as unknown; + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const cwd = (value as Record).cwd; + return typeof cwd === "string" && cwd.length > 0 ? cwd : null; + } catch { + return null; + } +} + function formatWorkingSet(trace: Trace): string { const view = foldWorkingSet(trace.entries); - const index = buildPathIndex(trace.entries); + const index = buildPathIndex(trace.entries, { cwd: trace.cwd }); const graph = buildReferenceGraph(trace, index); const evictedTokens = [...view.evicted.values()].reduce((sum, state) => sum + state.tokensFreed, 0); const churn = view.itemsEvicted === 0 ? "n/a" : (view.recalls / view.itemsEvicted).toFixed(3); @@ -408,8 +420,15 @@ export async function runContextWorkingSetCommand(args: string[]): Promise 0) throw new Error(parsed.errors.join("; ")); const entries = filterEntriesToActivePath(parsed.entries, await pinnedLeafForSession(source)); + const cwd = await sessionCwdForSession(source); process.stdout.write( - formatWorkingSet({ id: basename(dirname(source)), source, entries, turnCount: buildPathIndex(entries).turnCount }), + formatWorkingSet({ + id: basename(dirname(source)), + source, + cwd, + entries, + turnCount: buildPathIndex(entries, { cwd }).turnCount, + }), ); return 0; } catch (error) { diff --git a/src/domains/context/working-set/contract.ts b/src/domains/context/working-set/contract.ts index 370a9ffa4..39a034eb9 100644 --- a/src/domains/context/working-set/contract.ts +++ b/src/domains/context/working-set/contract.ts @@ -107,6 +107,8 @@ export interface PressureInput { export interface PolicyInput { entries: ReadonlyArray; view: WorkingSetView; + /** Session working directory for the path index; null when unknown (paths stay relative). */ + cwd: string | null; settings: WorkingSetSettings; pressure: PressureInput; /** chars/4 estimator shared with `context-accounting.ts`, so replay and live agree. */ diff --git a/src/domains/context/working-set/path-index.ts b/src/domains/context/working-set/path-index.ts index 180a5bf9f..f54dd3e8e 100644 --- a/src/domains/context/working-set/path-index.ts +++ b/src/domains/context/working-set/path-index.ts @@ -13,16 +13,18 @@ * what the session did. * * Pure, deterministic, single pass. No filesystem access: paths are resolved - * lexically against the session cwd when the entries carry the session header, - * and left as written when they do not. No `process.cwd()` fallback, because a - * replay run and a live run would then index the same ledger differently. + * lexically against the session cwd the caller passes (`options.cwd`), and only + * normalized when it does not. The cwd is never sniffed from the entries and + * never defaulted to `process.cwd()`: the live ledger readers strip the JSONL + * header, and a replay run and a live run must index the same ledger the same + * way. * * This is `extractFileOps` in `compaction/compact.ts` generalized: same * `path | file_path | filePath` argument reading, same tool-call pairing as * `chat-renderer.ts`, plus ranges, listings, failures, and turn positions. */ -import { basename, isAbsolute, normalize, resolve } from "node:path"; +import { basename, isAbsolute, join, normalize, resolve } from "node:path"; import type { MessageEntry, SessionEntry } from "../../session/entries.js"; import type { WorkingSetRef } from "./contract.js"; import { isRecord, toolResultText } from "./payload.js"; @@ -120,28 +122,27 @@ function isTurnStart(entry: SessionEntry): boolean { return entry.kind === "message" && entry.role === "user"; } -/** - * The session cwd, when the caller kept the JSONL header in the slice. The - * header is a `SessionFileEntry`, not a `SessionEntry`, so this is a runtime - * shape check rather than a `kind` test; without it every relative path stays - * exactly as the call wrote it. - */ -function sessionCwd(entries: ReadonlyArray): string | null { - for (const entry of entries) { - const record = entry as unknown as Record; - if (record.type !== "session") continue; - const cwd = record.cwd; - if (typeof cwd === "string" && cwd.length > 0 && isAbsolute(cwd)) return normalize(cwd); - } - return null; +export interface PathIndexOptions { + /** Session working directory; relative arguments resolve against it. Null leaves them relative (normalized). */ + cwd?: string | null; } -/** Lexical canonicalization only: no realpath, no `process.cwd()`, no `~` expansion. */ +/** + * Lexical canonicalization only: no realpath, no `process.cwd()`, no `~` + * expansion. With a cwd, `src/a.ts`, `./src/a.ts`, and `/cwd/src/a.ts` all key + * the same file; without one the first two still do. + */ function canonicalize(value: string, cwd: string | null): string { const trimmed = value.trim(); if (trimmed.length === 0) return ""; if (isAbsolute(trimmed)) return normalize(trimmed); - return cwd === null ? trimmed : resolve(cwd, trimmed); + return cwd === null ? normalize(trimmed) : resolve(cwd, trimmed); +} + +function usableCwd(options: PathIndexOptions | undefined): string | null { + const cwd = options?.cwd; + if (typeof cwd !== "string" || cwd.trim().length === 0 || !isAbsolute(cwd)) return null; + return normalize(cwd); } function stableStringify(value: unknown): string { @@ -300,16 +301,16 @@ function commandVerb(args: Record | null): string | null { } /** - * A listing prints paths relative to what it searched, so they resolve against - * the observation's own path. A single-file search root surfaces its own - * basename, which resolves back to the root rather than to a child of it. + * A listing prints paths relative to what it searched, so they join onto the + * observation's own path before canonicalizing, whether that root is absolute + * or still relative. A single-file search root surfaces its own basename, + * which resolves back to the root rather than to a child of it. */ function resolveSurfaced(value: string, root: string, cwd: string | null): string { if (isAbsolute(value)) return normalize(value); - if (root.length > 0 && isAbsolute(root)) { - return basename(root) === value ? root : resolve(root, value); - } - return canonicalize(value, cwd); + if (root.length === 0) return canonicalize(value, cwd); + if (basename(root) === value) return canonicalize(root, cwd); + return canonicalize(join(root, value), cwd); } /** A listing result only surfaces paths when the call was a listing in the first place. */ @@ -357,8 +358,8 @@ function toolResultObservation( }; } -export function buildPathIndex(entries: ReadonlyArray): PathIndex { - const cwd = sessionCwd(entries); +export function buildPathIndex(entries: ReadonlyArray, options?: PathIndexOptions): PathIndex { + const cwd = usableCwd(options); const calls = collectToolCalls(entries); const observations: PathObservation[] = []; const byRef = new Map(); diff --git a/src/domains/context/working-set/policies/structural.ts b/src/domains/context/working-set/policies/structural.ts index 338ff4fd2..0d728ff05 100644 --- a/src/domains/context/working-set/policies/structural.ts +++ b/src/domains/context/working-set/policies/structural.ts @@ -91,7 +91,7 @@ export const structuralPolicy: WorkingSetPolicy = { id: "structural-v1", select(input: PolicyInput): ReadonlyArray { const { entries, view, settings, pressure, estimateTokens } = input; - const index = buildPathIndex(entries); + const index = buildPathIndex(entries, { cwd: input.cwd }); const cutoffIndex = protectionCutoffIndex(entries, settings.protectLastTurns); const candidates: EvictionCandidate[] = []; const claimed = new Set(); diff --git a/src/domains/context/working-set/replay/controls.ts b/src/domains/context/working-set/replay/controls.ts index 528a1a10e..a5dae6fae 100644 --- a/src/domains/context/working-set/replay/controls.ts +++ b/src/domains/context/working-set/replay/controls.ts @@ -13,7 +13,7 @@ function controlId(id: string): WorkingSetPolicyId { function eligibleToolResults(input: PolicyInput): SessionEntry[] { const cutoff = protectionCutoffIndex(input.entries, input.settings.protectLastTurns); - const index = buildPathIndex(input.entries); + const index = buildPathIndex(input.entries, { cwd: input.cwd }); const out: SessionEntry[] = []; for (let entryIndex = cutoff - 1; entryIndex >= 0; entryIndex -= 1) { const entry = input.entries[entryIndex]; diff --git a/src/domains/context/working-set/replay/load-clio.ts b/src/domains/context/working-set/replay/load-clio.ts index 0d5d21247..b5aef5154 100644 --- a/src/domains/context/working-set/replay/load-clio.ts +++ b/src/domains/context/working-set/replay/load-clio.ts @@ -70,17 +70,18 @@ async function collectLedgerFiles(input: string, out: Set): Promise 0 ? value.cwd : null }; } catch { - return basename(dirname(source)); + return fallback; } } - return basename(dirname(source)); + return fallback; } async function pinnedLeafTurnId(source: string): Promise { @@ -166,9 +167,11 @@ export async function loadClioTraces( continue; } const entries = cleanActiveEntries(parsed.entries, await pinnedLeafTurnId(source)); + const facts = sessionFacts(raw, source); const trace: Trace = { - id: sessionId(raw, source), + id: facts.id, source, + cwd: facts.cwd, entries, turnCount: countReplayTurns(entries), }; @@ -182,7 +185,7 @@ export async function loadClioTraces( continue; } if (filter.requireFileReread) { - const graph = buildReferenceGraph(trace, buildPathIndex(entries)); + const graph = buildReferenceGraph(trace, buildPathIndex(entries, { cwd: trace.cwd })); if (!graph.edges.some((edge) => edge.kind === "file_reread")) { filtered.no_file_reread = (filtered.no_file_reread ?? 0) + 1; continue; diff --git a/src/domains/context/working-set/replay/runner.ts b/src/domains/context/working-set/replay/runner.ts index 0a3b351ec..af566acc5 100644 --- a/src/domains/context/working-set/replay/runner.ts +++ b/src/domains/context/working-set/replay/runner.ts @@ -77,6 +77,7 @@ export function replayTrace(trace: Trace, policy: WorkingSetPolicy, config: Repl const plan = planEviction(policy, { entries: selectVisibleEntries(soFar, leaf ?? undefined), view, + cwd: trace.cwd, settings: config.settings, pressure: { tokens, diff --git a/src/domains/context/working-set/replay/trace.ts b/src/domains/context/working-set/replay/trace.ts index 3d1d8bef8..07c941ebe 100644 --- a/src/domains/context/working-set/replay/trace.ts +++ b/src/domains/context/working-set/replay/trace.ts @@ -5,6 +5,8 @@ import { isTurnStart } from "../horizon.js"; export interface Trace { id: string; source: string; + /** Session working directory the ledger's relative paths resolve against; null when the source did not record one. */ + cwd: string | null; entries: ReadonlyArray; turnCount: number; } diff --git a/src/interactive/turn-context.ts b/src/interactive/turn-context.ts index ef543551a..9f9b34292 100644 --- a/src/interactive/turn-context.ts +++ b/src/interactive/turn-context.ts @@ -466,6 +466,7 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { planned = (deps.planEviction ?? planEviction)(policy, { entries: selectVisibleEntries(entries, state.lastTurnId ?? undefined), view, + cwd: deps.session.current()?.cwd ?? null, settings: settings.context.workingSet, pressure: { tokens: estimate.tokens, diff --git a/tests/contracts/working-set-age-horizon.test.ts b/tests/contracts/working-set-age-horizon.test.ts index f7cfac5e2..72feebe67 100644 --- a/tests/contracts/working-set-age-horizon.test.ts +++ b/tests/contracts/working-set-age-horizon.test.ts @@ -123,6 +123,7 @@ function policyInput(entries: ReadonlyArray, overrides: Partial { assert.equal(indexOne("read", { path: "src/a.ts", tail: 30 }).range, null); }); -test("path index: an absolute argument is kept, a relative one without a header is left as written", () => { +test("path index: an absolute argument is kept, a relative one without a cwd is normalized but left relative", () => { assert.equal(indexOne("read", { path: "/elsewhere/b.ts" }).path, "/elsewhere/b.ts"); - const made = call("read", { path: "src/a.ts" }); + const made = call("read", { path: "./src/../src/a.ts" }); const entries: SessionEntry[] = [user("go"), made.entry, result("read", made.id, "body")]; assert.equal(buildPathIndex(entries).observations[0]?.path, "src/a.ts"); + assert.equal(buildPathIndex(entries, { cwd: null }).observations[0]?.path, "src/a.ts"); +}); + +test("path index: with a cwd, relative, dot-relative, and absolute spellings key the same file", () => { + const spellings = ["src/a.ts", "./src/a.ts", `${CWD}/src/a.ts`, "src/./a.ts"]; + for (const spelling of spellings) { + assert.equal(indexOne("read", { path: spelling }).path, `${CWD}/src/a.ts`, spelling); + } +}); + +test("path index: a listing under a relative root joins its output onto that root", () => { + const made = call("find", { pattern: "**/*.ts", path: "src" }); + const entries: SessionEntry[] = [user("go"), made.entry, result("find", made.id, "a.ts\nnested/b.ts")]; + assert.deepEqual(buildPathIndex(entries).observations[0]?.surfaced, ["src/a.ts", "src/nested/b.ts"]); + assert.deepEqual(buildPathIndex(entries, OPTS).observations[0]?.surfaced, ["/repo/src/a.ts", "/repo/src/nested/b.ts"]); }); test("path index: grep surfaces the path before the line number", () => { @@ -168,13 +183,12 @@ test("path index: an error result keeps its observation and surfaces nothing", ( test("path index: a fileEntry is write evidence with no tool call", () => { const entries: SessionEntry[] = [ - HEADER, user("go"), { kind: "fileEntry", turnId: "f1", parentTurnId: null, timestamp: TS, path: "src/a.ts", operation: "create" }, { kind: "fileEntry", turnId: "f2", parentTurnId: null, timestamp: TS, path: "src/b.ts", operation: "edit" }, { kind: "fileEntry", turnId: "f3", parentTurnId: null, timestamp: TS, path: "src/c.ts", operation: "read" }, ]; - const index = buildPathIndex(entries); + const index = buildPathIndex(entries, OPTS); assert.deepEqual( index.observations.map((observation) => [observation.ref.entry, observation.op, observation.path]), [ @@ -197,8 +211,8 @@ test("path index: argsKey is order-independent and distinguishes different argum }); test("path index: an unpaired result carries an empty argsKey rather than a guess", () => { - const entries: SessionEntry[] = [HEADER, user("go"), result("read", "call-missing", "body")]; - const observation = buildPathIndex(entries).observations[0]; + const entries: SessionEntry[] = [user("go"), result("read", "call-missing", "body")]; + const observation = buildPathIndex(entries, OPTS).observations[0]; assert.equal(observation?.argsKey, ""); assert.equal(observation?.path, ""); assert.equal(observation?.toolCallId, "call-missing"); @@ -206,7 +220,6 @@ test("path index: an unpaired result carries an empty argsKey rather than a gues test("path index: a call streamed as an assistant content block still pairs", () => { const entries: SessionEntry[] = [ - HEADER, user("go"), { kind: "message", @@ -220,7 +233,7 @@ test("path index: a call streamed as an assistant content block still pairs", () }, result("read", "call-block", "body"), ]; - const observation = buildPathIndex(entries).observations[0]; + const observation = buildPathIndex(entries, OPTS).observations[0]; assert.equal(observation?.path, "/repo/src/a.ts"); assert.equal(observation?.argsKey, '{"path":"src/a.ts"}'); }); @@ -229,7 +242,6 @@ test("path index: turn positions count turn starts strictly before an entry", () const first = call("read", { path: "a.ts" }); const second = call("read", { path: "b.ts" }); const entries: SessionEntry[] = [ - HEADER, user("one"), first.entry, result("read", first.id, "body", { turnId: "r1" }), @@ -247,7 +259,7 @@ test("path index: turn positions count turn starts strictly before an entry", () second.entry, result("read", second.id, "body", { turnId: "r2" }), ]; - const index = buildPathIndex(entries); + const index = buildPathIndex(entries, OPTS); assert.equal(index.turnCount, 2); assert.equal(index.byRef.get("r1")?.turnIndex, 1); assert.equal(index.byRef.get("r2")?.turnIndex, 2); @@ -261,7 +273,6 @@ test("path index: byPath groups every observation of one file in ledger order", const edit = call("edit", { path: "src/a.ts", edits: [] }); const other = call("read", { path: "src/b.ts" }); const entries: SessionEntry[] = [ - HEADER, user("go"), read.entry, result("read", read.id, "body", { turnId: "r1" }), @@ -270,7 +281,7 @@ test("path index: byPath groups every observation of one file in ledger order", other.entry, result("read", other.id, "body", { turnId: "r2" }), ]; - const index = buildPathIndex(entries); + const index = buildPathIndex(entries, OPTS); assert.deepEqual( index.byPath.get("/repo/src/a.ts")?.map((observation) => observation.ref.entry), ["r1", "e1"], @@ -289,12 +300,12 @@ test("path index: byPath groups every observation of one file in ledger order", test("path index: unobserved tools produce no observation", () => { const made = call("web_fetch", { url: "https://example.com" }); - const entries: SessionEntry[] = [HEADER, user("go"), made.entry, result("web_fetch", made.id, "page")]; - assert.deepEqual(buildPathIndex(entries).observations, []); + const entries: SessionEntry[] = [user("go"), made.entry, result("web_fetch", made.id, "page")]; + assert.deepEqual(buildPathIndex(entries, OPTS).observations, []); }); test("path index: the same ledger indexes identically twice", () => { const made = call("grep", { pattern: "x", path: "src" }); - const entries: SessionEntry[] = [HEADER, user("go"), made.entry, result("grep", made.id, "src/a.ts:1: x")]; - assert.deepEqual(buildPathIndex(entries).observations, buildPathIndex(entries).observations); + const entries: SessionEntry[] = [user("go"), made.entry, result("grep", made.id, "src/a.ts:1: x")]; + assert.deepEqual(buildPathIndex(entries, OPTS).observations, buildPathIndex(entries, OPTS).observations); }); diff --git a/tests/contracts/working-set-replay.test.ts b/tests/contracts/working-set-replay.test.ts index c0188bb56..a8f0f47f9 100644 --- a/tests/contracts/working-set-replay.test.ts +++ b/tests/contracts/working-set-replay.test.ts @@ -106,6 +106,7 @@ describe("contracts/working-set replay-lite", () => { const input: PolicyInput = { entries: prefix, view: foldWorkingSet(prefix, leaf), + cwd: trace.cwd, settings: SETTINGS, pressure: { tokens, contextWindow: 12_000, threshold: 0.8, target: 0.6 }, estimateTokens, diff --git a/tests/contracts/working-set-structural.test.ts b/tests/contracts/working-set-structural.test.ts index dad1303ae..9524acb6d 100644 --- a/tests/contracts/working-set-structural.test.ts +++ b/tests/contracts/working-set-structural.test.ts @@ -17,7 +17,6 @@ import { isSessionEntry, type SessionEntry } from "../../src/domains/session/ent const CWD = "/repo"; const TS = "2026-08-21T00:00:00.000Z"; -const HEADER = { type: "session", version: 4, id: "s1", timestamp: TS, cwd: CWD } as unknown as SessionEntry; /** Big enough to clear the default 200-token floor. */ function body(label: string, lines = 100): string { @@ -30,7 +29,7 @@ function body(label: string, lines = 100): string { * is the ref a policy names. */ class Ledger { - readonly entries: SessionEntry[] = [HEADER]; + readonly entries: SessionEntry[] = []; private seq = 0; private id(prefix: string): string { @@ -128,12 +127,12 @@ function policyInput(entries: ReadonlyArray, overrides: Partial (isSessionEntry(entry) ? estimateTokens(entry) : 0), + estimateTokens, ...overrides, }; } @@ -239,6 +238,23 @@ test("structural: a listing whose surfaced paths were all read is consumed", () assert.equal(byRef(select(ledger.entries)).get(listing)?.reason, "listing_consumed"); }); +test("structural: a listing under a relative root is consumed without any cwd at all (live shape)", () => { + // find prints paths relative to the directory it searched; the model then + // reads them relative to the workspace. Before the join-onto-root fix this + // never matched unless the root was ".", so listing_consumed was dead live. + const surfaced = Array.from({ length: 12 }, (_, i) => `domains/context/working-set/generated/component_${i}/index.ts`); + const ledger = new Ledger(); + ledger.user(); + const listing = ledger.find("src", surfaced); + for (const path of surfaced) { + ledger.user(); + ledger.read(`./src/${path}`); + } + ledger.pad(); + + assert.equal(byRef(select(ledger.entries, { cwd: null })).get(listing)?.reason, "listing_consumed"); +}); + test("structural: a listing that surfaced nothing is never consumed", () => { const ledger = new Ledger(); ledger.user(); @@ -391,7 +407,7 @@ test("structural: a mutation in the active turn is protected even without the ho assert.ok(entry); const input = policyInput(entries); - const index = buildPathIndex(entries); + const index = buildPathIndex(entries, { cwd: CWD }); // cutoffIndex past the end takes the horizon out of the answer, leaving the // active-turn predicate as the only thing that can protect this write. assert.equal(isProtected(entry, { entryIndex, cutoffIndex: entries.length, input, index }), true); diff --git a/tests/contracts/working-set-visible.test.ts b/tests/contracts/working-set-visible.test.ts index 76134db79..5e0a66538 100644 --- a/tests/contracts/working-set-visible.test.ts +++ b/tests/contracts/working-set-visible.test.ts @@ -87,6 +87,7 @@ function input(ledger: Ledger, protectLastTurns: number): PolicyInput { return { entries: selectVisibleEntries(ledger.entries, ledger.leaf()), view: foldWorkingSet(ledger.entries, ledger.leaf()), + cwd: null, settings: { ...DEFAULT_WORKING_SET_SETTINGS, protectLastTurns }, pressure: { tokens: 90_000, contextWindow: 100_000, threshold: 0.8, target: 0.6 }, estimateTokens, From 33df878d557904a7de677624d9e390a34275e235 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:07:40 -0500 Subject: [PATCH 19/45] fix(context): measure the eviction floor on the body, and refuse items that free nothing minEvictableTokens compared the whole payload estimate, which counts details, resultSummary, and the observation envelope the model never sees. A two-byte bash result under a fat exec record cleared the floor and was replaced by a marker longer than its body. The floor now measures the body text, and planEviction drops any item whose tokensFreed is not positive regardless of the policy that proposed it. The age-horizon parity test states the one rule the plan adds over the destructive mask instead of hiding it behind a zero floor. --- src/domains/context/working-set/engine.ts | 7 +++- src/domains/context/working-set/payload.ts | 11 ++++++ .../working-set/policies/age-horizon.ts | 6 +-- src/domains/context/working-set/protect.ts | 7 ++-- .../contracts/working-set-age-horizon.test.ts | 37 +++++++++++++++++++ .../contracts/working-set-structural.test.ts | 14 +++---- 6 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/domains/context/working-set/engine.ts b/src/domains/context/working-set/engine.ts index 59d68bdae..9a1a1e87a 100644 --- a/src/domains/context/working-set/engine.ts +++ b/src/domains/context/working-set/engine.ts @@ -150,11 +150,16 @@ export function planEviction(policy: WorkingSetPolicy, input: PolicyInput): Evic if (entry === undefined) continue; const marker = markerFor(entry, candidate); if (marker === null) continue; + // A marker at least as long as the body it replaces is a cold turn bought + // for nothing, whatever the policy's reason. Refused here so no policy can + // record an eviction that freed nothing. + const tokensFreed = tokensFreedByEviction(input.estimateTokens, entry, candidate); + if (tokensFreed <= 0) continue; claimed.add(key); items.push({ ref: candidate.ref, reason: candidate.reason, - tokensFreed: tokensFreedByEviction(input.estimateTokens, entry, candidate), + tokensFreed, marker, ...(candidate.by === undefined ? {} : { by: candidate.by }), }); diff --git a/src/domains/context/working-set/payload.ts b/src/domains/context/working-set/payload.ts index 0d538c39e..b61383a27 100644 --- a/src/domains/context/working-set/payload.ts +++ b/src/domains/context/working-set/payload.ts @@ -74,6 +74,17 @@ export function toolResultText(result: unknown): string { return stringifyBody(result); } +/** + * Estimated tokens of the body the marker would replace: the text the model + * reads, not the payload JSON. `details`, `resultSummary`, and the observation + * envelope never reach the model, so a floor measured on the whole payload + * would let a two-byte result with a fat envelope through and buy a marker + * longer than the body it replaced. + */ +export function toolResultBodyTokens(payload: unknown): number { + return Math.ceil(toolResultText(toolResultPayload(payload).result).length / 4); +} + function nestedRecord(parent: Record | null, key: string): Record | null { if (parent === null) return null; const value = parent[key]; diff --git a/src/domains/context/working-set/policies/age-horizon.ts b/src/domains/context/working-set/policies/age-horizon.ts index 7c0fd622a..49db5430e 100644 --- a/src/domains/context/working-set/policies/age-horizon.ts +++ b/src/domains/context/working-set/policies/age-horizon.ts @@ -22,12 +22,12 @@ import type { EvictionCandidate, PolicyInput, WorkingSetPolicy } from "../contract.js"; import { protectionCutoffIndex } from "../horizon.js"; -import { hasLegacyCompactionMarker, hasThinking } from "../payload.js"; +import { hasLegacyCompactionMarker, hasThinking, toolResultBodyTokens } from "../payload.js"; export const ageHorizonPolicy: WorkingSetPolicy = { id: "age-horizon", select(input: PolicyInput): ReadonlyArray { - const { entries, view, settings, estimateTokens } = input; + const { entries, view, settings } = input; const cutoff = protectionCutoffIndex(entries, settings.protectLastTurns); const candidates: EvictionCandidate[] = []; // Newest-safe-first: the entry closest to the protection horizon is the @@ -39,7 +39,7 @@ export const ageHorizonPolicy: WorkingSetPolicy = { if (view.evicted.has(entry.turnId)) continue; if (entry.role === "tool_result") { if (hasLegacyCompactionMarker(entry.payload)) continue; - if (estimateTokens(entry) < settings.minEvictableTokens) continue; + if (toolResultBodyTokens(entry.payload) < settings.minEvictableTokens) continue; candidates.push({ ref: { entry: entry.turnId }, reason: "age_horizon" }); continue; } diff --git a/src/domains/context/working-set/protect.ts b/src/domains/context/working-set/protect.ts index 91135cfe2..a2480ccca 100644 --- a/src/domains/context/working-set/protect.ts +++ b/src/domains/context/working-set/protect.ts @@ -15,7 +15,7 @@ import type { SessionEntry } from "../../session/entries.js"; import type { PolicyInput } from "./contract.js"; import type { PathIndex, PathObservation } from "./path-index.js"; -import { hasLegacyCompactionMarker, isRecord } from "./payload.js"; +import { hasLegacyCompactionMarker, isRecord, toolResultBodyTokens } from "./payload.js"; export interface ProtectionContext { entryIndex: number; @@ -86,8 +86,9 @@ export function isProtected(entry: SessionEntry, ctx: ProtectionContext): boolea if (ctx.entryIndex >= ctx.cutoffIndex) return true; if (entry.role === "assistant") return false; - // Below the floor the marker costs more than the body it replaces. - if (ctx.input.estimateTokens(entry) < ctx.input.settings.minEvictableTokens) return true; + // Below the floor the marker costs more than the body it replaces. The + // floor is the body's size, not the payload's: details never reach the model. + if (toolResultBodyTokens(entry.payload) < ctx.input.settings.minEvictableTokens) return true; // A body the legacy destructive stage already replaced has nothing left to evict. if (hasLegacyCompactionMarker(entry.payload)) return true; if (isBlockedResult(entry.payload)) return true; diff --git a/tests/contracts/working-set-age-horizon.test.ts b/tests/contracts/working-set-age-horizon.test.ts index 72feebe67..f3479d004 100644 --- a/tests/contracts/working-set-age-horizon.test.ts +++ b/tests/contracts/working-set-age-horizon.test.ts @@ -173,6 +173,43 @@ test("age-horizon: the default floor keeps a result too small to be worth a mark assert.equal(selected.size + 1, maskedRefs(entries, PROTECT_LAST_TURNS).size); }); +test("age-horizon: the planned event matches the destructive mask for every body longer than its marker", () => { + // `select` with no floor names everything the mask rewrote (the test above). + // The plan is stricter by exactly one rule: a body that its own marker would + // not shorten is refused, because evicting it frees nothing. Turn 3's "ok" + // is the only such body in the fixture. + const entries = ledger(); + const plan = planEviction(agePolicy, policyInput(entries, { minEvictableTokens: 0 })); + assert.ok(plan); + const planned = new Set(plan.items.map((item) => item.ref.entry)); + const masked = maskedRefs(entries, PROTECT_LAST_TURNS); + assert.equal(masked.has("t3"), true, "the mask rewrote the tiny body"); + masked.delete("t3"); + assert.deepEqual(planned, masked); + assert.ok(plan.items.every((item) => item.tokensFreed > 0)); +}); + +test("age-horizon: the floor measures the body the marker replaces, not the payload envelope", () => { + const entries = ledger(); + // A two-byte body under a 1.5KB details envelope: the payload clears any + // floor, the body clears none. + const fat = entries.find((entry) => entry.turnId === "t3"); + assert.ok(fat && fat.kind === "message"); + const payload = fat.payload as { result: Record }; + payload.result = { ...payload.result, details: { exec: { env: "x".repeat(1_500), argv: ["true"] } } }; + assert.ok(estimateTokens(fat) > DEFAULT_WORKING_SET_SETTINGS.minEvictableTokens, "the envelope alone clears the floor"); + + const selected = new Set(agePolicy.select(policyInput(entries)).map((c) => c.ref.entry)); + assert.equal(selected.has("t3"), false); + const plan = planEviction(agePolicy, policyInput(entries, { minEvictableTokens: 0 })); + assert.ok(plan); + assert.equal( + plan.items.some((item) => item.ref.entry === "t3"), + false, + "even with no floor the engine refuses an item that frees nothing", + ); +}); + test("age-horizon: units already out of the working set are never re-selected", () => { const entries = ledger(); entries.push({ diff --git a/tests/contracts/working-set-structural.test.ts b/tests/contracts/working-set-structural.test.ts index 9524acb6d..8fba19718 100644 --- a/tests/contracts/working-set-structural.test.ts +++ b/tests/contracts/working-set-structural.test.ts @@ -209,8 +209,8 @@ test("structural: staleness outranks supersession when both apply", () => { }); test("structural: a listing with unread surfaced paths stays (charter scenario 5)", () => { - // Long enough that the listing itself clears the minEvictableTokens floor. - const surfaced = Array.from({ length: 12 }, (_, i) => `domains/context/working-set/generated/component_${i}/index.ts`); + // Long enough that the listing body itself clears the minEvictableTokens floor. + const surfaced = Array.from({ length: 16 }, (_, i) => `domains/context/working-set/generated/component_${i}/index.ts`); const ledger = new Ledger(); ledger.user(); const listing = ledger.find("src", surfaced); @@ -220,12 +220,12 @@ test("structural: a listing with unread surfaced paths stays (charter scenario 5 } ledger.pad(); - assert.equal(byRef(select(ledger.entries)).has(listing), false, "7 surfaced paths are still unread"); + assert.equal(byRef(select(ledger.entries)).has(listing), false, "11 surfaced paths are still unread"); }); test("structural: a listing whose surfaced paths were all read is consumed", () => { - // Long enough that the listing itself clears the minEvictableTokens floor. - const surfaced = Array.from({ length: 12 }, (_, i) => `domains/context/working-set/generated/component_${i}/index.ts`); + // Long enough that the listing body itself clears the minEvictableTokens floor. + const surfaced = Array.from({ length: 16 }, (_, i) => `domains/context/working-set/generated/component_${i}/index.ts`); const ledger = new Ledger(); ledger.user(); const listing = ledger.find("src", surfaced); @@ -242,7 +242,7 @@ test("structural: a listing under a relative root is consumed without any cwd at // find prints paths relative to the directory it searched; the model then // reads them relative to the workspace. Before the join-onto-root fix this // never matched unless the root was ".", so listing_consumed was dead live. - const surfaced = Array.from({ length: 12 }, (_, i) => `domains/context/working-set/generated/component_${i}/index.ts`); + const surfaced = Array.from({ length: 16 }, (_, i) => `domains/context/working-set/generated/component_${i}/index.ts`); const ledger = new Ledger(); ledger.user(); const listing = ledger.find("src", surfaced); @@ -575,7 +575,7 @@ test("structural: the same ledger selects identically twice", () => { }); test("structural: planEviction turns a mixed selection into a valid ledger entry", () => { - const surfaced = Array.from({ length: 12 }, (_, i) => `domains/context/working-set/generated/surfaced_${i}/index.ts`); + const surfaced = Array.from({ length: 16 }, (_, i) => `domains/context/working-set/generated/surfaced_${i}/index.ts`); const ledger = new Ledger(); ledger.user(); const staleRead = ledger.read("src/m.ts"); From b3348b803fdeaabd0e1d1ff43039067d24e6187f Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:09:20 -0500 Subject: [PATCH 20/45] fix(context): stamp working_set_evict on every tier An eviction rewrites the prefix, so the next call is cold on Anthropic as much as on a single-slot local backend. The reason was dropped with the tier gate that dispatch and compaction disturbances still need; it now stamps on every tier while those two keep the local-native gate. --- src/interactive/turn-context.ts | 14 +++++--- .../context-working-set-wiring.test.ts | 36 +++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/interactive/turn-context.ts b/src/interactive/turn-context.ts index 9f9b34292..5f25b1b3a 100644 --- a/src/interactive/turn-context.ts +++ b/src/interactive/turn-context.ts @@ -176,13 +176,19 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { const pendingColdReasons = new Set(); let runExpectedColdReasons: string[] = []; let nextAssistantColdReasons: string[] = []; + // A working-set eviction changes the prefix itself, so it cools every + // tier's cache, not only a single-slot local one. Dispatch and compaction + // disturbances keep the local-native gate below. + const TIER_INDEPENDENT_COLD_REASONS: ReadonlySet = new Set(["working_set_evict"]); + const stampsOnTier = (reason: string, runtimeId: string | undefined): boolean => + TIER_INDEPENDENT_COLD_REASONS.has(reason) || + (runtimeId !== undefined && deps.providers.getRuntime(runtimeId)?.tier === "local-native"); const noteColdReason = (reason: string): void => { if (!state.streaming) { pendingColdReasons.add(reason); return; } - const runtimeId = state.runtime?.runtimeId; - if (!runtimeId || deps.providers.getRuntime(runtimeId)?.tier !== "local-native") return; + if (!stampsOnTier(reason, state.runtime?.runtimeId)) return; if (!runExpectedColdReasons.includes(reason)) runExpectedColdReasons.push(reason); if (nextAssistantColdReasons.includes(reason)) return; nextAssistantColdReasons.push(reason); @@ -901,9 +907,9 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { runExpectedColdReasons = []; nextAssistantColdReasons = []; if (pendingColdReasons.size > 0) { - const reasons = [...pendingColdReasons]; + const reasons = [...pendingColdReasons].filter((reason) => stampsOnTier(reason, runtimeId)); pendingColdReasons.clear(); - if (deps.providers.getRuntime(runtimeId)?.tier === "local-native") { + if (reasons.length > 0) { runExpectedColdReasons = reasons; nextAssistantColdReasons = reasons; deps.emitNotice(`[context engine] backend prefix cache likely cold this turn: ${reasons.join(", ")}`); diff --git a/tests/contracts/context-working-set-wiring.test.ts b/tests/contracts/context-working-set-wiring.test.ts index 35ace7b39..844c0038a 100644 --- a/tests/contracts/context-working-set-wiring.test.ts +++ b/tests/contracts/context-working-set-wiring.test.ts @@ -142,7 +142,7 @@ function fakePlan(): EvictionPlan { }; } -function harness(enabled = true, withSummary = true) { +function harness(enabled = true, withSummary = true, tier: "local-native" | "cloud" = "local-native") { const entries = fixtureEntries(); const session = fakeSession(entries); const settings = testSettings(enabled); @@ -162,7 +162,7 @@ function harness(enabled = true, withSummary = true) { const context = createTurnContext({ state, getSettings: () => settings, - providers: { getRuntime: () => ({ tier: "local-native" }) } as never, + providers: { getRuntime: () => ({ tier }) } as never, session: session.contract, readSessionEntries: () => entries, ...(withSummary @@ -243,6 +243,38 @@ describe("contracts/context working-set compaction wiring", () => { deepStrictEqual(h.context.contextLedger().promptCache?.expectedColdReasons, ["working_set_evict"]); }); + it("stamps working_set_evict on a cloud tier too: the prefix changed, whatever the backend", async () => { + delete process.env.CLIO_CODER_LEGACY_MASK; + const h = harness(true, false, "cloud"); + await h.context.runAutoCompact(h.runtime, false); + + h.context.consumeExpectedColdReasons("test-runtime"); + const usage = { + input: 1_000, + output: 10, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1_010, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + deepStrictEqual(h.context.promptCachePayloadForAssistant(usage).expectedColdReasons, ["working_set_evict"]); + }); + + it("keeps the dispatch disturbance gated to local-native tiers", async () => { + const h = harness(true, false, "cloud"); + h.bus.emit(BusChannels.DispatchStarted, {} as never); + h.context.consumeExpectedColdReasons("test-runtime"); + const usage = { + input: 1_000, + output: 10, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1_010, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + strictEqual(h.context.promptCachePayloadForAssistant(usage).expectedColdReasons, undefined); + }); + it("attributes an in-run post-tool eviction to the immediate continuation", async () => { delete process.env.CLIO_CODER_LEGACY_MASK; const h = harness(true, false); From 6fdfec14f369686895b93b5e433aa23e7b0e295b Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:11:02 -0500 Subject: [PATCH 21/45] fix(session): resume version-3 sessions as a no-op migration to version 4 Version 4 only added the working-set ledger kinds; a version-3 ledger reads as-is. Rejecting it stranded every session the operator had on upgrade, and the picker still listed them. runMigrations now returns a migrated result for 3, resumeSessionState restamps the metadata so the next reader sees 4, and the rejects for < 3 and > 4 stay exactly as they were. --- src/domains/session/manager.ts | 11 +++++++-- src/domains/session/migrations/index.ts | 12 +++++++++- tests/contracts/session-boundary.test.ts | 30 ++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/domains/session/manager.ts b/src/domains/session/manager.ts index 2d6cfd6aa..a0d822a31 100644 --- a/src/domains/session/manager.ts +++ b/src/domains/session/manager.ts @@ -70,9 +70,16 @@ export function resumeSessionState(sessionId: string): { } { const persistedMeta = readSessionMeta(sessionId) as SessionMeta; const paths = sessionPaths(persistedMeta); - runMigrations(persistedMeta, paths.meta); + const migration = runMigrations(persistedMeta, paths.meta); const { meta, writer, tree } = engineResumeSession(sessionId); - return { state: { meta: meta as SessionMeta, writer }, nodes: tree }; + const state: SessionManagerState = { meta: meta as SessionMeta, writer }; + if (migration.migrated) { + // The ledger needed no transformation; only the stamp moves, so the next + // reader does not re-run the same no-op. + state.meta.sessionFormatVersion = migration.to; + persistSessionMeta(state); + } + return { state, nodes: tree }; } export function appendTurn(state: SessionManagerState, input: TurnInput): ClioTurnRecord { diff --git a/src/domains/session/migrations/index.ts b/src/domains/session/migrations/index.ts index 363120c4a..9bbe4a3d1 100644 --- a/src/domains/session/migrations/index.ts +++ b/src/domains/session/migrations/index.ts @@ -7,6 +7,10 @@ * disposable, it belongs to a newer Clio, and this build would silently drop * whatever that build understood and this one does not. Downgrading and * resuming would then write the truncated reading back over the original. + * + * Version 3 to 4 is the one additive step: 4 only adds the working-set kinds + * (`contextEviction`, `contextRecall`), so a version-3 ledger is read as-is + * and the metadata is restamped. Nothing in the file changes. */ import { CURRENT_SESSION_FORMAT_VERSION } from "../../../engine/session.js"; @@ -14,6 +18,9 @@ import type { SessionMeta } from "../contract.js"; export { CURRENT_SESSION_FORMAT_VERSION }; +/** Oldest version this build reads without transforming the ledger. */ +export const OLDEST_READABLE_SESSION_FORMAT_VERSION = 3; + export interface MigrationResult { migrated: boolean; from: number; @@ -22,7 +29,7 @@ export interface MigrationResult { export function runMigrations(meta: SessionMeta, sessionPath: string): MigrationResult { const from = meta.sessionFormatVersion ?? 1; - if (from < CURRENT_SESSION_FORMAT_VERSION) { + if (from < OLDEST_READABLE_SESSION_FORMAT_VERSION) { throw new Error( `session metadata has an unsupported format version (expected version ${CURRENT_SESSION_FORMAT_VERSION}, got ${from}): ${sessionPath}. Remove the session directory to start a new session.`, ); @@ -32,5 +39,8 @@ export function runMigrations(meta: SessionMeta, sessionPath: string): Migration `session was written by a newer Clio (format version ${from}, this build reads version ${CURRENT_SESSION_FORMAT_VERSION}): ${sessionPath}. Upgrade clio-coder to resume this session.`, ); } + if (from < CURRENT_SESSION_FORMAT_VERSION) { + return { migrated: true, from, to: CURRENT_SESSION_FORMAT_VERSION }; + } return { migrated: false, from, to: from }; } diff --git a/tests/contracts/session-boundary.test.ts b/tests/contracts/session-boundary.test.ts index 0c0c1bb51..81438b897 100644 --- a/tests/contracts/session-boundary.test.ts +++ b/tests/contracts/session-boundary.test.ts @@ -1,10 +1,11 @@ -import { strictEqual, throws } from "node:assert/strict"; +import { deepStrictEqual, strictEqual, throws } from "node:assert/strict"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, it } from "node:test"; import { clioStateDir } from "../../src/core/xdg.js"; import { listSessionsForCwd } from "../../src/domains/session/history.js"; import { resumeSessionState } from "../../src/domains/session/manager.js"; +import { runMigrations } from "../../src/domains/session/migrations/index.js"; import { CURRENT_SESSION_FORMAT_VERSION, createSession, @@ -110,7 +111,7 @@ describe("contracts/session-boundary", () => { }); }); - for (const version of [1, 2, 3] as const) { + for (const version of [1, 2] as const) { it(`rejects session format version ${version} with an operator remedy`, async () => { const { meta, writer } = createSession({ cwd: scratch }); await writer.close(); @@ -138,6 +139,31 @@ describe("contracts/session-boundary", () => { }); }); + // Version 4 only added the working-set kinds; a version-3 ledger is readable + // as-is, so an upgrade must not strand every session the operator has. + it("resumes a version-3 session as a no-op migration and restamps the metadata", async () => { + const { meta, writer } = createSession({ cwd: scratch }); + await writer.close(); + const paths = sessionPaths(meta); + writeFileSync(paths.meta, JSON.stringify({ ...meta, sessionFormatVersion: 3 })); + + deepStrictEqual(runMigrations({ ...meta, sessionFormatVersion: 3 } as never, paths.meta), { + migrated: true, + from: 3, + to: CURRENT_SESSION_FORMAT_VERSION, + }); + const resumed = resumeSessionState(meta.id); + strictEqual(resumed.state.meta.sessionFormatVersion, CURRENT_SESSION_FORMAT_VERSION); + strictEqual(JSON.parse(readFileSync(paths.meta, "utf8")).sessionFormatVersion, CURRENT_SESSION_FORMAT_VERSION); + await resumed.state.writer.close(); + // A second resume sees the current version and migrates nothing. + deepStrictEqual(runMigrations(resumed.state.meta, paths.meta), { + migrated: false, + from: CURRENT_SESSION_FORMAT_VERSION, + to: CURRENT_SESSION_FORMAT_VERSION, + }); + }); + it("stamps the current format version on a new session", () => { const { meta } = createSession({ cwd: scratch }); strictEqual(meta.sessionFormatVersion, CURRENT_SESSION_FORMAT_VERSION); From 7e7beaf3765d3ef7a588754711937d2b082d1796 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:12:48 -0500 Subject: [PATCH 22/45] fix(context): a blocked call does not resolve an earlier failure findLaterSuccess treated any non-error later call as the success that resolves a failure, and a call the safety rails refused carries no isError. The path index now records the admission verdict and the resolver skips it. --- src/domains/context/working-set/path-index.ts | 5 +++++ src/domains/context/working-set/protect.ts | 4 +++- tests/contracts/working-set-structural.test.ts | 13 +++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/domains/context/working-set/path-index.ts b/src/domains/context/working-set/path-index.ts index f54dd3e8e..6ab371ac5 100644 --- a/src/domains/context/working-set/path-index.ts +++ b/src/domains/context/working-set/path-index.ts @@ -69,6 +69,8 @@ export interface PathObservation { /** Listing ops only: concrete file paths the result surfaced, resolved like `path`. */ surfaced: ReadonlyArray; isError: boolean; + /** The safety rails refused the call: no observation happened, and it resolves nothing. */ + isBlocked: boolean; /** Turn starts (user message, bashExecution, branchSummary) strictly before this entry. */ turnIndex: number; /** Index in the entries array this index was built from. */ @@ -339,6 +341,7 @@ function toolResultObservation( if (op === undefined) return null; const args = call !== undefined && isRecord(call.args) ? call.args : null; const isError = obj?.isError === true || obj?.error === true; + const isBlocked = obj?.outcome === "blocked" || typeof obj?.blockReason === "string"; const path = observedPath(op, args, context.cwd); const surfaced = shouldParseSurfaced(op, args, isError) ? surfacedPaths(op, args, toolResultText(obj?.result ?? entry.payload), path, context.cwd) @@ -352,6 +355,7 @@ function toolResultObservation( range: op === "read" ? readRange(args) : null, surfaced, isError, + isBlocked, turnIndex: context.turnIndex, entryIndex: context.entryIndex, argsKey: call?.argsKey ?? "", @@ -382,6 +386,7 @@ export function buildPathIndex(entries: ReadonlyArray, options?: P range: null, surfaced: [], isError: false, + isBlocked: false, turnIndex, entryIndex, argsKey: "", diff --git a/src/domains/context/working-set/protect.ts b/src/domains/context/working-set/protect.ts index a2480ccca..e5deec335 100644 --- a/src/domains/context/working-set/protect.ts +++ b/src/domains/context/working-set/protect.ts @@ -45,6 +45,8 @@ function isErrorResult(payload: unknown): boolean { * The later call that resolved this failure: same tool with byte-identical * arguments, or, for the path-identified ops, the same file by any route. Null * when nothing after it succeeded, which is what keeps the failure protected. + * A refused call is not a success: the safety rails returned a verdict, not + * the observation the failure was trying to make. * * Shared with `structural.ts` rung 3 on purpose: the rule that evicts a * resolved failure and the predicate that protects an unresolved one must @@ -52,7 +54,7 @@ function isErrorResult(payload: unknown): boolean { */ export function findLaterSuccess(observation: PathObservation, index: PathIndex): PathObservation | null { for (const candidate of index.observations) { - if (candidate.entryIndex <= observation.entryIndex || candidate.isError) continue; + if (candidate.entryIndex <= observation.entryIndex || candidate.isError || candidate.isBlocked) continue; if (candidate.toolName === observation.toolName && observation.argsKey.length > 0) { if (candidate.argsKey === observation.argsKey) return candidate; } diff --git a/tests/contracts/working-set-structural.test.ts b/tests/contracts/working-set-structural.test.ts index 8fba19718..2a2033512 100644 --- a/tests/contracts/working-set-structural.test.ts +++ b/tests/contracts/working-set-structural.test.ts @@ -297,6 +297,19 @@ test("structural: an unresolved failure is protected", () => { assert.equal(byRef(select(ledger.entries)).has(failure), false); }); +test("structural: a blocked repeat of a failed call does not resolve the failure", () => { + const ledger = new Ledger(); + ledger.user(); + const failure = ledger.bash("rm -rf build", `rm: cannot remove 'build': Permission denied\n${body("stack")}`, { + isError: true, + }); + ledger.user(); + ledger.call("bash", { command: "rm -rf build" }, body("refused"), { blocked: true }); + ledger.pad(); + + assert.equal(byRef(select(ledger.entries)).has(failure), false, "a refusal is a verdict, not a success"); +}); + test("structural: range reads only supersede ranges they contain", () => { // A property sweep over deterministic offset/limit pairs: an earlier read is // evicted only when the later read's lines contain it. From bbe1c15b28a72f4496e267384272a5414cc43225 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:12:48 -0500 Subject: [PATCH 23/45] fix(context): recall errors always list the evicted refs on the active path The nearest-ref guess is a prefix match over time-ordered ids and usually names an unrelated result; the listing is what lets the next call succeed, so it is no longer gated on the guess coming up empty. --- src/tools/context/index.ts | 6 ++++-- tests/contracts/context-tool-recall.test.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/tools/context/index.ts b/src/tools/context/index.ts index ce8b6d188..f393cd38c 100644 --- a/src/tools/context/index.ts +++ b/src/tools/context/index.ts @@ -534,11 +534,13 @@ function runRecallScope( const view = foldWorkingSet(entries, leaf); const resolved = resolveRecall(entries, view, ref, leaf); if (!resolved.ok) { + // The listing is what lets the next call succeed; the nearest-ref guess is + // a prefix match over time-ordered ids and is usually an unrelated result. const evictedRefs = [...view.evicted.keys()]; const listing = - "nearest" in resolved.error && resolved.error.nearest === null && evictedRefs.length > 0 + evictedRefs.length > 0 ? ` Evicted refs on the active path: ${evictedRefs.slice(0, 8).join(", ")}${evictedRefs.length > 8 ? ", …" : ""}.` - : ""; + : " No refs are evicted on the active path."; return { kind: "error", message: `context: ${recallErrorMessage(resolved.error, entries)}${listing}` }; } const { result } = resolved; diff --git a/tests/contracts/context-tool-recall.test.ts b/tests/contracts/context-tool-recall.test.ts index 53d18a8ca..63bb958cd 100644 --- a/tests/contracts/context-tool-recall.test.ts +++ b/tests/contracts/context-tool-recall.test.ts @@ -132,7 +132,8 @@ describe("contracts/context recall scope", () => { const tool = createContextTool({ session: deps }); const notEvicted = await tool.run({ scope: "recall", ref: "t2" }, undefined); assert.equal(notEvicted.kind, "error"); - if (notEvicted.kind === "error") assert.match(notEvicted.message, /not evicted.*Nearest evicted ref: t1\./); + if (notEvicted.kind === "error") + assert.match(notEvicted.message, /not evicted.*Nearest evicted ref: t1\. Evicted refs on the active path: t1\./); const offPath = await tool.run({ scope: "recall", ref: "nope" }, undefined); assert.equal(offPath.kind, "error"); if (offPath.kind === "error") From b1b4e591ac85adf1dba9154eedd80f6be39b7c93 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:13:56 -0500 Subject: [PATCH 24/45] feat(context): add /context recall and cold-turn attribution `/context recall ` reads an evicted tool-result body back into the transcript. It resolves through the same `resolveRecall` fold at the same live leaf as the tool path, appends a `contextRecall` entry with `trigger: "operator"`, and publishes `BusChannels.ContextRecalled`, so an operator recall counts as churn exactly like a model recall. The body goes to the transcript and nowhere else: an operator recall answers the person, so it never becomes model context and never costs window. The `/context` cache section now reads back the reasons `noteColdReason` stamped on the last run. A cold verdict Clio caused says `last cold turn: working-set eviction (expected)` and drops the shell-reused-but-backend-cold warning, which was reporting Clio's own eviction as a provider disagreement. An unexplained cold turn under a reused shell stays a warning. `recallParentTurnId` moves into recall.ts so the tool path and the operator path anchor the record identically; a record parented anywhere else folds onto the wrong branch. --- src/domains/context/working-set/recall.ts | 15 ++ src/interactive/context-overlay.ts | 33 ++- src/interactive/context-recall-command.ts | 127 +++++++++ src/interactive/interactive-slash-runtime.ts | 38 ++- src/interactive/slash-commands.ts | 31 ++- src/tools/context/index.ts | 18 +- .../context-overlay-working-set.test.ts | 39 +++ tests/contracts/context-recall-slash.test.ts | 242 ++++++++++++++++++ tests/contracts/slash-spec.test.ts | 5 +- 9 files changed, 530 insertions(+), 18 deletions(-) create mode 100644 src/interactive/context-recall-command.ts create mode 100644 tests/contracts/context-recall-slash.test.ts diff --git a/src/domains/context/working-set/recall.ts b/src/domains/context/working-set/recall.ts index 2a189a0bd..4b74c02b2 100644 --- a/src/domains/context/working-set/recall.ts +++ b/src/domains/context/working-set/recall.ts @@ -145,6 +145,21 @@ export function resolveRecall( }; } +/** + * The turn a `contextRecall` entry parents onto: the newest message on the + * active path. Every recall caller needs this and they must agree, because a + * record anchored anywhere else folds onto the wrong branch and a `/tree` + * switch would then show a recall the branch never made. + */ +export function recallParentTurnId(entries: ReadonlyArray, activeLeafTurnId?: string): string | null { + const active = filterEntriesToActivePath(entries, activeLeafTurnId); + for (let i = active.length - 1; i >= 0; i -= 1) { + const candidate = active[i]; + if (candidate?.kind === "message") return candidate.turnId; + } + return null; +} + export function buildRecallFields( result: RecallResult, meta: { trigger: RecallTrigger; toolCallId?: string }, diff --git a/src/interactive/context-overlay.ts b/src/interactive/context-overlay.ts index d57cd0b45..1b62a7315 100644 --- a/src/interactive/context-overlay.ts +++ b/src/interactive/context-overlay.ts @@ -93,6 +93,25 @@ function formatChurn(view: WorkingSetView): string { return (view.recalls / view.itemsEvicted).toFixed(2); } +/** + * Prose for one cache-disturbance reason. The wire values are stamped by + * `noteColdReason` in turn-context.ts and persisted on the assistant entry's + * `promptCache.expectedColdReasons`; the overlay reads them back, so an unknown + * reason renders as itself rather than disappearing. + */ +function coldReasonLabel(reason: string): string { + switch (reason) { + case "working_set_evict": + return "working-set eviction"; + case "compaction": + return "compaction"; + case "dispatch": + return "dispatch traffic"; + default: + return reason; + } +} + /** * The working-set section: what the projection has taken out of the window * and how often the model has asked for it back. Churn is recalls over items @@ -183,11 +202,21 @@ export function renderContextLedgerLines( const uncached = cache.uncachedInputTokens !== null ? `uncached input ${formatTokens(cache.uncachedInputTokens)}` : null; const line = ["prompt cache:", shell, "·", backend, "·", read, ...(uncached ? ["·", uncached] : [])].join(" "); + // Reasons Clio recorded before the run: working-set eviction, summary + // compaction, and dispatch traffic all move the byte prefix a local + // single-slot backend caches, so a cold turn after one of them is the + // expected outcome rather than a provider surprise. + const coldReasons = cache.backendVerdict === "cold" ? (cache.expectedColdReasons ?? []) : []; // A reused shell with a cold backend means Clio kept the bytes stable // but the provider re-prefilled anyway; surface that disagreement - // instead of hiding it. - const misleading = cache.shellReused && cache.backendVerdict === "cold"; + // instead of hiding it. An expected reason explains the same numbers, so + // it is reported on its own line and not as a warning. + const misleading = cache.shellReused && cache.backendVerdict === "cold" && coldReasons.length === 0; lines.push(theme.fg(misleading ? "warning" : "dim", line)); + if (coldReasons.length > 0) { + const reasons = coldReasons.map(coldReasonLabel).join(", "); + lines.push(theme.fg("dim", `last cold turn: ${reasons} (expected)`)); + } } if (ledger.lastCompaction) { diff --git a/src/interactive/context-recall-command.ts b/src/interactive/context-recall-command.ts new file mode 100644 index 000000000..940dcfde7 --- /dev/null +++ b/src/interactive/context-recall-command.ts @@ -0,0 +1,127 @@ +/** + * `/context recall `: the operator's half of working-set recall. + * + * The model recalls through `context(scope="recall", ref=...)`, which puts the + * body back in front of the model at the tail of the working set. This command + * answers a different question, asked by a person: what was in the result the + * marker replaced. So the body goes to the transcript and nowhere else. It is + * never submitted as a turn, never replayed, and never counted against the + * context window, which is why an operator can read a 40k-line build log back + * without paying for it. + * + * Everything else is identical to the tool path, deliberately: the same + * `resolveRecall` over the same fold at the same live leaf, the same + * `contextRecall` ledger entry (with `trigger: "operator"`), and the same + * `BusChannels.ContextRecalled` publication. A recall is a churn signal + * whoever asked for it, and a policy that keeps evicting something a human + * keeps reading back is a policy worth changing. + */ + +import type { ContextRecalledPayload } from "../core/bus-events.js"; +import type { EvictedState } from "../domains/context/working-set/contract.js"; +import { foldWorkingSet } from "../domains/context/working-set/fold.js"; +import { + buildRecallFields, + recallErrorMessage, + recallParentTurnId, + resolveRecall, +} from "../domains/context/working-set/recall.js"; +import type { SessionEntryInput } from "../domains/session/contract.js"; +import type { SessionEntry } from "../domains/session/entries.js"; + +/** Ledger access the command needs, mirroring the context tool's `ContextSessionDeps`. */ +export interface OperatorRecallDeps { + hasSession(): boolean; + readEntries(): ReadonlyArray; + /** The live append point (`/tree` pin or tree leaf); undefined lets the fold infer it. */ + activeLeafTurnId(): string | undefined; + appendEntry(entry: SessionEntryInput): SessionEntry; + /** Publisher for `BusChannels.ContextRecalled`; the runtime supplies the bus. */ + onRecalled?: (payload: ContextRecalledPayload) => void; + now?: () => number; +} + +export type OperatorRecallOutcome = + | { + ok: true; + /** One-line summary for the notice bar. */ + headline: string; + /** The original body, byte-exact, for the transcript. */ + body: string; + } + | { ok: false; message: string }; + +const MAX_LISTED_REFS = 8; + +function formatTokens(tokens: number): string { + return tokens.toLocaleString("en-US"); +} + +/** + * Ref, why it left, what it costs to read, and where the full artifact lives + * when the original result was offloaded. Nothing else: the body is on the next + * line and the operator is already looking at it. + */ +function headlineFor(ref: string, tokens: number, state: EvictedState | undefined, offloadPath?: string): string { + const parts = [ref]; + if (state !== undefined) { + parts.push(state.by === undefined ? `evicted: ${state.reason}` : `evicted: ${state.reason} by ${state.by}`); + } + parts.push(`${formatTokens(tokens)} tokens`); + if (offloadPath !== undefined) parts.push(`offload: ${offloadPath}`); + return `[/context recall] ${parts.join(" · ")}`; +} + +/** + * A ref that resolved to nothing is usually a typo or a stale marker, so the + * failure names what the operator could have typed instead: the nearest evicted + * ref when the error carries one, and otherwise the refs that are actually out. + */ +function failureMessage(message: string, evictedRefs: ReadonlyArray, hasNearest: boolean): string { + if (hasNearest || evictedRefs.length === 0) return `[/context recall] ${message}`; + const shown = evictedRefs.slice(0, MAX_LISTED_REFS).join(", "); + const more = evictedRefs.length > MAX_LISTED_REFS ? ", …" : ""; + return `[/context recall] ${message} Evicted refs on the active path: ${shown}${more}.`; +} + +export function runOperatorRecall(ref: string, deps: OperatorRecallDeps): OperatorRecallOutcome { + if (!deps.hasSession()) { + return { ok: false, message: "[/context recall] no active session; start one with /new or /resume first" }; + } + const trimmed = ref.trim(); + if (trimmed.length === 0) { + return { ok: false, message: "[/context recall] needs a ref: the turnId named in an [evicted ...] marker" }; + } + const entries = deps.readEntries(); + const leaf = deps.activeLeafTurnId(); + const view = foldWorkingSet(entries, leaf); + const resolved = resolveRecall(entries, view, trimmed, leaf); + if (!resolved.ok) { + const hasNearest = "nearest" in resolved.error && resolved.error.nearest !== null; + return { + ok: false, + message: failureMessage(recallErrorMessage(resolved.error, entries), [...view.evicted.keys()], hasNearest), + }; + } + const { result } = resolved; + const fields = buildRecallFields(result, { trigger: "operator" }); + try { + deps.appendEntry({ ...fields, parentTurnId: recallParentTurnId(entries, leaf) }); + } catch (err) { + return { + ok: false, + message: `[/context recall] recall of ${result.ref.entry} could not be recorded: ${err instanceof Error ? err.message : String(err)}`, + }; + } + deps.onRecalled?.({ + ref: result.ref.entry, + trigger: "operator", + tokensReadmitted: result.tokens, + at: deps.now?.() ?? Date.now(), + }); + return { + ok: true, + headline: headlineFor(result.ref.entry, result.tokens, view.evicted.get(result.ref.entry), result.offloadPath), + body: result.body, + }; +} diff --git a/src/interactive/interactive-slash-runtime.ts b/src/interactive/interactive-slash-runtime.ts index 37d677d5c..498c1bca7 100644 --- a/src/interactive/interactive-slash-runtime.ts +++ b/src/interactive/interactive-slash-runtime.ts @@ -1,5 +1,6 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; +import { BusChannels } from "../core/bus-events.js"; import type { ClioSettings } from "../core/config.js"; import type { SafeEventBus } from "../core/event-bus.js"; import type { PendingSkillRequest } from "../core/skill-activation.js"; @@ -28,6 +29,7 @@ import { type ChatPanel, createChatPanel } from "./chat-panel.js"; import { rehydrateChatPanelFromTurns } from "./chat-renderer.js"; import { runCompactWithNotice } from "./command-fallbacks.js"; import { appendNotice, appendOperatorCommand } from "./command-output.js"; +import { runOperatorRecall } from "./context-recall-command.js"; import { renderSessionHtml } from "./export-html/index.js"; import { dateLocal } from "./format-time.js"; import type { SettingsCenterRowId, SettingsSectionId } from "./overlays/settings.js"; @@ -100,8 +102,10 @@ export interface InteractiveSlashRuntimeDeps { * Leaf lookup for `/export`, so the export follows the branch the session * is actually on. current.jsonl still holds the abandoned turns after a * `/tree` pin, and an unscoped rehydrate reproduced them (issue #109). + * `/context recall` uses the same leaf for its fold, plus `appendEntry` for + * the `contextRecall` record. */ - session?: Pick; + session?: Pick; expandSubmit: (text: string) => Promise; openAskUser: AskUserHandler; openSkillsHub: () => void; @@ -512,6 +516,38 @@ export function createInteractiveSlashRuntime(deps: InteractiveSlashRuntimeDeps) } deps.openContextReset(); }, + ...(deps.session + ? { + runContextRecall: (ref: string) => { + const session = deps.session; + if (!session) return; + const outcome = runOperatorRecall(ref, { + hasSession: () => session.current() !== null, + readEntries: () => { + const sessionId = deps.chat.getSessionId(); + return sessionId ? deps.readStructuredEntries(sessionId) : []; + }, + activeLeafTurnId: () => { + const meta = session.current(); + return meta ? (session.tree(meta.id).leafId ?? undefined) : undefined; + }, + appendEntry: (entry) => session.appendEntry(entry), + onRecalled: (payload) => deps.bus.emit(BusChannels.ContextRecalled, payload), + ...(deps.now ? { now: () => (deps.now?.() ?? new Date()).getTime() } : {}), + }); + if (!outcome.ok) { + appendCommandNotice("error", outcome.message); + return; + } + appendCommandNotice("success", outcome.headline); + // The body is transcript output, not a notice: notices collapse + // newlines, and a recalled build log is worth nothing on one line. + // It is a replay block either way, so it never enters model context. + deps.io.stdout(`${outcome.body}\n`); + deps.requestRender(); + }, + } + : {}), runContextRefresh: () => { if (!deps.onContextRefresh) { deps.io.stderr("[/context refresh] context refresh not wired; pass onContextRefresh to startInteractive\n"); diff --git a/src/interactive/slash-commands.ts b/src/interactive/slash-commands.ts index f64a3ed70..a06a9008d 100644 --- a/src/interactive/slash-commands.ts +++ b/src/interactive/slash-commands.ts @@ -44,6 +44,8 @@ type SlashCommandVariant = | { kind: "init"; options: InitCommandOptions } | { kind: "context-clear"; options: ContextClearCommandOptions } | { kind: "context-refresh" } + /** `ref` is the turnId an `[evicted ...]` marker names. */ + | { kind: "context-recall"; ref: string } | { kind: "skill-selector" } | { kind: "skill-invocation"; text: string } | { kind: "prompts" } @@ -343,6 +345,13 @@ export interface SlashCommandContext { * Optional until the host wires onContextRefresh. */ runContextRefresh?: () => void; + /** + * Read an evicted tool-result body back into the transcript by ref, and + * record the `contextRecall` entry. Transcript-only: an operator recall + * answers the person, so the body never becomes model context. Optional + * until the host wires a session. + */ + runContextRecall?: (ref: string) => void; openSkillsHub?: () => void; listPrompts: () => ResourceList; /** @@ -481,6 +490,13 @@ const COMPACT_POSITIONALS: ReadonlyArray = [ { name: "instructions", required: false, rest: true }, ]; +/** + * `/context recall ` takes exactly one ref, not a rest tail: a ref is a + * single turnId, and a second token is a mistake worth reporting rather than + * text to fold into the first. + */ +const RECALL_POSITIONALS: ReadonlyArray = [{ name: "ref", required: true }]; + /** * A registered command whose arguments do not parse is a mistake to report, * not a chat message to send. Falling through to `unknown` submitted the raw @@ -859,11 +875,12 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ }, { name: "context", - description: "Context hub: window overlay plus compact, init, refresh, and reset", + description: "Context hub: window overlay plus compact, recall, init, refresh, and reset", group: "Inspect", - kinds: ["context-view", "compact", "init", "context-clear", "context-refresh"], + kinds: ["context-view", "compact", "context-recall", "init", "context-clear", "context-refresh"], subcommandDescriptions: { compact: "Compact session context", + recall: "Recall an evicted result", init: "Initialize project context", refresh: "Refresh project context", reset: "Reset project context", @@ -871,6 +888,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ args: { subcommands: { compact: { positionals: [...COMPACT_POSITIONALS] }, + recall: { positionals: [...RECALL_POSITIONALS] }, init: {}, refresh: {}, reset: {}, @@ -881,6 +899,8 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ switch (parsed.subcommand) { case "compact": return { kind: "compact", instructions: parsed.rest }; + case "recall": + return { kind: "context-recall", ref: parsed.positionals[0] ?? "" }; case "init": return { kind: "init", options: {} }; case "refresh": @@ -899,6 +919,13 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ case "compact": ctx.runCompact(command.instructions); return; + case "context-recall": + if (ctx.runContextRecall) { + ctx.runContextRecall(command.ref); + } else { + ctx.notice("error", "context recall is not wired; no session is bound to this process"); + } + return; case "init": ctx.runInit(command.options); return; diff --git a/src/tools/context/index.ts b/src/tools/context/index.ts index ce8b6d188..fe905ad15 100644 --- a/src/tools/context/index.ts +++ b/src/tools/context/index.ts @@ -4,7 +4,12 @@ import type { ContextRecalledPayload } from "../../core/bus-events.js"; import { SKILL_SUGGESTION_ANCHOR } from "../../core/skill-activation.js"; import { ToolNames } from "../../core/tool-names.js"; import { foldWorkingSet } from "../../domains/context/working-set/fold.js"; -import { buildRecallFields, recallErrorMessage, resolveRecall } from "../../domains/context/working-set/recall.js"; +import { + buildRecallFields, + recallErrorMessage, + recallParentTurnId, + resolveRecall, +} from "../../domains/context/working-set/recall.js"; import { checkSkillDrift, discoverMarketplaceSkills, @@ -16,7 +21,6 @@ import { } from "../../domains/resources/index.js"; import type { SessionEntryInput } from "../../domains/session/contract.js"; import type { SessionEntry } from "../../domains/session/entries.js"; -import { filterEntriesToActivePath } from "../../domains/session/tree/active-path.js"; import type { WorkspaceSnapshot } from "../../domains/session/workspace/index.js"; import { finalizeObservation, @@ -548,15 +552,7 @@ function runRecallScope( }); // The recall record parents onto the live leaf so the fold sees it on // this branch and only this branch. - const active = filterEntriesToActivePath(entries, leaf); - let parentTurnId: string | null = null; - for (let i = active.length - 1; i >= 0; i -= 1) { - const candidate = active[i]; - if (candidate?.kind === "message") { - parentTurnId = candidate.turnId; - break; - } - } + const parentTurnId = recallParentTurnId(entries, leaf); let recorded: SessionEntry; try { recorded = session.appendEntry({ ...fields, parentTurnId }); diff --git a/tests/contracts/context-overlay-working-set.test.ts b/tests/contracts/context-overlay-working-set.test.ts index ff4e59ab4..e3e573316 100644 --- a/tests/contracts/context-overlay-working-set.test.ts +++ b/tests/contracts/context-overlay-working-set.test.ts @@ -98,6 +98,45 @@ describe("context overlay working-set section", () => { ok(strip(renderEvictedTokensLine(12_345)).endsWith("12,345 tokens")); }); + // The reason is stamped by turn-context.ts, persisted on the assistant + // entry's promptCache, and folded back into the ledger by + // noteRunCacheSummary. This is the last hop: the overlay has to say the cold + // turn was expected, or an operator reads "backend cold" as a provider fault + // and goes looking for a bug in the prefix cache. + it("attributes an expected cold turn to working-set eviction instead of warning", () => { + const coldLedger = (expectedColdReasons?: string[]) => + buildContextLedger({ + provider: "mock", + model: "model-a", + contextWindow: 4000, + messageTokens: 1200, + promptCache: { + shellReused: true, + cacheReadTokens: 0, + cacheWriteTokens: 0, + uncachedInputTokens: 12_000, + backendVerdict: "cold", + ...(expectedColdReasons ? { expectedColdReasons } : {}), + }, + }); + const cacheLineOf = (rendered: string[]): string => + rendered.find((line) => strip(line).includes("prompt cache:")) ?? ""; + + const explained = renderContextLedgerLines(coldLedger(["working_set_evict"]), 68); + const explainedText = strip(explained.join("\n")); + ok(explainedText.includes("prompt cache: shell reused \u00b7 backend cold"), explainedText); + ok(explainedText.includes("last cold turn: working-set eviction (expected)"), explainedText); + + const unexplained = renderContextLedgerLines(coldLedger(), 68); + const unexplainedText = strip(unexplained.join("\n")); + ok(!unexplainedText.includes("last cold turn:"), unexplainedText); + + // Same words, different token: a cold turn Clio caused is explained, and a + // cold turn it cannot explain stays the warning it always was. + strictEqual(strip(cacheLineOf(explained)), strip(cacheLineOf(unexplained))); + ok(cacheLineOf(explained) !== cacheLineOf(unexplained), "an explained cold turn must drop the warning token"); + }); + it("churn is n/a with nothing evicted, and the section is absent without a fold", () => { const empty = strip(renderContextLedgerLines(ledger(), 68, EMPTY_WORKING_SET_VIEW).join("\n")); ok(empty.includes("working set · policy none"), empty); diff --git a/tests/contracts/context-recall-slash.test.ts b/tests/contracts/context-recall-slash.test.ts new file mode 100644 index 000000000..f496a7f00 --- /dev/null +++ b/tests/contracts/context-recall-slash.test.ts @@ -0,0 +1,242 @@ +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { BusChannels, type ContextRecalledPayload } from "../../src/core/bus-events.js"; +import { createSafeEventBus } from "../../src/core/event-bus.js"; +import type { DispatchContract } from "../../src/domains/dispatch/contract.js"; +import type { ProvidersContract } from "../../src/domains/providers/index.js"; +import type { SessionEntryInput } from "../../src/domains/session/contract.js"; +import type { MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; +import { + createInteractiveSlashRuntime, + type InteractiveSlashRuntimeDeps, +} from "../../src/interactive/interactive-slash-runtime.js"; +import { parseSlashCommand } from "../../src/interactive/slash-commands.js"; +import { transcriptDetail } from "../../src/interactive/transcript-detail.js"; + +let clock = 0; +function stamp(): string { + clock += 1; + return new Date(1_700_000_000_000 + clock * 1000).toISOString(); +} + +function user(turnId: string, parentTurnId: string | null): SessionEntry { + return { kind: "message", turnId, parentTurnId, timestamp: stamp(), role: "user", payload: { text: turnId } }; +} + +function toolResult( + turnId: string, + parentTurnId: string, + text: string, + details?: Record, +): MessageEntry { + return { + kind: "message", + turnId, + parentTurnId, + timestamp: stamp(), + role: "tool_result", + payload: { + toolCallId: `call-${turnId}`, + toolName: "read", + result: { + content: [{ type: "text", text }], + details: { resultSize: { bytes: text.length, truncated: false }, ...(details ?? {}) }, + }, + isError: false, + }, + }; +} + +function eviction(turnId: string, parentTurnId: string, refs: string[]): SessionEntry { + return { + kind: "contextEviction", + turnId, + parentTurnId, + timestamp: stamp(), + policyId: "structural-v1", + trigger: "pressure", + evicted: refs.map((entry) => ({ + ref: { entry }, + reason: "superseded_read" as const, + tokensFreed: 700, + marker: `[evicted ref=${entry}]`, + by: "t9", + })), + tokensBefore: 3_000, + tokensAfter: 2_300, + pressureBefore: 0.85, + snapshotIdBefore: null, + }; +} + +/** Multi-line and non-ASCII, so a lossy transcript path is visible in the output. */ +const BODY = "line one\n\tline two \nüñîçødé — 日本語\nend"; + +function fixture(): SessionEntry[] { + return [ + user("u1", null), + toolResult("t1", "u1", BODY), + toolResult("t2", "t1", "still in context"), + user("u2", "t2"), + eviction("e1", "u2", ["t1"]), + ]; +} + +function createHarness(entries: SessionEntry[] = fixture()) { + const notices: Array<{ level: string; text: string }> = []; + const stdout: string[] = []; + const submitted: string[] = []; + const recalled: ContextRecalledPayload[] = []; + const appended: SessionEntry[] = []; + const bus = createSafeEventBus(); + bus.on(BusChannels.ContextRecalled, (payload) => { + recalled.push(payload); + }); + const deps: InteractiveSlashRuntimeDeps = { + io: { + stdout: (text) => stdout.push(text), + stderr: (text) => stdout.push(`stderr:${text}`), + }, + bus, + dispatch: {} as DispatchContract, + providers: {} as ProvidersContract, + chat: { + getSessionId: () => "session-recall", + isStreaming: () => false, + submit: async (text) => { + submitted.push(text); + }, + }, + chatPanel: { + // The notice sink renders through appendReplayBlock; render wide enough + // that no assertion here is really testing the wrap point. + appendReplayBlock: (renderBlock) => { + notices.push({ level: "block", text: renderBlock(400, transcriptDetail("verbose")).join("\n") }); + }, + appendUser: (text) => submitted.push(`user:${text}`), + clearFoldOverrides: () => undefined, + }, + session: { + current: () => ({ id: "session-recall" }) as never, + tree: () => ({ leafId: "e1" }) as never, + appendEntry: (input: SessionEntryInput) => { + const entry = { ...input, turnId: input.turnId ?? `gen-${entries.length}`, timestamp: stamp() } as SessionEntry; + entries.push(entry); + appended.push(entry); + return entry; + }, + }, + stateDir: "/tmp/clio-context-recall-test", + shutdown: () => undefined, + requestRender: () => undefined, + refreshFooter: () => undefined, + dismissContextBootstrapNotices: () => undefined, + recordSubmittedTurn: () => submitted.push("record-turn"), + readStructuredEntries: () => entries, + expandSubmit: async (text) => ({ text, images: [], workingContextPaths: [], pendingSkillRequests: [] }), + openAskUser: async () => ({ answers: [], cancelled: true }), + openSkillsHub: () => undefined, + openCost: () => undefined, + openContextView: () => undefined, + openTasks: () => undefined, + openDecisions: () => undefined, + openMemory: () => undefined, + openView: () => undefined, + openModel: () => undefined, + openSettings: () => undefined, + openResume: () => undefined, + startNewSession: () => undefined, + openTree: () => undefined, + openMessagePicker: () => undefined, + openHelp: () => undefined, + openAgents: () => undefined, + openPrompts: () => undefined, + openExtensions: () => undefined, + openContextReset: () => undefined, + setEditorText: () => undefined, + }; + const ESC = String.fromCharCode(27); + const strip = (text: string): string => text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), ""); + return { + runtime: createInteractiveSlashRuntime(deps), + entries, + appended, + recalled, + submitted, + transcript: () => [...notices.map((notice) => notice.text), ...stdout].map(strip).join("\n"), + }; +} + +describe("contracts//context recall", () => { + it("parses one required ref and refuses a bare or over-long invocation", () => { + deepStrictEqual(parseSlashCommand("/context recall t1"), { kind: "context-recall", ref: "t1" }); + const bare = parseSlashCommand("/context recall"); + strictEqual(bare.kind, "usage-error"); + ok(bare.kind === "usage-error" && bare.reason.includes("ref"), bare.kind === "usage-error" ? bare.reason : ""); + const extra = parseSlashCommand("/context recall t1 t2"); + strictEqual(extra.kind, "usage-error"); + // A bare `/context` still opens the overlay: adding a subcommand must not + // change what the no-argument spelling does. + deepStrictEqual(parseSlashCommand("/context"), { kind: "context-view" }); + }); + + it("prints the body to the transcript, records the recall as operator, and publishes the event", () => { + const h = createHarness(); + + h.runtime.dispatchCommand("/context recall t1"); + + const transcript = h.transcript(); + ok(transcript.includes("[/context recall] t1"), transcript); + ok(transcript.includes("evicted: superseded_read by t9"), transcript); + ok(transcript.includes("tokens"), transcript); + // Byte-exact body, still on its own lines. + ok(transcript.includes("üñîçødé — 日本語"), transcript); + ok(transcript.includes("\tline two"), transcript); + + strictEqual(h.appended.length, 1); + const record = h.appended[0]; + strictEqual(record?.kind, "contextRecall"); + if (record?.kind === "contextRecall") { + strictEqual(record.trigger, "operator"); + strictEqual(record.ref.entry, "t1"); + strictEqual(record.toolCallId, undefined, "an operator recall has no tool call to attribute"); + strictEqual(record.parentTurnId, "u2", "the record anchors onto the newest message on the active path"); + ok(record.tokensReadmitted > 0); + } + + deepStrictEqual( + h.recalled.map((payload) => ({ ref: payload.ref, trigger: payload.trigger })), + [{ ref: "t1", trigger: "operator" }], + ); + + // An operator recall answers the person, so nothing reaches the model. + deepStrictEqual(h.submitted, []); + }); + + it("names the offload pointer instead of promising an inlined file", () => { + const entries = fixture(); + entries[1] = toolResult("t1", "u1", BODY, { + observation: { offloadPath: "/tmp/clio/offload/t1.txt" }, + }); + const h = createHarness(entries); + + h.runtime.dispatchCommand("/context recall t1"); + + ok(h.transcript().includes("offload: /tmp/clio/offload/t1.txt"), h.transcript()); + }); + + it("reports recall failures with the shared message and records nothing", () => { + const h = createHarness(); + + h.runtime.dispatchCommand("/context recall t2"); + ok(h.transcript().includes("is not evicted; its content is already in context"), h.transcript()); + + h.runtime.dispatchCommand("/context recall zzz"); + const transcript = h.transcript(); + ok(transcript.includes("is not on the active path"), transcript); + ok(transcript.includes("Evicted refs on the active path: t1."), transcript); + + deepStrictEqual(h.appended, []); + deepStrictEqual(h.recalled, []); + }); +}); diff --git a/tests/contracts/slash-spec.test.ts b/tests/contracts/slash-spec.test.ts index eb234798f..a8411b872 100644 --- a/tests/contracts/slash-spec.test.ts +++ b/tests/contracts/slash-spec.test.ts @@ -816,7 +816,7 @@ describe("contracts/slash-spec", () => { for (const retired of ["exit", "ctx", "compact", "models", "config"]) { strictEqual(byName.has(retired), false, `/${retired} is not suggested`); } - strictEqual(byName.get("context")?.argumentHint, "compact | init | refresh | reset"); + strictEqual(byName.get("context")?.argumentHint, "compact | recall | init | refresh | reset"); strictEqual(byName.get("quit")?.argumentHint, undefined); strictEqual(byName.get("help")?.argumentHint, "[query]"); strictEqual(byName.get("share")?.argumentHint, "[runId] | export | import"); @@ -830,7 +830,7 @@ describe("contracts/slash-spec", () => { } }); - it("completes exactly the four canonical context actions with short stable descriptions", async () => { + it("completes exactly the five canonical context actions with short stable descriptions", async () => { const byName = new Map(buildSlashAutocompleteCommands().map((command) => [command.name, command])); const context = byName.get("context"); @@ -839,6 +839,7 @@ describe("contracts/slash-spec", () => { all?.map((item) => ({ label: item.label, description: item.description })), [ { label: "compact", description: "Compact session context" }, + { label: "recall", description: "Recall an evicted result" }, { label: "init", description: "Initialize project context" }, { label: "refresh", description: "Refresh project context" }, { label: "reset", description: "Reset project context" }, From 22195a836e2b3c09db9ba86d6262bc01e91114ea Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:14:05 -0500 Subject: [PATCH 25/45] docs(context): document the working-set layer New `docs/context-working-set.md`: the vocabulary, why eviction is a projection rather than a rewrite, the `contextEviction` and `contextRecall` records at session format v4, the marker contract with output from `renderMarker`, the protection predicates and both policies with their rule order, recall semantics, the settings block, the operator surfaces, and a short list of what this release does not do. `docs/context-engine.md` reorders single-threshold compaction around the three mechanisms in the order they run: working-set eviction, recall, then the LLM summary as a last resort. Cache-divergence honesty names the three recorded cold reasons and the overlay line that reports them. The settings section becomes a table matching the inventory rows, and the format-version paragraph moves to v4. Glossary gains working set, projection, eviction, recall, and marker. CHANGELOG gains an Unreleased entry covering the layer, the additive v4 bump, the `CLIO_CODER_LEGACY_MASK=1` escape hatch and its removal next release, and the new `context.workingSet` keys. --- CHANGELOG.md | 18 ++++ docs/README.md | 5 +- docs/commands-and-modes.md | 2 +- docs/context-engine.md | 54 ++++++++-- docs/context-working-set.md | 184 +++++++++++++++++++++++++++++++++ docs/documentation-coverage.md | 6 +- docs/documentation-guide.md | 7 +- docs/glossary.md | 22 +++- 8 files changed, 278 insertions(+), 20 deletions(-) create mode 100644 docs/context-working-set.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 607956b10..6a501c540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to Clio Coder are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow Semantic Versioning; pre-1.0 minor releases may include incompatible changes. +## Unreleased (0.3.4) + +### Added +- Non-destructive working-set eviction. When context pressure crosses `compaction.threshold`, Clio now records which tool-result bodies and closed-turn thinking blocks leave the model's working set instead of rewriting them out of the session. The bodies stay in the ledger, the transcript keeps showing them, and each one is replaced in model replay by a one-line marker naming the ref, the reason, the size, and the exact call that brings it back. +- Exact recall by ref. The model reads an evicted body back with `context(scope="recall", ref="")`; the operator reads one into the transcript with `/context recall `, which never enters model context. A recall does not un-evict: the marker stays byte-identical so the provider prefix cache is untouched, and repeated recalls of one ref are the churn signal. +- Two eviction policies. `age-horizon` is the default and reproduces the previous age-based selection, minus results whose body is below `context.workingSet.minEvictableTokens`. The opt-in `structural-v1` selects by what the session did since (`stale_after_mutation`, `superseded_read`, `failure_resolved`, `listing_consumed`, `thinking_turn_closed`) and falls back to age only under pressure. +- `/context` reports the working set: policy, evicted items, evicted tokens, events, recalls, and churn. Evicted tool rows carry a dim `evicted · ` tag in the transcript. +- Cache-honesty attribution for eviction. An applied event stamps `working_set_evict` on the next assistant entry's `promptCache.expectedColdReasons`, and `/context` reports `last cold turn: working-set eviction (expected)` instead of warning about a cold backend it caused itself. +- New guide: `docs/context-working-set.md`. + +### Changed +- Session format version 4. The bump is additive: it adds the `contextEviction` and `contextRecall` records and changes no existing entry, so a version 3 session migrates to 4 in place on open with nothing rewritten. Only a session written by a newer build is refused. The bump is one-way for the operator, and a 0.3.3 binary cannot open a session this release wrote. +- New settings under `context.workingSet`: `enabled` (default `true`), `policy` (default `age-horizon`), `target` (default `0.6`), `protectLastTurns` (default `6`), and `minEvictableTokens` (default `200`). `compaction.excludeLastTurns` now governs only the legacy mask path. +- Compaction reports a `working_set` stage on `ContextPruned`, and the middleware `on_compaction` hook gains the `working_set_evict` and `working_set_recall` stages. + +### Fixed +- Auto-compaction no longer destroys observations. The stale-observation mask rewrote persisted bodies through `session.replaceEntries`, so masked content was gone from `/resume`, `/tree`, `/fork`, and the HTML export as well as from the model. `CLIO_CODER_LEGACY_MASK=1` restores that stage for one release as a compatibility escape hatch; it is removed in the next release. + ## 0.3.3 - 2026-08-21 ### Changed diff --git a/docs/README.md b/docs/README.md index 472274fd1..9484bb6d1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ current source, tests, and `CHANGELOG.md`. | --- | --- | | Commands, slash commands, operating posture, keybindings, dispatch, verification, and troubleshooting | [commands-and-modes.md](commands-and-modes.md) ([Interactive Blueprint](html/commands_blueprint.html)) | | Context window resolution, per-model probe capabilities, token accounting, per-turn snapshots, compaction, and context priming | [context-engine.md](context-engine.md) ([Interactive Blueprint](html/context_blueprint.html)) | +| Non-destructive working-set eviction, markers, eviction policies, recall by ref, and the `contextEviction` / `contextRecall` records | [context-working-set.md](context-working-set.md) | | Runtime targets, local model configuration, fleet profiles, and auth | [configuration-and-targets.md](configuration-and-targets.md) ([Interactive Blueprint](html/configuration_blueprint.html)) | | Every environment variable the runtime reads: guardrail overrides, directory layout, debug toggles, and internal plumbing | [environment-variables.md](environment-variables.md) ([Interactive Blueprint](html/environment_blueprint.html)) | | Argonne ALCF Sophia/Metis inference targets over Globus OAuth | [alcf-provider.md](alcf-provider.md) ([Interactive Blueprint](html/alcf_blueprint.html)) | @@ -43,12 +44,12 @@ current source, tests, and `CHANGELOG.md`. | Multi-node fleet dispatch: process-safe admission, attested workers, measured routing, activation, agent automation, topologies, and receipts | [fleet-dispatch.md](fleet-dispatch.md) ([Interactive Blueprint](html/fleet_dispatch_blueprint.html)) | | Multi-process capacity leases, heartbeat TTLs, cross-process locks, and cluster drain controls | [capacity-and-scheduling.md](capacity-and-scheduling.md) ([Interactive Blueprint](html/capacity_scheduling_blueprint.html)) | | Executable multi-node demo with reviewer gate and receipt provenance walkthrough | [fleet-demo-runbook.md](fleet-demo-runbook.md) | -| Session lifecycle, on-disk ledger format v3, `/tree` active-path lineage, `/fork`, `/resume`, checkpoints, and recovery | [session-lifecycle.md](session-lifecycle.md) ([Interactive Blueprint](html/session_lifecycle_blueprint.html)) | +| Session lifecycle, on-disk ledger format v4, `/tree` active-path lineage, `/fork`, `/resume`, checkpoints, and recovery | [session-lifecycle.md](session-lifecycle.md) ([Interactive Blueprint](html/session_lifecycle_blueprint.html)) | | Agent Client Protocol (ACP) server over stdio, tool mediation, non-stall permissions, and error taxonomy | [acp.md](acp.md) ([Interactive Blueprint](html/acp_blueprint.html)) | | Version registry and migration policies for all 9 serialized artifact schemas | [artifact-versions.md](artifact-versions.md) ([Interactive Blueprint](html/artifact_versions_blueprint.html)) | | Process exit code taxonomy, `--help` standard, machine-readable JSON streaming, and headless output contracts | [exit-codes-and-output.md](exit-codes-and-output.md) ([Interactive Blueprint](html/exit_codes_blueprint.html)) | | Actionable error remediation and diagnostics keyed by exact user-facing messages | [troubleshooting.md](troubleshooting.md) ([Interactive Blueprint](html/troubleshooting_blueprint.html)) | -| Canonical definitions of 40 core architectural concepts mapped to `src/` types | [glossary.md](glossary.md) ([Interactive Blueprint](html/glossary_blueprint.html)) | +| Canonical definitions of 45 core architectural concepts mapped to `src/` types | [glossary.md](glossary.md) ([Interactive Blueprint](html/glossary_blueprint.html)) | | Complete source-to-documentation mapping matrix and subsystem coverage status | [documentation-coverage.md](documentation-coverage.md) | | Issue-driven development lifecycle: file-ticket through release, label taxonomy, and dogfooding setup | [development-pipeline.md](development-pipeline.md) | | Proactive task memory architecture, session task bank, intervention rules, and handoff carrying | [proactive-memory.md](proactive-memory.md) ([Interactive Blueprint](html/memory_blueprint.html)) | diff --git a/docs/commands-and-modes.md b/docs/commands-and-modes.md index d907a48e5..5766f6be2 100644 --- a/docs/commands-and-modes.md +++ b/docs/commands-and-modes.md @@ -152,7 +152,7 @@ The registry table below lists the available interactive slash commands. On a ba | `/agents` | `/agents` | List Clio agents and ACP delegation agents | | `/targets` | `/targets` | Open Settings → Targets: health, use, connect, probe, remove | | `/cost` | `/cost` | Show session token and cost totals | -| `/context` | `/context compact [instructions] \| /context init \| /context refresh \| /context reset` | Context hub: window overlay plus compact, init, refresh, and reset | +| `/context` | `/context compact [instructions] \| /context recall \| /context init \| /context refresh \| /context reset` | Context hub: window overlay plus compact, recall, init, refresh, and reset | | `/fleet` | `/fleet` | Open Settings → Fleet: defaults, profiles, agent bindings, nodes | | `/decisions` | `/decisions` | Show settled interview decisions and operator revisions | | `/tasks` | `/tasks add \| /tasks hand \| /tasks done \| /tasks drop ` | Show the session board or manage project operator tasks | diff --git a/docs/context-engine.md b/docs/context-engine.md index b7b388472..7a7042e12 100644 --- a/docs/context-engine.md +++ b/docs/context-engine.md @@ -5,7 +5,9 @@ Clio Coder tracks context pressure, records per-turn snapshots, and protects the provider context with bounded tool results plus single-threshold compaction. -Source of truth lives in `src/domains/session/context-accounting.ts`, `src/domains/session/context-ledger.ts`, `src/domains/session/compaction/`, `src/domains/session/migrations/index.ts`, and the chat-loop integration in `src/interactive/chat-loop.ts`. +Source of truth lives in `src/domains/session/context-accounting.ts`, `src/domains/session/context-ledger.ts`, `src/domains/session/compaction/`, `src/domains/context/working-set/`, `src/domains/session/migrations/index.ts`, and the chat-loop integration in `src/interactive/chat-loop.ts`. + +The non-destructive eviction layer has its own guide: [context-working-set.md](context-working-set.md). ## Context window resolution @@ -23,7 +25,7 @@ The estimator in `context-accounting.ts` uses a four-characters-per-token family At submit time, Clio captures a context snapshot and persists a slim JSONL record under the session directory as `context-snapshots.jsonl`. The slim record keeps token counts, segment metadata, signatures, and hashes, not the heavy prompt or transcript text. When provider usage arrives, `reconcileSnapshot` folds actual input and output counts back into the ledger. -Session metadata enforces session format version 3 (`CURRENT_SESSION_FORMAT_VERSION = 3`). Before resuming any session, Clio checks `sessionFormatVersion`; earlier formats are rejected outright with an error rather than silently migrated. +Session metadata enforces session format version 4 (`CURRENT_SESSION_FORMAT_VERSION = 4`). Version 4 is additive: it adds the `contextEviction` and `contextRecall` records and changes no existing entry. A version 3 session therefore migrates to 4 in place when Clio opens it, and no entry is rewritten. Only a session written by a newer build is refused, with an error naming the version it read and pointing at upgrading. The bump is one-way for the operator: a 0.3.3 binary cannot open a session this release wrote. The `/context` overlay and footer meter read the same ledger categories: `system`, `tools`, `agents`, `skills`, `memory`, `project`, `messages`, `pending`, `reserve`, `free`, and `streaming`. @@ -31,28 +33,52 @@ The `/context` overlay and footer meter read the same ledger categories: `system Auto-compaction is controlled by one pressure threshold. Pressure is `estimated_tokens / context_window`. The default threshold is `0.8`. -When `compaction.auto` is enabled and pressure crosses the threshold before a request, Clio first applies the configured working-set policy. The policy appends a `contextEviction` ledger entry and projects selected tool observations and thinking out of model replay; the original entries remain intact and recallable. The one-release destructive mask compatibility path runs only when `CLIO_CODER_LEGACY_MASK=1`. +Crossing that threshold engages three mechanisms in a fixed order. The first two are cheap, reversible, and call no model. Only the third rewrites what the session says about itself. + +### 1. Working-set eviction + +When `compaction.auto` is enabled and pressure crosses the threshold before a request, Clio applies the configured working-set policy first. The policy selects tool-result bodies and closed-turn thinking blocks, `runAutoCompact` appends one `contextEviction` ledger entry, and `refreshAgentMessagesFromSession` projects those units out of model replay behind a one-line marker. Nothing is deleted: the ledger keeps the original bodies, the transcript keeps showing them, and `/resume`, `/tree`, `/fork`, and the HTML export are unaffected. + +Already-evicted units are never selected again. Recent turns keep their full observations and thinking, governed by `context.workingSet.protectLastTurns`. Results whose estimated body is below `context.workingSet.minEvictableTokens` (200 tokens by default) are kept whatever their age, because a marker would cost more than the body it replaces. The default `age-horizon` policy is therefore the selection the old destructive mask made minus those small results, not a byte-identical reproduction of it. + +If the projection drops pressure below the threshold, Clio sends the request and no summary runs. The policies, the protection predicates, the marker format, and the ledger records are documented in [context-working-set.md](context-working-set.md). + +### 2. Recall + +An evicted body comes back on demand and only on demand. The marker names the exact call: `context(scope="recall", ref="")` returns the original body byte-exact through the observation envelope and appends a `contextRecall` entry. Operators use `/context recall `, which prints the body to the transcript without putting it into model context. -The legacy escape hatch uses the old marker format: +A recall does not un-evict. The marker stays byte-identical where it was, so the provider prefix cache is untouched, and repeated recalls of the same ref are the churn signal the `/context` overlay reports. + +### 3. LLM summary, as a last resort + +If pressure remains above the threshold after eviction, Clio runs the summary compaction path: it calls the summarization model, appends a `compactionSummary` entry, refreshes projected replay messages from the session, and continues. This is the only mechanism that spends tokens and the only one whose output is a lossy paraphrase, which is why it runs last. + +Manual `/context compact`, `CLIO_CODER_FORCE_COMPACT=1`, and overflow recovery force the summary path directly and skip every pre-stage. The overflow guard runs before the user turn is committed, so a blocked oversized request does not leave an unanswered user entry in the ledger. + +### The legacy mask escape hatch + +`CLIO_CODER_LEGACY_MASK=1` restores the destructive pre-stage working-set eviction replaced. It calls `session.replaceEntries` and rewrites the persisted bodies, so masked content is gone for the operator as well as the model. It uses the old marker format: ```text [Observation masked: output was lines, chars - contents masked to save context. Re-run the tool for current content.] Preview: ``` -Already-evicted entries are not selected again. Recent turns keep their full observations and thinking. If projection drops pressure below the threshold, Clio sends the request without an LLM summary. If pressure remains above the threshold, Clio runs the summary compaction path, appends a compaction summary entry, refreshes projected replay messages from the session, and continues. +It exists for one release as a compatibility diagnosis path and is removed in the next. -When the ledger is replayed to the model, compaction summaries, branch summaries, and bash executions become standardized user-role message text. Clio imports `COMPACTION_SUMMARY_PREFIX`, `BRANCH_SUMMARY_PREFIX`, their suffixes, and `bashExecutionToText` through `src/engine/messages.ts`; `src/interactive/chat-renderer.ts` maps Clio's entry shapes onto them and applies replay truncation. +### Replay text -Manual `/context compact`, `CLIO_CODER_FORCE_COMPACT=1`, and overflow recovery force the LLM summary path directly. The overflow guard runs before the user turn is committed, so a blocked oversized request does not leave an unanswered user entry in the ledger. +When the ledger is replayed to the model, compaction summaries, branch summaries, and bash executions become standardized user-role message text. Clio imports `COMPACTION_SUMMARY_PREFIX`, `BRANCH_SUMMARY_PREFIX`, their suffixes, and `bashExecutionToText` through `src/engine/messages.ts`; `src/interactive/chat-renderer.ts` maps Clio's entry shapes onto them and applies replay truncation. The working-set projection runs before that builder, so markers are what the replay text is built from. ## Cache-divergence honesty -Compaction rewrites the replayed history. On a local backend with a single prefix-cache slot, the next turn after compaction is expected to be cold because the byte prefix changed. Dispatch traffic can disturb the same slot. +Compaction and eviction both change the replayed history. On a local backend with a single prefix-cache slot, the next turn after either one is expected to be cold because the byte prefix moved. Dispatch traffic can disturb the same slot. -Clio records these disturbances once on the next assistant entry as `promptCache.expectedColdReasons`. The user sees one dim notice, and the same reasons persist on that entry in the session ledger next to the per-call cache data. +Clio records these disturbances once on the next assistant entry as `promptCache.expectedColdReasons`. The recorded reasons are `working_set_evict` for an applied eviction event, `compaction` for the summary path, and `dispatch` for interleaved worker traffic. Only `local-native` targets stamp reasons and notify, because they are the tier a single interleaved run actually costs. The user sees one dim notice, and the same reasons persist on that entry in the session ledger next to the per-call cache data. Per-call cache verdicts are `hot`, `partial`, `cold`, and `small`. They are derived from provider usage and persisted with `timing { ttftMs, apiMs }` and `promptCache { input, cacheRead, cacheWrite, backendVerdict }` when available. +The `/context` overlay closes the loop. When the last settled run came back `cold` and Clio had recorded a reason for it, the overlay adds a line naming that reason, for example `last cold turn: working-set eviction (expected)`, and reports the cache line without the warning token. A reused prompt shell with a cold backend and no recorded reason stays a warning: Clio kept the bytes stable and the provider re-prefilled anyway, which is a disagreement worth surfacing. + ## Settings The public settings use one compaction threshold plus a non-destructive working-set stage: @@ -76,7 +102,15 @@ context: `compaction.auto` controls the pre-request trigger. Manual `/context compact` still runs when `auto` is false. `compaction.model` optionally selects a dedicated summarization model, and `compaction.systemPrompt` optionally points at a prompt override file. `compaction.excludeLastTurns` only governs the temporary legacy mask path; working-set protection uses `context.workingSet.protectLastTurns`. -`context.workingSet.enabled: false` skips eviction and goes directly to summary compaction; it does not restore the destructive mask. `policy` selects `age-horizon` or the opt-in `structural-v1` policy. `target` is the pressure ratio an eviction event batches down to, `protectLastTurns` is the recent user-turn horizon whose observations remain available, and `minEvictableTokens` keeps results whose estimated savings would be too small. Set `CLIO_CODER_LEGACY_MASK=1` only as a temporary compatibility escape hatch for the old destructive mask stage. +| Key | Default | Accepted | Meaning | +| --- | --- | --- | --- | +| `context.workingSet.enabled` | `true` | boolean | `false` skips eviction and goes directly to summary compaction. It does not restore the destructive mask. | +| `context.workingSet.policy` | `age-horizon` | `age-horizon`, `structural-v1` | Candidate selection rule set. `structural-v1` is opt-in. | +| `context.workingSet.target` | `0.6` | number greater than 0 and less than 1 | Used-over-window ratio an applied eviction event batches down to. | +| `context.workingSet.protectLastTurns` | `6` | integer ≥ 1 | Recent turns whose observations and thinking are never evicted. | +| `context.workingSet.minEvictableTokens` | `200` | integer ≥ 0 | Results below this estimate are never evicted, because the marker would cost more than the body. | + +Set `CLIO_CODER_LEGACY_MASK=1` only as a temporary compatibility escape hatch for the old destructive mask stage. See [context-working-set.md](context-working-set.md) for what each policy selects and why. Settings validation is strict: an older file still carrying the removed `compaction.thresholds` block fails to load with the exact key path during normal startup. Edit removed or unknown keys deliberately; `clio-coder doctor --fix` does not transform settings into the current schema. diff --git a/docs/context-working-set.md b/docs/context-working-set.md new file mode 100644 index 000000000..eb4b10920 --- /dev/null +++ b/docs/context-working-set.md @@ -0,0 +1,184 @@ +# Working Set + +The working set is the part of the session ledger the model actually receives on the next request. When context pressure crosses `compaction.threshold`, Clio narrows that view before it considers summarizing anything: selected tool-result bodies and closed-turn thinking blocks stop being replayed, and a one-line marker takes each body's place. Nothing is deleted. The ledger keeps every byte the tools produced, the transcript keeps showing them, and the model can ask for any evicted body back by ref. + +Source of truth is `src/domains/context/working-set/` (`contract.ts`, `fold.ts`, `project.ts`, `marker.ts`, `protect.ts`, `engine.ts`, `recall.ts`, `policies/`), the ledger records in `src/domains/session/entries.ts`, and the compaction stage in `src/interactive/turn-context.ts` (`runAutoCompact`). + +> [!WARNING] +> This is an experimental community alpha surface. The default policy is `age-horizon`, which reproduces the selection Clio already made before this layer existed. `structural-v1` is opt-in. + +## Vocabulary + +| Term | Definition | +| --- | --- | +| Working set | What the model sees on the next request: the ledger with the current projection applied. It is never a file. | +| Ledger | The durable append-only session record (`current.jsonl`). The working-set layer appends to it and never rewrites it. | +| Evicted | A unit whose body the projection replaces with a marker. The ledger entry that holds the original body is untouched. | +| Offloaded | A result the observation envelope already wrote to a file because it exceeded the per-call cap. Its marker carries the pointer instead of a preview, and recall returns the pointer rather than inlining the file. | +| Recall | Readmitting an evicted body by ref, through `context(scope="recall", ref=...)` for the model or `/context recall ` for the operator. | +| Marker | The byte-stable one-line stub the projection renders in place of an evicted body. It names the ref, the reason, the size, and the exact call that brings the body back. | +| Projection | A pure, in-memory transform from ledger entries to the entries the replay builder hands the model. `projectWorkingSet(entries, view)` is that function. | + +## Eviction is a projection, not a rewrite + +The stage this layer replaces rewrote history. `maskStaleObservations` walked the entries, replaced observation bodies with a masked-out string, and called `session.replaceEntries`. That destroyed the only copy: after a mask, `/resume`, `/tree`, `/fork`, and the HTML export all showed the placeholder, and the content was gone for the operator as well as the model. + +The working-set layer separates the two audiences. What leaves is recorded as a `contextEviction` entry, appended like any other. `refreshAgentMessagesFromSession` folds those entries into a `WorkingSetView` and applies `projectWorkingSet` before `buildReplayAgentMessagesFromTurns` runs, so only the messages bound for the provider carry markers. Every reader that shows the session to a human reads the raw ledger and sees the full bodies. + +Three properties follow from that shape: + +- **Idempotence.** Projecting an already-projected slice reproduces it byte for byte, because the marker comes from the ledger entry rather than from the body being replaced. +- **Branch safety.** The fold runs through `filterEntriesToActivePath` (issue #94), so an eviction recorded on a branch `/tree` later abandoned cannot project onto the live one, and a fork inherits the view of its shared prefix. +- **Determinism.** A policy is a pure function of `PolicyInput`. The same ledger and the same settings select the same units in a live session and in an offline replay of that session. + +Usage anchors recorded before an eviction described a longer prompt than the model will now receive, so the projection stamps `contextUsageInvalidated` on assistant entries that precede the newest eviction event. Without that, `calculateContextTokens` would keep reporting the pre-eviction size and the pressure estimator would never see the space the event freed. + +## Ledger records and format v4 + +Two entry kinds carry the layer, both defined in `src/domains/session/entries.ts`: + +| Kind | Fields | Meaning | +| --- | --- | --- | +| `contextEviction` | `policyId`, `trigger` (`pressure` or `operator`), `evicted[]`, `tokensBefore`, `tokensAfter`, `pressureBefore`, `snapshotIdBefore` | One applied event. Each `evicted[]` item is `{ ref, reason, tokensFreed, marker, by? }`. | +| `contextRecall` | `ref`, `trigger` (`tool` or `operator`), `tokensReadmitted`, `toolCallId?` | One readmission of one ref. It is a churn record, not an un-eviction. | + +`reason` is one of `superseded_read`, `stale_after_mutation`, `listing_consumed`, `failure_resolved`, `thinking_turn_closed`, `age_horizon`, `operator`. A `ref` is the `turnId` of the ledger entry that holds the unit: for a `tool_result` message the unit is the result body, and for an `assistant` message it is every thinking block the message carries. Per-block eviction is deliberately not modelled. + +Adding those kinds bumps the session format to version 4 (`CURRENT_SESSION_FORMAT_VERSION = 4` in `src/engine/session.ts`). The bump is additive: no existing entry kind changes shape, so a version 3 session migrates to 4 in place when Clio opens it and no entry is rewritten. `runMigrations` refuses only what it cannot read, a session written by a newer build, with "upgrade clio-coder to resume this session". The bump is still one-way for the operator: Clio 0.3.3 does not know these kinds and cannot open a session this release wrote. + +## The marker contract + +A marker is one line, its fields are in fixed order, and it carries no timestamp and no counter. That is not cosmetic. The marker is persisted inside the `contextEviction` entry and replayed on every subsequent request, so a marker whose bytes drifted between renders would cold-start the provider prefix cache on a turn that evicted nothing new. It would also make two replays of the same recorded ledger disagree. + +Field order is `ref`, `reason`, `by`, `tool`, `path`, `size`, `offload`, `recall`, then the body tail. Undefined fields are omitted rather than rendered empty. Real output from `renderMarker` in `src/domains/context/working-set/marker.ts`: + +```text +[evicted ref=0198f3c2-7a10-7c31-9d44-2b0c5f1e88a3 reason=stale_after_mutation by=0198f3c2-9b02-7f55-8e10-6d21ac9e4471 tool=read path=src/domains/context/working-set/engine.ts size=41 lines/3.8KB recall=context(scope="recall", ref="0198f3c2-7a10-7c31-9d44-2b0c5f1e88a3") preview="export function planEviction(policy: WorkingSetPolicy, input: PolicyInput): EvictionPlan | null { export function planEv"] +``` + +```text +[evicted ref=0198f3c2-1d44-7a90-b201-77c0e1a2f5de reason=failure_resolved by=0198f3c3-0002-7ab1-9c33-14ff90bb2c07 tool=bash size=4 lines/152B recall=context(scope="recall", ref="0198f3c2-1d44-7a90-b201-77c0e1a2f5de") first_line="src/interactive/turn-context.ts(466,15): error TS2345: Argument of type 'PolicyInput' is not assignable to parameter of "] +``` + +```text +[evicted ref=0198f3c4-55aa-7be2-8f01-9a3d6c2b1e77 reason=listing_consumed tool=grep size=1 lines/234.4KB offload=/home/dev/.local/state/clio-coder/offload/0198f3c4-grep.txt recall=context(scope="recall", ref="0198f3c4-55aa-7be2-8f01-9a3d6c2b1e77")] +``` + +Three rules govern the tail. Most reasons render `preview`: the first 120 characters of the body, whitespace collapsed and double quotes escaped, so the preview cannot break the quoted field or spill onto a second line. A `failure_resolved` eviction renders `first_line` instead, because the line that says what failed is worth the marker's tokens where a preview of a stack trace is not. An offloaded body renders neither, because the `offload=` pointer already promises the full artifact at a stable path and a preview would spend tokens repeating it. + +Thinking eviction renders no marker at all. The reasoning simply stops being replayed. A marker there would spend tokens announcing that something the model cannot act on is gone. + +## Policies + +A policy answers one question: which units should leave. It never writes, never reads a clock, and never calls a model. `planEviction` then materializes the selection into `EvictedItem`s with markers rendered and tokens measured, and prices the result against the projection the model will actually receive. + +### Protection predicates + +`protect.ts` runs before every rule in `structural-v1` and is absolute. A policy is allowed to be wrong about relevance; it is not allowed to drop these: + +1. Anything that is not a `tool_result` or `assistant` message. Operator words, compaction and branch summaries, skill activations, task ledgers, worker runs, and bash executions are the session's record of itself. +2. Anything inside the recent window, which starts at `protectionCutoffIndex(entries, protectLastTurns)`. A turn starts at a user message, a `bashExecution`, or a `branchSummary`. +3. A result whose estimated size is below `minEvictableTokens`, where the marker would cost more than the body it replaces. +4. A body the legacy destructive stage already replaced, which has nothing left to evict. +5. A call the safety rails blocked. A refused call is a decision the session made, not an observation it can re-fetch. +6. A write or edit the turn in flight is still standing on. +7. A failure nothing later resolved, and any unindexed failure, because without an observation there is no way to ask whether it was resolved. + +### `age-horizon` (default) + +The rule `maskStaleObservations` applied, recorded instead of destroyed. Every `tool_result` body older than the protection horizon leaves the working set, and every `assistant` message older than the horizon loses its thinking blocks. Same turn-start definition, same cutoff, and a body carrying a legacy compaction marker is skipped the same way. + +One skip condition is new, so this is today's selection minus small results rather than a byte-identical reproduction of it: a result whose estimated body is below `minEvictableTokens` (200 tokens by default) stays, whatever its age, because the marker would cost more than the body it replaces. The old mask had no such floor and masked those results too. Thinking has no size floor either way, because dropping it renders no marker. + +Candidates arrive newest-safe-first, so a caller that stops early has evicted the newest safe unit rather than the oldest one. It ships as the default so this release changes one thing at a time: the ledger stops being rewritten while what the model receives stays what it received before. + +Age is not a quality signal. A file read twenty turns ago and never touched since is more useful than a directory listing from two turns ago, which is the whole reason `structural-v1` exists. + +### `structural-v1` (opt-in) + +Rule order is the policy. Each rung emits candidates newest-first, every candidate passes `isProtected`, and no unit is claimed twice, so a read that is both stale and superseded is evicted for the reason that came first and carries the `by` ref that explains it. The rungs, in order: + +| # | Reason | Fires when | +| --- | --- | --- | +| 1 | `stale_after_mutation` | A read-class observation is followed by a write or edit of the same file. Whatever the body said is now a claim about a file that no longer exists in that form. | +| 2 | `superseded_read` | A later successful read of the same file covers this one's lines. A full read covers everything; any other read covers only an identical or containing range, and an unknown range covers nothing. | +| 3 | `failure_resolved` | A later call succeeded with byte-identical arguments, or, for `read`, `grep`, and `find`, reached the same file by any route. | +| 4 | `listing_consumed` | Every path the listing surfaced went on to be read. One surfaced path still unread and the listing stays, because that is the path the agent comes back to. | +| 5 | `thinking_turn_closed` | An assistant message beyond the protection horizon carries thinking blocks. | +| 6 | `age_horizon` | Only under pressure, and only until the projection reaches `target`. | + +Rungs 1 through 5 are unconditional: redundant content is free to drop, whatever the pressure. Rung 6 is the only one that looks at token counts, and it stops the moment the projected size reaches `context.workingSet.target × contextWindow`. Newest-first within a rung is a cost decision: evicting the youngest safe unit keeps the cold region after the eviction point small, so the turn that pays for the event pays least. + +The facts the rungs read come from `path-index.ts`, one deterministic pass over the active-path entries producing one observation per tool result that names a path: which file, which line range, which paths a listing surfaced, whether the call failed, and where in the turn sequence it sits. Tools that observe no path (dispatch, web fetch, tasks, ask user, context) produce no observation. There are no content fingerprints. + +## Recall + +Recall is explicit and by ref. There is no auto-readmission: the marker tells the model exactly which call brings the body back, and the model decides. + +`resolveRecall(entries, view, ref, activeLeafTurnId)` resolves a ref against the fold at the live leaf and returns the original body byte-exact, read with the same field precedence the projection would have used. It fails in three typed ways, and each message names the nearest valid ref when one exists: + +- `invalid_ref` when the ref is empty or carries whitespace. +- `not_on_active_path` when the session has no such turn on this branch, which includes a ref from a branch `/tree` abandoned. +- `not_evicted` when the unit is still in context. An assistant turn reports separately that thinking is not recallable. + +**A recall does not un-evict.** The key stays in `view.evicted`, the marker stays byte-identical at its original position, and the recalled body arrives at the tail of the working set inside the recall result. Readmitting it in place would duplicate the bytes and invalidate the provider prefix cache for everything after that point, which costs more than the recall saved. + +That also makes recall the churn signal. `churn = recalls / itemsEvicted` over the active path. A high churn number means the policy keeps evicting content the session still needs, which is a reason to change the policy rather than to raise the threshold. + +An offloaded result returns its pointer, never the file. The model gets the same `full: ` promise the original tool result ended with and reads it with `read` when it wants it. + +The two entry points differ in where the body lands: + +| Caller | Entry point | Where the body goes | Ledger record | +| --- | --- | --- | --- | +| Model | `context(scope="recall", ref=...)` | Back into the working set through the normal observation envelope, so the per-turn pool and the self cap still apply | `contextRecall` with `trigger: "tool"` and the `toolCallId` | +| Operator | `/context recall ` | The transcript only. It is never submitted as a turn and never counted against the context window | `contextRecall` with `trigger: "operator"` | + +Both publish `BusChannels.ContextRecalled`, and both route through the middleware `on_compaction` hook as stage `working_set_recall`. + +## Settings + +```yaml +context: + workingSet: + enabled: true + policy: age-horizon + target: 0.6 + protectLastTurns: 6 + minEvictableTokens: 200 +``` + +| Key | Default | Accepted | Meaning | +| --- | --- | --- | --- | +| `context.workingSet.enabled` | `true` | boolean | Master switch. `false` skips eviction and goes straight to summary compaction. It does not restore the destructive mask. | +| `context.workingSet.policy` | `age-horizon` | `age-horizon`, `structural-v1` | Candidate selection rule set. | +| `context.workingSet.target` | `0.6` | number greater than 0 and less than 1 | Used-over-window ratio an applied event batches down to. | +| `context.workingSet.protectLastTurns` | `6` | integer ≥ 1 | Recent turns whose observations and thinking are never evicted. | +| `context.workingSet.minEvictableTokens` | `200` | integer ≥ 0 | Results below this estimate are never evicted. | + +`compaction.excludeLastTurns` governs only the temporary legacy mask path; working-set protection uses `protectLastTurns`. Settings validation is strict, so an unknown key under this block fails startup with its exact path. + +`CLIO_CODER_LEGACY_MASK=1` restores the destructive stale-observation stage for one release as a compatibility escape hatch. It rewrites the ledger, and it is removed in the next release. + +## What the operator sees + +- **`/context` overlay.** A working-set section under the category legend: the policy that produced the most recent event, evicted item count, evicted tokens, event count, recall count, and churn. Evicted tokens render as one line after the legend rather than as a meter category, because they are outside the window rather than a slice of it. +- **Transcript.** An evicted tool row keeps its full body and gains a dim `evicted · ` tag. The transcript shows the ledger, never the projection, so `/resume`, `/tree`, `/fork`, and the HTML export are unaffected by eviction. +- **`/context recall `.** Prints the ref, why it was evicted, the token count, and the offload pointer when there is one, followed by the original body. Transcript only. +- **Prompt cache line.** Every applied event stamps `working_set_evict` on the next assistant entry's `promptCache.expectedColdReasons`. When the last settled run came back cold for that reason, the overlay adds `last cold turn: working-set eviction (expected)` and drops the shell-reused-but-backend-cold warning, because the cold turn is explained rather than surprising. +- **Notice.** One line per applied event: `[context engine] working_set: N items evicted by ; ~X tokens -> ~Y tokens`. + +## Not in this release + +These are tracked follow-ups, not available behavior: + +- **Auto-readmission.** Nothing brings an evicted body back on its own. There are no path fingerprints and no registry of what the model is likely to need next. +- **Cost model and deferred scheduling.** Pressure is the only trigger. There is no break-even horizon, no deferred eviction plan, and no piggybacking beyond the fact that the working-set stage already runs first inside `runAutoCompact`. +- **Claude Code transcript replay.** Replay reads Clio session ledgers only. There is no loader for other harnesses' transcript formats. +- **Digests.** A marker carries tool, size, and a first-line preview. The generated summaries from #165 are not embedded in it. + +## See also + +- [context-engine.md](context-engine.md) for context window resolution, token accounting, and how this stage sits ahead of summary compaction. +- [session-lifecycle.md](session-lifecycle.md) for the ledger format, active-path lineage, and branching. +- [glossary.md](glossary.md) for the one-line definitions of these terms. diff --git a/docs/documentation-coverage.md b/docs/documentation-coverage.md index a2ca17289..2be626eac 100644 --- a/docs/documentation-coverage.md +++ b/docs/documentation-coverage.md @@ -18,7 +18,7 @@ This matrix maps every top-level directory in `src/` and every domain directory | `src/domains/agents/` | 12 built-in recipes, agent catalog, recipe schema, fleet commands, fleet contract v4 | [built-in-agents.md](built-in-agents.md), [fleet-dispatch.md](fleet-dispatch.md) | `documented` | Documented in built-in agent recipes guide and fleet dispatch architecture. | | `src/domains/components/` | Component scanning, snapshots, hashing, diffing | [middleware-and-components.md](middleware-and-components.md) | `documented` | Documented in active component snapshot and middleware guide. | | `src/domains/config/` | Configuration contracts, file watcher, keybinding definitions, setting classifiers | [configuration-and-targets.md](configuration-and-targets.md), [commands-and-modes.md](commands-and-modes.md) | `documented` | Documented in configuration targets and command/keybinding reference. | -| `src/domains/context/` | `CLIO-CODER.md` bootstrap, codewiki generation, prompt context assembly, project rules | [context-engine.md](context-engine.md) | `documented` | Documented in context window, token accounting, and compaction reference. | +| `src/domains/context/` | `CLIO-CODER.md` bootstrap, codewiki generation, prompt context assembly, project rules, non-destructive working-set eviction (`age-horizon` and `structural-v1` policies, protection predicates, path index, byte-stable markers, recall by ref) | [context-engine.md](context-engine.md), [context-working-set.md](context-working-set.md) | `documented` | Context window, token accounting, and the three compaction mechanisms in the engine reference; the working-set layer has its own guide covering the vocabulary, both ledger record kinds and format v4, the marker contract, both policies with their rule order, recall semantics, and the operator surfaces. | | `src/domains/dispatch/` | Fleet orchestration, assignment store, batch tracker, admission, route planner, receipt integrity v15 | [fleet-dispatch.md](fleet-dispatch.md), [dispatch-architecture-rationale.md](dispatch-architecture-rationale.md), [worker-dispatch-mechanics.md](worker-dispatch-mechanics.md) | `documented` | Multi-node fleet dispatch, admission invariants, and receipt verification fully documented. | | `src/domains/eval/` | Suite v2 YAML schema, eval runner, metrics, reporters, workspace sandboxing | [eval-runner.md](eval-runner.md), [evals-internal.md](evals-internal.md) | `documented` | Documented in eval runner and soak benchmark guides. | | `src/domains/evidence/` | Evidence bundles, findings taxonomy, provenance store, failure attribution | [evidence-and-memory.md](evidence-and-memory.md) | `documented` | Documented in evidence directory structures and memory retrieval guide. | @@ -33,7 +33,7 @@ This matrix maps every top-level directory in `src/` and every domain directory | `src/domains/resources/` | Skill package discovery, marketplace index resolution, prompt resources | [skills-marketplace.md](skills-marketplace.md), [extensions-and-sharing.md](extensions-and-sharing.md) | `documented` | Skills marketplace, publishing flows, and resource managers documented. | | `src/domains/safety/` | Policy engine, action classifiers, damage-control rules, path policies, finish contract, audit log | [safety-model.md](safety-model.md), [scientific-validation.md](scientific-validation.md) | `documented` | Policy evaluation order, 10-step sequence, write containment, and finish contract documented. | | `src/domains/scheduling/` | Capacity lease acquisition, heartbeats, expiry, cross-process locks, cluster scheduling | [capacity-and-scheduling.md](capacity-and-scheduling.md), [fleet-dispatch.md](fleet-dispatch.md) | `documented` | Dedicated capacity leasing, heartbeat TTL, and cross-process lock reference. | -| `src/domains/session/` | Context ledger v3, tree branching (`/tree`), `/fork`, `/resume`, checkpoints, protected-artifact journal | [session-lifecycle.md](session-lifecycle.md) | `documented` | Dedicated session lifecycle guide covering ledger format v3, branching, journal, and recovery. | +| `src/domains/session/` | Session ledger format v4, tree branching (`/tree`), `/fork`, `/resume`, checkpoints, protected-artifact journal | [session-lifecycle.md](session-lifecycle.md), [context-working-set.md](context-working-set.md) | `documented` | Dedicated session lifecycle guide covering branching, journal, and recovery; the `contextEviction` and `contextRecall` records added at format v4 are specified in the working-set guide. | | `src/domains/share/` | Portable share archive bundles, manifest verification, import/export flows | [extensions-and-sharing.md](extensions-and-sharing.md) | `documented` | Share archives and portable bundle formats documented in extensions guide. | | `src/domains/webhook/` | Empty directory | None (Inert) | `inert` | Directory contains no active modules or exports in v0.3.3. | @@ -42,5 +42,5 @@ This matrix maps every top-level directory in `src/` and every domain directory In addition to source subsystem mappings, the documentation set includes cross-cutting contracts: 1. [artifact-versions.md](artifact-versions.md): Canonical version registry and migration contract for all 9 serialized artifact schemas across Clio Coder. -2. [glossary.md](glossary.md): Formal definitions of 40 core architectural concepts mapped to their TypeScript types in `src/`. +2. [glossary.md](glossary.md): Formal definitions of 45 core architectural concepts mapped to their TypeScript types in `src/`. 3. [troubleshooting.md](troubleshooting.md): Comprehensive diagnostic and remediation guide keyed by exact user-facing error strings. diff --git a/docs/documentation-guide.md b/docs/documentation-guide.md index 833ffb361..c21f5875f 100644 --- a/docs/documentation-guide.md +++ b/docs/documentation-guide.md @@ -34,7 +34,8 @@ Classify claims clearly: | [README.md](../README.md) | `CHANGELOG.md`, package metadata, release receipts | Product overview, install, first run, alpha framing, and release status. | | [docs/README.md](README.md) | This docs directory | Documentation hub. | | [commands-and-modes.md](commands-and-modes.md) | `src/cli/index.ts`, `src/cli/args.ts`, `src/interactive/slash-commands.ts`, `src/domains/dispatch/**` | CLI commands, headless run flags (`--session`, `--continue`, `--json-events`), session continuity, `--json` wire projection promise, slash commands, keybindings, live steering. | -| [context-engine.md](context-engine.md) | `src/domains/context/**`, `src/domains/session/context-accounting.ts`, `src/domains/session/context-ledger.ts`, `src/domains/session/compaction/` | Context window resolution, per-model probe capabilities, token accounting, snapshots, progressive compaction, model-driven `clio-coder context init`, format v3 session enforcement. | +| [context-engine.md](context-engine.md) | `src/domains/context/**`, `src/domains/session/context-accounting.ts`, `src/domains/session/context-ledger.ts`, `src/domains/session/compaction/` | Context window resolution, per-model probe capabilities, token accounting, snapshots, the three compaction mechanisms, model-driven `clio-coder context init`, format v4 session enforcement. | +| [context-working-set.md](context-working-set.md) | `src/domains/context/working-set/**`, `src/domains/session/entries.ts`, `src/interactive/turn-context.ts` | Working-set vocabulary, eviction as a projection, the `contextEviction` / `contextRecall` records, the marker contract, the `age-horizon` and `structural-v1` policies, recall semantics, and the operator surfaces. | | [architecture.md](architecture.md) | `tests/boundaries/check-boundaries.ts`, `src/core/domain-loader.ts`, `src/engine/**`, `src/worker/**` | Source layout, 5 enforced boundary rules (dependency direction vs import form), runtime flow mermaid diagram, event/audit model, detect-and-rollback write boundaries. | | [dispatch-architecture-rationale.md](dispatch-architecture-rationale.md) | `src/domains/dispatch/**`, `tests/boundaries/check-boundaries.ts` | Design rationale, not behavior: invariants that cross the seams a dispatch split would use, what any future split must preserve, the one dispatch→eval import, and the closed barrel-import decision. | | [configuration-and-targets.md](configuration-and-targets.md) | `src/core/defaults.ts`, `src/core/config.ts`, `src/domains/providers/**`, `src/cli/configure.ts`, `src/cli/targets.ts`, `src/cli/models.ts`, `src/cli/auth.ts` | TargetDescriptor, contextWindowProvenance (`configured`, `discovered`, `catalog`, `runtime-default`), settings.yaml, strict validation, saved defaults vs live routing. | @@ -49,12 +50,12 @@ Classify claims clearly: | [capacity-and-scheduling.md](capacity-and-scheduling.md) | `src/domains/scheduling/**`, `src/domains/dispatch/capacity-lease.ts`, `src/domains/dispatch/reservation-store.ts` | Multi-process capacity leases (`dispatch-admission.json`), heartbeat TTLs, cross-process transaction locks (`dispatch-admission.json.lock`), and cluster drain controls. | | [worker-dispatch-mechanics.md](worker-dispatch-mechanics.md) | `src/worker/**` | NDJSON parent-child socket protocols, control/bulk lane demuxing, watchdog timers, worker attestation (13 protocol fields), permission parking, exit codes. | | [fleet-demo-runbook.md](fleet-demo-runbook.md) | `src/domains/dispatch/**` | Multi-node fleet demo: SSH setup, C++ build/repair workflow, reviewer gates, receipt verification v15. | -| [session-lifecycle.md](session-lifecycle.md) | `src/engine/session.ts`, `src/domains/session/**` | Session lifecycle, on-disk ledger format v3 (`current.jsonl`), tree branching (`tree.json`), active-path lineage selection, `/fork`, `/resume`, checkpoints, and write-ahead protected-artifact journal. | +| [session-lifecycle.md](session-lifecycle.md) | `src/engine/session.ts`, `src/domains/session/**` | Session lifecycle, on-disk ledger format v4 (`current.jsonl`), tree branching (`tree.json`), active-path lineage selection, `/fork`, `/resume`, checkpoints, and write-ahead protected-artifact journal. | | [acp.md](acp.md) | `src/engine/acp/**`, `src/cli/acp.ts` | Agent Client Protocol (ACP) server over stdio, tool mediation, non-stall permission handling, timeout bounds, and error taxonomy. | | [artifact-versions.md](artifact-versions.md) | `src/domains/dispatch/receipt-integrity.ts`, `src/engine/session.ts`, `src/worker/spec-contract.ts`, `src/domains/agents/fleet-contract.ts`, `src/domains/eval/schema/`, `src/domains/observability/trace-store.ts` | Version registry and migration policies for all 9 serialized artifact schemas across Clio Coder. | | [exit-codes-and-output.md](exit-codes-and-output.md) | `src/cli/**`, `src/entry/**` | Global process exit codes (0, 1, 2, 3), `--help` standard on stdout, machine-readable JSON streaming (`--json`, `--json-events`), and headless stdout deliverable contracts. | | [troubleshooting.md](troubleshooting.md) | `src/core/**`, `src/cli/**`, `src/domains/**` | Actionable error remediation and diagnostics keyed by exact user-facing messages. | -| [glossary.md](glossary.md) | `src/domains/dispatch/types.ts`, `src/tools/**`, `src/domains/agents/**`, `src/core/**` | Canonical definitions of 40 core architectural concepts mapped to `src/` types. | +| [glossary.md](glossary.md) | `src/domains/dispatch/types.ts`, `src/tools/**`, `src/domains/agents/**`, `src/core/**` | Canonical definitions of 45 core architectural concepts mapped to `src/` types. | | [documentation-coverage.md](documentation-coverage.md) | `src/**` | Complete source-to-documentation mapping matrix and subsystem coverage status. | | [tui-design.md](tui-design.md) | `src/interactive/theme/tokens.ts`, `src/interactive/theme/glyphs.ts` | TUI color system, glyph vocabulary (`contextReserve`), structural layouts, state choreography, code ink. | | [installation-and-lifecycle.md](installation-and-lifecycle.md) | `src/cli/paths.ts`, `src/cli/doctor.ts`, `src/cli/uninstall.ts`, `src/cli/removal.ts` | Installation, upgrade, reset, uninstallation, launcher ownership and what `--remove-binary` will and will not remove, partial-failure behavior, configuration folders (`credentials.yaml` `0o600`), and permissions. | diff --git a/docs/glossary.md b/docs/glossary.md index 2eaae9ffe..e1b73b51c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,6 +1,6 @@ # Clio Coder Glossary -This document defines core architectural concepts and terminology used throughout Clio Coder, mapped to their authoritative TypeScript type definitions in `src/`. +This document defines the 45 core architectural concepts and terminology used throughout Clio Coder, mapped to their authoritative TypeScript type definitions in `src/`. --- @@ -165,3 +165,23 @@ This document defines core architectural concepts and terminology used throughou ### 40. Delegate - **Definition**: Another coding agent Clio drives over ACP stdio as if it were a worker, configured under `delegation.agents` and invoked with `/delegate`. A delegate is a foreign harness, not a model target. - **Owning Type**: `DelegationAgentConfig` in `src/core/defaults.ts`. + +### 41. Working Set +- **Definition**: The part of the session ledger the model receives on the next request. It is the ledger with the current eviction projection applied, and it exists only in memory; the ledger itself is never narrowed. Not to be confused with the context ledger, which is the accounting of how the window is spent. +- **Owning Type**: `WorkingSetView` in `src/domains/context/working-set/contract.ts`. + +### 42. Projection +- **Definition**: The pure, idempotent transform from ledger entries to the entries the replay builder hands the model. It substitutes markers for evicted bodies and drops thinking from closed turns, returning unaffected entries by reference. Nothing about it is persisted. +- **Owning Type**: `projectWorkingSet` in `src/domains/context/working-set/project.ts`. + +### 43. Eviction +- **Definition**: The decision that a tool-result body or an assistant turn's thinking leaves the working set, recorded as an append-only ledger entry with a typed reason. It removes nothing: the original entry stays in the ledger and stays visible in the transcript, `/resume`, `/fork`, and the HTML export. +- **Owning Type**: `ContextEvictionEntry` in `src/domains/session/entries.ts`. + +### 44. Recall +- **Definition**: Readmitting an evicted body by ref, through `context(scope="recall", ref=...)` for the model or `/context recall ` for the operator. A recall does not un-evict: the marker stays where it was so the provider prefix cache is untouched, and repeated recalls of one ref are the churn signal. +- **Owning Type**: `ContextRecallEntry` in `src/domains/session/entries.ts`; resolution in `resolveRecall` in `src/domains/context/working-set/recall.ts`. + +### 45. Marker +- **Definition**: The byte-stable one-line stub the projection renders in place of an evicted body, naming the ref, the reason, the tool, the size, and the exact recall call. It carries no timestamp and no counter, because a marker whose bytes drifted between renders would cold-start the prefix cache on a turn that evicted nothing new. +- **Owning Type**: `renderMarker` in `src/domains/context/working-set/marker.ts`. From 3e73fe412d0694cd75ad506333f04bf134b02b4a Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:16:13 -0500 Subject: [PATCH 26/45] fix(context): replay pressure measures the visible slice, and lint fixups The runner's pressure sum counted every entry in the prefix while the policy now sees only the entries after the compaction cut; on a ledger with a summary the two disagreed and every turn would have fired an event the policy could not satisfy. The sum now projects the same visible slice under the full-path fold. Also formats the files the earlier commits touched. --- .../context/working-set/replay/runner.ts | 5 +++- src/domains/context/working-set/visible.ts | 7 +++--- .../contracts/working-set-age-horizon.test.ts | 5 +++- tests/contracts/working-set-visible.test.ts | 23 +++++++++++++++---- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/domains/context/working-set/replay/runner.ts b/src/domains/context/working-set/replay/runner.ts index af566acc5..7592a19b3 100644 --- a/src/domains/context/working-set/replay/runner.ts +++ b/src/domains/context/working-set/replay/runner.ts @@ -37,10 +37,13 @@ export interface ReplayTraceResult { entries: ReadonlyArray; } +/** Tokens of what the model would see: the visible slice (after any compaction cut) under the full-path fold. */ function sumProjectedTokens(entries: ReadonlyArray, activeLeafTurnId?: string): number { const view = foldWorkingSet(entries, activeLeafTurnId); let tokens = 0; - for (const entry of projectWorkingSet(entries, view)) tokens += estimateTokens(entry); + for (const entry of projectWorkingSet(selectVisibleEntries(entries, activeLeafTurnId), view)) { + tokens += estimateTokens(entry); + } return tokens; } diff --git a/src/domains/context/working-set/visible.ts b/src/domains/context/working-set/visible.ts index ec40e7aed..999fde43a 100644 --- a/src/domains/context/working-set/visible.ts +++ b/src/domains/context/working-set/visible.ts @@ -31,9 +31,8 @@ export function selectVisibleEntries(entries: ReadonlyArray, activ const compaction = active[compactionIndex]; if (compaction?.kind !== "compactionSummary") return active; const firstKeptIndex = - compaction.firstKeptTurnId.length > 0 - ? active.findIndex((entry) => entry.turnId === compaction.firstKeptTurnId) - : -1; - const kept = firstKeptIndex >= 0 && firstKeptIndex < compactionIndex ? active.slice(firstKeptIndex, compactionIndex) : []; + compaction.firstKeptTurnId.length > 0 ? active.findIndex((entry) => entry.turnId === compaction.firstKeptTurnId) : -1; + const kept = + firstKeptIndex >= 0 && firstKeptIndex < compactionIndex ? active.slice(firstKeptIndex, compactionIndex) : []; return [...kept, ...active.slice(compactionIndex + 1)]; } diff --git a/tests/contracts/working-set-age-horizon.test.ts b/tests/contracts/working-set-age-horizon.test.ts index f3479d004..7c42284c5 100644 --- a/tests/contracts/working-set-age-horizon.test.ts +++ b/tests/contracts/working-set-age-horizon.test.ts @@ -197,7 +197,10 @@ test("age-horizon: the floor measures the body the marker replaces, not the payl assert.ok(fat && fat.kind === "message"); const payload = fat.payload as { result: Record }; payload.result = { ...payload.result, details: { exec: { env: "x".repeat(1_500), argv: ["true"] } } }; - assert.ok(estimateTokens(fat) > DEFAULT_WORKING_SET_SETTINGS.minEvictableTokens, "the envelope alone clears the floor"); + assert.ok( + estimateTokens(fat) > DEFAULT_WORKING_SET_SETTINGS.minEvictableTokens, + "the envelope alone clears the floor", + ); const selected = new Set(agePolicy.select(policyInput(entries)).map((c) => c.ref.entry)); assert.equal(selected.has("t3"), false); diff --git a/tests/contracts/working-set-visible.test.ts b/tests/contracts/working-set-visible.test.ts index 5e0a66538..f5e277096 100644 --- a/tests/contracts/working-set-visible.test.ts +++ b/tests/contracts/working-set-visible.test.ts @@ -6,8 +6,8 @@ import { planEviction } from "../../src/domains/context/working-set/engine.js"; import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; import { ageHorizonPolicy, structuralPolicy } from "../../src/domains/context/working-set/policies/index.js"; import { selectVisibleEntries } from "../../src/domains/context/working-set/visible.js"; -import { estimateAgentMessageTokens } from "../../src/domains/session/context-accounting.js"; import { estimateTokens } from "../../src/domains/session/compaction/tokens.js"; +import { estimateAgentMessageTokens } from "../../src/domains/session/context-accounting.js"; import type { ContextEvictionEntry, SessionEntry } from "../../src/domains/session/entries.js"; import { buildModelReplayAgentMessagesFromTurns } from "../../src/interactive/model-session-replay.js"; @@ -61,7 +61,12 @@ class Ledger { parentTurnId: this.last, timestamp: TS, role: "tool_result", - payload: { toolCallId: callId, toolName: "read", result: { content: [{ type: "text", text: body(path) }] }, isError: false }, + payload: { + toolCallId: callId, + toolName: "read", + result: { content: [{ type: "text", text: body(path) }] }, + isError: false, + }, }); } @@ -95,7 +100,10 @@ function input(ledger: Ledger, protectLastTurns: number): PolicyInput { } function replayTokens(entries: ReadonlyArray): number { - return buildModelReplayAgentMessagesFromTurns(entries).reduce((sum, message) => sum + estimateAgentMessageTokens(message), 0); + return buildModelReplayAgentMessagesFromTurns(entries).reduce( + (sum, message) => sum + estimateAgentMessageTokens(message), + 0, + ); } function withEvent(ledger: Ledger, plan: NonNullable>): SessionEntry[] { @@ -140,7 +148,9 @@ test("visible: the compaction cut removes everything before firstKeptTurnId and visible.some((entry) => entry.kind === "compactionSummary"), false, ); - assert.equal(visible[0]?.payload && (visible[0].payload as { text?: string }).text, "after summary"); + const first = visible[0]; + assert.ok(first && first.kind === "message"); + assert.equal((first.payload as { text?: string }).text, "after summary"); }); test("visible: a ledger without a compaction is the active path unchanged", () => { @@ -178,6 +188,9 @@ test("visible: with one post-cut result past the horizon, it is the only item an // prices the message content the model receives. The two differ only by // that stamp, a handful of tokens, never by a body behind the cut. const claimed = plan.tokensBefore - plan.tokensAfter; - assert.ok(Math.abs(claimed - (before - after)) <= 8, `${policy.id}: claimed ${claimed}, replay lost ${before - after}`); + assert.ok( + Math.abs(claimed - (before - after)) <= 8, + `${policy.id}: claimed ${claimed}, replay lost ${before - after}`, + ); } }); From 94622bc510c36f69326b785f749559e6b24c569f Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:18:20 -0500 Subject: [PATCH 27/45] feat(context): add Claude Code replay loading --- docs/commands-and-modes.md | 21 +- src/cli/context-working-set.ts | 25 +- .../working-set/replay/load-claude-code.ts | 561 ++++++++++++++++++ .../context/working-set/replay/metrics.ts | 3 + .../context/working-set/replay/report.ts | 9 +- .../working-set-replay-claude-code.test.ts | 228 +++++++ tests/contracts/working-set-replay.test.ts | 7 +- .../context-replay/claude-code-01.jsonl | 31 + .../generate-claude-code-fixture.mjs | 113 ++++ 9 files changed, 983 insertions(+), 15 deletions(-) create mode 100644 src/domains/context/working-set/replay/load-claude-code.ts create mode 100644 tests/contracts/working-set-replay-claude-code.test.ts create mode 100644 tests/fixtures/context-replay/claude-code-01.jsonl create mode 100644 tests/fixtures/context-replay/generate-claude-code-fixture.mjs diff --git a/docs/commands-and-modes.md b/docs/commands-and-modes.md index 92b92a382..c4a27c346 100644 --- a/docs/commands-and-modes.md +++ b/docs/commands-and-modes.md @@ -78,7 +78,7 @@ For process exit codes, stdout deliverable guarantees, and machine-readable JSON | `clio-coder context wiki [--update] [--status] [--depth auto\|simple\|medium\|detailed] [--target ] [--model ] [--thinking off\|low\|medium\|high]` | Generate, update, or inspect the agent-authored Markdown wiki under `.clio-coder/wiki/`. | | `clio-coder context reset [--all] [--yes]` | Clear accumulated project context artifacts; `--all` also removes `CLIO-CODER.md`. `--yes` (or `-y`) answers every confirmation and is required when stdin is not a terminal. | | `clio-coder context index [--json]` | Build the structural codewiki index without model calls; writes `.clio-coder/codewiki.json` and `.clio-coder/state.json` and prints coverage plus a structural hash. | -| `clio-coder context replay --sessions ... [--policies ] [--budgets ] [--threshold ] [--target ] [--seed ] [--no-filter] [--json ] [--md ]` | Replay working-set policies over Clio session ledgers and report retention, precision, token savings, churn, and summary headroom. | +| `clio-coder context replay --sessions ... [--format clio\|claude-code\|auto] [--policies ] [--budgets ] [--threshold ] [--target ] [--seed ] [--no-filter] [--json ] [--md ]` | Replay working-set policies over Clio or Claude Code session ledgers and report retention, precision, token savings, churn, and summary headroom. | | `clio-coder context working-set --session ` | Inspect one session's durable working-set fold and path-index summary without modifying the ledger. | ## Headless Run Flags @@ -524,12 +524,19 @@ incremental updates. ### Working-set replay `clio-coder context replay --sessions ...` accepts individual session directories, -Clio sessions roots, and `current.jsonl` files. It removes prior eviction/recall sidecars, -selects the active branch, and drives the live fold, projection, policy, and eviction planner -at deterministic turn boundaries. The default inclusion cascade requires at least eight -turns, eight tool results, and one file re-read; `--no-filter` retains every readable trace. -Markdown goes to stdout unless `--md` names a file, while `--json` writes a stable report -including the configuration, git revision when available, and exact command line. +Clio sessions roots, Claude Code project roots, and JSONL files. `--format auto` is the +default and distinguishes a Clio session header from the first semantic Claude Code +user, assistant, or summary record after metadata; `--format clio` and +`--format claude-code` force a loader. Clio traces remove prior eviction/recall sidecars +and select the active branch. Claude Code traces skip sidechain/subagent and summary-only +files, normalize tool calls and results into Clio's message shapes, and retain the recorded +cwd for path indexing. Both drive the live fold, projection, policy, and eviction planner at +deterministic turn boundaries. The default inclusion cascade requires at least eight turns, +eight tool results, and one file re-read; `--no-filter` retains every otherwise-readable +trace. Markdown goes to stdout unless `--md` names a file, while `--json` writes a stable +report including the configuration, git revision when available, and exact command line. +The summary-headroom mean always carries its contributing trace count because traces that +never require summary compaction do not enter that nullable mean. `clio-coder context working-set --session ` is a read-only inspection command for one ledger. It prints evicted refs with reason, superseding ref, and token count; aggregate diff --git a/src/cli/context-working-set.ts b/src/cli/context-working-set.ts index 85b6a4b34..1e6da290b 100644 --- a/src/cli/context-working-set.ts +++ b/src/cli/context-working-set.ts @@ -8,7 +8,8 @@ import { foldWorkingSet } from "../domains/context/working-set/fold.js"; import { buildPathIndex } from "../domains/context/working-set/path-index.js"; import { resolveWorkingSetPolicy } from "../domains/context/working-set/policies/index.js"; import { makeOraclePolicy, makeRandomPolicy, nonePolicy } from "../domains/context/working-set/replay/controls.js"; -import { loadClioTraces, type ReplayLoadCascade } from "../domains/context/working-set/replay/load-clio.js"; +import { loadReplayTraces, type ReplayInputFormat } from "../domains/context/working-set/replay/load-claude-code.js"; +import type { ReplayLoadCascade } from "../domains/context/working-set/replay/load-clio.js"; import { aggregateReplayMetrics, type ReplayMeasurement } from "../domains/context/working-set/replay/metrics.js"; import { buildReferenceGraph, type ReferenceGraph } from "../domains/context/working-set/replay/reference-graph.js"; import { @@ -28,10 +29,11 @@ const REPLAY_HELP = `Usage: Options: --policies comma-separated none,random,age-horizon,structural-v1,oracle --budgets comma-separated budgets (default: 16000,32000,64000) + --format clio, claude-code, or auto (default: auto) --threshold pressure threshold (default: 0.8) --target post-eviction pressure target (default: 0.6) --seed deterministic random-policy seed (default: 0) - --no-filter include every readable Clio ledger + --no-filter include every readable transcript --json write the stable JSON report --md write Markdown instead of printing it `; @@ -54,6 +56,7 @@ interface ReplayArgs { threshold: number; target: number; seed: number; + format: ReplayInputFormat; noFilter: boolean; jsonPath?: string; markdownPath?: string; @@ -102,6 +105,7 @@ function parseReplayArgs(args: ReadonlyArray): ReplayArgs { threshold: 0.8, target: 0.6, seed: 0, + format: "auto", noFilter: false, }; let policiesExplicit = false; @@ -121,7 +125,14 @@ function parseReplayArgs(args: ReadonlyArray): ReplayArgs { if (consumed === 0) throw new CliUsageError("--sessions requires at least one path"); continue; } - if (arg === "--policies" || arg === "--budgets" || arg === "--threshold" || arg === "--target" || arg === "--seed") { + if ( + arg === "--policies" || + arg === "--budgets" || + arg === "--format" || + arg === "--threshold" || + arg === "--target" || + arg === "--seed" + ) { const value = requiredValue(args, index, arg); index += 1; if (arg === "--policies") { @@ -138,6 +149,11 @@ function parseReplayArgs(args: ReadonlyArray): ReplayArgs { } return budget; }); + } else if (arg === "--format") { + if (value !== "clio" && value !== "claude-code" && value !== "auto") { + throw new CliUsageError("--format must be clio, claude-code, or auto"); + } + parsed.format = value; } else if (arg === "--threshold") { parsed.threshold = numberValue(value, arg); if (parsed.threshold <= 0 || parsed.threshold > 1) { @@ -226,7 +242,7 @@ export async function runContextReplayCommand(args: string[]): Promise { return 2; } try { - const loaded = await loadClioTraces(parsed.sessions, { filter: parsed.noFilter ? false : {} }); + const loaded = await loadReplayTraces(parsed.sessions, parsed.format, { filter: !parsed.noFilter }); const indexed = loaded.traces.map((trace) => { const index = buildPathIndex(trace.entries); return { trace, index, graph: buildReferenceGraph(trace, index) }; @@ -258,6 +274,7 @@ export async function runContextReplayCommand(args: string[]): Promise { threshold: parsed.threshold, target: parsed.target, seed: parsed.seed, + format: parsed.format, filter: parsed.noFilter ? "none" : "default", settings, }, diff --git a/src/domains/context/working-set/replay/load-claude-code.ts b/src/domains/context/working-set/replay/load-claude-code.ts new file mode 100644 index 000000000..7fbba3a4a --- /dev/null +++ b/src/domains/context/working-set/replay/load-claude-code.ts @@ -0,0 +1,561 @@ +/** + * Read-only Claude Code transcript loader for replay-lite. + * + * Claude Code persists provider-shaped JSONL rather than Clio session entries. + * This module normalizes that wire format once, at the corpus boundary, into + * the exact message payloads Clio's replay/index/policy code already reads. + * No policy or metric has a provider-specific branch. + */ + +import type { Dirent } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { basename, extname, resolve, sep } from "node:path"; +import { + type CustomEntry, + isSessionHeader, + type MessageEntry, + type SessionEntry, + type SessionHeader, +} from "../../../session/entries.js"; +import { buildPathIndex } from "../path-index.js"; +import { loadClioTraces, type ReplayLoadCascade } from "./load-clio.js"; +import { buildReferenceGraph } from "./reference-graph.js"; +import { countReplayTurns, type Trace } from "./trace.js"; + +export interface LoadClaudeCodeTraceOptions { + filter?: boolean; +} + +export type ReplayInputFormat = "clio" | "claude-code" | "auto"; + +interface ClaudeRecord { + type?: unknown; + message?: unknown; + summary?: unknown; + sessionId?: unknown; + cwd?: unknown; + timestamp?: unknown; + isSidechain?: unknown; + [key: string]: unknown; +} + +interface ParsedRecords { + records: ClaudeRecord[]; + malformed: number; +} + +interface Discovery { + files: string[]; + missingInputs: number; +} + +interface NormalizedTool { + name: string; + args: Record; +} + +const EPOCH = "1970-01-01T00:00:00.000Z"; +const CLAUDE_FILTER_KEYS = [ + "sidechain_or_subagent", + "summary_only", + "turns_lt_8", + "tool_results_lt_8", + "no_file_reread", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +async function collectJsonlFiles(input: string, out: Set): Promise { + const path = resolve(input); + let facts: Awaited>; + try { + facts = await stat(path); + } catch { + return false; + } + if (facts.isFile()) { + if (extname(path) === ".jsonl") out.add(path); + return true; + } + if (!facts.isDirectory()) return true; + + let children: Dirent[]; + try { + children = await readdir(path, { withFileTypes: true }); + } catch { + return true; + } + for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) { + const childPath = resolve(path, child.name); + if (child.isDirectory()) await collectJsonlFiles(childPath, out); + else if (child.isFile() && extname(child.name) === ".jsonl") out.add(childPath); + } + return true; +} + +export async function discoverReplayJsonlFiles(paths: ReadonlyArray): Promise { + const files = new Set(); + let missingInputs = 0; + for (const path of paths) { + if (!(await collectJsonlFiles(path, files))) missingInputs += 1; + } + return { files: [...files].sort((a, b) => a.localeCompare(b)), missingInputs }; +} + +function parseRecords(raw: string): ParsedRecords { + const records: ClaudeRecord[] = []; + let malformed = 0; + for (const line of raw.split("\n")) { + if (line.trim().length === 0) continue; + try { + const value = JSON.parse(line) as unknown; + if (isRecord(value)) records.push(value as ClaudeRecord); + else malformed += 1; + } catch { + // Claude Code can leave a partial final line after a crash. Match the + // research loader's lenience: retain the readable event prefix. + malformed += 1; + } + } + return { records, malformed }; +} + +/** Detect the first semantic record after metadata such as `mode`. */ +export function detectReplayInputFormat(raw: string): Exclude | null { + const { records } = parseRecords(raw); + for (const record of records) { + if (isSessionHeader(record)) return "clio"; + if ((record.type === "user" || record.type === "assistant") && isRecord(record.message)) { + return "claude-code"; + } + if (record.type === "summary" && record.summary !== undefined) return "claude-code"; + } + return null; +} + +function timestampOf(record: ClaudeRecord | undefined): string { + return stringValue(record?.timestamp) ?? EPOCH; +} + +function sessionIdOf(records: ReadonlyArray, source: string): string { + for (const record of records) { + const id = stringValue(record.sessionId); + if (id !== undefined) return id; + } + return basename(source, ".jsonl"); +} + +function cwdOf(records: ReadonlyArray): string | undefined { + for (const record of records) { + const cwd = stringValue(record.cwd); + if (cwd !== undefined) return cwd; + } + return undefined; +} + +function usagePayload(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const number = (key: string): number => { + const item = value[key]; + return typeof item === "number" && Number.isFinite(item) && item >= 0 ? item : 0; + }; + const input = number("input_tokens"); + const output = number("output_tokens"); + const cacheRead = number("cache_read_input_tokens"); + const cacheWrite = number("cache_creation_input_tokens"); + return { + input, + output, + cacheRead, + cacheWrite, + reasoning: 0, + totalTokens: input + output + cacheRead + cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function textFromToolResult(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) { + if (content === undefined || content === null) return ""; + try { + return JSON.stringify(content) ?? String(content); + } catch { + return String(content); + } + } + const parts: string[] = []; + for (const block of content) { + if (typeof block === "string") { + parts.push(block); + continue; + } + if (!isRecord(block)) continue; + if (typeof block.text === "string") parts.push(block.text); + else if (block.type === "image") parts.push("[image]"); + else { + try { + parts.push(JSON.stringify(block)); + } catch { + parts.push(String(block)); + } + } + } + return parts.join("\n"); +} + +function renamePath(args: Record, key = "file_path"): void { + if (typeof args.path !== "string" && typeof args[key] === "string") args.path = args[key]; + delete args[key]; +} + +function normalizeEditItems(value: unknown): unknown { + if (!Array.isArray(value)) return value; + return value.map((item) => { + if (!isRecord(item)) return item; + return { + oldText: item.oldText ?? item.old_string ?? "", + newText: item.newText ?? item.new_string ?? "", + }; + }); +} + +function normalizeTool(originalName: string, input: unknown): NormalizedTool { + const args: Record = isRecord(input) ? { ...input } : {}; + args.__claudeCodeTool = originalName; + + switch (originalName) { + case "Read": + renamePath(args); + return { name: "read", args }; + case "Edit": + case "MultiEdit": { + renamePath(args); + if (Array.isArray(args.edits)) args.edits = normalizeEditItems(args.edits); + else if (typeof args.old_string === "string" && typeof args.new_string === "string") { + args.edits = [{ oldText: args.old_string, newText: args.new_string }]; + } + delete args.old_string; + delete args.new_string; + delete args.replace_all; + return { name: "edit", args }; + } + case "Write": + renamePath(args); + return { name: "write", args }; + case "Grep": { + if (typeof args.glob !== "string" && typeof args.include === "string") args.glob = args.include; + delete args.include; + if (typeof args.output_mode === "string") { + args.mode = + args.output_mode === "files_with_matches" ? "files" : args.output_mode === "count" ? "count" : "content"; + delete args.output_mode; + } + return { name: "grep", args }; + } + case "Glob": + return { name: "find", args }; + case "LS": + return { name: "ls", args }; + case "Bash": + if (typeof args.timeout_ms !== "number" && typeof args.timeout === "number") args.timeout_ms = args.timeout; + delete args.timeout; + return { name: "bash", args }; + case "WebFetch": + return { name: "web_fetch", args }; + case "Task": + if (typeof args.task !== "string" && typeof args.prompt === "string") args.task = args.prompt; + if (typeof args.agent !== "string" && typeof args.subagent_type === "string") args.agent = args.subagent_type; + if (typeof args.briefing !== "string" && typeof args.description === "string") args.briefing = args.description; + delete args.prompt; + delete args.subagent_type; + delete args.description; + return { name: "dispatch", args }; + case "TodoWrite": { + const todos = Array.isArray(args.todos) ? args.todos : []; + args.action = "plan"; + args.title = "Claude Code todos"; + args.tasks = todos + .map((todo) => (isRecord(todo) && typeof todo.content === "string" ? todo.content : null)) + .filter((todo): todo is string => todo !== null); + return { name: "tasks", args }; + } + case "NotebookEdit": + renamePath(args, "notebook_path"); + return { name: "edit", args }; + default: + return { name: originalName.toLowerCase(), args }; + } +} + +function assistantBlocks(message: Record): { + content: Array>; + toolUses: Array>; +} { + const content: Array> = []; + const toolUses: Array> = []; + if (typeof message.content === "string") { + content.push({ type: "text", text: message.content }); + return { content, toolUses }; + } + if (!Array.isArray(message.content)) return { content, toolUses }; + for (const block of message.content) { + if (!isRecord(block)) continue; + if (block.type === "tool_use") { + toolUses.push(block); + continue; + } + if (block.type === "text" && typeof block.text === "string") { + content.push({ type: "text", text: block.text }); + } + if (block.type === "thinking" && typeof block.thinking === "string") { + content.push({ + type: "thinking", + thinking: block.thinking, + ...(typeof block.signature === "string" ? { signature: block.signature } : {}), + }); + } + } + return { content, toolUses }; +} + +function normalizeTranscript(records: ReadonlyArray, source: string): Trace { + const entries: SessionEntry[] = []; + const toolNames = new Map(); + let parentTurnId: string | null = null; + let eventIndex = 0; + const nextTurnId = (): string => { + const id = `cc-${String(eventIndex).padStart(6, "0")}`; + eventIndex += 1; + return id; + }; + const appendMessage = (role: MessageEntry["role"], payload: unknown, timestamp: string): void => { + const turnId = nextTurnId(); + entries.push({ kind: "message", role, payload, turnId, parentTurnId, timestamp }); + parentTurnId = turnId; + }; + + const id = sessionIdOf(records, source); + const cwd = cwdOf(records); + const firstRecord = + records.find((record) => record.isSidechain !== true && stringValue(record.cwd) !== undefined) ?? + records.find((record) => record.isSidechain !== true); + if (cwd !== undefined) { + const turnId = nextTurnId(); + const header: CustomEntry & Omit = { + type: "session", + version: 4, + id, + cwd, + timestamp: timestampOf(firstRecord), + kind: "custom", + customType: "claude-code-session-header", + turnId, + parentTurnId, + display: false, + }; + entries.push(header); + parentTurnId = turnId; + } + + for (const record of records) { + if (record.isSidechain === true) continue; + if (record.type !== "user" && record.type !== "assistant") continue; + if (!isRecord(record.message)) continue; + const timestamp = timestampOf(record); + if (record.type === "assistant") { + const { content, toolUses } = assistantBlocks(record.message); + const text = content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + const thinking = content + .filter((block) => block.type === "thinking") + .map((block) => block.thinking) + .join(""); + const usage = usagePayload(record.message.usage); + appendMessage( + "assistant", + { + text, + content, + ...(thinking.length > 0 ? { thinking } : {}), + ...(usage === undefined ? {} : { usage }), + }, + timestamp, + ); + for (const block of toolUses) { + const originalName = stringValue(block.name) ?? "tool"; + const toolCallId = stringValue(block.id) ?? `claude-tool-${eventIndex}`; + const tool = normalizeTool(originalName, block.input); + toolNames.set(toolCallId, tool.name); + appendMessage("tool_call", { toolCallId, name: tool.name, args: tool.args }, timestamp); + } + continue; + } + + const userContent = record.message.content; + if (typeof userContent === "string") { + appendMessage("user", { text: userContent }, timestamp); + continue; + } + if (!Array.isArray(userContent)) continue; + const userText: string[] = []; + for (const block of userContent) { + if (!isRecord(block)) continue; + if (block.type === "text" && typeof block.text === "string") userText.push(block.text); + if (block.type !== "tool_result") continue; + const toolCallId = stringValue(block.tool_use_id) ?? `claude-tool-result-${eventIndex}`; + appendMessage( + "tool_result", + { + toolCallId, + toolName: toolNames.get(toolCallId) ?? "tool", + result: { content: [{ type: "text", text: textFromToolResult(block.content) }] }, + isError: block.is_error === true, + }, + timestamp, + ); + } + // A Claude Code user record is the provider-call boundary, including a + // record that carries only tool results. Put the boundary after those + // results so replay's turn-start hook sees the exact completed prefix. + appendMessage("user", { text: userText.join("\n") }, timestamp); + } + + return { id, source, entries, turnCount: countReplayTurns(entries) }; +} + +function toolResultCount(entries: ReadonlyArray): number { + return entries.reduce((count, entry) => count + (entry.kind === "message" && entry.role === "tool_result" ? 1 : 0), 0); +} + +function emptyClaudeCascade(found: number, unreadable: number): ReplayLoadCascade { + return { + found, + unreadable, + filtered: Object.fromEntries(CLAUDE_FILTER_KEYS.map((key) => [key, 0])), + kept: 0, + }; +} + +function increment(cascade: ReplayLoadCascade, reason: (typeof CLAUDE_FILTER_KEYS)[number]): void { + cascade.filtered[reason] = (cascade.filtered[reason] ?? 0) + 1; +} + +function isSubagentPath(source: string): boolean { + return source.split(sep).includes("subagents"); +} + +export async function loadClaudeCodeTraces( + paths: ReadonlyArray, + opts: LoadClaudeCodeTraceOptions = {}, +): Promise<{ traces: Trace[]; cascade: ReplayLoadCascade }> { + const discovery = await discoverReplayJsonlFiles(paths); + const cascade = emptyClaudeCascade(discovery.files.length, discovery.missingInputs); + const traces: Trace[] = []; + + for (const source of discovery.files) { + if (isSubagentPath(source)) { + increment(cascade, "sidechain_or_subagent"); + continue; + } + let raw: string; + try { + raw = await readFile(source, "utf8"); + } catch { + cascade.unreadable += 1; + continue; + } + const parsed = parseRecords(raw); + const mainRecords = parsed.records.filter((record) => record.isSidechain !== true); + const conversation = mainRecords.filter((record) => record.type === "user" || record.type === "assistant"); + if (conversation.length === 0) { + if (parsed.records.some((record) => record.isSidechain === true)) { + increment(cascade, "sidechain_or_subagent"); + continue; + } + if (mainRecords.some((record) => record.type === "summary" || record.summary !== undefined)) { + increment(cascade, "summary_only"); + continue; + } + cascade.unreadable += 1; + continue; + } + const trace = normalizeTranscript(mainRecords, source); + if (opts.filter !== false) { + if (trace.turnCount < 8) { + increment(cascade, "turns_lt_8"); + continue; + } + if (toolResultCount(trace.entries) < 8) { + increment(cascade, "tool_results_lt_8"); + continue; + } + const graph = buildReferenceGraph(trace, buildPathIndex(trace.entries)); + if (!graph.edges.some((edge) => edge.kind === "file_reread")) { + increment(cascade, "no_file_reread"); + continue; + } + } + traces.push(trace); + } + + cascade.kept = traces.length; + return { traces, cascade }; +} + +function mergeFiltered(...sources: ReadonlyArray>): Record { + const ordered: string[] = [...CLAUDE_FILTER_KEYS]; + for (const source of sources) { + for (const key of Object.keys(source)) if (!ordered.includes(key)) ordered.push(key); + } + return Object.fromEntries(ordered.map((key) => [key, sources.reduce((sum, source) => sum + (source[key] ?? 0), 0)])); +} + +/** CLI-facing format router; auto classifies every discovered JSONL independently. */ +export async function loadReplayTraces( + paths: ReadonlyArray, + format: ReplayInputFormat, + opts: LoadClaudeCodeTraceOptions = {}, +): Promise<{ traces: Trace[]; cascade: ReplayLoadCascade }> { + if (format === "clio") return loadClioTraces(paths, { filter: opts.filter === false ? false : {} }); + if (format === "claude-code") return loadClaudeCodeTraces(paths, opts); + + const discovery = await discoverReplayJsonlFiles(paths); + const clio: string[] = []; + const claudeCode: string[] = []; + let unreadable = discovery.missingInputs; + for (const source of discovery.files) { + try { + const raw = await readFile(source, "utf8"); + const detected = detectReplayInputFormat(raw); + if (detected === "clio") clio.push(source); + else if (detected === "claude-code") claudeCode.push(source); + else unreadable += 1; + } catch { + unreadable += 1; + } + } + + const [clioLoaded, claudeLoaded] = await Promise.all([ + loadClioTraces(clio, { filter: opts.filter === false ? false : {} }), + loadClaudeCodeTraces(claudeCode, opts), + ]); + const traces = [...clioLoaded.traces, ...claudeLoaded.traces].sort((a, b) => a.source.localeCompare(b.source)); + return { + traces, + cascade: { + found: discovery.files.length, + unreadable: unreadable + clioLoaded.cascade.unreadable + claudeLoaded.cascade.unreadable, + filtered: mergeFiltered(clioLoaded.cascade.filtered, claudeLoaded.cascade.filtered), + kept: traces.length, + }, + }; +} diff --git a/src/domains/context/working-set/replay/metrics.ts b/src/domains/context/working-set/replay/metrics.ts index 0bc263878..eac087c0a 100644 --- a/src/domains/context/working-set/replay/metrics.ts +++ b/src/domains/context/working-set/replay/metrics.ts @@ -24,6 +24,8 @@ export interface ReplayMeasurement { export interface ReplayMetricAggregate { /** Arithmetic mean of the per-trace metrics; `traces` is the sample size. */ mean: ReplayMetrics; + /** Number of traces contributing to the nullable `turnsToFirstSummary` mean. */ + turnsToFirstSummaryCount: number; /** Headline pair-level retention pooled across every critical future reference. */ pooledRetention: number; pooledRetentionAt10: number; @@ -119,6 +121,7 @@ export function aggregateReplayMetrics(inputs: ReadonlyArray) churn: mean(measured.map((entry) => entry.metrics.churn)), turnsToFirstSummary: summaries.length === 0 ? null : mean(summaries), }, + turnsToFirstSummaryCount: summaries.length, pooledRetention: safeFraction(sum("retainedPairs"), sum("pairs"), 1), pooledRetentionAt10: safeFraction(sum("retainedPairsAt10"), sum("pairsAt10"), 1), }; diff --git a/src/domains/context/working-set/replay/report.ts b/src/domains/context/working-set/replay/report.ts index c3172170f..40e70f704 100644 --- a/src/domains/context/working-set/replay/report.ts +++ b/src/domains/context/working-set/replay/report.ts @@ -8,6 +8,7 @@ export interface ReplayReportConfig { threshold: number; target: number; seed: number; + format: "clio" | "claude-code" | "auto"; filter: "default" | "none"; settings: WorkingSetSettings; } @@ -26,7 +27,7 @@ export interface ReplayReportInput { commandLine: ReadonlyArray; } -function metricObject(metrics: ReplayMetrics): Record { +function metricObject(metrics: ReplayMetrics, turnsToFirstSummaryCount: number): Record { return { traces: metrics.traces, retention: metrics.retention, @@ -36,6 +37,7 @@ function metricObject(metrics: ReplayMetrics): Record { evictionEvents: metrics.evictionEvents, churn: metrics.churn, turnsToFirstSummary: metrics.turnsToFirstSummary, + turnsToFirstSummaryCount, }; } @@ -49,6 +51,7 @@ export function renderReplayJson(input: ReplayReportInput): string { threshold: input.config.threshold, target: input.config.target, seed: input.config.seed, + format: input.config.format, filter: input.config.filter, settings: { enabled: input.config.settings.enabled, @@ -72,7 +75,7 @@ export function renderReplayJson(input: ReplayReportInput): string { budgetTokens: result.budgetTokens, policyId: result.policyId, metrics: { - mean: metricObject(result.metrics.mean), + mean: metricObject(result.metrics.mean, result.metrics.turnsToFirstSummaryCount), pooledRetention: result.metrics.pooledRetention, pooledRetentionAt10: result.metrics.pooledRetentionAt10, }, @@ -121,7 +124,7 @@ export function renderReplayMarkdown(input: ReplayReportInput): string { if (result === undefined) continue; const metrics = result.metrics.mean; lines.push( - `| ${policy} | ${metrics.traces} | ${ratio(metrics.retention)} | ${ratio(result.metrics.pooledRetention)} | ${ratio(metrics.retentionAt10)} | ${ratio(metrics.evictionPrecision)} | ${quantity(metrics.tokensEvicted)} | ${quantity(metrics.evictionEvents)} | ${ratio(metrics.churn)} | ${metrics.turnsToFirstSummary === null ? "—" : quantity(metrics.turnsToFirstSummary)} |`, + `| ${policy} | ${metrics.traces} | ${ratio(metrics.retention)} | ${ratio(result.metrics.pooledRetention)} | ${ratio(metrics.retentionAt10)} | ${ratio(metrics.evictionPrecision)} | ${quantity(metrics.tokensEvicted)} | ${quantity(metrics.evictionEvents)} | ${ratio(metrics.churn)} | ${metrics.turnsToFirstSummary === null ? "—" : quantity(metrics.turnsToFirstSummary)} (n=${result.metrics.turnsToFirstSummaryCount}) |`, ); } } diff --git a/tests/contracts/working-set-replay-claude-code.test.ts b/tests/contracts/working-set-replay-claude-code.test.ts new file mode 100644 index 000000000..5f390ccd9 --- /dev/null +++ b/tests/contracts/working-set-replay-claude-code.test.ts @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { DEFAULT_WORKING_SET_SETTINGS } from "../../src/domains/context/working-set/defaults.js"; +import { buildPathIndex } from "../../src/domains/context/working-set/path-index.js"; +import { resolveWorkingSetPolicy } from "../../src/domains/context/working-set/policies/index.js"; +import { + detectReplayInputFormat, + loadClaudeCodeTraces, + loadReplayTraces, +} from "../../src/domains/context/working-set/replay/load-claude-code.js"; +import { buildReferenceGraph } from "../../src/domains/context/working-set/replay/reference-graph.js"; +import { replayTrace } from "../../src/domains/context/working-set/replay/runner.js"; +import type { Trace } from "../../src/domains/context/working-set/replay/trace.js"; +import type { MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; + +const FIXTURE = fileURLToPath(new URL("../fixtures/context-replay/claude-code-01.jsonl", import.meta.url)); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function messages(trace: Trace, role: MessageEntry["role"]): MessageEntry[] { + return trace.entries.filter((entry): entry is MessageEntry => entry.kind === "message" && entry.role === role); +} + +function payload(entry: MessageEntry): Record { + assert.ok(isRecord(entry.payload)); + return entry.payload; +} + +function callById(trace: Trace, id: string): MessageEntry { + const entry = messages(trace, "tool_call").find((candidate) => payload(candidate).toolCallId === id); + assert.ok(entry, `missing tool call ${id}`); + return entry; +} + +function resultById(trace: Trace, id: string): MessageEntry { + const entry = messages(trace, "tool_result").find((candidate) => payload(candidate).toolCallId === id); + assert.ok(entry, `missing tool result ${id}`); + return entry; +} + +async function fixture(): Promise { + const loaded = await loadClaudeCodeTraces([FIXTURE]); + assert.deepEqual(loaded.cascade, { + found: 1, + unreadable: 0, + filtered: { + sidechain_or_subagent: 0, + summary_only: 0, + turns_lt_8: 0, + tool_results_lt_8: 0, + no_file_reread: 0, + }, + kept: 1, + }); + const trace = loaded.traces[0]; + assert.ok(trace); + return trace; +} + +describe("contracts/working-set Claude Code replay loader", () => { + it("converts provider records into stable, linearly chained Clio messages", async () => { + const trace = await fixture(); + assert.equal(trace.id, "claude-code-replay-fixture-01"); + assert.equal(trace.turnCount, 15); + assert.equal(trace.entries.length, 57); + + const header = trace.entries[0] as SessionEntry & Record; + assert.equal(header.kind, "custom"); + assert.equal(header.type, "session"); + assert.equal(header.cwd, "/fixture/claude-code-repo"); + for (let index = 0; index < trace.entries.length; index += 1) { + const entry = trace.entries[index]; + assert.ok(entry); + assert.equal(entry.turnId, `cc-${String(index).padStart(6, "0")}`); + assert.equal(entry.parentTurnId, index === 0 ? null : trace.entries[index - 1]?.turnId); + } + assert.equal(JSON.stringify(trace.entries).includes("sidechain-only record"), false); + + assert.deepEqual( + { + user: messages(trace, "user").length, + assistant: messages(trace, "assistant").length, + toolCall: messages(trace, "tool_call").length, + toolResult: messages(trace, "tool_result").length, + }, + { user: 15, assistant: 15, toolCall: 13, toolResult: 13 }, + ); + }); + + it("normalizes tool names, argument keys, content shapes, usage, and pairing", async () => { + const trace = await fixture(); + const readCall = callById(trace, "tool-read-a"); + assert.deepEqual(payload(readCall), { + toolCallId: "tool-read-a", + name: "read", + args: { path: "src/a.ts", offset: 2, limit: 40, __claudeCodeTool: "Read" }, + }); + const grepCall = callById(trace, "tool-grep"); + assert.deepEqual(payload(grepCall), { + toolCallId: "tool-grep", + name: "grep", + args: { + pattern: "export", + path: ".", + __claudeCodeTool: "Grep", + glob: "src/*.ts", + mode: "content", + }, + }); + const editArgs = payload(callById(trace, "tool-edit-a")).args; + assert.deepEqual(editArgs, { + path: "src/a.ts", + __claudeCodeTool: "Edit", + edits: [{ oldText: "before", newText: "after" }], + }); + + for (const call of messages(trace, "tool_call")) { + const callPayload = payload(call); + const id = callPayload.toolCallId; + assert.equal(typeof id, "string"); + assert.equal(payload(resultById(trace, id as string)).toolName, callPayload.name); + } + const readResult = resultById(trace, "tool-read-a"); + const readResultPayload = payload(readResult); + assert.equal(readResultPayload.isError, false); + assert.ok(isRecord(readResultPayload.result)); + assert.equal(Array.isArray(readResultPayload.result.content), true); + assert.ok(trace.entries.indexOf(readCall) < trace.entries.indexOf(readResult)); + assert.equal(trace.entries[trace.entries.indexOf(readResult) + 1]?.kind, "message"); + assert.equal((trace.entries[trace.entries.indexOf(readResult) + 1] as MessageEntry).role, "user"); + assert.equal(payload(resultById(trace, "tool-bash-fail")).isError, true); + + const firstAssistant = messages(trace, "assistant")[0]; + assert.ok(firstAssistant); + const assistantPayload = payload(firstAssistant); + assert.equal(Array.isArray(assistantPayload.content), true); + assert.equal((assistantPayload.content as Array>)[0]?.type, "thinking"); + assert.deepEqual(assistantPayload.usage, { + input: 100, + output: 20, + cacheRead: 80, + cacheWrite: 5, + reasoning: 0, + totalTokens: 205, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }); + }); + + it("feeds cwd-aware normalized calls into the unchanged path index", async () => { + const trace = await fixture(); + const index = buildPathIndex(trace.entries); + const read = index.byRef.get(resultById(trace, "tool-read-a").turnId); + assert.deepEqual(read, { + ref: { entry: resultById(trace, "tool-read-a").turnId }, + toolCallId: "tool-read-a", + toolName: "read", + op: "read", + path: "/fixture/claude-code-repo/src/a.ts", + range: { offset: 1, limit: 40 }, + surfaced: [], + isError: false, + turnIndex: 1, + entryIndex: trace.entries.indexOf(resultById(trace, "tool-read-a")), + argsKey: '{"__claudeCodeTool":"Read","limit":40,"offset":2,"path":"src/a.ts"}', + }); + const grep = index.byRef.get(resultById(trace, "tool-grep").turnId); + assert.deepEqual(grep?.surfaced, ["/fixture/claude-code-repo/src/b.ts", "/fixture/claude-code-repo/src/c.ts"]); + const edit = index.byRef.get(resultById(trace, "tool-edit-a").turnId); + assert.equal(edit?.op, "edit"); + assert.equal(edit?.path, "/fixture/claude-code-repo/src/a.ts"); + }); + + it("labels the normalized reread, discovery, and rewrite edges", async () => { + const trace = await fixture(); + const graph = buildReferenceGraph(trace, buildPathIndex(trace.entries)); + assert.deepEqual(graph.edges, [ + { from: resultById(trace, "tool-read-a").turnId, toTurnIndex: 5, kind: "file_rewrite" }, + { from: resultById(trace, "tool-read-a").turnId, toTurnIndex: 6, kind: "file_reread" }, + { from: resultById(trace, "tool-grep").turnId, toTurnIndex: 3, kind: "file_discovery" }, + { from: resultById(trace, "tool-grep").turnId, toTurnIndex: 4, kind: "file_discovery" }, + ]); + }); + + it("counts subagent and summary-only files as distinct cascade exclusions", async () => { + const root = await mkdtemp(join(tmpdir(), "clio-cc-replay-")); + try { + const subagents = join(root, "session", "subagents"); + await mkdir(subagents, { recursive: true }); + await writeFile(join(subagents, "agent-fixture.jsonl"), await readFile(FIXTURE, "utf8"), "utf8"); + await writeFile( + join(root, "summary-only.jsonl"), + `${JSON.stringify({ type: "summary", summary: "synthetic summary only" })}\n`, + "utf8", + ); + const loaded = await loadClaudeCodeTraces([root], { filter: false }); + assert.equal(loaded.cascade.found, 2); + assert.equal(loaded.cascade.filtered.sidechain_or_subagent, 1); + assert.equal(loaded.cascade.filtered.summary_only, 1); + assert.equal(loaded.cascade.kept, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("auto-detects Claude Code and drives one live age-horizon eviction", async () => { + const raw = await readFile(FIXTURE, "utf8"); + assert.equal(detectReplayInputFormat(raw), "claude-code"); + const loaded = await loadReplayTraces([FIXTURE], "auto"); + const trace = loaded.traces[0]; + assert.ok(trace); + const replay = replayTrace(trace, resolveWorkingSetPolicy("age-horizon"), { + policyId: "age-horizon", + budgetTokens: 10_000, + threshold: 0.8, + target: 0.6, + settings: DEFAULT_WORKING_SET_SETTINGS, + seed: 0, + }); + assert.equal(replay.events.length, 1); + assert.equal(replay.entries.filter((entry) => entry.kind === "contextEviction").length, 1); + }); +}); diff --git a/tests/contracts/working-set-replay.test.ts b/tests/contracts/working-set-replay.test.ts index c0188bb56..4fbe4e899 100644 --- a/tests/contracts/working-set-replay.test.ts +++ b/tests/contracts/working-set-replay.test.ts @@ -239,6 +239,7 @@ describe("contracts/working-set replay-lite", () => { threshold: 0.8, target: 0.6, seed: 0, + format: "auto" as const, filter: "default" as const, settings: SETTINGS, }, @@ -257,15 +258,19 @@ describe("contracts/working-set replay-lite", () => { assert.equal(markdown.match(new RegExp(`^\\| ${policyId} \\|`, "gm"))?.length, budgets.length); } assert.equal(markdown.match(/^## Budget /gm)?.length, budgets.length); + assert.equal(markdown.match(/\(n=\d+\)/g)?.length, policies.length * budgets.length); const json = renderReplayJson(input); assert.equal(renderReplayJson(input), json, "stable input must render byte-identically"); const parsed = JSON.parse(json) as { provenance: { gitSha: string; commandLine: string[] }; - results: unknown[]; + results: Array<{ metrics: { mean: { turnsToFirstSummary: number | null; turnsToFirstSummaryCount: number } } }>; }; assert.equal(parsed.provenance.gitSha, "abc123"); assert.deepEqual(parsed.provenance.commandLine, input.commandLine); assert.equal(parsed.results.length, policies.length * budgets.length); + for (const result of parsed.results) { + assert.equal(result.metrics.mean.turnsToFirstSummaryCount, result.metrics.mean.turnsToFirstSummary === null ? 0 : 1); + } }); }); diff --git a/tests/fixtures/context-replay/claude-code-01.jsonl b/tests/fixtures/context-replay/claude-code-01.jsonl new file mode 100644 index 000000000..b59e99ef2 --- /dev/null +++ b/tests/fixtures/context-replay/claude-code-01.jsonl @@ -0,0 +1,31 @@ +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-001","parentUuid":null,"timestamp":"2026-08-21T01:00:01.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":"Inspect and update the fixture repository."}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-002","parentUuid":"raw-001","timestamp":"2026-08-21T01:00:02.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"thinking","thinking":"I should inspect a.ts before changing it.","signature":"fixture-signature"},{"type":"text","text":"Reading the primary file."},{"type":"tool_use","id":"tool-read-a","name":"Read","input":{"file_path":"src/a.ts","offset":2,"limit":40}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-003","parentUuid":"raw-002","timestamp":"2026-08-21T01:00:03.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-read-a","content":"a-v1 line 001 is deterministic Claude Code replay evidence.\na-v1 line 002 is deterministic Claude Code replay evidence.\na-v1 line 003 is deterministic Claude Code replay evidence.\na-v1 line 004 is deterministic Claude Code replay evidence.\na-v1 line 005 is deterministic Claude Code replay evidence.\na-v1 line 006 is deterministic Claude Code replay evidence.\na-v1 line 007 is deterministic Claude Code replay evidence.\na-v1 line 008 is deterministic Claude Code replay evidence.\na-v1 line 009 is deterministic Claude Code replay evidence.\na-v1 line 010 is deterministic Claude Code replay evidence.\na-v1 line 011 is deterministic Claude Code replay evidence.\na-v1 line 012 is deterministic Claude Code replay evidence.\na-v1 line 013 is deterministic Claude Code replay evidence.\na-v1 line 014 is deterministic Claude Code replay evidence.\na-v1 line 015 is deterministic Claude Code replay evidence.\na-v1 line 016 is deterministic Claude Code replay evidence.\na-v1 line 017 is deterministic Claude Code replay evidence.\na-v1 line 018 is deterministic Claude Code replay evidence.\na-v1 line 019 is deterministic Claude Code replay evidence.\na-v1 line 020 is deterministic Claude Code replay evidence.\na-v1 line 021 is deterministic Claude Code replay evidence.\na-v1 line 022 is deterministic Claude Code replay evidence.\na-v1 line 023 is deterministic Claude Code replay evidence.\na-v1 line 024 is deterministic Claude Code replay evidence.\na-v1 line 025 is deterministic Claude Code replay evidence.\na-v1 line 026 is deterministic Claude Code replay evidence.\na-v1 line 027 is deterministic Claude Code replay evidence.\na-v1 line 028 is deterministic Claude Code replay evidence.\na-v1 line 029 is deterministic Claude Code replay evidence.\na-v1 line 030 is deterministic Claude Code replay evidence.\na-v1 line 031 is deterministic Claude Code replay evidence.\na-v1 line 032 is deterministic Claude Code replay evidence.\na-v1 line 033 is deterministic Claude Code replay evidence.\na-v1 line 034 is deterministic Claude Code replay evidence.\na-v1 line 035 is deterministic Claude Code replay evidence.\na-v1 line 036 is deterministic Claude Code replay evidence.\na-v1 line 037 is deterministic Claude Code replay evidence.\na-v1 line 038 is deterministic Claude Code replay evidence.\na-v1 line 039 is deterministic Claude Code replay evidence.\na-v1 line 040 is deterministic Claude Code replay evidence.\na-v1 line 041 is deterministic Claude Code replay evidence.\na-v1 line 042 is deterministic Claude Code replay evidence.\na-v1 line 043 is deterministic Claude Code replay evidence.\na-v1 line 044 is deterministic Claude Code replay evidence.\na-v1 line 045 is deterministic Claude Code replay evidence.\na-v1 line 046 is deterministic Claude Code replay evidence.\na-v1 line 047 is deterministic Claude Code replay evidence.\na-v1 line 048 is deterministic Claude Code replay evidence.\na-v1 line 049 is deterministic Claude Code replay evidence.\na-v1 line 050 is deterministic Claude Code replay evidence.\na-v1 line 051 is deterministic Claude Code replay evidence.\na-v1 line 052 is deterministic Claude Code replay evidence.\na-v1 line 053 is deterministic Claude Code replay evidence.\na-v1 line 054 is deterministic Claude Code replay evidence.\na-v1 line 055 is deterministic Claude Code replay evidence.\na-v1 line 056 is deterministic Claude Code replay evidence.\na-v1 line 057 is deterministic Claude Code replay evidence.\na-v1 line 058 is deterministic Claude Code replay evidence.\na-v1 line 059 is deterministic Claude Code replay evidence.\na-v1 line 060 is deterministic Claude Code replay evidence.\na-v1 line 061 is deterministic Claude Code replay evidence.\na-v1 line 062 is deterministic Claude Code replay evidence.\na-v1 line 063 is deterministic Claude Code replay evidence.\na-v1 line 064 is deterministic Claude Code replay evidence."}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-004","parentUuid":"raw-003","timestamp":"2026-08-21T01:00:04.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-grep","name":"Grep","input":{"pattern":"export","path":".","include":"src/*.ts","output_mode":"content"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-005","parentUuid":"raw-004","timestamp":"2026-08-21T01:00:05.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-grep","content":[{"type":"text","text":"src/b.ts:4:export const b = 1;\nsrc/c.ts:7:export const c = 2;"}]}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-006","parentUuid":"raw-005","timestamp":"2026-08-21T01:00:06.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-read-b","name":"Read","input":{"file_path":"src/b.ts"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-007","parentUuid":"raw-006","timestamp":"2026-08-21T01:00:07.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-read-b","content":"b-v1 line 001 is deterministic Claude Code replay evidence.\nb-v1 line 002 is deterministic Claude Code replay evidence.\nb-v1 line 003 is deterministic Claude Code replay evidence.\nb-v1 line 004 is deterministic Claude Code replay evidence.\nb-v1 line 005 is deterministic Claude Code replay evidence.\nb-v1 line 006 is deterministic Claude Code replay evidence.\nb-v1 line 007 is deterministic Claude Code replay evidence.\nb-v1 line 008 is deterministic Claude Code replay evidence.\nb-v1 line 009 is deterministic Claude Code replay evidence.\nb-v1 line 010 is deterministic Claude Code replay evidence.\nb-v1 line 011 is deterministic Claude Code replay evidence.\nb-v1 line 012 is deterministic Claude Code replay evidence.\nb-v1 line 013 is deterministic Claude Code replay evidence.\nb-v1 line 014 is deterministic Claude Code replay evidence.\nb-v1 line 015 is deterministic Claude Code replay evidence.\nb-v1 line 016 is deterministic Claude Code replay evidence.\nb-v1 line 017 is deterministic Claude Code replay evidence.\nb-v1 line 018 is deterministic Claude Code replay evidence.\nb-v1 line 019 is deterministic Claude Code replay evidence.\nb-v1 line 020 is deterministic Claude Code replay evidence.\nb-v1 line 021 is deterministic Claude Code replay evidence.\nb-v1 line 022 is deterministic Claude Code replay evidence.\nb-v1 line 023 is deterministic Claude Code replay evidence.\nb-v1 line 024 is deterministic Claude Code replay evidence.\nb-v1 line 025 is deterministic Claude Code replay evidence.\nb-v1 line 026 is deterministic Claude Code replay evidence.\nb-v1 line 027 is deterministic Claude Code replay evidence.\nb-v1 line 028 is deterministic Claude Code replay evidence.\nb-v1 line 029 is deterministic Claude Code replay evidence.\nb-v1 line 030 is deterministic Claude Code replay evidence.\nb-v1 line 031 is deterministic Claude Code replay evidence.\nb-v1 line 032 is deterministic Claude Code replay evidence.\nb-v1 line 033 is deterministic Claude Code replay evidence.\nb-v1 line 034 is deterministic Claude Code replay evidence.\nb-v1 line 035 is deterministic Claude Code replay evidence.\nb-v1 line 036 is deterministic Claude Code replay evidence.\nb-v1 line 037 is deterministic Claude Code replay evidence.\nb-v1 line 038 is deterministic Claude Code replay evidence.\nb-v1 line 039 is deterministic Claude Code replay evidence.\nb-v1 line 040 is deterministic Claude Code replay evidence.\nb-v1 line 041 is deterministic Claude Code replay evidence.\nb-v1 line 042 is deterministic Claude Code replay evidence.\nb-v1 line 043 is deterministic Claude Code replay evidence.\nb-v1 line 044 is deterministic Claude Code replay evidence.\nb-v1 line 045 is deterministic Claude Code replay evidence.\nb-v1 line 046 is deterministic Claude Code replay evidence.\nb-v1 line 047 is deterministic Claude Code replay evidence.\nb-v1 line 048 is deterministic Claude Code replay evidence.\nb-v1 line 049 is deterministic Claude Code replay evidence.\nb-v1 line 050 is deterministic Claude Code replay evidence.\nb-v1 line 051 is deterministic Claude Code replay evidence.\nb-v1 line 052 is deterministic Claude Code replay evidence.\nb-v1 line 053 is deterministic Claude Code replay evidence.\nb-v1 line 054 is deterministic Claude Code replay evidence.\nb-v1 line 055 is deterministic Claude Code replay evidence.\nb-v1 line 056 is deterministic Claude Code replay evidence.\nb-v1 line 057 is deterministic Claude Code replay evidence.\nb-v1 line 058 is deterministic Claude Code replay evidence.\nb-v1 line 059 is deterministic Claude Code replay evidence.\nb-v1 line 060 is deterministic Claude Code replay evidence.\nb-v1 line 061 is deterministic Claude Code replay evidence.\nb-v1 line 062 is deterministic Claude Code replay evidence.\nb-v1 line 063 is deterministic Claude Code replay evidence.\nb-v1 line 064 is deterministic Claude Code replay evidence."}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-008","parentUuid":"raw-007","timestamp":"2026-08-21T01:00:08.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-read-c","name":"Read","input":{"file_path":"src/c.ts"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-009","parentUuid":"raw-008","timestamp":"2026-08-21T01:00:09.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-read-c","content":"c-v1 line 001 is deterministic Claude Code replay evidence.\nc-v1 line 002 is deterministic Claude Code replay evidence.\nc-v1 line 003 is deterministic Claude Code replay evidence.\nc-v1 line 004 is deterministic Claude Code replay evidence.\nc-v1 line 005 is deterministic Claude Code replay evidence.\nc-v1 line 006 is deterministic Claude Code replay evidence.\nc-v1 line 007 is deterministic Claude Code replay evidence.\nc-v1 line 008 is deterministic Claude Code replay evidence.\nc-v1 line 009 is deterministic Claude Code replay evidence.\nc-v1 line 010 is deterministic Claude Code replay evidence.\nc-v1 line 011 is deterministic Claude Code replay evidence.\nc-v1 line 012 is deterministic Claude Code replay evidence.\nc-v1 line 013 is deterministic Claude Code replay evidence.\nc-v1 line 014 is deterministic Claude Code replay evidence.\nc-v1 line 015 is deterministic Claude Code replay evidence.\nc-v1 line 016 is deterministic Claude Code replay evidence.\nc-v1 line 017 is deterministic Claude Code replay evidence.\nc-v1 line 018 is deterministic Claude Code replay evidence.\nc-v1 line 019 is deterministic Claude Code replay evidence.\nc-v1 line 020 is deterministic Claude Code replay evidence.\nc-v1 line 021 is deterministic Claude Code replay evidence.\nc-v1 line 022 is deterministic Claude Code replay evidence.\nc-v1 line 023 is deterministic Claude Code replay evidence.\nc-v1 line 024 is deterministic Claude Code replay evidence.\nc-v1 line 025 is deterministic Claude Code replay evidence.\nc-v1 line 026 is deterministic Claude Code replay evidence.\nc-v1 line 027 is deterministic Claude Code replay evidence.\nc-v1 line 028 is deterministic Claude Code replay evidence.\nc-v1 line 029 is deterministic Claude Code replay evidence.\nc-v1 line 030 is deterministic Claude Code replay evidence.\nc-v1 line 031 is deterministic Claude Code replay evidence.\nc-v1 line 032 is deterministic Claude Code replay evidence.\nc-v1 line 033 is deterministic Claude Code replay evidence.\nc-v1 line 034 is deterministic Claude Code replay evidence.\nc-v1 line 035 is deterministic Claude Code replay evidence.\nc-v1 line 036 is deterministic Claude Code replay evidence.\nc-v1 line 037 is deterministic Claude Code replay evidence.\nc-v1 line 038 is deterministic Claude Code replay evidence.\nc-v1 line 039 is deterministic Claude Code replay evidence.\nc-v1 line 040 is deterministic Claude Code replay evidence.\nc-v1 line 041 is deterministic Claude Code replay evidence.\nc-v1 line 042 is deterministic Claude Code replay evidence.\nc-v1 line 043 is deterministic Claude Code replay evidence.\nc-v1 line 044 is deterministic Claude Code replay evidence.\nc-v1 line 045 is deterministic Claude Code replay evidence.\nc-v1 line 046 is deterministic Claude Code replay evidence.\nc-v1 line 047 is deterministic Claude Code replay evidence.\nc-v1 line 048 is deterministic Claude Code replay evidence.\nc-v1 line 049 is deterministic Claude Code replay evidence.\nc-v1 line 050 is deterministic Claude Code replay evidence.\nc-v1 line 051 is deterministic Claude Code replay evidence.\nc-v1 line 052 is deterministic Claude Code replay evidence.\nc-v1 line 053 is deterministic Claude Code replay evidence.\nc-v1 line 054 is deterministic Claude Code replay evidence.\nc-v1 line 055 is deterministic Claude Code replay evidence.\nc-v1 line 056 is deterministic Claude Code replay evidence.\nc-v1 line 057 is deterministic Claude Code replay evidence.\nc-v1 line 058 is deterministic Claude Code replay evidence.\nc-v1 line 059 is deterministic Claude Code replay evidence.\nc-v1 line 060 is deterministic Claude Code replay evidence.\nc-v1 line 061 is deterministic Claude Code replay evidence.\nc-v1 line 062 is deterministic Claude Code replay evidence.\nc-v1 line 063 is deterministic Claude Code replay evidence.\nc-v1 line 064 is deterministic Claude Code replay evidence."}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-010","parentUuid":"raw-009","timestamp":"2026-08-21T01:00:10.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-edit-a","name":"Edit","input":{"file_path":"src/a.ts","old_string":"before","new_string":"after"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-011","parentUuid":"raw-010","timestamp":"2026-08-21T01:00:11.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-edit-a","content":"Updated src/a.ts successfully."}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-012","parentUuid":"raw-011","timestamp":"2026-08-21T01:00:12.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-reread-a","name":"Read","input":{"file_path":"src/a.ts"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-013","parentUuid":"raw-012","timestamp":"2026-08-21T01:00:13.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-reread-a","content":"a-v2 line 001 is deterministic Claude Code replay evidence.\na-v2 line 002 is deterministic Claude Code replay evidence.\na-v2 line 003 is deterministic Claude Code replay evidence.\na-v2 line 004 is deterministic Claude Code replay evidence.\na-v2 line 005 is deterministic Claude Code replay evidence.\na-v2 line 006 is deterministic Claude Code replay evidence.\na-v2 line 007 is deterministic Claude Code replay evidence.\na-v2 line 008 is deterministic Claude Code replay evidence.\na-v2 line 009 is deterministic Claude Code replay evidence.\na-v2 line 010 is deterministic Claude Code replay evidence.\na-v2 line 011 is deterministic Claude Code replay evidence.\na-v2 line 012 is deterministic Claude Code replay evidence.\na-v2 line 013 is deterministic Claude Code replay evidence.\na-v2 line 014 is deterministic Claude Code replay evidence.\na-v2 line 015 is deterministic Claude Code replay evidence.\na-v2 line 016 is deterministic Claude Code replay evidence.\na-v2 line 017 is deterministic Claude Code replay evidence.\na-v2 line 018 is deterministic Claude Code replay evidence.\na-v2 line 019 is deterministic Claude Code replay evidence.\na-v2 line 020 is deterministic Claude Code replay evidence.\na-v2 line 021 is deterministic Claude Code replay evidence.\na-v2 line 022 is deterministic Claude Code replay evidence.\na-v2 line 023 is deterministic Claude Code replay evidence.\na-v2 line 024 is deterministic Claude Code replay evidence.\na-v2 line 025 is deterministic Claude Code replay evidence.\na-v2 line 026 is deterministic Claude Code replay evidence.\na-v2 line 027 is deterministic Claude Code replay evidence.\na-v2 line 028 is deterministic Claude Code replay evidence.\na-v2 line 029 is deterministic Claude Code replay evidence.\na-v2 line 030 is deterministic Claude Code replay evidence.\na-v2 line 031 is deterministic Claude Code replay evidence.\na-v2 line 032 is deterministic Claude Code replay evidence.\na-v2 line 033 is deterministic Claude Code replay evidence.\na-v2 line 034 is deterministic Claude Code replay evidence.\na-v2 line 035 is deterministic Claude Code replay evidence.\na-v2 line 036 is deterministic Claude Code replay evidence.\na-v2 line 037 is deterministic Claude Code replay evidence.\na-v2 line 038 is deterministic Claude Code replay evidence.\na-v2 line 039 is deterministic Claude Code replay evidence.\na-v2 line 040 is deterministic Claude Code replay evidence.\na-v2 line 041 is deterministic Claude Code replay evidence.\na-v2 line 042 is deterministic Claude Code replay evidence.\na-v2 line 043 is deterministic Claude Code replay evidence.\na-v2 line 044 is deterministic Claude Code replay evidence.\na-v2 line 045 is deterministic Claude Code replay evidence.\na-v2 line 046 is deterministic Claude Code replay evidence.\na-v2 line 047 is deterministic Claude Code replay evidence.\na-v2 line 048 is deterministic Claude Code replay evidence.\na-v2 line 049 is deterministic Claude Code replay evidence.\na-v2 line 050 is deterministic Claude Code replay evidence.\na-v2 line 051 is deterministic Claude Code replay evidence.\na-v2 line 052 is deterministic Claude Code replay evidence.\na-v2 line 053 is deterministic Claude Code replay evidence.\na-v2 line 054 is deterministic Claude Code replay evidence.\na-v2 line 055 is deterministic Claude Code replay evidence.\na-v2 line 056 is deterministic Claude Code replay evidence.\na-v2 line 057 is deterministic Claude Code replay evidence.\na-v2 line 058 is deterministic Claude Code replay evidence.\na-v2 line 059 is deterministic Claude Code replay evidence.\na-v2 line 060 is deterministic Claude Code replay evidence.\na-v2 line 061 is deterministic Claude Code replay evidence.\na-v2 line 062 is deterministic Claude Code replay evidence.\na-v2 line 063 is deterministic Claude Code replay evidence.\na-v2 line 064 is deterministic Claude Code replay evidence."}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-014","parentUuid":"raw-013","timestamp":"2026-08-21T01:00:14.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-bash-fail","name":"Bash","input":{"command":"npm test -- fixture"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-015","parentUuid":"raw-014","timestamp":"2026-08-21T01:00:15.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-bash-fail","content":"fixture test failed with exit code 1","is_error":true}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-016","parentUuid":"raw-015","timestamp":"2026-08-21T01:00:16.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-bash-success","name":"Bash","input":{"command":"npm test -- fixture"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-017","parentUuid":"raw-016","timestamp":"2026-08-21T01:00:17.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-bash-success","content":"fixture test passed"}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-018","parentUuid":"raw-017","timestamp":"2026-08-21T01:00:18.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-read-d","name":"Read","input":{"file_path":"src/d.ts"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-019","parentUuid":"raw-018","timestamp":"2026-08-21T01:00:19.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-read-d","content":"d-v1 line 001 is deterministic Claude Code replay evidence.\nd-v1 line 002 is deterministic Claude Code replay evidence.\nd-v1 line 003 is deterministic Claude Code replay evidence.\nd-v1 line 004 is deterministic Claude Code replay evidence.\nd-v1 line 005 is deterministic Claude Code replay evidence.\nd-v1 line 006 is deterministic Claude Code replay evidence.\nd-v1 line 007 is deterministic Claude Code replay evidence.\nd-v1 line 008 is deterministic Claude Code replay evidence.\nd-v1 line 009 is deterministic Claude Code replay evidence.\nd-v1 line 010 is deterministic Claude Code replay evidence.\nd-v1 line 011 is deterministic Claude Code replay evidence.\nd-v1 line 012 is deterministic Claude Code replay evidence.\nd-v1 line 013 is deterministic Claude Code replay evidence.\nd-v1 line 014 is deterministic Claude Code replay evidence.\nd-v1 line 015 is deterministic Claude Code replay evidence.\nd-v1 line 016 is deterministic Claude Code replay evidence.\nd-v1 line 017 is deterministic Claude Code replay evidence.\nd-v1 line 018 is deterministic Claude Code replay evidence.\nd-v1 line 019 is deterministic Claude Code replay evidence.\nd-v1 line 020 is deterministic Claude Code replay evidence.\nd-v1 line 021 is deterministic Claude Code replay evidence.\nd-v1 line 022 is deterministic Claude Code replay evidence.\nd-v1 line 023 is deterministic Claude Code replay evidence.\nd-v1 line 024 is deterministic Claude Code replay evidence.\nd-v1 line 025 is deterministic Claude Code replay evidence.\nd-v1 line 026 is deterministic Claude Code replay evidence.\nd-v1 line 027 is deterministic Claude Code replay evidence.\nd-v1 line 028 is deterministic Claude Code replay evidence.\nd-v1 line 029 is deterministic Claude Code replay evidence.\nd-v1 line 030 is deterministic Claude Code replay evidence.\nd-v1 line 031 is deterministic Claude Code replay evidence.\nd-v1 line 032 is deterministic Claude Code replay evidence.\nd-v1 line 033 is deterministic Claude Code replay evidence.\nd-v1 line 034 is deterministic Claude Code replay evidence.\nd-v1 line 035 is deterministic Claude Code replay evidence.\nd-v1 line 036 is deterministic Claude Code replay evidence.\nd-v1 line 037 is deterministic Claude Code replay evidence.\nd-v1 line 038 is deterministic Claude Code replay evidence.\nd-v1 line 039 is deterministic Claude Code replay evidence.\nd-v1 line 040 is deterministic Claude Code replay evidence.\nd-v1 line 041 is deterministic Claude Code replay evidence.\nd-v1 line 042 is deterministic Claude Code replay evidence.\nd-v1 line 043 is deterministic Claude Code replay evidence.\nd-v1 line 044 is deterministic Claude Code replay evidence.\nd-v1 line 045 is deterministic Claude Code replay evidence.\nd-v1 line 046 is deterministic Claude Code replay evidence.\nd-v1 line 047 is deterministic Claude Code replay evidence.\nd-v1 line 048 is deterministic Claude Code replay evidence.\nd-v1 line 049 is deterministic Claude Code replay evidence.\nd-v1 line 050 is deterministic Claude Code replay evidence.\nd-v1 line 051 is deterministic Claude Code replay evidence.\nd-v1 line 052 is deterministic Claude Code replay evidence.\nd-v1 line 053 is deterministic Claude Code replay evidence.\nd-v1 line 054 is deterministic Claude Code replay evidence.\nd-v1 line 055 is deterministic Claude Code replay evidence.\nd-v1 line 056 is deterministic Claude Code replay evidence.\nd-v1 line 057 is deterministic Claude Code replay evidence.\nd-v1 line 058 is deterministic Claude Code replay evidence.\nd-v1 line 059 is deterministic Claude Code replay evidence.\nd-v1 line 060 is deterministic Claude Code replay evidence.\nd-v1 line 061 is deterministic Claude Code replay evidence.\nd-v1 line 062 is deterministic Claude Code replay evidence.\nd-v1 line 063 is deterministic Claude Code replay evidence.\nd-v1 line 064 is deterministic Claude Code replay evidence."}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-020","parentUuid":"raw-019","timestamp":"2026-08-21T01:00:20.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-read-e","name":"Read","input":{"file_path":"src/e.ts"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-021","parentUuid":"raw-020","timestamp":"2026-08-21T01:00:21.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-read-e","content":"e-v1 line 001 is deterministic Claude Code replay evidence.\ne-v1 line 002 is deterministic Claude Code replay evidence.\ne-v1 line 003 is deterministic Claude Code replay evidence.\ne-v1 line 004 is deterministic Claude Code replay evidence.\ne-v1 line 005 is deterministic Claude Code replay evidence.\ne-v1 line 006 is deterministic Claude Code replay evidence.\ne-v1 line 007 is deterministic Claude Code replay evidence.\ne-v1 line 008 is deterministic Claude Code replay evidence.\ne-v1 line 009 is deterministic Claude Code replay evidence.\ne-v1 line 010 is deterministic Claude Code replay evidence.\ne-v1 line 011 is deterministic Claude Code replay evidence.\ne-v1 line 012 is deterministic Claude Code replay evidence.\ne-v1 line 013 is deterministic Claude Code replay evidence.\ne-v1 line 014 is deterministic Claude Code replay evidence.\ne-v1 line 015 is deterministic Claude Code replay evidence.\ne-v1 line 016 is deterministic Claude Code replay evidence.\ne-v1 line 017 is deterministic Claude Code replay evidence.\ne-v1 line 018 is deterministic Claude Code replay evidence.\ne-v1 line 019 is deterministic Claude Code replay evidence.\ne-v1 line 020 is deterministic Claude Code replay evidence.\ne-v1 line 021 is deterministic Claude Code replay evidence.\ne-v1 line 022 is deterministic Claude Code replay evidence.\ne-v1 line 023 is deterministic Claude Code replay evidence.\ne-v1 line 024 is deterministic Claude Code replay evidence.\ne-v1 line 025 is deterministic Claude Code replay evidence.\ne-v1 line 026 is deterministic Claude Code replay evidence.\ne-v1 line 027 is deterministic Claude Code replay evidence.\ne-v1 line 028 is deterministic Claude Code replay evidence.\ne-v1 line 029 is deterministic Claude Code replay evidence.\ne-v1 line 030 is deterministic Claude Code replay evidence.\ne-v1 line 031 is deterministic Claude Code replay evidence.\ne-v1 line 032 is deterministic Claude Code replay evidence.\ne-v1 line 033 is deterministic Claude Code replay evidence.\ne-v1 line 034 is deterministic Claude Code replay evidence.\ne-v1 line 035 is deterministic Claude Code replay evidence.\ne-v1 line 036 is deterministic Claude Code replay evidence.\ne-v1 line 037 is deterministic Claude Code replay evidence.\ne-v1 line 038 is deterministic Claude Code replay evidence.\ne-v1 line 039 is deterministic Claude Code replay evidence.\ne-v1 line 040 is deterministic Claude Code replay evidence.\ne-v1 line 041 is deterministic Claude Code replay evidence.\ne-v1 line 042 is deterministic Claude Code replay evidence.\ne-v1 line 043 is deterministic Claude Code replay evidence.\ne-v1 line 044 is deterministic Claude Code replay evidence.\ne-v1 line 045 is deterministic Claude Code replay evidence.\ne-v1 line 046 is deterministic Claude Code replay evidence.\ne-v1 line 047 is deterministic Claude Code replay evidence.\ne-v1 line 048 is deterministic Claude Code replay evidence.\ne-v1 line 049 is deterministic Claude Code replay evidence.\ne-v1 line 050 is deterministic Claude Code replay evidence.\ne-v1 line 051 is deterministic Claude Code replay evidence.\ne-v1 line 052 is deterministic Claude Code replay evidence.\ne-v1 line 053 is deterministic Claude Code replay evidence.\ne-v1 line 054 is deterministic Claude Code replay evidence.\ne-v1 line 055 is deterministic Claude Code replay evidence.\ne-v1 line 056 is deterministic Claude Code replay evidence.\ne-v1 line 057 is deterministic Claude Code replay evidence.\ne-v1 line 058 is deterministic Claude Code replay evidence.\ne-v1 line 059 is deterministic Claude Code replay evidence.\ne-v1 line 060 is deterministic Claude Code replay evidence.\ne-v1 line 061 is deterministic Claude Code replay evidence.\ne-v1 line 062 is deterministic Claude Code replay evidence.\ne-v1 line 063 is deterministic Claude Code replay evidence.\ne-v1 line 064 is deterministic Claude Code replay evidence."}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-022","parentUuid":"raw-021","timestamp":"2026-08-21T01:00:22.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-read-f","name":"Read","input":{"file_path":"src/f.ts"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-023","parentUuid":"raw-022","timestamp":"2026-08-21T01:00:23.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-read-f","content":"f-v1 line 001 is deterministic Claude Code replay evidence.\nf-v1 line 002 is deterministic Claude Code replay evidence.\nf-v1 line 003 is deterministic Claude Code replay evidence.\nf-v1 line 004 is deterministic Claude Code replay evidence.\nf-v1 line 005 is deterministic Claude Code replay evidence.\nf-v1 line 006 is deterministic Claude Code replay evidence.\nf-v1 line 007 is deterministic Claude Code replay evidence.\nf-v1 line 008 is deterministic Claude Code replay evidence.\nf-v1 line 009 is deterministic Claude Code replay evidence.\nf-v1 line 010 is deterministic Claude Code replay evidence.\nf-v1 line 011 is deterministic Claude Code replay evidence.\nf-v1 line 012 is deterministic Claude Code replay evidence.\nf-v1 line 013 is deterministic Claude Code replay evidence.\nf-v1 line 014 is deterministic Claude Code replay evidence.\nf-v1 line 015 is deterministic Claude Code replay evidence.\nf-v1 line 016 is deterministic Claude Code replay evidence.\nf-v1 line 017 is deterministic Claude Code replay evidence.\nf-v1 line 018 is deterministic Claude Code replay evidence.\nf-v1 line 019 is deterministic Claude Code replay evidence.\nf-v1 line 020 is deterministic Claude Code replay evidence.\nf-v1 line 021 is deterministic Claude Code replay evidence.\nf-v1 line 022 is deterministic Claude Code replay evidence.\nf-v1 line 023 is deterministic Claude Code replay evidence.\nf-v1 line 024 is deterministic Claude Code replay evidence.\nf-v1 line 025 is deterministic Claude Code replay evidence.\nf-v1 line 026 is deterministic Claude Code replay evidence.\nf-v1 line 027 is deterministic Claude Code replay evidence.\nf-v1 line 028 is deterministic Claude Code replay evidence.\nf-v1 line 029 is deterministic Claude Code replay evidence.\nf-v1 line 030 is deterministic Claude Code replay evidence.\nf-v1 line 031 is deterministic Claude Code replay evidence.\nf-v1 line 032 is deterministic Claude Code replay evidence.\nf-v1 line 033 is deterministic Claude Code replay evidence.\nf-v1 line 034 is deterministic Claude Code replay evidence.\nf-v1 line 035 is deterministic Claude Code replay evidence.\nf-v1 line 036 is deterministic Claude Code replay evidence.\nf-v1 line 037 is deterministic Claude Code replay evidence.\nf-v1 line 038 is deterministic Claude Code replay evidence.\nf-v1 line 039 is deterministic Claude Code replay evidence.\nf-v1 line 040 is deterministic Claude Code replay evidence.\nf-v1 line 041 is deterministic Claude Code replay evidence.\nf-v1 line 042 is deterministic Claude Code replay evidence.\nf-v1 line 043 is deterministic Claude Code replay evidence.\nf-v1 line 044 is deterministic Claude Code replay evidence.\nf-v1 line 045 is deterministic Claude Code replay evidence.\nf-v1 line 046 is deterministic Claude Code replay evidence.\nf-v1 line 047 is deterministic Claude Code replay evidence.\nf-v1 line 048 is deterministic Claude Code replay evidence.\nf-v1 line 049 is deterministic Claude Code replay evidence.\nf-v1 line 050 is deterministic Claude Code replay evidence.\nf-v1 line 051 is deterministic Claude Code replay evidence.\nf-v1 line 052 is deterministic Claude Code replay evidence.\nf-v1 line 053 is deterministic Claude Code replay evidence.\nf-v1 line 054 is deterministic Claude Code replay evidence.\nf-v1 line 055 is deterministic Claude Code replay evidence.\nf-v1 line 056 is deterministic Claude Code replay evidence.\nf-v1 line 057 is deterministic Claude Code replay evidence.\nf-v1 line 058 is deterministic Claude Code replay evidence.\nf-v1 line 059 is deterministic Claude Code replay evidence.\nf-v1 line 060 is deterministic Claude Code replay evidence.\nf-v1 line 061 is deterministic Claude Code replay evidence.\nf-v1 line 062 is deterministic Claude Code replay evidence.\nf-v1 line 063 is deterministic Claude Code replay evidence.\nf-v1 line 064 is deterministic Claude Code replay evidence."}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-024","parentUuid":"raw-023","timestamp":"2026-08-21T01:00:24.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"text","text":"The first verification pass is complete."}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-025","parentUuid":"raw-024","timestamp":"2026-08-21T01:00:25.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":"Finish the remaining checks."}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-026","parentUuid":"raw-025","timestamp":"2026-08-21T01:00:26.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-read-g","name":"Read","input":{"file_path":"src/g.ts"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-027","parentUuid":"raw-026","timestamp":"2026-08-21T01:00:27.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-read-g","content":"g-v1 line 001 is deterministic Claude Code replay evidence.\ng-v1 line 002 is deterministic Claude Code replay evidence.\ng-v1 line 003 is deterministic Claude Code replay evidence.\ng-v1 line 004 is deterministic Claude Code replay evidence.\ng-v1 line 005 is deterministic Claude Code replay evidence.\ng-v1 line 006 is deterministic Claude Code replay evidence.\ng-v1 line 007 is deterministic Claude Code replay evidence.\ng-v1 line 008 is deterministic Claude Code replay evidence.\ng-v1 line 009 is deterministic Claude Code replay evidence.\ng-v1 line 010 is deterministic Claude Code replay evidence.\ng-v1 line 011 is deterministic Claude Code replay evidence.\ng-v1 line 012 is deterministic Claude Code replay evidence.\ng-v1 line 013 is deterministic Claude Code replay evidence.\ng-v1 line 014 is deterministic Claude Code replay evidence.\ng-v1 line 015 is deterministic Claude Code replay evidence.\ng-v1 line 016 is deterministic Claude Code replay evidence.\ng-v1 line 017 is deterministic Claude Code replay evidence.\ng-v1 line 018 is deterministic Claude Code replay evidence.\ng-v1 line 019 is deterministic Claude Code replay evidence.\ng-v1 line 020 is deterministic Claude Code replay evidence.\ng-v1 line 021 is deterministic Claude Code replay evidence.\ng-v1 line 022 is deterministic Claude Code replay evidence.\ng-v1 line 023 is deterministic Claude Code replay evidence.\ng-v1 line 024 is deterministic Claude Code replay evidence.\ng-v1 line 025 is deterministic Claude Code replay evidence.\ng-v1 line 026 is deterministic Claude Code replay evidence.\ng-v1 line 027 is deterministic Claude Code replay evidence.\ng-v1 line 028 is deterministic Claude Code replay evidence.\ng-v1 line 029 is deterministic Claude Code replay evidence.\ng-v1 line 030 is deterministic Claude Code replay evidence.\ng-v1 line 031 is deterministic Claude Code replay evidence.\ng-v1 line 032 is deterministic Claude Code replay evidence.\ng-v1 line 033 is deterministic Claude Code replay evidence.\ng-v1 line 034 is deterministic Claude Code replay evidence.\ng-v1 line 035 is deterministic Claude Code replay evidence.\ng-v1 line 036 is deterministic Claude Code replay evidence.\ng-v1 line 037 is deterministic Claude Code replay evidence.\ng-v1 line 038 is deterministic Claude Code replay evidence.\ng-v1 line 039 is deterministic Claude Code replay evidence.\ng-v1 line 040 is deterministic Claude Code replay evidence.\ng-v1 line 041 is deterministic Claude Code replay evidence.\ng-v1 line 042 is deterministic Claude Code replay evidence.\ng-v1 line 043 is deterministic Claude Code replay evidence.\ng-v1 line 044 is deterministic Claude Code replay evidence.\ng-v1 line 045 is deterministic Claude Code replay evidence.\ng-v1 line 046 is deterministic Claude Code replay evidence.\ng-v1 line 047 is deterministic Claude Code replay evidence.\ng-v1 line 048 is deterministic Claude Code replay evidence.\ng-v1 line 049 is deterministic Claude Code replay evidence.\ng-v1 line 050 is deterministic Claude Code replay evidence.\ng-v1 line 051 is deterministic Claude Code replay evidence.\ng-v1 line 052 is deterministic Claude Code replay evidence.\ng-v1 line 053 is deterministic Claude Code replay evidence.\ng-v1 line 054 is deterministic Claude Code replay evidence.\ng-v1 line 055 is deterministic Claude Code replay evidence.\ng-v1 line 056 is deterministic Claude Code replay evidence.\ng-v1 line 057 is deterministic Claude Code replay evidence.\ng-v1 line 058 is deterministic Claude Code replay evidence.\ng-v1 line 059 is deterministic Claude Code replay evidence.\ng-v1 line 060 is deterministic Claude Code replay evidence.\ng-v1 line 061 is deterministic Claude Code replay evidence.\ng-v1 line 062 is deterministic Claude Code replay evidence.\ng-v1 line 063 is deterministic Claude Code replay evidence.\ng-v1 line 064 is deterministic Claude Code replay evidence."}]}} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-028","parentUuid":"raw-027","timestamp":"2026-08-21T01:00:28.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"tool_use","id":"tool-write-h","name":"Write","input":{"file_path":"src/h.ts","content":"export const h = true;\n"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-029","parentUuid":"raw-028","timestamp":"2026-08-21T01:00:29.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-write-h","content":"Wrote src/h.ts."}]}} +{"type":"user","sessionId":"claude-code-replay-fixture-01","uuid":"raw-030","parentUuid":"raw-029","timestamp":"2026-08-21T01:00:30.000Z","cwd":"/fixture/claude-code-repo","isSidechain":true,"message":{"role":"user","content":"This sidechain-only record must never enter replay."},"agentId":"fixture-subagent"} +{"type":"assistant","sessionId":"claude-code-replay-fixture-01","uuid":"raw-031","parentUuid":"raw-030","timestamp":"2026-08-21T01:00:31.000Z","cwd":"/fixture/claude-code-repo","isSidechain":false,"message":{"role":"assistant","model":"claude-fixture","content":[{"type":"text","text":"All requested fixture work is complete."}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}} diff --git a/tests/fixtures/context-replay/generate-claude-code-fixture.mjs b/tests/fixtures/context-replay/generate-claude-code-fixture.mjs new file mode 100644 index 000000000..4ba2c79ba --- /dev/null +++ b/tests/fixtures/context-replay/generate-claude-code-fixture.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const output = join(dirname(fileURLToPath(import.meta.url)), "claude-code-01.jsonl"); +const records = []; +const sessionId = "claude-code-replay-fixture-01"; +let sequence = 0; + +function timestamp() { + sequence += 1; + return `2026-08-21T01:00:${String(sequence).padStart(2, "0")}.000Z`; +} + +function common(type, extra = {}) { + const index = records.length + 1; + return { + type, + sessionId, + uuid: `raw-${String(index).padStart(3, "0")}`, + parentUuid: index === 1 ? null : `raw-${String(index - 1).padStart(3, "0")}`, + timestamp: timestamp(), + cwd: "/fixture/claude-code-repo", + isSidechain: false, + ...extra, + }; +} + +function user(content, extra = {}) { + records.push(common("user", { message: { role: "user", content }, ...extra })); +} + +function assistant(content) { + records.push( + common("assistant", { + message: { + role: "assistant", + model: "claude-fixture", + content, + usage: { + input_tokens: 100, + output_tokens: 20, + cache_read_input_tokens: 80, + cache_creation_input_tokens: 5, + }, + }, + }), + ); +} + +function body(label, lines = 64) { + return Array.from( + { length: lines }, + (_, index) => `${label} line ${String(index + 1).padStart(3, "0")} is deterministic Claude Code replay evidence.`, + ).join("\n"); +} + +function toolUse(id, name, input, extraBlocks = []) { + assistant([...extraBlocks, { type: "tool_use", id, name, input }]); +} + +function toolResult(id, content, isError = false) { + user([{ type: "tool_result", tool_use_id: id, content, ...(isError ? { is_error: true } : {}) }]); +} + +user("Inspect and update the fixture repository."); +toolUse("tool-read-a", "Read", { file_path: "src/a.ts", offset: 2, limit: 40 }, [ + { type: "thinking", thinking: "I should inspect a.ts before changing it.", signature: "fixture-signature" }, + { type: "text", text: "Reading the primary file." }, +]); +toolResult("tool-read-a", body("a-v1")); +toolUse("tool-grep", "Grep", { + pattern: "export", + path: ".", + include: "src/*.ts", + output_mode: "content", +}); +toolResult("tool-grep", [{ type: "text", text: "src/b.ts:4:export const b = 1;\nsrc/c.ts:7:export const c = 2;" }]); +toolUse("tool-read-b", "Read", { file_path: "src/b.ts" }); +toolResult("tool-read-b", body("b-v1")); +toolUse("tool-read-c", "Read", { file_path: "src/c.ts" }); +toolResult("tool-read-c", body("c-v1")); +toolUse("tool-edit-a", "Edit", { + file_path: "src/a.ts", + old_string: "before", + new_string: "after", +}); +toolResult("tool-edit-a", "Updated src/a.ts successfully."); +toolUse("tool-reread-a", "Read", { file_path: "src/a.ts" }); +toolResult("tool-reread-a", body("a-v2")); +toolUse("tool-bash-fail", "Bash", { command: "npm test -- fixture" }); +toolResult("tool-bash-fail", "fixture test failed with exit code 1", true); +toolUse("tool-bash-success", "Bash", { command: "npm test -- fixture" }); +toolResult("tool-bash-success", "fixture test passed"); +toolUse("tool-read-d", "Read", { file_path: "src/d.ts" }); +toolResult("tool-read-d", body("d-v1")); +toolUse("tool-read-e", "Read", { file_path: "src/e.ts" }); +toolResult("tool-read-e", body("e-v1")); +toolUse("tool-read-f", "Read", { file_path: "src/f.ts" }); +toolResult("tool-read-f", body("f-v1")); +assistant([{ type: "text", text: "The first verification pass is complete." }]); +user("Finish the remaining checks."); +toolUse("tool-read-g", "Read", { file_path: "src/g.ts" }); +toolResult("tool-read-g", body("g-v1")); +toolUse("tool-write-h", "Write", { file_path: "src/h.ts", content: "export const h = true;\n" }); +toolResult("tool-write-h", "Wrote src/h.ts."); +user("This sidechain-only record must never enter replay.", { isSidechain: true, agentId: "fixture-subagent" }); +assistant([{ type: "text", text: "All requested fixture work is complete." }]); + +writeFileSync(output, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, "utf8"); +process.stdout.write(`${output}\n`); From 064209ce67230e1ae7becab582f10372c4cc4120 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:26:32 -0500 Subject: [PATCH 28/45] feat(context): carry Claude replay cwd --- .../working-set/replay/load-claude-code.ts | 78 ++++---- .../working-set-replay-claude-code.test.ts | 187 +++++++++++++++++- 2 files changed, 214 insertions(+), 51 deletions(-) diff --git a/src/domains/context/working-set/replay/load-claude-code.ts b/src/domains/context/working-set/replay/load-claude-code.ts index 7fbba3a4a..3daf84c6c 100644 --- a/src/domains/context/working-set/replay/load-claude-code.ts +++ b/src/domains/context/working-set/replay/load-claude-code.ts @@ -9,14 +9,8 @@ import type { Dirent } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; -import { basename, extname, resolve, sep } from "node:path"; -import { - type CustomEntry, - isSessionHeader, - type MessageEntry, - type SessionEntry, - type SessionHeader, -} from "../../../session/entries.js"; +import { basename, extname, join, resolve, sep } from "node:path"; +import { isSessionHeader, type MessageEntry, type SessionEntry } from "../../../session/entries.js"; import { buildPathIndex } from "../path-index.js"; import { loadClioTraces, type ReplayLoadCascade } from "./load-clio.js"; import { buildReferenceGraph } from "./reference-graph.js"; @@ -41,7 +35,7 @@ interface ClaudeRecord { interface ParsedRecords { records: ClaudeRecord[]; - malformed: number; + corrupt: boolean; } interface Discovery { @@ -85,6 +79,19 @@ async function collectJsonlFiles(input: string, out: Set): Promise): Promise): Promise { +async function discoverReplayJsonlFiles(paths: ReadonlyArray): Promise { const files = new Set(); let missingInputs = 0; for (const path of paths) { @@ -110,25 +117,27 @@ export async function discoverReplayJsonlFiles(paths: ReadonlyArray): Pr function parseRecords(raw: string): ParsedRecords { const records: ClaudeRecord[] = []; - let malformed = 0; - for (const line of raw.split("\n")) { - if (line.trim().length === 0) continue; + const lines = raw.split("\n").filter((line) => line.trim().length > 0); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line === undefined) continue; try { const value = JSON.parse(line) as unknown; if (isRecord(value)) records.push(value as ClaudeRecord); - else malformed += 1; + else throw new TypeError("JSONL record is not an object"); } catch { - // Claude Code can leave a partial final line after a crash. Match the - // research loader's lenience: retain the readable event prefix. - malformed += 1; + // Claude Code can leave a partial final line after a crash. Retain that + // readable prefix, but never stitch records across mid-file corruption. + return { records, corrupt: index < lines.length - 1 }; } } - return { records, malformed }; + return { records, corrupt: false }; } /** Detect the first semantic record after metadata such as `mode`. */ export function detectReplayInputFormat(raw: string): Exclude | null { - const { records } = parseRecords(raw); + const { records, corrupt } = parseRecords(raw); + if (corrupt) return null; for (const record of records) { if (isSessionHeader(record)) return "clio"; if ((record.type === "user" || record.type === "assistant") && isRecord(record.message)) { @@ -284,6 +293,7 @@ function normalizeTool(originalName: string, input: unknown): NormalizedTool { args.tasks = todos .map((todo) => (isRecord(todo) && typeof todo.content === "string" ? todo.content : null)) .filter((todo): todo is string => todo !== null); + delete args.todos; return { name: "tasks", args }; } case "NotebookEdit": @@ -342,27 +352,7 @@ function normalizeTranscript(records: ReadonlyArray, source: strin }; const id = sessionIdOf(records, source); - const cwd = cwdOf(records); - const firstRecord = - records.find((record) => record.isSidechain !== true && stringValue(record.cwd) !== undefined) ?? - records.find((record) => record.isSidechain !== true); - if (cwd !== undefined) { - const turnId = nextTurnId(); - const header: CustomEntry & Omit = { - type: "session", - version: 4, - id, - cwd, - timestamp: timestampOf(firstRecord), - kind: "custom", - customType: "claude-code-session-header", - turnId, - parentTurnId, - display: false, - }; - entries.push(header); - parentTurnId = turnId; - } + const cwd = cwdOf(records) ?? null; for (const record of records) { if (record.isSidechain === true) continue; @@ -429,7 +419,7 @@ function normalizeTranscript(records: ReadonlyArray, source: strin appendMessage("user", { text: userText.join("\n") }, timestamp); } - return { id, source, entries, turnCount: countReplayTurns(entries) }; + return { id, source, cwd, entries, turnCount: countReplayTurns(entries) }; } function toolResultCount(entries: ReadonlyArray): number { @@ -474,6 +464,10 @@ export async function loadClaudeCodeTraces( continue; } const parsed = parseRecords(raw); + if (parsed.corrupt) { + cascade.unreadable += 1; + continue; + } const mainRecords = parsed.records.filter((record) => record.isSidechain !== true); const conversation = mainRecords.filter((record) => record.type === "user" || record.type === "assistant"); if (conversation.length === 0) { @@ -498,7 +492,7 @@ export async function loadClaudeCodeTraces( increment(cascade, "tool_results_lt_8"); continue; } - const graph = buildReferenceGraph(trace, buildPathIndex(trace.entries)); + const graph = buildReferenceGraph(trace, buildPathIndex(trace.entries, { cwd: trace.cwd })); if (!graph.edges.some((edge) => edge.kind === "file_reread")) { increment(cascade, "no_file_reread"); continue; diff --git a/tests/contracts/working-set-replay-claude-code.test.ts b/tests/contracts/working-set-replay-claude-code.test.ts index 5f390ccd9..7aaa8f2f8 100644 --- a/tests/contracts/working-set-replay-claude-code.test.ts +++ b/tests/contracts/working-set-replay-claude-code.test.ts @@ -15,9 +15,10 @@ import { import { buildReferenceGraph } from "../../src/domains/context/working-set/replay/reference-graph.js"; import { replayTrace } from "../../src/domains/context/working-set/replay/runner.js"; import type { Trace } from "../../src/domains/context/working-set/replay/trace.js"; -import type { MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; +import type { MessageEntry } from "../../src/domains/session/entries.js"; const FIXTURE = fileURLToPath(new URL("../fixtures/context-replay/claude-code-01.jsonl", import.meta.url)); +const CLIO_FIXTURE = fileURLToPath(new URL("../fixtures/context-replay/fixture-01.jsonl", import.meta.url)); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -67,13 +68,10 @@ describe("contracts/working-set Claude Code replay loader", () => { it("converts provider records into stable, linearly chained Clio messages", async () => { const trace = await fixture(); assert.equal(trace.id, "claude-code-replay-fixture-01"); + assert.equal(trace.cwd, "/fixture/claude-code-repo"); assert.equal(trace.turnCount, 15); - assert.equal(trace.entries.length, 57); - - const header = trace.entries[0] as SessionEntry & Record; - assert.equal(header.kind, "custom"); - assert.equal(header.type, "session"); - assert.equal(header.cwd, "/fixture/claude-code-repo"); + assert.equal(trace.entries.length, 56); + assert.equal(trace.entries[0]?.kind, "message"); for (let index = 0; index < trace.entries.length; index += 1) { const entry = trace.entries[index]; assert.ok(entry); @@ -154,7 +152,7 @@ describe("contracts/working-set Claude Code replay loader", () => { it("feeds cwd-aware normalized calls into the unchanged path index", async () => { const trace = await fixture(); - const index = buildPathIndex(trace.entries); + const index = buildPathIndex(trace.entries, { cwd: trace.cwd }); const read = index.byRef.get(resultById(trace, "tool-read-a").turnId); assert.deepEqual(read, { ref: { entry: resultById(trace, "tool-read-a").turnId }, @@ -165,6 +163,7 @@ describe("contracts/working-set Claude Code replay loader", () => { range: { offset: 1, limit: 40 }, surfaced: [], isError: false, + isBlocked: false, turnIndex: 1, entryIndex: trace.entries.indexOf(resultById(trace, "tool-read-a")), argsKey: '{"__claudeCodeTool":"Read","limit":40,"offset":2,"path":"src/a.ts"}', @@ -178,7 +177,7 @@ describe("contracts/working-set Claude Code replay loader", () => { it("labels the normalized reread, discovery, and rewrite edges", async () => { const trace = await fixture(); - const graph = buildReferenceGraph(trace, buildPathIndex(trace.entries)); + const graph = buildReferenceGraph(trace, buildPathIndex(trace.entries, { cwd: trace.cwd })); assert.deepEqual(graph.edges, [ { from: resultById(trace, "tool-read-a").turnId, toTurnIndex: 5, kind: "file_rewrite" }, { from: resultById(trace, "tool-read-a").turnId, toTurnIndex: 6, kind: "file_reread" }, @@ -208,6 +207,176 @@ describe("contracts/working-set Claude Code replay loader", () => { } }); + it("normalizes every declared Claude Code tool mapping", async () => { + const root = await mkdtemp(join(tmpdir(), "clio-cc-tools-")); + const transcript = join(root, "tools.jsonl"); + const cases = [ + { + id: "multi", + original: "MultiEdit", + input: { file_path: "a.ts", edits: [{ old_string: "a", new_string: "b" }] }, + name: "edit", + args: { + path: "a.ts", + edits: [{ oldText: "a", newText: "b" }], + __claudeCodeTool: "MultiEdit", + }, + }, + { + id: "write", + original: "Write", + input: { file_path: "b.ts", content: "body" }, + name: "write", + args: { path: "b.ts", content: "body", __claudeCodeTool: "Write" }, + }, + { + id: "glob", + original: "Glob", + input: { pattern: "**/*.ts", path: "src" }, + name: "find", + args: { pattern: "**/*.ts", path: "src", __claudeCodeTool: "Glob" }, + }, + { + id: "ls", + original: "LS", + input: { path: "src" }, + name: "ls", + args: { path: "src", __claudeCodeTool: "LS" }, + }, + { + id: "bash", + original: "Bash", + input: { command: "npm test", timeout: 1234 }, + name: "bash", + args: { command: "npm test", timeout_ms: 1234, __claudeCodeTool: "Bash" }, + }, + { + id: "web", + original: "WebFetch", + input: { url: "https://example.test", prompt: "extract" }, + name: "web_fetch", + args: { url: "https://example.test", prompt: "extract", __claudeCodeTool: "WebFetch" }, + }, + { + id: "task", + original: "Task", + input: { prompt: "inspect", subagent_type: "scout", description: "map" }, + name: "dispatch", + args: { task: "inspect", agent: "scout", briefing: "map", __claudeCodeTool: "Task" }, + }, + { + id: "todos", + original: "TodoWrite", + input: { + todos: [ + { content: "one", status: "pending" }, + { content: "two", status: "done" }, + ], + }, + name: "tasks", + args: { + action: "plan", + title: "Claude Code todos", + tasks: ["one", "two"], + __claudeCodeTool: "TodoWrite", + }, + }, + { + id: "notebook", + original: "NotebookEdit", + input: { notebook_path: "notes.ipynb", new_source: "print(1)", edit_mode: "replace" }, + name: "edit", + args: { + path: "notes.ipynb", + new_source: "print(1)", + edit_mode: "replace", + __claudeCodeTool: "NotebookEdit", + }, + }, + { + id: "unknown", + original: "FutureTool", + input: { opaque: 7 }, + name: "futuretool", + args: { opaque: 7, __claudeCodeTool: "FutureTool" }, + }, + ] as const; + try { + const records = [ + { + type: "assistant", + sessionId: "tool-mapping", + cwd: "/fixture/tools", + timestamp: "2026-08-21T00:00:00.000Z", + message: { + content: cases.map((item) => ({ type: "tool_use", id: item.id, name: item.original, input: item.input })), + }, + }, + { + type: "user", + timestamp: "2026-08-21T00:00:01.000Z", + message: { + content: cases.map((item) => ({ + type: "tool_result", + tool_use_id: item.id, + content: `${item.id} result`, + })), + }, + }, + ]; + await writeFile(transcript, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, "utf8"); + const loaded = await loadClaudeCodeTraces([transcript], { filter: false }); + const trace = loaded.traces[0]; + assert.ok(trace); + for (const item of cases) { + assert.deepEqual(payload(callById(trace, item.id)), { + toolCallId: item.id, + name: item.name, + args: item.args, + }); + assert.deepEqual(payload(resultById(trace, item.id)), { + toolCallId: item.id, + toolName: item.name, + result: { content: [{ type: "text", text: `${item.id} result` }] }, + isError: false, + }); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("auto discovery ignores Clio sidecars and rejects mid-file corruption", async () => { + const root = await mkdtemp(join(tmpdir(), "clio-replay-auto-")); + try { + const session = join(root, "session"); + await mkdir(session, { recursive: true }); + await writeFile(join(session, "current.jsonl"), await readFile(CLIO_FIXTURE, "utf8"), "utf8"); + await writeFile(join(session, "context-snapshots.jsonl"), '{"snapshot":true}\n', "utf8"); + await writeFile(join(session, "prompt-manifest.jsonl"), '{"manifest":true}\n', "utf8"); + const clio = await loadReplayTraces([root], "auto", { filter: false }); + assert.equal(clio.cascade.found, 1); + assert.equal(clio.cascade.unreadable, 0); + assert.equal(clio.cascade.kept, 1); + + const fixtureRaw = await readFile(FIXTURE, "utf8"); + const fixtureLines = fixtureRaw.trimEnd().split("\n"); + const corrupt = join(root, "corrupt.jsonl"); + await writeFile(corrupt, `${fixtureLines[0]}\nnot-json\n${fixtureLines[1]}\n`, "utf8"); + const rejected = await loadClaudeCodeTraces([corrupt], { filter: false }); + assert.equal(rejected.cascade.unreadable, 1); + assert.equal(rejected.cascade.kept, 0); + + const partial = join(root, "partial-final.jsonl"); + await writeFile(partial, `${fixtureRaw}{"type":"assistant"`, "utf8"); + const recovered = await loadClaudeCodeTraces([partial], { filter: false }); + assert.equal(recovered.cascade.unreadable, 0); + assert.equal(recovered.cascade.kept, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("auto-detects Claude Code and drives one live age-horizon eviction", async () => { const raw = await readFile(FIXTURE, "utf8"); assert.equal(detectReplayInputFormat(raw), "claude-code"); From 0144accc6c96e4465f87591fe6cbf371b9175762 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 11:48:09 -0500 Subject: [PATCH 29/45] test(context): add the working-set acceptance scenarios The unit tests are pure over entry arrays. These drive the charter's section 7 scenarios through the product: a session written by the session domain into an isolated CLIO_CODER_HOME, pressure forced over the threshold with a stubbed target, the real `runAutoCompact` stage, and then the readers that actually disagree on purpose. S1 checks that the ledger keeps every body byte-for-byte while the projection carries markers, that `/resume` rehydration tags each evicted row with its reason, that the `/export` HTML keeps the bodies, and that the evidence bundle names every evicted ref. S2 recalls an evicted bash body through the context tool and proves the marker stays. S9 covers forks either side of the event and the #94 `/tree` case. S11 covers the thinking rule, S12 both halves of the default-off safety net including the legacy escape hatch, S3/S4/S5 the three structural reasons and their `by` refs, and the last file proves replay and live select the same refs over the frozen fixture. `tests/harness/working-set-session.ts` is the shared driver. One harness at a time: `isolateClioEnv` holds a process-wide env lock for the life of a window, so a scenario that builds a second harness before disposing the first deadlocks rather than failing. --- .../working-set-scenarios-replay.test.ts | 123 ++++ .../working-set-scenarios-structural.test.ts | 222 ++++++++ tests/contracts/working-set-scenarios.test.ts | 523 ++++++++++++++++++ tests/harness/working-set-session.ts | 386 +++++++++++++ 4 files changed, 1254 insertions(+) create mode 100644 tests/contracts/working-set-scenarios-replay.test.ts create mode 100644 tests/contracts/working-set-scenarios-structural.test.ts create mode 100644 tests/contracts/working-set-scenarios.test.ts create mode 100644 tests/harness/working-set-session.ts diff --git a/tests/contracts/working-set-scenarios-replay.test.ts b/tests/contracts/working-set-scenarios-replay.test.ts new file mode 100644 index 000000000..27ce5d3d9 --- /dev/null +++ b/tests/contracts/working-set-scenarios-replay.test.ts @@ -0,0 +1,123 @@ +/** + * Charter section 10: replay is live. + * + * The replay-lite runner exists to measure policies offline, and the only + * reason its numbers mean anything is that it drives the same `fold`, + * `planEviction`, and `project` code the live stage does. This scenario proves + * that on the frozen fixture: run `replayTrace` over it, then write the exact + * entry prefix its first event stood on into a real session, run the live + * `runAutoCompact` over that session, and compare the two evicted ref sets. + * + * The two sides disagree about the pressure number by construction. Replay + * measures the projected ledger; the live stage measures the agent's message + * list. `age-horizon` selection does not read either total (it has no target + * stop, only the protection horizon and the size floor), so the ref sets must + * still match exactly. A policy that started ranking by size would break this + * test, which is the point. + */ + +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { DEFAULT_WORKING_SET_SETTINGS } from "../../src/domains/context/working-set/defaults.js"; +import { ageHorizonPolicy } from "../../src/domains/context/working-set/policies/age-horizon.js"; +import { loadClioTraces } from "../../src/domains/context/working-set/replay/load-clio.js"; +import { replayTrace } from "../../src/domains/context/working-set/replay/runner.js"; +import { isReplayTurnStart } from "../../src/domains/context/working-set/replay/trace.js"; +import type { EvictedItem, MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; +import { createScenarioHarness, evictionEntries } from "../harness/working-set-session.js"; + +const FIXTURE = join(process.cwd(), "tests", "fixtures", "context-replay", "fixture-01.jsonl"); + +/** The budget the replay runs on. Any budget the first event fires under works; this one does. */ +const REPLAY_BUDGET_TOKENS = 16_000; +const THRESHOLD = 0.8; + +/** + * The entries `replayTrace` had accumulated when its `turnIndex`-th event + * fired: everything strictly before that turn's opening entry, which is where + * the runner takes its pressure reading. + */ +function entriesBeforeTurn(entries: ReadonlyArray, turnIndex: number): SessionEntry[] { + let seen = 0; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry === undefined || !isReplayTurnStart(entry)) continue; + seen += 1; + if (seen === turnIndex) return entries.slice(0, index); + } + throw new Error(`the trace has no turn ${turnIndex}`); +} + +function isMessage(entry: SessionEntry): entry is MessageEntry { + return entry.kind === "message"; +} + +describe("contracts/working-set scenarios (replay is live)", () => { + it("replayTrace and runAutoCompact evict the same refs on the frozen fixture", async () => { + const loaded = await loadClioTraces([FIXTURE], { filter: false }); + const trace = loaded.traces[0]; + ok(trace, `the frozen fixture must load from ${FIXTURE}`); + strictEqual(trace.id, "context-replay-fixture-01"); + + const replayed = replayTrace(trace, ageHorizonPolicy, { + policyId: ageHorizonPolicy.id, + budgetTokens: REPLAY_BUDGET_TOKENS, + threshold: THRESHOLD, + target: DEFAULT_WORKING_SET_SETTINGS.target, + settings: DEFAULT_WORKING_SET_SETTINGS, + seed: 1, + }); + const first = replayed.events[0]; + ok(first, "the fixture must cross the budget at least once"); + const replayRefs = first.items.map((item) => item.ref.entry).sort(); + ok(replayRefs.length > 0, "the first replay event evicted something"); + + // The exact prefix the runner stood on when it made that decision. + const prefix = entriesBeforeTurn(trace.entries, first.turnIndex); + ok(prefix.every(isMessage), "the fixture is all message entries, so it seeds through session.append"); + + const harness = await createScenarioHarness({ + prefix: "clio-ws-replay-parity-", + // Sized so the live pressure check fires over the same prefix. The + // number differs from the replay budget on purpose: the two estimators + // measure different things, and the selection must not care. + contextWindow: 8_000, + threshold: THRESHOLD, + policy: "age-horizon", + }); + try { + let parentId: string | null = null; + for (const entry of prefix) { + if (!isMessage(entry)) continue; + parentId = harness.session.append({ + id: entry.turnId, + parentId, + at: entry.timestamp, + kind: entry.role, + payload: entry.payload, + }).id; + } + ok(parentId !== null, "the prefix seeded at least one turn"); + harness.syncRuntimeFromLedger(parentId); + + await harness.context.runAutoCompact(harness.runtime, false); + + const live = evictionEntries(harness.entries())[0]; + ok(live, "the live stage must also evict over this prefix"); + strictEqual(live.policyId, ageHorizonPolicy.id); + const liveRefs = live.evicted.map((item) => item.ref.entry).sort(); + + deepStrictEqual(liveRefs, replayRefs, "replay and live must select the same units over the same ledger"); + // The markers are byte-stable, so the two runs also agree on what the + // model would have read in place of each body. + for (const item of first.items) { + const paired: EvictedItem | undefined = live.evicted.find((candidate) => candidate.ref.entry === item.ref.entry); + strictEqual(paired?.marker, item.marker, `marker drift for ${item.ref.entry}`); + strictEqual(paired?.reason, item.reason, `reason drift for ${item.ref.entry}`); + } + } finally { + await harness.dispose(); + } + }); +}); diff --git a/tests/contracts/working-set-scenarios-structural.test.ts b/tests/contracts/working-set-scenarios-structural.test.ts new file mode 100644 index 000000000..a7538ebdc --- /dev/null +++ b/tests/contracts/working-set-scenarios-structural.test.ts @@ -0,0 +1,222 @@ +/** + * Charter scenarios S3, S4, and S5 end to end, under `structural-v1`. + * + * The policy unit tests build `PolicyInput` by hand. These drive the same + * rules through the real stage: a scripted session on disk, the session cwd + * reaching the path index the way `runAutoCompact` passes it, and the reasons + * and `by` refs read back off the `contextEviction` record the session wrote. + * That is the part a pure test cannot cover, because the path index keys files + * by absolute path and the cwd only arrives from `session.current()`. + */ + +import { ok, strictEqual } from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { EvictedItem } from "../../src/domains/session/entries.js"; +import { + createScenarioHarness, + evictionEntries, + type ScenarioHarness, + type ScenarioToolCall, + type ScenarioTurn, + scenarioBody, + seedScenarioTurns, +} from "../harness/working-set-session.js"; + +/** The twelve files the `find` at turn 6 surfaces. */ +const SURFACED = Array.from( + { length: 12 }, + (_, index) => `src/generated/module-${String(index + 1).padStart(2, "0")}.ts`, +); + +/** + * A `find` result as the tool prints it: one path per line, plus the directory + * rows a real listing includes. The directories are what carries the body past + * `minEvictableTokens`; the path index skips any line ending in `/`, so only + * the twelve files count as surfaced and only they have to be read for the + * listing to be consumed. + */ +function findBody(): string { + const directories = Array.from( + { length: 40 }, + (_, index) => `src/generated/bucket-${String(index + 1).padStart(2, "0")}/`, + ); + return [...directories, ...SURFACED].join("\n"); +} + +function read(callId: string, path: string, label: string): ScenarioToolCall[] { + return [{ callId, tool: "read", args: { path }, body: scenarioBody(label, 30) }]; +} + +/** + * The scripted session the three rules read: + * turn 3 reads a.ts, turn 20 reads it again in full -> superseded_read + * turn 4 reads b.ts, turn 5 edits b.ts -> stale_after_mutation + * turn 6 finds twelve paths, turns 7..18 read them -> listing_consumed + * Turns 21 to 28 are filler so everything above sits outside the protection + * horizon when the policy runs. + */ +function structuralScript(): ScenarioTurn[] { + const turns: ScenarioTurn[] = []; + const filler = (id: string, label: string): ScenarioTurn => ({ + id, + user: `filler ${label}`, + calls: read(`filler-${label}`, `src/filler/${label}.ts`, `filler-${label}`), + assistant: { text: `filler ${label} done` }, + }); + + turns.push(filler("t01", "one"), filler("t02", "two")); + turns.push({ + id: "t03", + user: "read a.ts", + calls: read("read-a-first", "src/a.ts", "a-v1"), + assistant: { text: "read a" }, + }); + turns.push({ + id: "t04", + user: "read b.ts", + calls: read("read-b", "src/b.ts", "b-v1"), + assistant: { text: "read b" }, + }); + turns.push({ + id: "t05", + user: "edit b.ts", + calls: [ + { + callId: "edit-b", + tool: "edit", + args: { path: "src/b.ts", edits: [{ oldText: "before", newText: "after" }] }, + body: scenarioBody("b-edited", 8), + }, + ], + assistant: { text: "edited b" }, + }); + turns.push({ + id: "t06", + user: "find the generated modules", + calls: [ + { callId: "find-generated", tool: "find", args: { path: ".", pattern: "src/generated/*.ts" }, body: findBody() }, + ], + assistant: { text: "found twelve" }, + }); + // Five of the surfaced paths first, then the remaining seven: the listing is + // only consumed once every path it surfaced has been read. + SURFACED.forEach((path, index) => { + const id = `t${String(index + 7).padStart(2, "0")}`; + turns.push({ + id, + user: `read ${path}`, + calls: read(`read-generated-${index + 1}`, path, `generated-${index + 1}`), + assistant: { text: `read ${path}` }, + }); + }); + turns.push({ + id: "t19", + user: "unrelated step", + calls: read("read-unrelated", "src/unrelated.ts", "unrelated"), + assistant: { text: "unrelated done" }, + }); + turns.push({ + id: "t20", + user: "re-read a.ts in full", + calls: read("read-a-second", "src/a.ts", "a-v2"), + assistant: { text: "re-read a" }, + }); + for (let index = 21; index <= 28; index += 1) { + turns.push(filler(`t${index}`, `late-${index}`)); + } + return turns; +} + +function itemFor(items: ReadonlyArray, ref: string): EvictedItem | undefined { + return items.find((item) => item.ref.entry === ref); +} + +async function runStructuralScenario(): Promise<{ harness: ScenarioHarness; items: ReadonlyArray }> { + const harness = await createScenarioHarness({ + prefix: "clio-ws-structural-", + // Small enough that this scripted session crosses 0.8 and the stage + // reaches the policy at all. Rungs 1 to 5 are unconditional once it does. + contextWindow: 8_000, + threshold: 0.8, + policy: "structural-v1", + }); + // Dispose on any failure here: the harness holds the process-wide isolated-env + // lock, so throwing out of this function without releasing it hangs the next test. + try { + const seeded = seedScenarioTurns(harness.session, structuralScript()); + harness.syncRuntimeFromLedger(seeded.leafTurnId); + await harness.context.runAutoCompact(harness.runtime, false); + const eviction = evictionEntries(harness.entries())[0]; + if (eviction === undefined) throw new Error("the structural scenario produced no eviction event"); + strictEqual(eviction.policyId, "structural-v1"); + return { harness, items: eviction.evicted }; + } catch (error) { + await harness.dispose(); + throw error; + } +} + +describe("contracts/working-set scenarios (S3, S4, S5 under structural-v1)", () => { + it("S4: a read invalidated by a later edit of the same file is stale_after_mutation, by the edit", async () => { + const { harness, items } = await runStructuralScenario(); + try { + const item = itemFor(items, "t04-result-1"); + ok(item, `b.ts's read was not evicted; got ${items.map((i) => `${i.ref.entry}:${i.reason}`).join(" ")}`); + strictEqual(item.reason, "stale_after_mutation"); + strictEqual(item.by, "t05-result-1", "the `by` ref names the edit that invalidated it"); + ok(item.marker.includes("reason=stale_after_mutation"), item.marker); + ok(item.marker.includes("by=t05-result-1"), item.marker); + } finally { + await harness.dispose(); + } + }); + + it("S3: a read the agent repeated in full is superseded_read, by the later read", async () => { + const { harness, items } = await runStructuralScenario(); + try { + const item = itemFor(items, "t03-result-1"); + ok(item, `a.ts's first read was not evicted; got ${items.map((i) => `${i.ref.entry}:${i.reason}`).join(" ")}`); + strictEqual(item.reason, "superseded_read"); + strictEqual(item.by, "t20-result-1", "the `by` ref names the read that covered it"); + // Nothing supersedes the newer copy, so rung 2 never claims it. The age + // rung may still take it under pressure, which is a different reason and + // a different decision. + const superseding = itemFor(items, "t20-result-1"); + ok( + superseding === undefined || superseding.reason === "age_horizon", + `the superseding read must not be evicted as redundant, got ${superseding?.reason}`, + ); + } finally { + await harness.dispose(); + } + }); + + it("S5: a listing whose every surfaced path was read is listing_consumed, with no `by`", async () => { + const { harness, items } = await runStructuralScenario(); + try { + const item = itemFor(items, "t06-result-1"); + ok(item, `the find was not evicted; got ${items.map((i) => `${i.ref.entry}:${i.reason}`).join(" ")}`); + strictEqual(item.reason, "listing_consumed"); + strictEqual(item.by, undefined, "a consumed listing names no superseding entry"); + } finally { + await harness.dispose(); + } + }); + + it("selects the same units twice over the same ledger", async () => { + const shape = (items: ReadonlyArray): string => + items.map((item) => `${item.ref.entry}:${item.reason}:${item.by ?? "-"}`).join("|"); + // One run at a time: each harness holds the process-wide isolated-env + // lock until it disposes, so two live harnesses would deadlock. + const shapeOf = async (): Promise => { + const run = await runStructuralScenario(); + try { + return shape(run.items); + } finally { + await run.harness.dispose(); + } + }; + + strictEqual(await shapeOf(), await shapeOf(), "structural-v1 must be deterministic over one ledger"); + }); +}); diff --git a/tests/contracts/working-set-scenarios.test.ts b/tests/contracts/working-set-scenarios.test.ts new file mode 100644 index 000000000..ba43a2164 --- /dev/null +++ b/tests/contracts/working-set-scenarios.test.ts @@ -0,0 +1,523 @@ +/** + * Charter section 7 acceptance scenarios, end to end. + * + * Every scenario writes a real session through the session domain, forces + * pressure over the threshold, runs the real `runAutoCompact` stage, and then + * reads the result back through the four readers that matter: the ledger file, + * the model projection the agent receives, `/resume` rehydration, and the + * `/export` HTML document. The point of the layer is that those four disagree + * on purpose, so a scenario that only checked one of them would pass while the + * layer was destroying content. + */ + +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { clioDataDir, clioStateDir } from "../../src/core/xdg.js"; +import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; +import { openLedger } from "../../src/domains/dispatch/state.js"; +import { buildEvidence } from "../../src/domains/evidence/index.js"; +import { buildContextLedger } from "../../src/domains/session/context-ledger.js"; +import type { SessionEntry } from "../../src/domains/session/entries.js"; +import { createChatPanel } from "../../src/interactive/chat-panel.js"; +import { rehydrateChatPanelFromTurns } from "../../src/interactive/chat-renderer.js"; +import { renderContextLedgerLines } from "../../src/interactive/context-overlay.js"; +import { renderSessionHtml } from "../../src/interactive/export-html/index.js"; +import { createContextTool } from "../../src/tools/context/index.js"; +import { + createScenarioHarness, + evictionEntries, + ledgerBody, + projectionText, + recallEntries, + type ScenarioHarness, + type ScenarioTurn, + scenarioBody, + seedScenarioTurns, +} from "../harness/working-set-session.js"; + +const ESC = String.fromCharCode(27); +const strip = (text: string): string => text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), ""); + +/** The width `/export` renders at, so an export assertion matches what the operator gets. */ +const EXPORT_RENDER_WIDTH = 100; + +/** + * ~40 turns of ordinary work: 30 reads and 20 bash results with a few KB of + * body each, enough to cross 0.8 of a 32k window well before the last turn. + */ +function largeSessionScript(): ScenarioTurn[] { + const turns: ScenarioTurn[] = []; + for (let index = 1; index <= 40; index += 1) { + const id = `t${String(index).padStart(2, "0")}`; + const calls = []; + // Reads run late enough that the protected recent window holds some of + // them; bash runs early, so the age rung has something to take. + if (index >= 11) { + calls.push({ + callId: `read-${index}`, + tool: "read", + args: { path: `src/module-${index}.ts` }, + body: scenarioBody(`read-${index}`, 30), + }); + } + if (index <= 20) { + calls.push({ + callId: `bash-${index}`, + tool: "bash", + args: { command: `npm run check -- --shard ${index}` }, + body: scenarioBody(`bash-${index}`, 30), + }); + } + turns.push({ + id, + user: `step ${index}: keep working`, + calls, + assistant: { text: `step ${index} done` }, + }); + } + return turns; +} + +/** Render the ledger the way `/export` does, then hand it to the real HTML exporter. */ +function exportHtml(entries: ReadonlyArray, leafTurnId: string, sessionId: string): string { + const panel = createChatPanel({ unboundedToolBodies: true, getOutputVerbosity: () => "verbose" }); + rehydrateChatPanelFromTurns(panel, entries, { unboundedToolBodies: true, activeLeafTurnId: leafTurnId }); + return renderSessionHtml({ + sessionId, + exportedAt: "2026-08-21T12:00:00.000Z", + ansiLines: panel.render(EXPORT_RENDER_WIDTH), + }); +} + +/** Render the ledger the way `/resume` does. */ +function rehydratedTranscript(entries: ReadonlyArray, leafTurnId: string): string { + const panel = createChatPanel({ unboundedToolBodies: true, getOutputVerbosity: () => "verbose" }); + rehydrateChatPanelFromTurns(panel, entries, { unboundedToolBodies: true, activeLeafTurnId: leafTurnId }); + return strip(panel.render(EXPORT_RENDER_WIDTH).join("\n")); +} + +async function runLargeSessionScenario(prefix: string): Promise<{ + harness: ScenarioHarness; + leafTurnId: string; + bodyByRef: Map; + resultTurnIds: string[]; +}> { + const harness = await createScenarioHarness({ prefix, contextWindow: 32_000, threshold: 0.8 }); + const seeded = seedScenarioTurns(harness.session, largeSessionScript()); + harness.syncRuntimeFromLedger(seeded.leafTurnId); + await harness.context.runAutoCompact(harness.runtime, false); + return { + harness, + leafTurnId: seeded.leafTurnId, + bodyByRef: seeded.bodyByRef, + resultTurnIds: seeded.resultTurnIds, + }; +} + +describe("contracts/working-set scenarios (S1 non-destructive by construction)", () => { + it("keeps every body in the ledger, projects markers, and shows the bodies to the operator", async () => { + const { harness, leafTurnId, bodyByRef } = await runLargeSessionScenario("clio-ws-s1-"); + try { + const entries = harness.entries(); + const evictions = evictionEntries(entries); + + // One applied event, and the stage said so on the wire. + strictEqual(evictions.length, 1, "pressure over threshold applies exactly one eviction event"); + const eviction = evictions[0]; + ok(eviction, "the eviction entry exists"); + strictEqual(eviction.trigger, "pressure"); + strictEqual(eviction.policyId, "age-horizon"); + ok(eviction.evicted.length > 0, "the event evicted at least one unit"); + strictEqual(harness.pruned[0]?.stage, "working_set"); + ok(harness.hookStages.includes("working_set_evict")); + + // The ledger is intact. Every seeded body is still in the file verbatim, + // and every tool_result entry still parses to the bytes it was written + // with: an eviction that rewrote a payload would fail here. + const raw = harness.rawLedger(); + for (const [ref, body] of bodyByRef) { + ok(raw.includes(JSON.stringify(body).slice(1, -1)), `ledger lost the body of ${ref}`); + strictEqual(ledgerBody(entries, ref), body, `tool_result ${ref} was rewritten in the ledger`); + } + + // The model projection: markers where the event evicted, full bodies + // where it did not. + const evictedRefs = eviction.evicted.map((item) => item.ref.entry); + const projection = projectionText(harness.runtime); + for (const ref of evictedRefs) { + ok(projection.includes(`[evicted ref=${ref}`), `projection is missing the marker for ${ref}`); + const body = bodyByRef.get(ref); + if (body !== undefined) { + ok(!projection.includes(body.slice(0, 200)), `projection still carries the evicted body of ${ref}`); + } + } + const survivors = [...bodyByRef.keys()].filter((ref) => !evictedRefs.includes(ref)); + ok(survivors.length > 0, "the protection horizon kept something"); + for (const ref of survivors) { + const body = bodyByRef.get(ref) ?? ""; + ok(projection.includes(body.slice(0, 200)), `projection dropped the protected body of ${ref}`); + } + + // /resume rehydration: the full body plus the reason it left the model's + // working set. The transcript shows the ledger, never the projection. + const transcript = rehydratedTranscript(entries, leafTurnId); + const firstEvicted = evictedRefs[0] ?? ""; + const firstBody = bodyByRef.get(firstEvicted) ?? ""; + ok(firstBody.length > 0, "the first evicted ref is a tool result with a body"); + ok(transcript.includes(firstBody.split("\n")[0] ?? ""), "rehydration must show the evicted body"); + // Every evicted tool result is tagged, not just the first: the tag is + // how an operator reading a resumed transcript knows which rows the + // model can no longer see. + const taggedRows = transcript.split("evicted · age_horizon").length - 1; + strictEqual(taggedRows, evictedRefs.length, "every evicted row must carry the reason tag"); + ok(!transcript.includes(`[evicted ref=${firstEvicted}`), "the transcript never renders the model's marker"); + + // HTML export: the operator's archive keeps the body too. + const html = exportHtml(entries, leafTurnId, harness.sessionId()); + ok(html.includes(firstBody.split("\n")[0] ?? ""), "the HTML export must carry the evicted body"); + ok(!html.includes(`[evicted ref=${firstEvicted}`), "the HTML export never renders the model's marker"); + } finally { + await harness.dispose(); + } + }); + + it("reports the eviction and every evicted ref in the evidence bundle", async () => { + const { harness } = await runLargeSessionScenario("clio-ws-s1-evidence-"); + try { + const eviction = evictionEntries(harness.entries())[0]; + ok(eviction, "the scenario produced an eviction to report"); + + // `clio-coder evidence build --session` selects by the run rows that + // name the session, so the bundle needs one run to exist at all. The + // row is the only fabricated artifact here; the transcript it renders + // is read from the session ledger this scenario actually wrote. + const ledger = openLedger(); + ledger.create({ + agentId: "coder", + executionRole: "builder", + task: "working-set scenario", + targetId: "scenario-target", + wireModelId: "scenario-model", + runtimeId: "scenario-runtime", + runtimeKind: "http", + sessionId: harness.sessionId(), + cwd: harness.cwd, + }); + await ledger.persist(); + + const built = await buildEvidence({ + dataDir: clioDataDir(), + stateDir: clioStateDir(), + sessionId: harness.sessionId(), + }); + const transcript = readFileSync(join(built.directory, "transcript.md"), "utf8"); + + ok( + transcript.includes(`contextEviction policy=age-horizon trigger=pressure items=${eviction.evicted.length}`), + transcript.slice(0, 2000), + ); + for (const item of eviction.evicted) { + ok( + transcript.includes(`evicted ref=${item.ref.entry} reason=${item.reason}`), + `the bundle omitted evicted ref ${item.ref.entry}`, + ); + } + } finally { + await harness.dispose(); + } + }); +}); + +describe("contracts/working-set scenarios (S2 exact recall)", () => { + it("returns the ledger bytes, records the recall, and leaves the marker in place", async () => { + const { harness, leafTurnId } = await runLargeSessionScenario("clio-ws-s2-"); + try { + const before = harness.entries(); + const eviction = evictionEntries(before)[0]; + ok(eviction, "S2 stands on the S1 eviction"); + // A bash result, because a recall that only worked for `read` would + // pass the tool-level unit tests and still be useless in practice. + const bashRef = "t20-result-2"; + ok( + eviction.evicted.some((item) => item.ref.entry === bashRef), + `${bashRef} must be one of the evicted refs`, + ); + const expected = ledgerBody(before, bashRef); + ok(expected.length > 0, "the ledger still holds the bash body"); + + // The real tool, wired to the real session: the same deps + // `core-bootstrap.ts` builds for a bound orchestrator session. + const tool = createContextTool({ + session: { + hasSession: () => harness.session.current() !== null, + readEntries: () => harness.entries(), + activeLeafTurnId: () => harness.session.tree(harness.sessionId()).leafId ?? undefined, + appendEntry: (entry) => harness.session.appendEntry(entry), + }, + }); + const result = await tool.run({ scope: "recall", ref: bashRef }, { toolCallId: "recall-call-1" }); + + strictEqual(result.kind, "ok"); + if (result.kind !== "ok") return; + strictEqual(result.output, expected, "recall must return the ledger body byte-exact"); + + const after = harness.entries(); + const recalls = recallEntries(after); + strictEqual(recalls.length, 1); + strictEqual(recalls[0]?.ref.entry, bashRef); + strictEqual(recalls[0]?.trigger, "tool"); + strictEqual(recalls[0]?.toolCallId, "recall-call-1"); + + // A recall is not an un-eviction: rebuild the projection and the marker + // is still exactly where it was. + harness.syncRuntimeFromLedger(leafTurnId); + const projection = projectionText(harness.runtime); + ok(projection.includes(`[evicted ref=${bashRef}`), "the marker survives the recall"); + ok(!projection.includes(expected.slice(0, 200)), "the body is not readmitted at its original position"); + + // /context reports it as churn. + const view = foldWorkingSet(after, leafTurnId); + strictEqual(view.recalls, 1); + strictEqual(view.itemsEvicted, eviction.evicted.length); + const overlay = strip( + renderContextLedgerLines( + buildContextLedger({ provider: "scenario-target", model: "scenario-model", contextWindow: 32_000 }), + 68, + view, + ).join("\n"), + ); + const churn = (1 / eviction.evicted.length).toFixed(2); + ok(overlay.includes("1 recall ·"), overlay); + ok(overlay.includes(`churn ${churn}`), overlay); + } finally { + await harness.dispose(); + } + }); +}); + +/** A shorter session: enough pressure on a 4k window to evict, small enough to branch by hand. */ +function smallSessionScript(count = 12): ScenarioTurn[] { + return Array.from({ length: count }, (_, index) => { + const id = `s${String(index + 1).padStart(2, "0")}`; + return { + id, + user: `small step ${index + 1}`, + calls: [ + { + callId: `read-${index + 1}`, + tool: "read", + args: { path: `src/small-${index + 1}.ts` }, + body: scenarioBody(`small-${index + 1}`, 30), + }, + ], + assistant: { text: `small step ${index + 1} done` }, + } satisfies ScenarioTurn; + }); +} + +describe("contracts/working-set scenarios (S9 forks and branch switches)", () => { + it("a fork before the eviction inherits no view, and a fork at its anchor inherits one", async () => { + const harness = await createScenarioHarness({ prefix: "clio-ws-s9-fork-", contextWindow: 4_000, threshold: 0.8 }); + try { + const seeded = seedScenarioTurns(harness.session, smallSessionScript()); + harness.syncRuntimeFromLedger(seeded.leafTurnId); + await harness.context.runAutoCompact(harness.runtime, false); + + const parentId = harness.sessionId(); + const eviction = evictionEntries(harness.entries())[0]; + ok(eviction, "the small session produced an eviction"); + strictEqual(eviction.parentTurnId, seeded.leafTurnId, "the event anchors on the branch it was made on"); + + // Forking from a turn before the event: the child never saw it. + harness.session.fork("s03-assistant"); + const earlyChild = harness.entries(); + strictEqual( + foldWorkingSet(earlyChild).evicted.size, + 0, + "a fork from before the eviction must start with a full working set", + ); + strictEqual(evictionEntries(earlyChild).length, 0, "the child ledger carries no eviction record"); + + // Forking from the turn the event anchors on: the child inherits it. + harness.session.resume(parentId); + harness.session.fork(seeded.leafTurnId); + const lateChild = harness.entries(); + const inherited = foldWorkingSet(lateChild); + strictEqual(inherited.evicted.size, eviction.evicted.length, "a fork at the anchor inherits the whole view"); + for (const item of eviction.evicted) { + ok(inherited.evicted.has(item.ref.entry), `the fork lost ${item.ref.entry}`); + } + } finally { + await harness.dispose(); + } + }); + + // Issue #94: current.jsonl is append-only, so after a /tree switch the file + // still holds the abandoned branch. An eviction recorded there must not + // project onto the branch the session is now on. + it("a /tree switch to a sibling branch never projects the abandoned branch's evictions", async () => { + const harness = await createScenarioHarness({ prefix: "clio-ws-s9-tree-", contextWindow: 4_000, threshold: 0.8 }); + try { + const seeded = seedScenarioTurns(harness.session, smallSessionScript()); + harness.syncRuntimeFromLedger(seeded.leafTurnId); + await harness.context.runAutoCompact(harness.runtime, false); + const abandoned = evictionEntries(harness.entries())[0]; + ok(abandoned, "the first branch produced an eviction"); + + // Switch back and grow a sibling branch off s03-assistant. + harness.session.switchTurn("s03-assistant"); + const sibling = seedScenarioTurns( + harness.session, + [ + { id: "b01", user: "sibling step 1", assistant: { text: "sibling 1" } }, + { id: "b02", user: "sibling step 2", assistant: { text: "sibling 2" } }, + ], + 5_000, + "s03-assistant", + ); + + const entries = harness.entries(); + strictEqual( + foldWorkingSet(entries, sibling.leafTurnId).evicted.size, + 0, + "the sibling branch must see no eviction from the abandoned one", + ); + strictEqual( + foldWorkingSet(entries, seeded.leafTurnId).evicted.size, + abandoned.evicted.length, + "the original branch keeps its own view", + ); + + // The projection follows the fold, so nothing on the sibling branch is + // replaced by a marker. + harness.syncRuntimeFromLedger(sibling.leafTurnId); + ok(!projectionText(harness.runtime).includes("[evicted ref="), "the sibling branch projects no markers"); + } finally { + await harness.dispose(); + } + }); +}); + +describe("contracts/working-set scenarios (S11 thinking rule)", () => { + it("drops thinking beyond the horizon from the projection and keeps it everywhere else", async () => { + const harness = await createScenarioHarness({ + prefix: "clio-ws-s11-", + contextWindow: 4_000, + threshold: 0.8, + protectLastTurns: 3, + }); + try { + const turns: ScenarioTurn[] = Array.from({ length: 10 }, (_, index) => ({ + id: `k${String(index + 1).padStart(2, "0")}`, + user: `thinking step ${index + 1}`, + calls: [ + { + callId: `read-${index + 1}`, + tool: "read", + args: { path: `src/think-${index + 1}.ts` }, + body: scenarioBody(`think-${index + 1}`, 30), + }, + ], + assistant: { text: `answer ${index + 1}`, thinking: `PRIVATE-REASONING-${String(index + 1).padStart(2, "0")}` }, + })); + const seeded = seedScenarioTurns(harness.session, turns); + harness.syncRuntimeFromLedger(seeded.leafTurnId); + await harness.context.runAutoCompact(harness.runtime, false); + + const entries = harness.entries(); + const eviction = evictionEntries(entries)[0]; + ok(eviction, "the thinking session produced an eviction"); + const closedThinking = eviction.evicted.filter((item) => item.reason === "thinking_turn_closed"); + ok(closedThinking.length > 0, "assistant turns beyond the horizon lose their thinking"); + strictEqual( + closedThinking.every((item) => item.marker === ""), + true, + "thinking eviction renders no marker", + ); + + // The projection: gone beyond the horizon, present inside it. With + // protectLastTurns 3 the last three turns keep their reasoning. + const projection = projectionText(harness.runtime); + const reasoning = (index: number): string => `PRIVATE-REASONING-${String(index).padStart(2, "0")}`; + for (let index = 1; index <= 7; index += 1) { + ok(!projection.includes(reasoning(index)), `turn ${index} thinking must leave the working set`); + } + for (const index of [8, 9, 10]) { + ok(projection.includes(reasoning(index)), `turn ${index} is inside the horizon and keeps thinking`); + } + + // The ledger keeps every block, and so does /resume. + const raw = harness.rawLedger(); + for (let index = 1; index <= 10; index += 1) { + ok(raw.includes(reasoning(index)), `the ledger dropped turn ${index}'s thinking`); + } + const transcript = rehydratedTranscript(entries, seeded.leafTurnId); + ok(transcript.includes(reasoning(1)), "rehydration replays the reasoning the model no longer sees"); + ok(transcript.includes(reasoning(10)), transcript.slice(0, 400)); + } finally { + await harness.dispose(); + } + }); +}); + +describe("contracts/working-set scenarios (S12 default-off safety)", () => { + it("disabled: the ledger is untouched and the summary stage is reached", async () => { + const harness = await createScenarioHarness({ + prefix: "clio-ws-s12-off-", + contextWindow: 4_000, + threshold: 0.8, + workingSetEnabled: false, + autoCompact: async () => null, + }); + try { + const seeded = seedScenarioTurns(harness.session, smallSessionScript()); + harness.syncRuntimeFromLedger(seeded.leafTurnId); + const before = harness.rawLedger(); + + await harness.context.runAutoCompact(harness.runtime, false); + + strictEqual(harness.rawLedger(), before, "a disabled working set must not write to the ledger"); + strictEqual(evictionEntries(harness.entries()).length, 0); + strictEqual(harness.summaryCalls(), 1, "pressure still reaches the summary stage"); + deepStrictEqual(harness.hookStages, ["llm_summary"]); + } finally { + await harness.dispose(); + } + }); + + // The escape hatch is scheduled for removal. Until it is deleted, assert it + // still does the destructive thing it promises, so its removal is a visible + // change rather than a silent one. + it("CLIO_CODER_LEGACY_MASK=1 still rewrites the ledger in place", async () => { + const previous = process.env.CLIO_CODER_LEGACY_MASK; + process.env.CLIO_CODER_LEGACY_MASK = "1"; + const harness = await createScenarioHarness({ + prefix: "clio-ws-s12-legacy-", + contextWindow: 4_000, + threshold: 0.8, + }); + try { + const seeded = seedScenarioTurns(harness.session, smallSessionScript()); + harness.syncRuntimeFromLedger(seeded.leafTurnId); + const before = harness.rawLedger(); + const firstBody = seeded.bodyByRef.get("s01-result-1") ?? ""; + ok(before.includes(JSON.stringify(firstBody).slice(1, -1)), "the body starts out in the ledger"); + + await harness.context.runAutoCompact(harness.runtime, false); + + const after = harness.rawLedger(); + ok(after !== before, "the legacy stage rewrites current.jsonl"); + ok(!after.includes(JSON.stringify(firstBody).slice(1, -1)), "the legacy stage destroys the original body"); + ok(after.includes("Observation masked:"), "the legacy marker format is what replaced it"); + strictEqual(evictionEntries(harness.entries()).length, 0, "the legacy path records no eviction entry"); + ok(harness.hookStages.includes("mask_observations")); + strictEqual(harness.pruned[0]?.stage, "mask_observations"); + } finally { + if (previous === undefined) delete process.env.CLIO_CODER_LEGACY_MASK; + else process.env.CLIO_CODER_LEGACY_MASK = previous; + await harness.dispose(); + } + }); +}); diff --git a/tests/harness/working-set-session.ts b/tests/harness/working-set-session.ts new file mode 100644 index 000000000..b9d51cf93 --- /dev/null +++ b/tests/harness/working-set-session.ts @@ -0,0 +1,386 @@ +/** + * A real session on disk, driven through the real compaction stage. + * + * The working-set unit tests are pure over entry arrays. These scenarios ask a + * different question: does the layer hold when the ledger is the engine's own + * JSONL writer, the projection is the one `refreshAgentMessagesFromSession` + * installs on the agent, and the readers are the ones `/resume`, `/export`, and + * `clio-coder evidence build` actually call. So nothing here fakes the session + * contract: `createSessionBundle` writes through `engine/session.ts` into an + * isolated CLIO_CODER_HOME, and every assertion reads it back the way the + * product does. + * + * The one stub is the model. Compaction is triggered by pressure, and pressure + * is `estimateAgentContextTokens` over the agent's message list, so the harness + * seeds that list from the ledger through the same replay builder the chat loop + * uses. No provider is contacted and no summary model runs unless a scenario + * asks for one. + * + * Determinism: every seeded turn carries an explicit id and timestamp, so a + * scenario's assertions never depend on a clock. The `contextEviction` record + * the stage appends gets its turnId and timestamp from the session writer; + * scenarios assert its shape, never those two fields. + * + * One harness at a time. `isolateClioEnv` holds a process-wide lock on the + * CLIO_CODER_* environment for the life of the window, so a scenario that + * builds a second harness before disposing the first deadlocks rather than + * failing. Sequence them. + */ + +import { readFileSync } from "node:fs"; +import { BusChannels, type ContextPrunedPayload } from "../../src/core/bus-events.js"; +import type { ClioSettings } from "../../src/core/config.js"; +import { DEFAULT_SETTINGS, type WorkingSetPolicyId } from "../../src/core/defaults.js"; +import type { DomainContext } from "../../src/core/domain-loader.js"; +import { createSafeEventBus, type SafeEventBus } from "../../src/core/event-bus.js"; +import { collectSessionEntries } from "../../src/domains/session/compaction/session-entries.js"; +import type { SessionContract } from "../../src/domains/session/contract.js"; +import type { SessionEntry } from "../../src/domains/session/entries.js"; +import { createSessionBundle } from "../../src/domains/session/extension.js"; +import { openSession, sessionPaths } from "../../src/engine/session.js"; +import type { AgentMessage } from "../../src/engine/types.js"; +import { buildModelReplayAgentMessagesFromTurns } from "../../src/interactive/model-session-replay.js"; +import { createTurnContext, type TurnContext } from "../../src/interactive/turn-context.js"; +import { type AgentRuntime, type ChatTurnState, createTurnState } from "../../src/interactive/turn-state.js"; +import { type IsolatedClioEnv, isolateClioEnv } from "./scratch-env.js"; + +/** First seeded timestamp. Every later entry is this plus one second per step. */ +const CLOCK_START = Date.parse("2026-08-21T00:00:00.000Z"); + +export function scenarioTimestamp(step: number): string { + return new Date(CLOCK_START + step * 1000).toISOString(); +} + +export interface ScenarioToolCall { + callId: string; + tool: string; + args: Record; + /** The tool-result body, verbatim. Scenarios assert on these bytes. */ + body: string; + isError?: boolean; + /** Present on `write` and `edit` results the path index reads as mutations. */ + details?: Record; +} + +export interface ScenarioTurn { + /** Turn id prefix; entries are `-user`, `-call-`, `-result-`, `-assistant`. */ + id: string; + user: string; + calls?: ReadonlyArray; + /** An assistant turn closing the turn. `thinking` becomes a thinking content block. */ + assistant?: { text?: string; thinking?: string }; +} + +/** Bodies large enough to matter, stable enough to assert on byte-for-byte. */ +export function scenarioBody(label: string, lines: number): string { + return Array.from( + { length: lines }, + (_, index) => `${label} line ${String(index + 1).padStart(4, "0")} :: deterministic working-set scenario payload`, + ).join("\n"); +} + +function toolResultPayload(call: ScenarioToolCall): Record { + return { + toolCallId: call.callId, + toolName: call.tool, + result: { + content: [{ type: "text", text: call.body }], + details: { + resultSize: { bytes: call.body.length, shownBytes: call.body.length, truncated: false }, + ...(call.details ?? {}), + }, + }, + isError: call.isError === true, + resultSummary: { bytes: call.body.length, truncated: false }, + }; +} + +function assistantPayload(assistant: { text?: string; thinking?: string }): Record { + const content: unknown[] = []; + if (assistant.thinking !== undefined) content.push({ type: "thinking", thinking: assistant.thinking }); + content.push({ type: "text", text: assistant.text ?? "ok" }); + return { content, stopReason: "stop" }; +} + +export interface SeedResult { + /** Turn id of the last appended message: the session leaf. */ + leafTurnId: string; + /** Every tool-result turn id, in ledger order. */ + resultTurnIds: string[]; + /** Body by tool-result turn id, so a scenario can assert bytes without re-deriving them. */ + bodyByRef: Map; +} + +/** + * Append a scripted conversation through the real session writer. Every append + * chains onto the previous one, which is the invariant `session.append` + * enforces, so this is the same sequence a live turn would produce. + */ +export function seedScenarioTurns( + session: SessionContract, + turns: ReadonlyArray, + startStep = 1, + /** Chain onto an existing turn, e.g. the pin a `/tree` switch just set. Null starts a root. */ + parentTurnId: string | null = null, +): SeedResult { + let parentId: string | null = parentTurnId; + let step = startStep; + const resultTurnIds: string[] = []; + const bodyByRef = new Map(); + const next = (): string => scenarioTimestamp(step++); + + for (const turn of turns) { + parentId = session.append({ + id: `${turn.id}-user`, + parentId, + at: next(), + kind: "user", + payload: { text: turn.user }, + }).id; + let callIndex = 0; + for (const call of turn.calls ?? []) { + callIndex += 1; + parentId = session.append({ + id: `${turn.id}-call-${callIndex}`, + parentId, + at: next(), + kind: "tool_call", + payload: { toolCallId: call.callId, toolName: call.tool, name: call.tool, args: call.args }, + }).id; + const resultId = `${turn.id}-result-${callIndex}`; + parentId = session.append({ + id: resultId, + parentId, + at: next(), + kind: "tool_result", + payload: toolResultPayload(call), + }).id; + resultTurnIds.push(resultId); + bodyByRef.set(resultId, call.body); + } + if (turn.assistant !== undefined) { + parentId = session.append({ + id: `${turn.id}-assistant`, + parentId, + at: next(), + kind: "assistant", + payload: assistantPayload(turn.assistant), + }).id; + } + } + if (parentId === null) throw new Error("seedScenarioTurns: no turns seeded"); + return { leafTurnId: parentId, resultTurnIds, bodyByRef }; +} + +export interface ScenarioHarnessOptions { + prefix: string; + contextWindow?: number; + threshold?: number; + policy?: WorkingSetPolicyId; + workingSetEnabled?: boolean; + protectLastTurns?: number; + minEvictableTokens?: number; + target?: number; + /** Present means the summary stage has somewhere to go; absent means it is a no-op. */ + autoCompact?: () => Promise; +} + +export interface ScenarioHarness { + /** Isolated workspace root; also the session cwd the path index resolves against. */ + cwd: string; + session: SessionContract; + settings: ClioSettings; + state: ChatTurnState; + runtime: AgentRuntime; + context: TurnContext; + bus: SafeEventBus; + notices: string[]; + hookStages: string[]; + pruned: ContextPrunedPayload[]; + summaryCalls: () => number; + /** Entries as the product reads them: the engine's turn reader over current.jsonl. */ + entries: () => SessionEntry[]; + /** The ledger file verbatim, for byte-for-byte survival checks. */ + rawLedger: () => string; + sessionId: () => string; + /** Rebuild the agent's message list from the ledger, so the pressure check is real. */ + syncRuntimeFromLedger: (leafTurnId: string) => void; + dispose: () => Promise; +} + +function scenarioSettings(options: ScenarioHarnessOptions): ClioSettings { + const settings = structuredClone(DEFAULT_SETTINGS) as ClioSettings; + settings.compaction.threshold = options.threshold ?? 0.8; + settings.compaction.excludeLastTurns = 1; + settings.context.workingSet.enabled = options.workingSetEnabled ?? true; + settings.context.workingSet.policy = options.policy ?? "age-horizon"; + if (options.protectLastTurns !== undefined) settings.context.workingSet.protectLastTurns = options.protectLastTurns; + if (options.minEvictableTokens !== undefined) { + settings.context.workingSet.minEvictableTokens = options.minEvictableTokens; + } + if (options.target !== undefined) settings.context.workingSet.target = options.target; + return settings; +} + +/** + * The agent the turn context drives. Only the four fields the pressure + * estimator and the snapshot capture read are real; nothing here contacts a + * provider, and `messages` is always rebuilt from the ledger by the same + * builder the chat loop uses. + */ +function scenarioRuntime(contextWindow: number): AgentRuntime { + return { + targetId: "scenario-target", + runtimeId: "scenario-runtime", + wireModelId: "scenario-model", + agent: { + sessionId: undefined, + state: { + systemPrompt: "", + messages: [] as AgentMessage[], + tools: [], + model: undefined, + thinkingLevel: "off", + }, + } as never, + runtimeResolution: { + contextWindowDetails: { + desiredContextWindow: contextWindow, + effectiveContextWindow: contextWindow, + contextWindowSource: "descriptor-default", + }, + } as never, + }; +} + +export async function createScenarioHarness(options: ScenarioHarnessOptions): Promise { + const isolated: IsolatedClioEnv = await isolateClioEnv(options.prefix); + const bus = createSafeEventBus(); + const domainContext = { bus, getContract: () => undefined } as unknown as DomainContext; + const session = createSessionBundle(domainContext).contract; + session.create({ cwd: isolated.dir, model: "scenario-model", target: "scenario-target" }); + + const settings = scenarioSettings(options); + const contextWindow = options.contextWindow ?? 32_000; + const state = createTurnState("off"); + const runtime = scenarioRuntime(contextWindow); + state.runtime = runtime; + + const notices: string[] = []; + const hookStages: string[] = []; + const pruned: ContextPrunedPayload[] = []; + bus.on(BusChannels.ContextPruned, (payload) => { + pruned.push(payload); + }); + let summaryCalls = 0; + + const entries = (): SessionEntry[] => { + const meta = session.current(); + if (!meta) return []; + return collectSessionEntries(openSession(meta.id).turns(), sessionPaths(meta).current); + }; + + const context = createTurnContext({ + state, + getSettings: () => settings, + providers: { getRuntime: () => null } as never, + session, + readSessionEntries: entries, + ...(options.autoCompact + ? { + autoCompact: async () => { + summaryCalls += 1; + return options.autoCompact ? await options.autoCompact() : null; + }, + } + : {}), + bus, + middleware: { + fireCompactionHook(stage: string) { + hookStages.push(stage); + }, + } as never, + emitNotice: (text: string) => notices.push(text), + }); + + return { + cwd: isolated.dir, + session, + settings, + state, + runtime, + context, + bus, + notices, + hookStages, + pruned, + summaryCalls: () => summaryCalls, + entries, + rawLedger: () => { + const meta = session.current(); + return meta ? readFileSync(sessionPaths(meta).current, "utf8") : ""; + }, + sessionId: () => session.current()?.id ?? "", + syncRuntimeFromLedger: (leafTurnId: string) => { + state.lastTurnId = leafTurnId; + runtime.agent.state.messages = buildModelReplayAgentMessagesFromTurns(entries(), { activeLeafTurnId: leafTurnId }); + }, + dispose: async () => { + context.dispose(); + await session.close(); + isolated.restore(); + }, + }; +} + +/** + * Every string the model would actually read, joined. Walking the message tree + * rather than stringifying it keeps newlines and quotes unescaped, so an + * assertion can compare against the seeded body byte-for-byte. + */ +export function projectionText(runtime: AgentRuntime): string { + const parts: string[] = []; + const walk = (value: unknown): void => { + if (typeof value === "string") { + parts.push(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) walk(item); + return; + } + if (value !== null && typeof value === "object") { + for (const item of Object.values(value)) walk(item); + } + }; + walk(runtime.agent.state.messages); + return parts.join("\n"); +} + +export function evictionEntries( + entries: ReadonlyArray, +): Array> { + return entries.filter( + (entry): entry is Extract => entry.kind === "contextEviction", + ); +} + +export function recallEntries( + entries: ReadonlyArray, +): Array> { + return entries.filter( + (entry): entry is Extract => entry.kind === "contextRecall", + ); +} + +/** Body of one tool-result entry exactly as the ledger holds it. */ +export function ledgerBody(entries: ReadonlyArray, turnId: string): string { + const entry = entries.find((candidate) => candidate.turnId === turnId); + if (entry === undefined || entry.kind !== "message" || entry.role !== "tool_result") return ""; + const payload = entry.payload as { result?: { content?: Array<{ type?: string; text?: string }> } }; + const parts = payload.result?.content ?? []; + return parts + .filter((block) => block.type === "text" && typeof block.text === "string") + .map((block) => block.text ?? "") + .join(""); +} From cb4d7a07b58344e8f8ece2a92990a12cfdccac45 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 12:14:18 -0500 Subject: [PATCH 30/45] feat(context): make working-set replay incremental --- docs/commands-and-modes.md | 7 +- src/cli/context-working-set.ts | 27 +- .../context/working-set/replay/controls.ts | 37 ++- .../context/working-set/replay/metrics.ts | 11 + .../context/working-set/replay/report.ts | 7 +- .../context/working-set/replay/runner.ts | 133 ++++++++-- tests/contracts/cli-context-replay.test.ts | 71 ++++++ .../working-set-replay-incremental.test.ts | 236 ++++++++++++++++++ tests/contracts/working-set-replay.test.ts | 34 ++- 9 files changed, 530 insertions(+), 33 deletions(-) create mode 100644 tests/contracts/cli-context-replay.test.ts create mode 100644 tests/contracts/working-set-replay-incremental.test.ts diff --git a/docs/commands-and-modes.md b/docs/commands-and-modes.md index 00c99d8d7..4c3b18a38 100644 --- a/docs/commands-and-modes.md +++ b/docs/commands-and-modes.md @@ -78,7 +78,7 @@ For process exit codes, stdout deliverable guarantees, and machine-readable JSON | `clio-coder context wiki [--update] [--status] [--depth auto\|simple\|medium\|detailed] [--target ] [--model ] [--thinking off\|low\|medium\|high]` | Generate, update, or inspect the agent-authored Markdown wiki under `.clio-coder/wiki/`. | | `clio-coder context reset [--all] [--yes]` | Clear accumulated project context artifacts; `--all` also removes `CLIO-CODER.md`. `--yes` (or `-y`) answers every confirmation and is required when stdin is not a terminal. | | `clio-coder context index [--json]` | Build the structural codewiki index without model calls; writes `.clio-coder/codewiki.json` and `.clio-coder/state.json` and prints coverage plus a structural hash. | -| `clio-coder context replay --sessions ... [--format clio\|claude-code\|auto] [--policies ] [--budgets ] [--threshold ] [--target ] [--seed ] [--no-filter] [--json ] [--md ]` | Replay working-set policies over Clio or Claude Code session ledgers and report retention, precision, token savings, churn, and summary headroom. | +| `clio-coder context replay --sessions ... [--format clio\|claude-code\|auto] [--policies ] [--budgets ] [--threshold ] [--target ] [--protect-last-turns ] [--min-evictable-tokens ] [--seed ] [--no-filter] [--json ] [--md ]` | Replay working-set policies over Clio or Claude Code session ledgers and report retention, precision, token savings, saturation, churn, and summary headroom. | | `clio-coder context working-set --session ` | Inspect one session's durable working-set fold and path-index summary without modifying the ledger. | ## Headless Run Flags @@ -535,6 +535,11 @@ deterministic turn boundaries. The default inclusion cascade requires at least e eight tool results, and one file re-read; `--no-filter` retains every otherwise-readable trace. Markdown goes to stdout unless `--md` names a file, while `--json` writes a stable report including the configuration, git revision when available, and exact command line. +`--protect-last-turns` and `--min-evictable-tokens` override those two working-set settings +for the replay only; they never update saved settings. Saturated events is pooled over +applied eviction events and reports how often a policy exhausted its usable candidates, +which distinguishes a budget that measures policy choice from one that simply runs out of +evictable material. The summary-headroom mean always carries its contributing trace count because traces that never require summary compaction do not enter that nullable mean. diff --git a/src/cli/context-working-set.ts b/src/cli/context-working-set.ts index 8c2f023e5..dc4df5774 100644 --- a/src/cli/context-working-set.ts +++ b/src/cli/context-working-set.ts @@ -32,6 +32,10 @@ Options: --format clio, claude-code, or auto (default: auto) --threshold pressure threshold (default: 0.8) --target post-eviction pressure target (default: 0.6) + --protect-last-turns + protected recent turns (default: ${DEFAULT_WORKING_SET_SETTINGS.protectLastTurns}) + --min-evictable-tokens + minimum tool-result body tokens (default: ${DEFAULT_WORKING_SET_SETTINGS.minEvictableTokens}) --seed deterministic random-policy seed (default: 0) --no-filter include every readable transcript --json write the stable JSON report @@ -55,6 +59,8 @@ interface ReplayArgs { budgets: number[]; threshold: number; target: number; + protectLastTurns: number; + minEvictableTokens: number; seed: number; format: ReplayInputFormat; noFilter: boolean; @@ -104,6 +110,8 @@ function parseReplayArgs(args: ReadonlyArray): ReplayArgs { budgets: [16_000, 32_000, 64_000], threshold: 0.8, target: 0.6, + protectLastTurns: DEFAULT_WORKING_SET_SETTINGS.protectLastTurns, + minEvictableTokens: DEFAULT_WORKING_SET_SETTINGS.minEvictableTokens, seed: 0, format: "auto", noFilter: false, @@ -131,6 +139,8 @@ function parseReplayArgs(args: ReadonlyArray): ReplayArgs { arg === "--format" || arg === "--threshold" || arg === "--target" || + arg === "--protect-last-turns" || + arg === "--min-evictable-tokens" || arg === "--seed" ) { const value = requiredValue(args, index, arg); @@ -164,6 +174,16 @@ function parseReplayArgs(args: ReadonlyArray): ReplayArgs { if (parsed.target <= 0 || parsed.target >= 1) { throw new CliUsageError("--target must be greater than 0 and less than 1"); } + } else if (arg === "--protect-last-turns") { + parsed.protectLastTurns = numberValue(value, arg); + if (!Number.isInteger(parsed.protectLastTurns) || parsed.protectLastTurns < 1) { + throw new CliUsageError("--protect-last-turns must be an integer at least 1"); + } + } else if (arg === "--min-evictable-tokens") { + parsed.minEvictableTokens = numberValue(value, arg); + if (!Number.isInteger(parsed.minEvictableTokens) || parsed.minEvictableTokens < 0) { + throw new CliUsageError("--min-evictable-tokens must be a non-negative integer"); + } } else { parsed.seed = numberValue(value, arg); if (!Number.isInteger(parsed.seed)) throw new CliUsageError("--seed must be an integer"); @@ -247,7 +267,12 @@ export async function runContextReplayCommand(args: string[]): Promise { const index = buildPathIndex(trace.entries, { cwd: trace.cwd }); return { trace, index, graph: buildReferenceGraph(trace, index) }; }); - const settings = { ...DEFAULT_WORKING_SET_SETTINGS, target: parsed.target }; + const settings = { + ...DEFAULT_WORKING_SET_SETTINGS, + target: parsed.target, + protectLastTurns: parsed.protectLastTurns, + minEvictableTokens: parsed.minEvictableTokens, + }; const results: ReplayPolicyResult[] = []; for (const budgetTokens of parsed.budgets) { for (const policyId of parsed.policies) { diff --git a/src/domains/context/working-set/replay/controls.ts b/src/domains/context/working-set/replay/controls.ts index a5dae6fae..f7a837673 100644 --- a/src/domains/context/working-set/replay/controls.ts +++ b/src/domains/context/working-set/replay/controls.ts @@ -7,6 +7,12 @@ import { isProtected } from "../protect.js"; import type { ReferenceGraph } from "./reference-graph.js"; import { countReplayTurns } from "./trace.js"; +/** Replay-only diagnostic surface; it does not widen the live policy contract. */ +export interface ReplayCandidatePoolPolicy extends WorkingSetPolicy { + /** Number of currently usable candidates before target-based truncation. */ + replayCandidateCount(input: PolicyInput): number; +} + function controlId(id: string): WorkingSetPolicyId { return id as WorkingSetPolicyId; } @@ -38,19 +44,29 @@ function takeToTarget(input: PolicyInput, entries: ReadonlyArray): return selected; } -export function makeOraclePolicy(graph: ReferenceGraph): WorkingSetPolicy { +export function makeOraclePolicy(graph: ReferenceGraph): ReplayCandidatePoolPolicy { + let lastInput: PolicyInput | null = null; + let lastCandidateCount = 0; + const safeEntries = (input: PolicyInput): SessionEntry[] => { + const currentTurn = countReplayTurns(input.entries) + 1; + return eligibleToolResults(input).filter((entry) => { + const futureTurns = graph.futureTurnsOf.get(entry.turnId) ?? []; + return futureTurns.every((turn) => turn < currentTurn); + }); + }; return { id: controlId("oracle"), select(input): ReadonlyArray { // Replay calls before the next turn-start entry is appended. A reference // in that next turn is therefore still future from the model's view. - const currentTurn = countReplayTurns(input.entries) + 1; - const safe = eligibleToolResults(input).filter((entry) => { - const futureTurns = graph.futureTurnsOf.get(entry.turnId) ?? []; - return futureTurns.every((turn) => turn < currentTurn); - }); + const safe = safeEntries(input); + lastInput = input; + lastCandidateCount = safe.length; return takeToTarget(input, safe); }, + replayCandidateCount(input): number { + return input === lastInput ? lastCandidateCount : safeEntries(input).length; + }, }; } @@ -68,11 +84,15 @@ function mulberry32(seed: number): () => number { }; } -export function makeRandomPolicy(seed: number): WorkingSetPolicy { +export function makeRandomPolicy(seed: number): ReplayCandidatePoolPolicy { + let lastInput: PolicyInput | null = null; + let lastCandidateCount = 0; return { id: controlId("random"), select(input): ReadonlyArray { const entries = [...eligibleToolResults(input)]; + lastInput = input; + lastCandidateCount = entries.length; const random = mulberry32(seed); for (let index = entries.length - 1; index > 0; index -= 1) { const swap = Math.floor(random() * (index + 1)); @@ -82,6 +102,9 @@ export function makeRandomPolicy(seed: number): WorkingSetPolicy { } return takeToTarget(input, entries); }, + replayCandidateCount(input): number { + return input === lastInput ? lastCandidateCount : eligibleToolResults(input).length; + }, }; } diff --git a/src/domains/context/working-set/replay/metrics.ts b/src/domains/context/working-set/replay/metrics.ts index eac087c0a..54cb4ad96 100644 --- a/src/domains/context/working-set/replay/metrics.ts +++ b/src/domains/context/working-set/replay/metrics.ts @@ -10,6 +10,8 @@ export interface ReplayMetrics { evictionPrecision: number; tokensEvicted: number; evictionEvents: number; + /** Fraction of applied events that exhausted the policy's usable candidates. */ + saturatedEvents: number; churn: number; turnsToFirstSummary: number | null; } @@ -37,6 +39,7 @@ interface MeasuredTrace { retainedPairs: number; pairsAt10: number; retainedPairsAt10: number; + saturatedEventCount: number; } function safeFraction(numerator: number, denominator: number, empty: number): number { @@ -66,7 +69,9 @@ function measure(input: ReplayMeasurement): MeasuredTrace { let safelyEvictedItems = 0; let churnedItems = 0; let tokensEvicted = 0; + let saturatedEventCount = 0; for (const event of input.replay.events) { + if (event.saturated) saturatedEventCount += 1; for (const item of event.items) { evictedItems += 1; tokensEvicted += item.tokensFreed; @@ -85,6 +90,7 @@ function measure(input: ReplayMeasurement): MeasuredTrace { evictionPrecision: safeFraction(safelyEvictedItems, evictedItems, 1), tokensEvicted, evictionEvents: input.replay.events.length, + saturatedEvents: safeFraction(saturatedEventCount, input.replay.events.length, 0), churn: safeFraction(churnedItems, evictedItems, 0), turnsToFirstSummary: input.replay.turnsToFirstSummary, }, @@ -92,6 +98,7 @@ function measure(input: ReplayMeasurement): MeasuredTrace { retainedPairs, pairsAt10, retainedPairsAt10, + saturatedEventCount, }; } @@ -110,6 +117,8 @@ export function aggregateReplayMetrics(inputs: ReadonlyArray) .filter((value): value is number => value !== null); const sum = (field: "pairs" | "retainedPairs" | "pairsAt10" | "retainedPairsAt10"): number => measured.reduce((total, entry) => total + entry[field], 0); + const totalEvents = measured.reduce((total, entry) => total + entry.metrics.evictionEvents, 0); + const saturatedEvents = measured.reduce((total, entry) => total + entry.saturatedEventCount, 0); return { mean: { traces: measured.length, @@ -118,6 +127,8 @@ export function aggregateReplayMetrics(inputs: ReadonlyArray) evictionPrecision: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.evictionPrecision)), tokensEvicted: mean(measured.map((entry) => entry.metrics.tokensEvicted)), evictionEvents: mean(measured.map((entry) => entry.metrics.evictionEvents)), + // Event-pooled: zero-event traces must not dilute the saturation rate. + saturatedEvents: safeFraction(saturatedEvents, totalEvents, 0), churn: mean(measured.map((entry) => entry.metrics.churn)), turnsToFirstSummary: summaries.length === 0 ? null : mean(summaries), }, diff --git a/src/domains/context/working-set/replay/report.ts b/src/domains/context/working-set/replay/report.ts index 40e70f704..656fe3ae8 100644 --- a/src/domains/context/working-set/replay/report.ts +++ b/src/domains/context/working-set/replay/report.ts @@ -35,6 +35,7 @@ function metricObject(metrics: ReplayMetrics, turnsToFirstSummaryCount: number): evictionPrecision: metrics.evictionPrecision, tokensEvicted: metrics.tokensEvicted, evictionEvents: metrics.evictionEvents, + saturatedEvents: metrics.saturatedEvents, churn: metrics.churn, turnsToFirstSummary: metrics.turnsToFirstSummary, turnsToFirstSummaryCount, @@ -116,15 +117,15 @@ export function renderReplayMarkdown(input: ReplayReportInput): string { "", `## Budget ${budget}`, "", - "| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | churn (mean) | turns to first summary (mean) |", - "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + "| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", ); for (const policy of input.config.policies) { const result = input.results.find((entry) => entry.budgetTokens === budget && entry.policyId === policy); if (result === undefined) continue; const metrics = result.metrics.mean; lines.push( - `| ${policy} | ${metrics.traces} | ${ratio(metrics.retention)} | ${ratio(result.metrics.pooledRetention)} | ${ratio(metrics.retentionAt10)} | ${ratio(metrics.evictionPrecision)} | ${quantity(metrics.tokensEvicted)} | ${quantity(metrics.evictionEvents)} | ${ratio(metrics.churn)} | ${metrics.turnsToFirstSummary === null ? "—" : quantity(metrics.turnsToFirstSummary)} (n=${result.metrics.turnsToFirstSummaryCount}) |`, + `| ${policy} | ${metrics.traces} | ${ratio(metrics.retention)} | ${ratio(result.metrics.pooledRetention)} | ${ratio(metrics.retentionAt10)} | ${ratio(metrics.evictionPrecision)} | ${quantity(metrics.tokensEvicted)} | ${quantity(metrics.evictionEvents)} | ${ratio(metrics.saturatedEvents)} | ${ratio(metrics.churn)} | ${metrics.turnsToFirstSummary === null ? "—" : quantity(metrics.turnsToFirstSummary)} (n=${result.metrics.turnsToFirstSummaryCount}) |`, ); } } diff --git a/src/domains/context/working-set/replay/runner.ts b/src/domains/context/working-set/replay/runner.ts index 7592a19b3..328d88937 100644 --- a/src/domains/context/working-set/replay/runner.ts +++ b/src/domains/context/working-set/replay/runner.ts @@ -1,11 +1,12 @@ import type { WorkingSetSettings } from "../../../../core/defaults.js"; import { estimateTokens } from "../../../session/compaction/tokens.js"; import type { ContextEvictionEntry, EvictedItem, SessionEntry } from "../../../session/entries.js"; -import type { WorkingSetPolicy } from "../contract.js"; +import { EMPTY_WORKING_SET_VIEW, type PolicyInput, type WorkingSetPolicy, type WorkingSetView } from "../contract.js"; import { buildEvictionFields, planEviction } from "../engine.js"; import { foldWorkingSet } from "../fold.js"; import { projectWorkingSet } from "../project.js"; import { selectVisibleEntries } from "../visible.js"; +import type { ReplayCandidatePoolPolicy } from "./controls.js"; import { isReplayTurnStart, type Trace } from "./trace.js"; export interface ReplayConfig { @@ -22,6 +23,8 @@ export interface ReplayEvictionEvent { items: ReadonlyArray; tokensBefore: number; tokensAfter: number; + /** True when this event exhausted the policy's usable candidate pool. */ + saturated: boolean; } export interface ReplayTraceResult { @@ -37,22 +40,102 @@ export interface ReplayTraceResult { entries: ReadonlyArray; } -/** Tokens of what the model would see: the visible slice (after any compaction cut) under the full-path fold. */ -function sumProjectedTokens(entries: ReadonlyArray, activeLeafTurnId?: string): number { - const view = foldWorkingSet(entries, activeLeafTurnId); +function sumTokens(entries: ReadonlyArray): number { let tokens = 0; - for (const entry of projectWorkingSet(selectVisibleEntries(entries, activeLeafTurnId), view)) { - tokens += estimateTokens(entry); - } + for (const entry of entries) tokens += estimateTokens(entry); return tokens; } -function lastMessageTurnId(entries: ReadonlyArray): string | null { - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (entry?.kind === "message") return entry.turnId; +function hasCandidatePool(policy: WorkingSetPolicy): policy is ReplayCandidatePoolPolicy { + return "replayCandidateCount" in policy && typeof policy.replayCandidateCount === "function"; +} + +function eventSaturated( + policy: WorkingSetPolicy, + input: PolicyInput, + plan: { items: ReadonlyArray; tokensAfter: number }, +): boolean { + if (policy.id === "age-horizon") return true; + const targetTokens = input.pressure.target * input.pressure.contextWindow; + if (policy.id === "structural-v1") { + const thresholdTokens = input.pressure.threshold * input.pressure.contextWindow; + const usedAgeRung = plan.items.some((item) => item.reason === "age_horizon"); + // Rungs 1-5 can legitimately stop between target and threshold. Saturation + // means rung 6 actually ran and exhausted its pool before reaching target. + return plan.tokensAfter > targetTokens && (plan.tokensAfter > thresholdTokens || usedAgeRung); + } + if (hasCandidatePool(policy)) return plan.items.length === policy.replayCandidateCount(input); + return plan.tokensAfter > targetTokens; +} + +interface IncrementalProjection { + raw: SessionEntry[]; + projected: SessionEntry[]; + indexByTurnId: Map; + tokens: number; +} + +function rebuildProjection( + soFar: ReadonlyArray, + leaf: string | null, + view: WorkingSetView, +): IncrementalProjection { + const raw = selectVisibleEntries(soFar, leaf ?? undefined); + const projected = projectWorkingSet(raw, view); + return { + raw, + projected, + indexByTurnId: new Map(raw.map((entry, index) => [entry.turnId, index])), + tokens: sumTokens(projected), + }; +} + +function projectAppendedEntry(entry: SessionEntry, state: IncrementalProjection, view: WorkingSetView): SessionEntry { + if (view.evictionEvents === 0) return entry; + const lastEventId = view.lastEvictionTurnId; + if (lastEventId !== null && state.indexByTurnId.has(lastEventId)) { + // The new entry follows the visible cutoff event, so it is unchanged. + return entry; + } + // The latest event is behind a compaction cut. projectWorkingSet deliberately + // treats an absent event as later than this slice, so new assistants inherit + // the same usage-invalidation stamp as the rest of the visible slice. + return projectWorkingSet([entry], view)[0] ?? entry; +} + +function appendVisibleEntry(entry: SessionEntry, state: IncrementalProjection, view: WorkingSetView): void { + const projected = projectAppendedEntry(entry, state, view); + state.indexByTurnId.set(entry.turnId, state.raw.length); + state.raw.push(entry); + state.projected.push(projected); + state.tokens += estimateTokens(projected); +} + +function applyEvictionProjection( + state: IncrementalProjection, + synthetic: ContextEvictionEntry, + view: WorkingSetView, + items: ReadonlyArray, +): void { + const affected = new Set(); + for (const item of items) { + const index = state.indexByTurnId.get(item.ref.entry); + if (index !== undefined) affected.add(index); + } + // The new cutoff invalidates usage on every assistant before it. Scanning + // assistants once per applied event is linear in events, never in turns. + for (let index = 0; index < state.raw.length - 1; index += 1) { + const entry = state.raw[index]; + if (entry?.kind === "message" && entry.role === "assistant") affected.add(index); + } + for (const index of affected) { + const source = state.raw[index]; + const before = state.projected[index]; + if (source === undefined || before === undefined) continue; + const after = projectWorkingSet([source, synthetic], view)[0] ?? source; + state.projected[index] = after; + state.tokens += estimateTokens(after) - estimateTokens(before); } - return null; } /** Live plan/fold/project code driven at deterministic ledger turn boundaries. */ @@ -68,17 +151,19 @@ export function replayTrace(trace: Trace, policy: WorkingSetPolicy, config: Repl let evictionSequence = 0; let turnIndex = 0; let turnsToFirstSummary: number | null = null; + let lastMessageTurnId: string | null = null; + let view: WorkingSetView = EMPTY_WORKING_SET_VIEW; + let visible: IncrementalProjection = { raw: [], projected: [], indexByTurnId: new Map(), tokens: 0 }; const pressureLimit = config.threshold * config.budgetTokens; for (const entry of trace.entries) { if (isReplayTurnStart(entry)) { turnIndex += 1; - const leaf = lastMessageTurnId(soFar); - const tokens = sumProjectedTokens(soFar, leaf ?? undefined); + const leaf = lastMessageTurnId; + const tokens = visible.tokens; if (tokens > pressureLimit) { - const view = foldWorkingSet(soFar, leaf ?? undefined); - const plan = planEviction(policy, { - entries: selectVisibleEntries(soFar, leaf ?? undefined), + const input: PolicyInput = { + entries: visible.raw, view, cwd: trace.cwd, settings: config.settings, @@ -89,8 +174,10 @@ export function replayTrace(trace: Trace, policy: WorkingSetPolicy, config: Repl target: config.target, }, estimateTokens, - }); + }; + const plan = planEviction(policy, input); if (plan !== null) { + const saturated = eventSaturated(policy, input, plan); evictionSequence += 1; const previous = soFar[soFar.length - 1]; const synthetic: ContextEvictionEntry = { @@ -104,11 +191,15 @@ export function replayTrace(trace: Trace, policy: WorkingSetPolicy, config: Repl timestamp: previous?.timestamp ?? entry.timestamp, }; soFar.push(synthetic); + appendVisibleEntry(synthetic, visible, view); + view = foldWorkingSet(soFar, leaf ?? undefined); + applyEvictionProjection(visible, synthetic, view, plan.items); events.push({ turnIndex, items: plan.items, tokensBefore: plan.tokensBefore, tokensAfter: plan.tokensAfter, + saturated, }); for (const item of plan.items) { if (toolResults.has(item.ref.entry) && !evictedAtTurn.has(item.ref.entry)) { @@ -116,11 +207,13 @@ export function replayTrace(trace: Trace, policy: WorkingSetPolicy, config: Repl } } } - const postTokens = sumProjectedTokens(soFar, leaf ?? undefined); - if (turnsToFirstSummary === null && postTokens > pressureLimit) turnsToFirstSummary = turnIndex; + if (turnsToFirstSummary === null && visible.tokens > pressureLimit) turnsToFirstSummary = turnIndex; } } soFar.push(entry); + if (entry.kind === "compactionSummary") visible = rebuildProjection(soFar, lastMessageTurnId, view); + else appendVisibleEntry(entry, visible, view); + if (entry.kind === "message") lastMessageTurnId = entry.turnId; } return { diff --git a/tests/contracts/cli-context-replay.test.ts b/tests/contracts/cli-context-replay.test.ts new file mode 100644 index 000000000..9cb50339a --- /dev/null +++ b/tests/contracts/cli-context-replay.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { makeScratchHome, runCli } from "../harness/spawn.js"; + +const FIXTURE = fileURLToPath(new URL("../fixtures/context-replay/fixture-01.jsonl", import.meta.url)); + +describe("contracts/cli context replay overrides", () => { + const scratch = makeScratchHome("clio-context-replay-home-"); + const outputs: string[] = []; + after(async () => { + for (const output of outputs) await rm(output, { recursive: true, force: true }); + scratch.cleanup(); + }); + + for (const [flag, value] of [ + ["--protect-last-turns", "0"], + ["--protect-last-turns", "1.5"], + ["--min-evictable-tokens", "-1"], + ["--min-evictable-tokens", "2.5"], + ] as const) { + it(`rejects ${flag} ${value}`, async () => { + const result = await runCli(["context", "replay", "--sessions", FIXTURE, flag, value], { + env: scratch.env, + }); + assert.equal(result.code, 2, `stdout=${result.stdout}\nstderr=${result.stderr}`); + assert.match(result.stderr, new RegExp(flag)); + }); + } + + it("records valid replay-only overrides and the saturation metric", async () => { + const output = await mkdtemp(join(tmpdir(), "clio-context-replay-output-")); + outputs.push(output); + const jsonPath = join(output, "replay.json"); + const markdownPath = join(output, "replay.md"); + const result = await runCli( + [ + "context", + "replay", + "--sessions", + FIXTURE, + "--no-filter", + "--policies", + "none", + "--budgets", + "12000", + "--protect-last-turns", + "2", + "--min-evictable-tokens", + "17", + "--json", + jsonPath, + "--md", + markdownPath, + ], + { env: scratch.env }, + ); + assert.equal(result.code, 0, `stdout=${result.stdout}\nstderr=${result.stderr}`); + const artifact = JSON.parse(await readFile(jsonPath, "utf8")) as { + config: { settings: { protectLastTurns: number; minEvictableTokens: number } }; + results: Array<{ metrics: { mean: { saturatedEvents: number } } }>; + }; + assert.equal(artifact.config.settings.protectLastTurns, 2); + assert.equal(artifact.config.settings.minEvictableTokens, 17); + assert.equal(artifact.results[0]?.metrics.mean.saturatedEvents, 0); + assert.match(await readFile(markdownPath, "utf8"), /\| saturated events \|/); + }); +}); diff --git a/tests/contracts/working-set-replay-incremental.test.ts b/tests/contracts/working-set-replay-incremental.test.ts new file mode 100644 index 000000000..400659c8b --- /dev/null +++ b/tests/contracts/working-set-replay-incremental.test.ts @@ -0,0 +1,236 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import type { WorkingSetPolicy, WorkingSetSettings } from "../../src/domains/context/working-set/contract.js"; +import { DEFAULT_WORKING_SET_SETTINGS } from "../../src/domains/context/working-set/defaults.js"; +import { buildEvictionFields, planEviction } from "../../src/domains/context/working-set/engine.js"; +import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; +import { resolveWorkingSetPolicy } from "../../src/domains/context/working-set/policies/index.js"; +import { projectWorkingSet } from "../../src/domains/context/working-set/project.js"; +import { loadClaudeCodeTraces } from "../../src/domains/context/working-set/replay/load-claude-code.js"; +import { loadClioTraces } from "../../src/domains/context/working-set/replay/load-clio.js"; +import { + type ReplayConfig, + type ReplayEvictionEvent, + type ReplayTraceResult, + replayTrace, +} from "../../src/domains/context/working-set/replay/runner.js"; +import { isReplayTurnStart, type Trace } from "../../src/domains/context/working-set/replay/trace.js"; +import { selectVisibleEntries } from "../../src/domains/context/working-set/visible.js"; +import { estimateTokens } from "../../src/domains/session/compaction/tokens.js"; +import type { ContextEvictionEntry, SessionEntry } from "../../src/domains/session/entries.js"; + +const CLIO_FIXTURE = fileURLToPath(new URL("../fixtures/context-replay/fixture-01.jsonl", import.meta.url)); +const CLAUDE_FIXTURE = fileURLToPath(new URL("../fixtures/context-replay/claude-code-01.jsonl", import.meta.url)); +const SETTINGS: WorkingSetSettings = { + ...DEFAULT_WORKING_SET_SETTINGS, + protectLastTurns: 3, + minEvictableTokens: 0, +}; + +interface ReferenceResult { + events: ReadonlyArray>; + evictedAtTurn: ReadonlyMap; + turnsToFirstSummary: number | null; + entries: ReadonlyArray; +} + +function projectedTokens(entries: ReadonlyArray, leaf: string | null): number { + const view = foldWorkingSet(entries, leaf ?? undefined); + return projectWorkingSet(selectVisibleEntries(entries, leaf ?? undefined), view).reduce( + (sum, entry) => sum + estimateTokens(entry), + 0, + ); +} + +/** Frozen copy of the quadratic runner, retained only as an equivalence oracle. */ +function referenceReplay(trace: Trace, policy: WorkingSetPolicy, config: ReplayConfig): ReferenceResult { + const soFar: SessionEntry[] = []; + const events: Array> = []; + const evictedAtTurn = new Map(); + const toolResults = new Set( + trace.entries + .filter((entry) => entry.kind === "message" && entry.role === "tool_result") + .map((entry) => entry.turnId), + ); + let sequence = 0; + let turnIndex = 0; + let turnsToFirstSummary: number | null = null; + const pressureLimit = config.threshold * config.budgetTokens; + for (const entry of trace.entries) { + if (isReplayTurnStart(entry)) { + turnIndex += 1; + const leaf = [...soFar].reverse().find((candidate) => candidate.kind === "message")?.turnId ?? null; + const tokens = projectedTokens(soFar, leaf); + if (tokens > pressureLimit) { + const view = foldWorkingSet(soFar, leaf ?? undefined); + const plan = planEviction(policy, { + entries: selectVisibleEntries(soFar, leaf ?? undefined), + view, + cwd: trace.cwd, + settings: config.settings, + pressure: { + tokens, + contextWindow: config.budgetTokens, + threshold: config.threshold, + target: config.target, + }, + estimateTokens, + }); + if (plan !== null) { + sequence += 1; + const previous = soFar[soFar.length - 1]; + const synthetic: ContextEvictionEntry = { + ...buildEvictionFields(plan, { + trigger: "pressure", + pressureBefore: tokens / config.budgetTokens, + snapshotIdBefore: null, + }), + turnId: `replay-evict-${sequence}`, + parentTurnId: leaf, + timestamp: previous?.timestamp ?? entry.timestamp, + }; + soFar.push(synthetic); + events.push({ + turnIndex, + items: plan.items, + tokensBefore: plan.tokensBefore, + tokensAfter: plan.tokensAfter, + }); + for (const item of plan.items) { + if (toolResults.has(item.ref.entry) && !evictedAtTurn.has(item.ref.entry)) { + evictedAtTurn.set(item.ref.entry, turnIndex); + } + } + } + if (turnsToFirstSummary === null && projectedTokens(soFar, leaf) > pressureLimit) { + turnsToFirstSummary = turnIndex; + } + } + } + soFar.push(entry); + } + return { events, evictedAtTurn, turnsToFirstSummary, entries: soFar }; +} + +function comparable(result: ReplayTraceResult | ReferenceResult) { + return { + events: result.events.map(({ turnIndex, items, tokensBefore, tokensAfter }) => ({ + turnIndex, + items, + tokensBefore, + tokensAfter, + })), + evictedAtTurn: [...result.evictedAtTurn], + turnsToFirstSummary: result.turnsToFirstSummary, + evictions: result.entries.filter((entry) => entry.kind === "contextEviction"), + }; +} + +function replayConfig(policyId: string, budgetTokens: number): ReplayConfig { + return { policyId, budgetTokens, threshold: 0.8, target: 0.6, settings: SETTINGS, seed: 0 }; +} + +function timestamp(index: number): string { + return `2026-08-21T00:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`; +} + +function syntheticTrace(turns: number): Trace { + const entries: SessionEntry[] = []; + let parentTurnId: string | null = null; + let event = 0; + const append = (entry: SessionEntry): void => { + entries.push(entry); + if (entry.kind === "message") parentTurnId = entry.turnId; + event += 1; + }; + for (let turn = 1; turn <= turns; turn += 1) { + const userId = `synthetic-user-${turn}`; + append({ + kind: "message", + role: "user", + payload: { text: `turn ${turn}` }, + turnId: userId, + parentTurnId, + timestamp: timestamp(event), + }); + const assistantId = `synthetic-assistant-${turn}`; + const thinking = turn % 5 === 0 ? `reasoning-${turn}-${"t".repeat(180)}` : ""; + append({ + kind: "message", + role: "assistant", + payload: { + text: "reading", + content: [...(thinking.length > 0 ? [{ type: "thinking", thinking }] : []), { type: "text", text: "reading" }], + ...(thinking.length > 0 ? { thinking } : {}), + usage: { input: 100, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 120 }, + }, + turnId: assistantId, + parentTurnId, + timestamp: timestamp(event), + }); + const callId = `synthetic-call-${turn}`; + append({ + kind: "message", + role: "tool_call", + payload: { toolCallId: callId, name: "read", args: { path: `src/file-${turn % 25}.ts` } }, + turnId: `${callId}-entry`, + parentTurnId, + timestamp: timestamp(event), + }); + append({ + kind: "message", + role: "tool_result", + payload: { + toolCallId: callId, + toolName: "read", + result: { content: [{ type: "text", text: `src/file-${turn % 25}.ts\n${"x".repeat(360)}` }] }, + isError: false, + }, + turnId: `synthetic-result-${turn}`, + parentTurnId, + timestamp: timestamp(event), + }); + if (turn === 220) { + entries.push({ + kind: "compactionSummary", + summary: "synthetic compaction bridge", + tokensBefore: 30_000, + firstKeptTurnId: "synthetic-user-180", + turnId: "synthetic-compaction-1", + parentTurnId, + timestamp: timestamp(event), + }); + event += 1; + } + } + return { id: `synthetic-${turns}`, source: "synthetic", cwd: "/fixture/synthetic", entries, turnCount: turns }; +} + +describe("contracts/working-set incremental replay", () => { + it("is event-identical to the quadratic reference on both frozen fixtures", async () => { + const clio = (await loadClioTraces([CLIO_FIXTURE])).traces[0]; + const claude = (await loadClaudeCodeTraces([CLAUDE_FIXTURE])).traces[0]; + assert.ok(clio); + assert.ok(claude); + for (const [trace, budget] of [ + [clio, 12_000], + [claude, 10_000], + ] as const) { + const config = replayConfig("age-horizon", budget); + assert.deepEqual( + comparable(replayTrace(trace, resolveWorkingSetPolicy("age-horizon"), config)), + comparable(referenceReplay(trace, resolveWorkingSetPolicy("age-horizon"), config)), + ); + } + }); + + it("is event-identical across 400 turns, repeated evictions, usage stamps, and a compaction cut", () => { + const trace = syntheticTrace(400); + const config = replayConfig("age-horizon", 8_000); + const actual = replayTrace(trace, resolveWorkingSetPolicy("age-horizon"), config); + const expected = referenceReplay(trace, resolveWorkingSetPolicy("age-horizon"), config); + assert.ok(actual.events.length > 2, "synthetic trace must exercise repeated incremental events"); + assert.deepEqual(comparable(actual), comparable(expected)); + }); +}); diff --git a/tests/contracts/working-set-replay.test.ts b/tests/contracts/working-set-replay.test.ts index 1bfc1b3ac..e2815c815 100644 --- a/tests/contracts/working-set-replay.test.ts +++ b/tests/contracts/working-set-replay.test.ts @@ -99,6 +99,7 @@ describe("contracts/working-set replay-lite", () => { assert.equal(replay.events.length, 1, "fixture budget should isolate one append-only eviction event"); const event = replay.events[0]; assert.ok(event); + assert.equal(event.saturated, true, "age-horizon drains every eligible candidate"); const prefix = prefixBeforeTurn(trace, event.turnIndex); const leaf = lastMessage(prefix); @@ -161,6 +162,7 @@ describe("contracts/working-set replay-lite", () => { ], tokensBefore: 900, tokensAfter: 650, + saturated: true, }, ], evictedAtTurn: new Map([["result-01", 1]]), @@ -177,9 +179,29 @@ describe("contracts/working-set replay-lite", () => { assert.equal(metrics.evictionPrecision, 0); assert.equal(metrics.tokensEvicted, 250); assert.equal(metrics.evictionEvents, 1); + assert.equal(metrics.saturatedEvents, 1); assert.equal(metrics.churn, 1); }); + it("pools saturated events by event count rather than by trace", async () => { + const { trace, graph } = await fixture(); + const index = buildPathIndex(trace.entries); + const base = replayTrace(trace, nonePolicy, config("none")); + const event = (saturated: boolean) => ({ + turnIndex: 1, + items: [], + tokensBefore: 900, + tokensAfter: 900, + saturated, + }); + const aggregate = aggregateReplayMetrics([ + { trace, index, graph, replay: { ...base, events: [event(true), event(false)] } }, + { trace, index, graph, replay: { ...base, events: [event(true)] } }, + { trace, index, graph, replay: base }, + ]); + assert.equal(aggregate.mean.saturatedEvents, 2 / 3); + }); + it("oracle never evicts a critical ref before its final reference", async () => { const { trace, graph } = await fixture(); const replay = replayTrace( @@ -260,17 +282,27 @@ describe("contracts/working-set replay-lite", () => { } assert.equal(markdown.match(/^## Budget /gm)?.length, budgets.length); assert.equal(markdown.match(/\(n=\d+\)/g)?.length, policies.length * budgets.length); + assert.match(markdown, /\| saturated events \|/); const json = renderReplayJson(input); assert.equal(renderReplayJson(input), json, "stable input must render byte-identically"); const parsed = JSON.parse(json) as { provenance: { gitSha: string; commandLine: string[] }; - results: Array<{ metrics: { mean: { turnsToFirstSummary: number | null; turnsToFirstSummaryCount: number } } }>; + results: Array<{ + metrics: { + mean: { + saturatedEvents: number; + turnsToFirstSummary: number | null; + turnsToFirstSummaryCount: number; + }; + }; + }>; }; assert.equal(parsed.provenance.gitSha, "abc123"); assert.deepEqual(parsed.provenance.commandLine, input.commandLine); assert.equal(parsed.results.length, policies.length * budgets.length); for (const result of parsed.results) { + assert.equal(typeof result.metrics.mean.saturatedEvents, "number"); assert.equal(result.metrics.mean.turnsToFirstSummaryCount, result.metrics.mean.turnsToFirstSummary === null ? 0 : 1); } }); From 44f7e94f7d275a15b7ea36725b6c61604aa9a73c Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 12:55:14 -0500 Subject: [PATCH 31/45] feat(context): default the working-set policy to structural-v1 Replayed over 165 Claude Code transcripts (clio-coder project, default filter) at 32k, 64k, and 128k budgets with protection horizons of 6 and 2 turns, structural-v1 retention is at or above age-horizon in every cell and clearly above it once the budget stops saturating (0.831 vs 0.781 at 128k, random 0.779), with higher eviction precision, fewer tokens evicted, and lower churn. age-horizon is within a point of random at every budget, as ctx-rm predicted for any age policy. Tables and commands are committed under benchmarks/results/context-replay/. --- CHANGELOG.md | 2 +- .../claude-code-2026-08-21-protect-2.json | 356 ++++++++++++++++++ .../claude-code-2026-08-21-protect-2.md | 47 +++ .../claude-code-2026-08-21-protect-6.json | 356 ++++++++++++++++++ .../claude-code-2026-08-21-protect-6.md | 47 +++ docs/configuration-and-targets.md | 4 +- docs/context-engine.md | 6 +- docs/context-working-set.md | 6 +- src/core/defaults.ts | 8 +- src/domains/context/working-set/defaults.ts | 11 +- tests/contracts/config-working-set.test.ts | 4 +- 11 files changed, 828 insertions(+), 19 deletions(-) create mode 100644 benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json create mode 100644 benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md create mode 100644 benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json create mode 100644 benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a501c540..c6993072f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to Clio Coder are documented in this file. The format follow ### Added - Non-destructive working-set eviction. When context pressure crosses `compaction.threshold`, Clio now records which tool-result bodies and closed-turn thinking blocks leave the model's working set instead of rewriting them out of the session. The bodies stay in the ledger, the transcript keeps showing them, and each one is replaced in model replay by a one-line marker naming the ref, the reason, the size, and the exact call that brings it back. - Exact recall by ref. The model reads an evicted body back with `context(scope="recall", ref="")`; the operator reads one into the transcript with `/context recall `, which never enters model context. A recall does not un-evict: the marker stays byte-identical so the provider prefix cache is untouched, and repeated recalls of one ref are the churn signal. -- Two eviction policies. `age-horizon` is the default and reproduces the previous age-based selection, minus results whose body is below `context.workingSet.minEvictableTokens`. The opt-in `structural-v1` selects by what the session did since (`stale_after_mutation`, `superseded_read`, `failure_resolved`, `listing_consumed`, `thinking_turn_closed`) and falls back to age only under pressure. +- Two eviction policies. `structural-v1` is the default: it selects by what the session did since (`stale_after_mutation`, `superseded_read`, `failure_resolved`, `listing_consumed`, `thinking_turn_closed`) and falls back to age only under pressure. `age-horizon` reproduces the previous age-based selection, minus results whose body is below `context.workingSet.minEvictableTokens`. Replayed over 165 Claude Code transcripts at a 128k budget, `structural-v1` retained 0.831 of later-referenced results against 0.781 for `age-horizon` and 0.779 for random eviction; the tables are under `benchmarks/results/context-replay/`. - `/context` reports the working set: policy, evicted items, evicted tokens, events, recalls, and churn. Evicted tool rows carry a dim `evicted · ` tag in the transcript. - Cache-honesty attribution for eviction. An applied event stamps `working_set_evict` on the next assistant entry's `promptCache.expectedColdReasons`, and `/context` reports `last cold turn: working-set eviction (expected)` instead of warning about a cold backend it caused itself. - New guide: `docs/context-working-set.md`. diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json new file mode 100644 index 000000000..71561d525 --- /dev/null +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json @@ -0,0 +1,356 @@ +{ + "schema": "clio-context-replay-v1", + "config": { + "policies": ["none", "random", "age-horizon", "structural-v1", "oracle"], + "budgets": [32000, 64000, 128000], + "threshold": 0.8, + "target": 0.6, + "seed": 0, + "format": "auto", + "filter": "default", + "settings": { + "enabled": true, + "policy": "age-horizon", + "target": 0.6, + "protectLastTurns": 2, + "minEvictableTokens": 200 + } + }, + "provenance": { + "gitSha": "cb4d7a07b58344e8f8ece2a92990a12cfdccac45", + "commandLine": [ + "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", + "--import", + "tsx", + "/home/akougkas/iowarp/clio-coder-ws-wiring/src/cli/index.ts", + "context", + "replay", + "--sessions", + "/home/akougkas/.claude/projects/-home-akougkas-iowarp-clio-coder", + "--policies", + "none,random,age-horizon,structural-v1,oracle", + "--budgets", + "32000,64000,128000", + "--protect-last-turns", + "2", + "--md", + "/tmp/cc-project-p2.md", + "--json", + "/tmp/cc-project-p2.json" + ] + }, + "cascade": { + "found": 302, + "unreadable": 2, + "filtered": { + "no_file_reread": 101, + "sidechain_or_subagent": 17, + "summary_only": 0, + "tool_results_lt_8": 3, + "turns_lt_8": 14 + }, + "kept": 165 + }, + "results": [ + { + "budgetTokens": 32000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "churn": 0, + "turnsToFirstSummary": 18.426829268292682, + "turnsToFirstSummaryCount": 164 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 32000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.3891266274168913, + "retentionAt10": 0.7624751586495949, + "evictionPrecision": 0.9420974870639153, + "tokensEvicted": 67601.84848484848, + "evictionEvents": 54.7030303030303, + "saturatedEvents": 0.9643252825171726, + "churn": 0.057902512936084984, + "turnsToFirstSummary": 39.725, + "turnsToFirstSummaryCount": 160 + }, + "pooledRetention": 0.3166510757717493, + "pooledRetentionAt10": 0.6326942482341069 + } + }, + { + "budgetTokens": 32000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.4052406300503513, + "retentionAt10": 0.7874741634028641, + "evictionPrecision": 0.9700931215762035, + "tokensEvicted": 102990.87878787878, + "evictionEvents": 78.67272727272727, + "saturatedEvents": 1, + "churn": 0.029906878423796013, + "turnsToFirstSummary": 54.81578947368421, + "turnsToFirstSummaryCount": 152 + }, + "pooledRetention": 0.3246024321796071, + "pooledRetentionAt10": 0.6417759838546923 + } + }, + { + "budgetTokens": 32000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.4170611362028952, + "retentionAt10": 0.7928038450665378, + "evictionPrecision": 0.970769190402395, + "tokensEvicted": 102754.73333333334, + "evictionEvents": 82.55757575757576, + "saturatedEvents": 0.9066950521215681, + "churn": 0.029230809597604917, + "turnsToFirstSummary": 52.666666666666664, + "turnsToFirstSummaryCount": 153 + }, + "pooledRetention": 0.3292797006548176, + "pooledRetentionAt10": 0.6397578203834511 + } + }, + { + "budgetTokens": 32000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 67602.67878787879, + "evictionEvents": 55.67878787878788, + "saturatedEvents": 0.9794274518341134, + "churn": 0, + "turnsToFirstSummary": 34.34375, + "turnsToFirstSummaryCount": 160 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "churn": 0, + "turnsToFirstSummary": 37.57324840764331, + "turnsToFirstSummaryCount": 157 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5295844426253361, + "retentionAt10": 0.8531465075995417, + "evictionPrecision": 0.9536416483402413, + "tokensEvicted": 66268.01818181819, + "evictionEvents": 40.7030303030303, + "saturatedEvents": 0.9541393686718285, + "churn": 0.04635835165975869, + "turnsToFirstSummary": 77.75862068965517, + "turnsToFirstSummaryCount": 145 + }, + "pooledRetention": 0.4499532273152479, + "pooledRetentionAt10": 0.7487386478304743 + } + }, + { + "budgetTokens": 64000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5396326704729105, + "retentionAt10": 0.8919924416262478, + "evictionPrecision": 0.9767721779142171, + "tokensEvicted": 101194.2606060606, + "evictionEvents": 50.878787878787875, + "saturatedEvents": 1, + "churn": 0.023227822085782862, + "turnsToFirstSummary": 110.024, + "turnsToFirstSummaryCount": 125 + }, + "pooledRetention": 0.4616463985032741, + "pooledRetentionAt10": 0.805247225025227 + } + }, + { + "budgetTokens": 64000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5670056686801388, + "retentionAt10": 0.8941705143350112, + "evictionPrecision": 0.9782494942999655, + "tokensEvicted": 100305.41212121212, + "evictionEvents": 56.32121212121212, + "saturatedEvents": 0.8517163456365006, + "churn": 0.021750505700034426, + "turnsToFirstSummary": 102.96899224806202, + "turnsToFirstSummaryCount": 129 + }, + "pooledRetention": 0.49859681945743684, + "pooledRetentionAt10": 0.8062563067608476 + } + }, + { + "budgetTokens": 64000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 66376.7696969697, + "evictionEvents": 41.339393939393936, + "saturatedEvents": 0.9673068465034452, + "churn": 0, + "turnsToFirstSummary": 71.6896551724138, + "turnsToFirstSummaryCount": 145 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "churn": 0, + "turnsToFirstSummary": 86.14285714285714, + "turnsToFirstSummaryCount": 140 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7548640102774834, + "retentionAt10": 0.9484446069013612, + "evictionPrecision": 0.9751224764528812, + "tokensEvicted": 59933.569696969695, + "evictionEvents": 20.006060606060608, + "saturatedEvents": 0.9403211148136928, + "churn": 0.024877523547118807, + "turnsToFirstSummary": 152.67708333333334, + "turnsToFirstSummaryCount": 96 + }, + "pooledRetention": 0.7043966323666978, + "pooledRetentionAt10": 0.9142280524722503 + } + }, + { + "budgetTokens": 128000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7710672132375215, + "retentionAt10": 0.9834813967108783, + "evictionPrecision": 0.9877803351462859, + "tokensEvicted": 90092.87878787878, + "evictionEvents": 18.145454545454545, + "saturatedEvents": 1, + "churn": 0.01221966485371426, + "turnsToFirstSummary": 210.38181818181818, + "turnsToFirstSummaryCount": 55 + }, + "pooledRetention": 0.7301216089803555, + "pooledRetentionAt10": 0.9616548940464178 + } + }, + { + "budgetTokens": 128000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.8169060925178981, + "retentionAt10": 0.9757320945306298, + "evictionPrecision": 0.9892505292068529, + "tokensEvicted": 85565.81212121212, + "evictionEvents": 22.048484848484847, + "saturatedEvents": 0.7553600879604178, + "churn": 0.010749470793147019, + "turnsToFirstSummary": 202.05084745762713, + "turnsToFirstSummaryCount": 59 + }, + "pooledRetention": 0.7483629560336763, + "pooledRetentionAt10": 0.9535822401614531 + } + }, + { + "budgetTokens": 128000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 60099.96363636364, + "evictionEvents": 20.55757575757576, + "saturatedEvents": 0.9498820754716981, + "churn": 0, + "turnsToFirstSummary": 147.84375, + "turnsToFirstSummaryCount": 96 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + } + ] +} diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md new file mode 100644 index 000000000..10428c4b9 --- /dev/null +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md @@ -0,0 +1,47 @@ + + + +# Clio working-set replay + +## Inclusion cascade + +| stage | traces | +| --- | ---: | +| found | 302 | +| unreadable | 2 | +| sidechain_or_subagent | 17 | +| summary_only | 0 | +| turns_lt_8 | 14 | +| tool_results_lt_8 | 3 | +| no_file_reread | 101 | +| kept | 165 | + +## Budget 32000 + +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 18.4 (n=164) | +| random | 165 | 0.389 | 0.317 | 0.762 | 0.942 | 67601.8 | 54.7 | 0.964 | 0.058 | 39.7 (n=160) | +| age-horizon | 165 | 0.405 | 0.325 | 0.787 | 0.970 | 102990.9 | 78.7 | 1.000 | 0.030 | 54.8 (n=152) | +| structural-v1 | 165 | 0.417 | 0.329 | 0.793 | 0.971 | 102754.7 | 82.6 | 0.907 | 0.029 | 52.7 (n=153) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 67602.7 | 55.7 | 0.979 | 0.000 | 34.3 (n=160) | + +## Budget 64000 + +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 37.6 (n=157) | +| random | 165 | 0.530 | 0.450 | 0.853 | 0.954 | 66268.0 | 40.7 | 0.954 | 0.046 | 77.8 (n=145) | +| age-horizon | 165 | 0.540 | 0.462 | 0.892 | 0.977 | 101194.3 | 50.9 | 1.000 | 0.023 | 110.0 (n=125) | +| structural-v1 | 165 | 0.567 | 0.499 | 0.894 | 0.978 | 100305.4 | 56.3 | 0.852 | 0.022 | 103.0 (n=129) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 66376.8 | 41.3 | 0.967 | 0.000 | 71.7 (n=145) | + +## Budget 128000 + +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 86.1 (n=140) | +| random | 165 | 0.755 | 0.704 | 0.948 | 0.975 | 59933.6 | 20.0 | 0.940 | 0.025 | 152.7 (n=96) | +| age-horizon | 165 | 0.771 | 0.730 | 0.983 | 0.988 | 90092.9 | 18.1 | 1.000 | 0.012 | 210.4 (n=55) | +| structural-v1 | 165 | 0.817 | 0.748 | 0.976 | 0.989 | 85565.8 | 22.0 | 0.755 | 0.011 | 202.1 (n=59) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 60100.0 | 20.6 | 0.950 | 0.000 | 147.8 (n=96) | diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json new file mode 100644 index 000000000..bd62b37fd --- /dev/null +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json @@ -0,0 +1,356 @@ +{ + "schema": "clio-context-replay-v1", + "config": { + "policies": ["none", "random", "age-horizon", "structural-v1", "oracle"], + "budgets": [32000, 64000, 128000], + "threshold": 0.8, + "target": 0.6, + "seed": 0, + "format": "auto", + "filter": "default", + "settings": { + "enabled": true, + "policy": "age-horizon", + "target": 0.6, + "protectLastTurns": 6, + "minEvictableTokens": 200 + } + }, + "provenance": { + "gitSha": "cb4d7a07b58344e8f8ece2a92990a12cfdccac45", + "commandLine": [ + "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", + "--import", + "tsx", + "/home/akougkas/iowarp/clio-coder-ws-wiring/src/cli/index.ts", + "context", + "replay", + "--sessions", + "/home/akougkas/.claude/projects/-home-akougkas-iowarp-clio-coder", + "--policies", + "none,random,age-horizon,structural-v1,oracle", + "--budgets", + "32000,64000,128000", + "--protect-last-turns", + "6", + "--md", + "/tmp/cc-project-p6.md", + "--json", + "/tmp/cc-project-p6.json" + ] + }, + "cascade": { + "found": 302, + "unreadable": 2, + "filtered": { + "no_file_reread": 100, + "sidechain_or_subagent": 17, + "summary_only": 0, + "tool_results_lt_8": 4, + "turns_lt_8": 14 + }, + "kept": 165 + }, + "results": [ + { + "budgetTokens": 32000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "churn": 0, + "turnsToFirstSummary": 18.426829268292682, + "turnsToFirstSummaryCount": 164 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 32000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.4833177066874543, + "retentionAt10": 0.9261196589198183, + "evictionPrecision": 0.9473257732807969, + "tokensEvicted": 66849.2787878788, + "evictionEvents": 57.7939393939394, + "saturatedEvents": 0.9826971476510067, + "churn": 0.052674226719202966, + "turnsToFirstSummary": 31.90740740740741, + "turnsToFirstSummaryCount": 162 + }, + "pooledRetention": 0.431244153414406, + "pooledRetentionAt10": 0.875882946518668 + } + }, + { + "budgetTokens": 32000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.4869686158647096, + "retentionAt10": 0.9351686139077687, + "evictionPrecision": 0.9725388619474085, + "tokensEvicted": 101707.84848484848, + "evictionEvents": 83.76969696969697, + "saturatedEvents": 1, + "churn": 0.02746113805259143, + "turnsToFirstSummary": 40.745222929936304, + "turnsToFirstSummaryCount": 157 + }, + "pooledRetention": 0.43358278765201125, + "pooledRetentionAt10": 0.8779011099899092 + } + }, + { + "budgetTokens": 32000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.48781054300941923, + "retentionAt10": 0.9273801344446482, + "evictionPrecision": 0.9729190426257791, + "tokensEvicted": 101485.41818181818, + "evictionEvents": 86.83636363636364, + "saturatedEvents": 0.9286711334450027, + "churn": 0.027080957374220775, + "turnsToFirstSummary": 38.80379746835443, + "turnsToFirstSummaryCount": 158 + }, + "pooledRetention": 0.4345182413470533, + "pooledRetentionAt10": 0.8789101917255298 + } + }, + { + "budgetTokens": 32000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 66884.45454545454, + "evictionEvents": 58.842424242424244, + "saturatedEvents": 0.9907302502832424, + "churn": 0, + "turnsToFirstSummary": 28.641975308641975, + "turnsToFirstSummaryCount": 162 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "churn": 0, + "turnsToFirstSummary": 37.57324840764331, + "turnsToFirstSummaryCount": 157 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5882949630186936, + "retentionAt10": 0.9574288301148334, + "evictionPrecision": 0.9576639856600655, + "tokensEvicted": 65689.54545454546, + "evictionEvents": 41.83030303030303, + "saturatedEvents": 0.9588525065198493, + "churn": 0.04233601433993452, + "turnsToFirstSummary": 74.8972602739726, + "turnsToFirstSummaryCount": 146 + }, + "pooledRetention": 0.5346117867165575, + "pooledRetentionAt10": 0.9233097880928355 + } + }, + { + "budgetTokens": 64000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5838286020312077, + "retentionAt10": 0.9722075189873377, + "evictionPrecision": 0.9772436541772238, + "tokensEvicted": 99935.21212121213, + "evictionEvents": 53.26060606060606, + "saturatedEvents": 1, + "churn": 0.02275634582277612, + "turnsToFirstSummary": 102.97692307692307, + "turnsToFirstSummaryCount": 130 + }, + "pooledRetention": 0.5280636108512629, + "pooledRetentionAt10": 0.9475277497477296 + } + }, + { + "budgetTokens": 64000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.604976770513591, + "retentionAt10": 0.9671820141891055, + "evictionPrecision": 0.9795901302078288, + "tokensEvicted": 99347.78787878787, + "evictionEvents": 58.121212121212125, + "saturatedEvents": 0.8663190823774766, + "churn": 0.02040986979217111, + "turnsToFirstSummary": 98.46969696969697, + "turnsToFirstSummaryCount": 132 + }, + "pooledRetention": 0.5594013096351731, + "pooledRetentionAt10": 0.9475277497477296 + } + }, + { + "budgetTokens": 64000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 65534.44242424242, + "evictionEvents": 42.68484848484849, + "saturatedEvents": 0.9752946187704103, + "churn": 0, + "turnsToFirstSummary": 68.71428571428571, + "turnsToFirstSummaryCount": 147 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "churn": 0, + "turnsToFirstSummary": 86.14285714285714, + "turnsToFirstSummaryCount": 140 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7785849994792741, + "retentionAt10": 0.9898164966346785, + "evictionPrecision": 0.9768056012832772, + "tokensEvicted": 59476.357575757575, + "evictionEvents": 20.587878787878786, + "saturatedEvents": 0.9449514277303503, + "churn": 0.023194398716723085, + "turnsToFirstSummary": 151.2164948453608, + "turnsToFirstSummaryCount": 97 + }, + "pooledRetention": 0.7422825070159027, + "pooledRetentionAt10": 0.9818365287588294 + } + }, + { + "budgetTokens": 128000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7805842537890286, + "retentionAt10": 0.9976396894846092, + "evictionPrecision": 0.9886698447282122, + "tokensEvicted": 89686.7696969697, + "evictionEvents": 18.915151515151514, + "saturatedEvents": 1, + "churn": 0.011330155271787852, + "turnsToFirstSummary": 206.03508771929825, + "turnsToFirstSummaryCount": 57 + }, + "pooledRetention": 0.7502338634237605, + "pooledRetentionAt10": 0.992936427850656 + } + }, + { + "budgetTokens": 128000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.8308409632691356, + "retentionAt10": 0.9972609016058214, + "evictionPrecision": 0.989808384521122, + "tokensEvicted": 84848.4303030303, + "evictionEvents": 22.44242424242424, + "saturatedEvents": 0.7709964893329733, + "churn": 0.010191615478877817, + "turnsToFirstSummary": 194.88524590163934, + "turnsToFirstSummaryCount": 61 + }, + "pooledRetention": 0.7694106641721234, + "pooledRetentionAt10": 0.9919273461150353 + } + }, + { + "budgetTokens": 128000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 59886.63636363636, + "evictionEvents": 21.315151515151516, + "saturatedEvents": 0.9550753483082173, + "churn": 0, + "turnsToFirstSummary": 146.659793814433, + "turnsToFirstSummaryCount": 97 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + } + ] +} diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md new file mode 100644 index 000000000..ce0e7ae9f --- /dev/null +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md @@ -0,0 +1,47 @@ + + + +# Clio working-set replay + +## Inclusion cascade + +| stage | traces | +| --- | ---: | +| found | 302 | +| unreadable | 2 | +| sidechain_or_subagent | 17 | +| summary_only | 0 | +| turns_lt_8 | 14 | +| tool_results_lt_8 | 4 | +| no_file_reread | 100 | +| kept | 165 | + +## Budget 32000 + +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 18.4 (n=164) | +| random | 165 | 0.483 | 0.431 | 0.926 | 0.947 | 66849.3 | 57.8 | 0.983 | 0.053 | 31.9 (n=162) | +| age-horizon | 165 | 0.487 | 0.434 | 0.935 | 0.973 | 101707.8 | 83.8 | 1.000 | 0.027 | 40.7 (n=157) | +| structural-v1 | 165 | 0.488 | 0.435 | 0.927 | 0.973 | 101485.4 | 86.8 | 0.929 | 0.027 | 38.8 (n=158) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 66884.5 | 58.8 | 0.991 | 0.000 | 28.6 (n=162) | + +## Budget 64000 + +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 37.6 (n=157) | +| random | 165 | 0.588 | 0.535 | 0.957 | 0.958 | 65689.5 | 41.8 | 0.959 | 0.042 | 74.9 (n=146) | +| age-horizon | 165 | 0.584 | 0.528 | 0.972 | 0.977 | 99935.2 | 53.3 | 1.000 | 0.023 | 103.0 (n=130) | +| structural-v1 | 165 | 0.605 | 0.559 | 0.967 | 0.980 | 99347.8 | 58.1 | 0.866 | 0.020 | 98.5 (n=132) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 65534.4 | 42.7 | 0.975 | 0.000 | 68.7 (n=147) | + +## Budget 128000 + +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 86.1 (n=140) | +| random | 165 | 0.779 | 0.742 | 0.990 | 0.977 | 59476.4 | 20.6 | 0.945 | 0.023 | 151.2 (n=97) | +| age-horizon | 165 | 0.781 | 0.750 | 0.998 | 0.989 | 89686.8 | 18.9 | 1.000 | 0.011 | 206.0 (n=57) | +| structural-v1 | 165 | 0.831 | 0.769 | 0.997 | 0.990 | 84848.4 | 22.4 | 0.771 | 0.010 | 194.9 (n=61) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 59886.6 | 21.3 | 0.955 | 0.000 | 146.7 (n=97) | diff --git a/docs/configuration-and-targets.md b/docs/configuration-and-targets.md index 7baa1b71d..efdb305ad 100644 --- a/docs/configuration-and-targets.md +++ b/docs/configuration-and-targets.md @@ -228,7 +228,7 @@ compaction: context: workingSet: enabled: true - policy: age-horizon + policy: structural-v1 target: 0.6 protectLastTurns: 6 minEvictableTokens: 200 @@ -585,7 +585,7 @@ Every one of these has an environment override for a single process; see [enviro | `compaction.threshold` | `0.8` | number in 0 to 1 | next turn | | `compaction.excludeLastTurns` | `6` | integer ≥ 1 | next turn | | `context.workingSet.enabled` | `true` | boolean | next turn | -| `context.workingSet.policy` | `age-horizon` | `age-horizon` or `structural-v1` | next turn | +| `context.workingSet.policy` | `structural-v1` | `age-horizon` or `structural-v1` | next turn | | `context.workingSet.target` | `0.6` | number greater than 0 and less than 1 | next turn | | `context.workingSet.protectLastTurns` | `6` | integer ≥ 1 | next turn | | `context.workingSet.minEvictableTokens` | `200` | integer ≥ 0 | next turn | diff --git a/docs/context-engine.md b/docs/context-engine.md index 7a7042e12..858bf7dd2 100644 --- a/docs/context-engine.md +++ b/docs/context-engine.md @@ -39,7 +39,7 @@ Crossing that threshold engages three mechanisms in a fixed order. The first two When `compaction.auto` is enabled and pressure crosses the threshold before a request, Clio applies the configured working-set policy first. The policy selects tool-result bodies and closed-turn thinking blocks, `runAutoCompact` appends one `contextEviction` ledger entry, and `refreshAgentMessagesFromSession` projects those units out of model replay behind a one-line marker. Nothing is deleted: the ledger keeps the original bodies, the transcript keeps showing them, and `/resume`, `/tree`, `/fork`, and the HTML export are unaffected. -Already-evicted units are never selected again. Recent turns keep their full observations and thinking, governed by `context.workingSet.protectLastTurns`. Results whose estimated body is below `context.workingSet.minEvictableTokens` (200 tokens by default) are kept whatever their age, because a marker would cost more than the body it replaces. The default `age-horizon` policy is therefore the selection the old destructive mask made minus those small results, not a byte-identical reproduction of it. +Already-evicted units are never selected again. Recent turns keep their full observations and thinking, governed by `context.workingSet.protectLastTurns`. Results whose estimated body is below `context.workingSet.minEvictableTokens` (200 tokens by default) are kept whatever their age, because a marker would cost more than the body it replaces. The `age-horizon` policy is therefore the selection the old destructive mask made minus those small results, not a byte-identical reproduction of it; the default `structural-v1` policy applies its structural rules before any age rule. If the projection drops pressure below the threshold, Clio sends the request and no summary runs. The policies, the protection predicates, the marker format, and the ledger records are documented in [context-working-set.md](context-working-set.md). @@ -94,7 +94,7 @@ compaction: context: workingSet: enabled: true - policy: age-horizon + policy: structural-v1 target: 0.6 protectLastTurns: 6 minEvictableTokens: 200 @@ -105,7 +105,7 @@ context: | Key | Default | Accepted | Meaning | | --- | --- | --- | --- | | `context.workingSet.enabled` | `true` | boolean | `false` skips eviction and goes directly to summary compaction. It does not restore the destructive mask. | -| `context.workingSet.policy` | `age-horizon` | `age-horizon`, `structural-v1` | Candidate selection rule set. `structural-v1` is opt-in. | +| `context.workingSet.policy` | `structural-v1` | `age-horizon`, `structural-v1` | Candidate selection rule set. `age-horizon` is the pre-layer age selection. | | `context.workingSet.target` | `0.6` | number greater than 0 and less than 1 | Used-over-window ratio an applied eviction event batches down to. | | `context.workingSet.protectLastTurns` | `6` | integer ≥ 1 | Recent turns whose observations and thinking are never evicted. | | `context.workingSet.minEvictableTokens` | `200` | integer ≥ 0 | Results below this estimate are never evicted, because the marker would cost more than the body. | diff --git a/docs/context-working-set.md b/docs/context-working-set.md index eb4b10920..9edf8102a 100644 --- a/docs/context-working-set.md +++ b/docs/context-working-set.md @@ -5,7 +5,7 @@ The working set is the part of the session ledger the model actually receives on Source of truth is `src/domains/context/working-set/` (`contract.ts`, `fold.ts`, `project.ts`, `marker.ts`, `protect.ts`, `engine.ts`, `recall.ts`, `policies/`), the ledger records in `src/domains/session/entries.ts`, and the compaction stage in `src/interactive/turn-context.ts` (`runAutoCompact`). > [!WARNING] -> This is an experimental community alpha surface. The default policy is `age-horizon`, which reproduces the selection Clio already made before this layer existed. `structural-v1` is opt-in. +> This is an experimental community alpha surface. The default policy is `structural-v1`, chosen from the replay tables under `benchmarks/results/context-replay/`. `age-horizon` reproduces the selection Clio made before this layer existed and stays available. ## Vocabulary @@ -84,7 +84,7 @@ A policy answers one question: which units should leave. It never writes, never 6. A write or edit the turn in flight is still standing on. 7. A failure nothing later resolved, and any unindexed failure, because without an observation there is no way to ask whether it was resolved. -### `age-horizon` (default) +### `age-horizon` The rule `maskStaleObservations` applied, recorded instead of destroyed. Every `tool_result` body older than the protection horizon leaves the working set, and every `assistant` message older than the horizon loses its thinking blocks. Same turn-start definition, same cutoff, and a body carrying a legacy compaction marker is skipped the same way. @@ -94,7 +94,7 @@ Candidates arrive newest-safe-first, so a caller that stops early has evicted th Age is not a quality signal. A file read twenty turns ago and never touched since is more useful than a directory listing from two turns ago, which is the whole reason `structural-v1` exists. -### `structural-v1` (opt-in) +### `structural-v1` (default) Rule order is the policy. Each rung emits candidates newest-first, every candidate passes `isProtected`, and no unit is claimed twice, so a read that is both stale and superseded is evicted for the reason that came first and carries the `by` ref that explains it. The rungs, in order: diff --git a/src/core/defaults.ts b/src/core/defaults.ts index 2fa8ed98f..7a6aece98 100644 --- a/src/core/defaults.ts +++ b/src/core/defaults.ts @@ -655,15 +655,17 @@ compaction: # Non-destructive working-set eviction before summary compaction. # enabled false skips eviction and goes directly to the summary stage. -# policy age-horizon preserves today's age-based selection; -# structural-v1 opts into structure-aware selection. +# policy structural-v1 evicts by what the session did since +# (re-reads, edits, resolved failures, consumed listings) +# and falls back to age only under pressure; +# age-horizon is the previous age-based selection. # target pressure ratio an applied eviction batches down to. # protectLastTurns recent user turns whose observations remain in the working set. # minEvictableTokens entries below this estimate remain in the working set. context: workingSet: enabled: true - policy: age-horizon + policy: structural-v1 target: 0.6 protectLastTurns: 6 minEvictableTokens: 200 diff --git a/src/domains/context/working-set/defaults.ts b/src/domains/context/working-set/defaults.ts index 85c5b90cf..268427969 100644 --- a/src/domains/context/working-set/defaults.ts +++ b/src/domains/context/working-set/defaults.ts @@ -4,10 +4,11 @@ * free of a backward domain dependency; this module pairs it with the value * the DEFAULT_SETTINGS tree and the engine read at runtime. * - * `enabled: true` with `policy: "age-horizon"` is today's selection (every - * tool-result body and thinking block beyond the protection horizon) recorded - * as a projection instead of a ledger rewrite. `structural-v1` stays opt-in - * until replay-lite shows it ahead of `age-horizon`. + * `structural-v1` is the default: typed path-keyed rules first, the age + * rule last and batched to `target`. On 165 Claude Code transcripts it held + * retention 0.831 against 0.781 for `age-horizon` and 0.779 for random at a + * 128k budget (benchmarks/results/context-replay/). `age-horizon` stays + * available as the exact pre-layer selection recorded through the ledger. */ import type { WorkingSetSettings } from "../../../core/defaults.js"; @@ -16,7 +17,7 @@ export type { WorkingSetPolicyId, WorkingSetSettings } from "../../../core/defau export const DEFAULT_WORKING_SET_SETTINGS: WorkingSetSettings = { enabled: true, - policy: "age-horizon", + policy: "structural-v1", target: 0.6, protectLastTurns: 6, minEvictableTokens: 200, diff --git a/tests/contracts/config-working-set.test.ts b/tests/contracts/config-working-set.test.ts index c1478ed3a..f5b94cf75 100644 --- a/tests/contracts/config-working-set.test.ts +++ b/tests/contracts/config-working-set.test.ts @@ -10,7 +10,7 @@ describe("contracts/context working-set settings", () => { context: { workingSet: { enabled: false, - policy: "structural-v1", + policy: "age-horizon", target: 0.55, protectLastTurns: 3, minEvictableTokens: 0, @@ -21,7 +21,7 @@ describe("contracts/context working-set settings", () => { deepStrictEqual(result.issues, []); deepStrictEqual(result.settings.context.workingSet, { enabled: false, - policy: "structural-v1", + policy: "age-horizon", target: 0.55, protectLastTurns: 3, minEvictableTokens: 0, From 42e0d1a67f503fc1355d05bdecd16049f9be047c Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 13:09:15 -0500 Subject: [PATCH 32/45] refactor(context): one compaction cut for replay and policy input selectReplayEntries and selectVisibleEntries each computed "everything after the latest compaction's firstKeptTurnId". compactionCut in visible.ts is now the one definition and the replay builder composes it with its own orphan repair. Also: path-index imports isTurnStart from horizon.ts instead of keeping a copy, the isReplayTurnStart alias is gone, and the unused graph-free oraclePolicy export is removed. --- src/domains/context/working-set/horizon.ts | 4 -- src/domains/context/working-set/path-index.ts | 11 +---- .../context/working-set/replay/controls.ts | 3 -- .../context/working-set/replay/runner.ts | 5 +- .../context/working-set/replay/trace.ts | 8 +--- src/domains/context/working-set/visible.ts | 46 +++++++++++++------ src/interactive/chat-renderer.ts | 28 ++++------- .../working-set-replay-incremental.test.ts | 5 +- tests/contracts/working-set-replay.test.ts | 5 +- .../working-set-scenarios-replay.test.ts | 4 +- 10 files changed, 53 insertions(+), 66 deletions(-) diff --git a/src/domains/context/working-set/horizon.ts b/src/domains/context/working-set/horizon.ts index 8ff54e07b..8a0d30953 100644 --- a/src/domains/context/working-set/horizon.ts +++ b/src/domains/context/working-set/horizon.ts @@ -6,10 +6,6 @@ * once. It is the same cutoff `maskStaleObservations` used before this layer * existed, which is what keeps `age-horizon` selection-identical to the * destructive stage it replaced. - * - * `path-index.ts` keeps its own copy of `isTurnStart` on purpose: it is - * cherry-picked on its own for the replay reference graph, so it stays free of - * intra-layer imports beyond the payload readers. */ import type { SessionEntry } from "../../session/entries.js"; diff --git a/src/domains/context/working-set/path-index.ts b/src/domains/context/working-set/path-index.ts index 6ab371ac5..3abeb90f1 100644 --- a/src/domains/context/working-set/path-index.ts +++ b/src/domains/context/working-set/path-index.ts @@ -27,6 +27,7 @@ import { basename, isAbsolute, join, normalize, resolve } from "node:path"; import type { MessageEntry, SessionEntry } from "../../session/entries.js"; import type { WorkingSetRef } from "./contract.js"; +import { isTurnStart } from "./horizon.js"; import { isRecord, toolResultText } from "./payload.js"; /** @@ -114,16 +115,6 @@ const CODE_NAV_PATH_MODES = new Set(["path", "outline", "deps", "dependents"]); /** Ops whose result is a list of other paths. */ const LISTING_OPS = new Set(["grep", "find", "ls", "bash"]); -/** - * Turn starts, the same three kinds the protection horizon counts. A local `!` - * bash execution and a branch summary each open a stretch of work the way an - * operator message does. - */ -function isTurnStart(entry: SessionEntry): boolean { - if (entry.kind === "bashExecution" || entry.kind === "branchSummary") return true; - return entry.kind === "message" && entry.role === "user"; -} - export interface PathIndexOptions { /** Session working directory; relative arguments resolve against it. Null leaves them relative (normalized). */ cwd?: string | null; diff --git a/src/domains/context/working-set/replay/controls.ts b/src/domains/context/working-set/replay/controls.ts index f7a837673..23a281a9f 100644 --- a/src/domains/context/working-set/replay/controls.ts +++ b/src/domains/context/working-set/replay/controls.ts @@ -70,9 +70,6 @@ export function makeOraclePolicy(graph: ReferenceGraph): ReplayCandidatePoolPoli }; } -/** Graph-free export for callers that need a registry-shaped control. */ -export const oraclePolicy: WorkingSetPolicy = makeOraclePolicy({ edges: [], futureTurnsOf: new Map() }); - function mulberry32(seed: number): () => number { let value = seed >>> 0; return () => { diff --git a/src/domains/context/working-set/replay/runner.ts b/src/domains/context/working-set/replay/runner.ts index 328d88937..ca3761b4a 100644 --- a/src/domains/context/working-set/replay/runner.ts +++ b/src/domains/context/working-set/replay/runner.ts @@ -4,10 +4,11 @@ import type { ContextEvictionEntry, EvictedItem, SessionEntry } from "../../../s import { EMPTY_WORKING_SET_VIEW, type PolicyInput, type WorkingSetPolicy, type WorkingSetView } from "../contract.js"; import { buildEvictionFields, planEviction } from "../engine.js"; import { foldWorkingSet } from "../fold.js"; +import { isTurnStart } from "../horizon.js"; import { projectWorkingSet } from "../project.js"; import { selectVisibleEntries } from "../visible.js"; import type { ReplayCandidatePoolPolicy } from "./controls.js"; -import { isReplayTurnStart, type Trace } from "./trace.js"; +import type { Trace } from "./trace.js"; export interface ReplayConfig { policyId: string; @@ -157,7 +158,7 @@ export function replayTrace(trace: Trace, policy: WorkingSetPolicy, config: Repl const pressureLimit = config.threshold * config.budgetTokens; for (const entry of trace.entries) { - if (isReplayTurnStart(entry)) { + if (isTurnStart(entry)) { turnIndex += 1; const leaf = lastMessageTurnId; const tokens = visible.tokens; diff --git a/src/domains/context/working-set/replay/trace.ts b/src/domains/context/working-set/replay/trace.ts index 07c941ebe..b241e085f 100644 --- a/src/domains/context/working-set/replay/trace.ts +++ b/src/domains/context/working-set/replay/trace.ts @@ -11,15 +11,11 @@ export interface Trace { turnCount: number; } -/** Turn boundaries shared by the live age horizon and replay runner. */ -export function isReplayTurnStart(entry: SessionEntry): boolean { - return isTurnStart(entry); -} - +/** Turns in the sense the protection horizon counts them (`isTurnStart` in horizon.ts). */ export function countReplayTurns(entries: ReadonlyArray): number { let count = 0; for (const entry of entries) { - if (isReplayTurnStart(entry)) count += 1; + if (isTurnStart(entry)) count += 1; } return count; } diff --git a/src/domains/context/working-set/visible.ts b/src/domains/context/working-set/visible.ts index 999fde43a..1c5f8e8ec 100644 --- a/src/domains/context/working-set/visible.ts +++ b/src/domains/context/working-set/visible.ts @@ -5,11 +5,10 @@ * that a compaction summary already removed from the replay. Those items price * as real savings, so the event records tokens it never freed, the structural * age rung stops early believing it reached target, and the summary stage runs - * again for nothing. This helper applies the same two cuts the replay builder - * applies (`selectReplayEntries` in chat-renderer.ts): the active path, then - * everything from the latest compaction's `firstKeptTurnId` onward. The - * `compactionSummary` entry itself is left out; it is never a candidate and the - * policy has no use for it. + * again for nothing. So the policy input and the replay builder share one cut: + * `compactionCut` is the single definition of "after the latest compaction", + * and `selectReplayEntries` in chat-renderer.ts builds on it rather than + * keeping a second copy that has to agree. * * The fold (`WorkingSetView`) deliberately keeps running over the full active * path so refs evicted before a later compaction stay known as evicted. @@ -18,21 +17,38 @@ import type { SessionEntry } from "../../session/entries.js"; import { filterEntriesToActivePath } from "../../session/tree/active-path.js"; -export function selectVisibleEntries(entries: ReadonlyArray, activeLeafTurnId?: string): SessionEntry[] { - const active = filterEntriesToActivePath(entries, activeLeafTurnId); +export interface CompactionCut { + /** Index of the latest `compactionSummary` in the slice; -1 when there is none. */ + compactionIndex: number; + /** + * What the model sees after the cut, without the compaction entry itself: + * the kept tail from `firstKeptTurnId` up to the summary, then everything + * after it. The whole slice when there is no compaction. + */ + visible: SessionEntry[]; +} + +/** Apply the latest compaction's cut to a slice that is already on one path, in ledger order. */ +export function compactionCut(entries: ReadonlyArray): CompactionCut { let compactionIndex = -1; - for (let i = active.length - 1; i >= 0; i -= 1) { - if (active[i]?.kind === "compactionSummary") { + for (let i = entries.length - 1; i >= 0; i -= 1) { + if (entries[i]?.kind === "compactionSummary") { compactionIndex = i; break; } } - if (compactionIndex < 0) return active; - const compaction = active[compactionIndex]; - if (compaction?.kind !== "compactionSummary") return active; + const compaction = entries[compactionIndex]; + if (compaction?.kind !== "compactionSummary") return { compactionIndex: -1, visible: [...entries] }; const firstKeptIndex = - compaction.firstKeptTurnId.length > 0 ? active.findIndex((entry) => entry.turnId === compaction.firstKeptTurnId) : -1; + compaction.firstKeptTurnId.length > 0 + ? entries.findIndex((entry) => entry.turnId === compaction.firstKeptTurnId) + : -1; const kept = - firstKeptIndex >= 0 && firstKeptIndex < compactionIndex ? active.slice(firstKeptIndex, compactionIndex) : []; - return [...kept, ...active.slice(compactionIndex + 1)]; + firstKeptIndex >= 0 && firstKeptIndex < compactionIndex ? entries.slice(firstKeptIndex, compactionIndex) : []; + return { compactionIndex, visible: [...kept, ...entries.slice(compactionIndex + 1)] }; +} + +/** Active path, then the compaction cut: the slice a policy may select from. */ +export function selectVisibleEntries(entries: ReadonlyArray, activeLeafTurnId?: string): SessionEntry[] { + return compactionCut(filterEntriesToActivePath(entries, activeLeafTurnId)).visible; } diff --git a/src/interactive/chat-renderer.ts b/src/interactive/chat-renderer.ts index 8161ddfff..a55755dc2 100644 --- a/src/interactive/chat-renderer.ts +++ b/src/interactive/chat-renderer.ts @@ -15,6 +15,7 @@ import { ToolNames } from "../core/tool-names.js"; import { foldWorkingSet } from "../domains/context/working-set/fold.js"; +import { compactionCut } from "../domains/context/working-set/visible.js"; import type { BashExecutionEntry, BranchSummaryEntry, @@ -836,13 +837,6 @@ function truncateAtTurn(entries: ReadonlyArray, uptoTurnId?: strin return entries.slice(0, index + 1); } -function latestCompactionIndex(entries: ReadonlyArray): number { - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i]?.kind === "compactionSummary") return i; - } - return -1; -} - function toolCallIdsInEntry(entry: SessionEntry): string[] { if (entry.kind !== "message") return []; if (entry.role === "tool_call") return [extractToolCall(entry).id]; @@ -920,19 +914,13 @@ export function selectReplayEntries( ): SessionEntry[] { const active = filterEntriesToActivePath(turns, options.activeLeafTurnId ?? options.uptoTurnId); const entries = truncateAtTurn(active, options.uptoTurnId); - const compactionIndex = latestCompactionIndex(entries); - if (compactionIndex < 0) return dropLegacyToolResultAssistantDuplicates(entries); - - const compaction = entries[compactionIndex] as CompactionSummaryEntry; - const selected: SessionEntry[] = [compaction]; - const firstKeptIndex = compaction.firstKeptTurnId - ? entries.findIndex((entry) => entry.turnId === compaction.firstKeptTurnId) - : -1; - if (firstKeptIndex >= 0 && firstKeptIndex < compactionIndex) { - selected.push(...entries.slice(firstKeptIndex, compactionIndex)); - } - selected.push(...entries.slice(compactionIndex + 1)); - return dropLegacyToolResultAssistantDuplicates(repairToolResultOrphans(entries, selected, compactionIndex)); + // The cut itself is the working-set layer's definition of "what the model + // can see", shared with the eviction policy input so the two cannot drift. + const cut = compactionCut(entries); + if (cut.compactionIndex < 0) return dropLegacyToolResultAssistantDuplicates(entries); + const compaction = entries[cut.compactionIndex] as CompactionSummaryEntry; + const selected: SessionEntry[] = [compaction, ...cut.visible]; + return dropLegacyToolResultAssistantDuplicates(repairToolResultOrphans(entries, selected, cut.compactionIndex)); } function dropLegacyToolResultAssistantDuplicates(entries: ReadonlyArray): SessionEntry[] { diff --git a/tests/contracts/working-set-replay-incremental.test.ts b/tests/contracts/working-set-replay-incremental.test.ts index 400659c8b..3024a4102 100644 --- a/tests/contracts/working-set-replay-incremental.test.ts +++ b/tests/contracts/working-set-replay-incremental.test.ts @@ -5,6 +5,7 @@ import type { WorkingSetPolicy, WorkingSetSettings } from "../../src/domains/con import { DEFAULT_WORKING_SET_SETTINGS } from "../../src/domains/context/working-set/defaults.js"; import { buildEvictionFields, planEviction } from "../../src/domains/context/working-set/engine.js"; import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; +import { isTurnStart } from "../../src/domains/context/working-set/horizon.js"; import { resolveWorkingSetPolicy } from "../../src/domains/context/working-set/policies/index.js"; import { projectWorkingSet } from "../../src/domains/context/working-set/project.js"; import { loadClaudeCodeTraces } from "../../src/domains/context/working-set/replay/load-claude-code.js"; @@ -15,7 +16,7 @@ import { type ReplayTraceResult, replayTrace, } from "../../src/domains/context/working-set/replay/runner.js"; -import { isReplayTurnStart, type Trace } from "../../src/domains/context/working-set/replay/trace.js"; +import type { Trace } from "../../src/domains/context/working-set/replay/trace.js"; import { selectVisibleEntries } from "../../src/domains/context/working-set/visible.js"; import { estimateTokens } from "../../src/domains/session/compaction/tokens.js"; import type { ContextEvictionEntry, SessionEntry } from "../../src/domains/session/entries.js"; @@ -58,7 +59,7 @@ function referenceReplay(trace: Trace, policy: WorkingSetPolicy, config: ReplayC let turnsToFirstSummary: number | null = null; const pressureLimit = config.threshold * config.budgetTokens; for (const entry of trace.entries) { - if (isReplayTurnStart(entry)) { + if (isTurnStart(entry)) { turnIndex += 1; const leaf = [...soFar].reverse().find((candidate) => candidate.kind === "message")?.turnId ?? null; const tokens = projectedTokens(soFar, leaf); diff --git a/tests/contracts/working-set-replay.test.ts b/tests/contracts/working-set-replay.test.ts index e2815c815..c0e8d3c59 100644 --- a/tests/contracts/working-set-replay.test.ts +++ b/tests/contracts/working-set-replay.test.ts @@ -9,6 +9,7 @@ import type { import { DEFAULT_WORKING_SET_SETTINGS } from "../../src/domains/context/working-set/defaults.js"; import { planEviction } from "../../src/domains/context/working-set/engine.js"; import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; +import { isTurnStart } from "../../src/domains/context/working-set/horizon.js"; import { buildPathIndex } from "../../src/domains/context/working-set/path-index.js"; import { resolveWorkingSetPolicy } from "../../src/domains/context/working-set/policies/index.js"; import { projectWorkingSet } from "../../src/domains/context/working-set/project.js"; @@ -33,7 +34,7 @@ import { type ReplayTraceResult, replayTrace, } from "../../src/domains/context/working-set/replay/runner.js"; -import { isReplayTurnStart, type Trace } from "../../src/domains/context/working-set/replay/trace.js"; +import type { Trace } from "../../src/domains/context/working-set/replay/trace.js"; import { estimateTokens } from "../../src/domains/session/compaction/tokens.js"; import type { SessionEntry } from "../../src/domains/session/entries.js"; @@ -65,7 +66,7 @@ function prefixBeforeTurn(trace: Trace, wantedTurn: number): SessionEntry[] { const prefix: SessionEntry[] = []; let turn = 0; for (const entry of trace.entries) { - if (isReplayTurnStart(entry)) { + if (isTurnStart(entry)) { turn += 1; if (turn === wantedTurn) return prefix; } diff --git a/tests/contracts/working-set-scenarios-replay.test.ts b/tests/contracts/working-set-scenarios-replay.test.ts index 27ce5d3d9..1f05e38fb 100644 --- a/tests/contracts/working-set-scenarios-replay.test.ts +++ b/tests/contracts/working-set-scenarios-replay.test.ts @@ -20,10 +20,10 @@ import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; import { join } from "node:path"; import { describe, it } from "node:test"; import { DEFAULT_WORKING_SET_SETTINGS } from "../../src/domains/context/working-set/defaults.js"; +import { isTurnStart } from "../../src/domains/context/working-set/horizon.js"; import { ageHorizonPolicy } from "../../src/domains/context/working-set/policies/age-horizon.js"; import { loadClioTraces } from "../../src/domains/context/working-set/replay/load-clio.js"; import { replayTrace } from "../../src/domains/context/working-set/replay/runner.js"; -import { isReplayTurnStart } from "../../src/domains/context/working-set/replay/trace.js"; import type { EvictedItem, MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; import { createScenarioHarness, evictionEntries } from "../harness/working-set-session.js"; @@ -42,7 +42,7 @@ function entriesBeforeTurn(entries: ReadonlyArray, turnIndex: numb let seen = 0; for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]; - if (entry === undefined || !isReplayTurnStart(entry)) continue; + if (entry === undefined || !isTurnStart(entry)) continue; seen += 1; if (seen === turnIndex) return entries.slice(0, index); } From 11294979c50dcc46538cd5e8f00a75635e79d130 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 13:10:03 -0500 Subject: [PATCH 33/45] refactor(context): recall reads payloads through payload.ts recall.ts carried private copies of isRecord, textFromContent, resultText, extractToolResult, and offloadPathOf that payload.ts already exports for the projection and the marker. One reader for the body the model saw. --- src/domains/context/working-set/recall.ts | 71 +++-------------------- 1 file changed, 9 insertions(+), 62 deletions(-) diff --git a/src/domains/context/working-set/recall.ts b/src/domains/context/working-set/recall.ts index 4b74c02b2..3c10b00a7 100644 --- a/src/domains/context/working-set/recall.ts +++ b/src/domains/context/working-set/recall.ts @@ -8,13 +8,12 @@ * appends. The ref stays evicted in the fold: the body rides the recall tool * result at the tail of the working set, so the marker and the prefix cache * are untouched and a repeat recall is the churn signal. Pure over entries: - * nothing here reads - * the session, writes the ledger, or calls a model. + * nothing here reads the session, writes the ledger, or calls a model. * - * The body is read the way `compaction/mask-observations.ts` reads a - * tool_result payload (`resultText`), so what recall returns is exactly what - * the projection would have rendered before eviction. No truncation happens - * here; the observation envelope applies the per-turn caps. + * The body is read through the same `payload.ts` readers the projection and + * the marker use, so what recall returns is exactly what the model saw before + * eviction. No truncation happens here; the observation envelope applies the + * per-turn caps. */ import { ceilChars } from "../../session/context-accounting.js"; @@ -22,62 +21,10 @@ import type { MessageEntry, SessionEntry } from "../../session/entries.js"; import { filterEntriesToActivePath } from "../../session/tree/active-path.js"; import type { ContextRecallFields, RecallError, RecallResult, RecallTrigger, WorkingSetView } from "./contract.js"; import { parseRefKey, refKey } from "./fold.js"; +import { offloadPathOf, toolResultPayload, toolResultText } from "./payload.js"; export type RecallOutcome = { ok: true; result: RecallResult } | { ok: false; error: RecallError }; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -function textFromContent(content: unknown): string { - if (!Array.isArray(content)) return ""; - const parts: string[] = []; - for (const block of content) { - if (!isRecord(block)) continue; - if (block.type === "text" && typeof block.text === "string") parts.push(block.text); - } - return parts.join(""); -} - -function stringifyWhole(value: unknown): string { - if (value === undefined || value === null) return ""; - if (typeof value === "string") return value; - try { - return JSON.stringify(value) ?? ""; - } catch { - return String(value); - } -} - -/** Same field precedence as `resultText` in mask-observations.ts, without the preview cap. */ -function resultText(result: unknown): string { - if (typeof result === "string") return result; - if (!isRecord(result)) return stringifyWhole(result); - const contentText = textFromContent(result.content); - if (contentText.length > 0) return contentText; - if (typeof result.text === "string") return result.text; - if (typeof result.output === "string") return result.output; - if (typeof result.message === "string") return result.message; - return stringifyWhole(result); -} - -function extractToolResult(payload: unknown): unknown { - const obj = isRecord(payload) ? payload : { result: payload }; - return obj.result ?? obj.output ?? obj.out ?? obj.content ?? payload; -} - -function offloadPathOf(result: unknown): string | undefined { - if (!isRecord(result) || !isRecord(result.details)) return undefined; - const details = result.details; - for (const key of ["resultSize", "observation"] as const) { - const record = details[key]; - if (isRecord(record) && typeof record.offloadPath === "string" && record.offloadPath.length > 0) { - return record.offloadPath; - } - } - return undefined; -} - function commonPrefixLength(a: string, b: string): number { const limit = Math.min(a.length, b.length); let i = 0; @@ -130,9 +77,9 @@ export function resolveRecall( if (isThinkingEntry(entry) || !view.evicted.has(key) || !isToolResultEntry(entry)) { return { ok: false, error: { kind: "not_evicted", ref: key, nearest: nearestEvictedRef(view, key) } }; } - const result = extractToolResult(entry.payload); - const body = resultText(result); - const offloadPath = offloadPathOf(result); + const payload = toolResultPayload(entry.payload); + const body = toolResultText(payload.result); + const offloadPath = offloadPathOf(payload); return { ok: true, result: { From 4ea7ef5bbbd2350ed578abcdcfd16a1f84636368 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 13:11:24 -0500 Subject: [PATCH 34/45] refactor(context): recall failures list the evicted refs instead of guessing the nearest one The longest-common-prefix guess over time-ordered ids named an unrelated result more often than not, and both callers already appended the listing that actually helps. recallErrorMessage now owns the listing (eight refs, then a count) and RecallError loses its nearest field. Tests: the two nearest-ref cases become listing cases. --- src/domains/context/working-set/contract.ts | 11 ++- src/domains/context/working-set/recall.ts | 74 +++++++++++---------- src/interactive/context-recall-command.ts | 25 ++----- src/tools/context/index.ts | 11 +-- tests/contracts/context-tool-recall.test.ts | 4 +- tests/contracts/working-set-recall.test.ts | 46 ++++++++----- 6 files changed, 80 insertions(+), 91 deletions(-) diff --git a/src/domains/context/working-set/contract.ts b/src/domains/context/working-set/contract.ts index 39a034eb9..126e746e7 100644 --- a/src/domains/context/working-set/contract.ts +++ b/src/domains/context/working-set/contract.ts @@ -40,10 +40,9 @@ export type { }; /** - * Ref keys index `WorkingSetView.evicted`. A key is the entry turnId - * (`ref.entry`); `fold.ts` owns the `refKey` / `parseRefKey` helpers. + * What the fold knows about one evicted unit. Keyed in `WorkingSetView.evicted` + * by the entry turnId (`ref.entry`); `fold.ts` owns `refKey` / `parseRefKey`. */ -/** What the fold knows about one evicted unit. */ export interface EvictedState { reason: EvictionReason; marker: string; @@ -144,10 +143,10 @@ export interface EvictionPlan { export type ContextEvictionFields = Omit; export type ContextRecallFields = Omit; -/** Typed failure for recall by ref. */ +/** Typed failure for recall by ref. `recallErrorMessage` lists the refs that are evicted beside it. */ export type RecallError = - | { kind: "not_on_active_path"; ref: string; nearest: string | null } - | { kind: "not_evicted"; ref: string; nearest: string | null } + | { kind: "not_on_active_path"; ref: string } + | { kind: "not_evicted"; ref: string } | { kind: "invalid_ref"; ref: string }; export interface RecallResult { diff --git a/src/domains/context/working-set/recall.ts b/src/domains/context/working-set/recall.ts index 3c10b00a7..3f9ea4d9e 100644 --- a/src/domains/context/working-set/recall.ts +++ b/src/domains/context/working-set/recall.ts @@ -19,37 +19,19 @@ import { ceilChars } from "../../session/context-accounting.js"; import type { MessageEntry, SessionEntry } from "../../session/entries.js"; import { filterEntriesToActivePath } from "../../session/tree/active-path.js"; -import type { ContextRecallFields, RecallError, RecallResult, RecallTrigger, WorkingSetView } from "./contract.js"; +import { + type ContextRecallFields, + EMPTY_WORKING_SET_VIEW, + type RecallError, + type RecallResult, + type RecallTrigger, + type WorkingSetView, +} from "./contract.js"; import { parseRefKey, refKey } from "./fold.js"; import { offloadPathOf, toolResultPayload, toolResultText } from "./payload.js"; export type RecallOutcome = { ok: true; result: RecallResult } | { ok: false; error: RecallError }; -function commonPrefixLength(a: string, b: string): number { - const limit = Math.min(a.length, b.length); - let i = 0; - while (i < limit && a.charCodeAt(i) === b.charCodeAt(i)) i += 1; - return i; -} - -/** - * The evicted ref key sharing the longest non-empty common prefix with `key`, - * or null when no evicted key shares a prefix. Ties keep fold order, which is - * ledger order of the eviction events. - */ -function nearestEvictedRef(view: WorkingSetView, key: string): string | null { - let best: string | null = null; - let bestLength = 0; - for (const candidate of view.evicted.keys()) { - const length = commonPrefixLength(candidate, key); - if (length > bestLength) { - best = candidate; - bestLength = length; - } - } - return best; -} - function isThinkingEntry(entry: SessionEntry): boolean { return entry.kind === "message" && entry.role === "assistant"; } @@ -70,12 +52,12 @@ export function resolveRecall( const active = filterEntriesToActivePath(entries, activeLeafTurnId); const entry = active.find((candidate) => candidate.turnId === key); if (entry === undefined) { - return { ok: false, error: { kind: "not_on_active_path", ref: key, nearest: nearestEvictedRef(view, key) } }; + return { ok: false, error: { kind: "not_on_active_path", ref: key } }; } // Thinking leaves the working set without a marker and is not recallable // in this slice; `recallErrorMessage` names that case from the entry. if (isThinkingEntry(entry) || !view.evicted.has(key) || !isToolResultEntry(entry)) { - return { ok: false, error: { kind: "not_evicted", ref: key, nearest: nearestEvictedRef(view, key) } }; + return { ok: false, error: { kind: "not_evicted", ref: key } }; } const payload = toolResultPayload(entry.payload); const body = toolResultText(payload.result); @@ -120,24 +102,44 @@ export function buildRecallFields( }; } +/** Refs listed in a recall failure before the list is cut with an ellipsis. */ +const MAX_LISTED_REFS = 8; + +/** + * The refs that are actually out, so the next call can name one of them. A + * guessed "nearest" ref was tried first and dropped: over time-ordered ids a + * prefix match names an unrelated result, and the listing is what helps. + */ +function evictedRefListing(view: WorkingSetView): string { + const refs = [...view.evicted.keys()]; + if (refs.length === 0) return "No refs are evicted on the active path."; + const shown = refs.slice(0, MAX_LISTED_REFS).join(", "); + const more = refs.length > MAX_LISTED_REFS ? `, and ${refs.length - MAX_LISTED_REFS} more` : ""; + return `Evicted refs on the active path: ${shown}${more}.`; +} + /** - * One-line operator/model-facing message for a recall failure. Names the - * nearest valid ref when one exists so the next call can succeed, and says - * why an assistant turn is refused instead of calling it "not evicted". + * One-line operator/model-facing message for a recall failure. Says why an + * assistant turn is refused instead of calling it "not evicted", and ends with + * the refs that can be recalled. */ -export function recallErrorMessage(error: RecallError, entries: ReadonlyArray = []): string { - const nearest = "nearest" in error && error.nearest !== null ? ` Nearest evicted ref: ${error.nearest}.` : ""; +export function recallErrorMessage( + error: RecallError, + entries: ReadonlyArray = [], + view: WorkingSetView = EMPTY_WORKING_SET_VIEW, +): string { + const listing = ` ${evictedRefListing(view)}`; switch (error.kind) { case "invalid_ref": return `recall ref must be a single turnId without whitespace; got '${error.ref}'.`; case "not_on_active_path": - return `ref ${error.ref} is not on the active path of this session (unknown or on an abandoned branch).${nearest}`; + return `ref ${error.ref} is not on the active path of this session (unknown or on an abandoned branch).${listing}`; case "not_evicted": { const entry = entries.find((candidate) => candidate.turnId === error.ref); if (entry !== undefined && isThinkingEntry(entry)) { - return `ref ${error.ref} is an assistant turn; thinking is not recallable.${nearest}`; + return `ref ${error.ref} is an assistant turn; thinking is not recallable.${listing}`; } - return `ref ${error.ref} is not evicted; its content is already in context.${nearest}`; + return `ref ${error.ref} is not evicted; its content is already in context.${listing}`; } } } diff --git a/src/interactive/context-recall-command.ts b/src/interactive/context-recall-command.ts index 940dcfde7..6450263ee 100644 --- a/src/interactive/context-recall-command.ts +++ b/src/interactive/context-recall-command.ts @@ -51,8 +51,6 @@ export type OperatorRecallOutcome = } | { ok: false; message: string }; -const MAX_LISTED_REFS = 8; - function formatTokens(tokens: number): string { return tokens.toLocaleString("en-US"); } @@ -72,18 +70,6 @@ function headlineFor(ref: string, tokens: number, state: EvictedState | undefine return `[/context recall] ${parts.join(" · ")}`; } -/** - * A ref that resolved to nothing is usually a typo or a stale marker, so the - * failure names what the operator could have typed instead: the nearest evicted - * ref when the error carries one, and otherwise the refs that are actually out. - */ -function failureMessage(message: string, evictedRefs: ReadonlyArray, hasNearest: boolean): string { - if (hasNearest || evictedRefs.length === 0) return `[/context recall] ${message}`; - const shown = evictedRefs.slice(0, MAX_LISTED_REFS).join(", "); - const more = evictedRefs.length > MAX_LISTED_REFS ? ", …" : ""; - return `[/context recall] ${message} Evicted refs on the active path: ${shown}${more}.`; -} - export function runOperatorRecall(ref: string, deps: OperatorRecallDeps): OperatorRecallOutcome { if (!deps.hasSession()) { return { ok: false, message: "[/context recall] no active session; start one with /new or /resume first" }; @@ -96,13 +82,10 @@ export function runOperatorRecall(ref: string, deps: OperatorRecallDeps): Operat const leaf = deps.activeLeafTurnId(); const view = foldWorkingSet(entries, leaf); const resolved = resolveRecall(entries, view, trimmed, leaf); - if (!resolved.ok) { - const hasNearest = "nearest" in resolved.error && resolved.error.nearest !== null; - return { - ok: false, - message: failureMessage(recallErrorMessage(resolved.error, entries), [...view.evicted.keys()], hasNearest), - }; - } + // A ref that resolves to nothing is usually a typo or a stale marker; the + // shared message ends with the refs the operator could have typed instead. + if (!resolved.ok) + return { ok: false, message: `[/context recall] ${recallErrorMessage(resolved.error, entries, view)}` }; const { result } = resolved; const fields = buildRecallFields(result, { trigger: "operator" }); try { diff --git a/src/tools/context/index.ts b/src/tools/context/index.ts index 16258c39c..30676e654 100644 --- a/src/tools/context/index.ts +++ b/src/tools/context/index.ts @@ -537,16 +537,7 @@ function runRecallScope( const leaf = session.activeLeafTurnId(); const view = foldWorkingSet(entries, leaf); const resolved = resolveRecall(entries, view, ref, leaf); - if (!resolved.ok) { - // The listing is what lets the next call succeed; the nearest-ref guess is - // a prefix match over time-ordered ids and is usually an unrelated result. - const evictedRefs = [...view.evicted.keys()]; - const listing = - evictedRefs.length > 0 - ? ` Evicted refs on the active path: ${evictedRefs.slice(0, 8).join(", ")}${evictedRefs.length > 8 ? ", …" : ""}.` - : " No refs are evicted on the active path."; - return { kind: "error", message: `context: ${recallErrorMessage(resolved.error, entries)}${listing}` }; - } + if (!resolved.ok) return { kind: "error", message: `context: ${recallErrorMessage(resolved.error, entries, view)}` }; const { result } = resolved; const fields = buildRecallFields(result, { trigger: "tool", diff --git a/tests/contracts/context-tool-recall.test.ts b/tests/contracts/context-tool-recall.test.ts index 63bb958cd..d4f33e673 100644 --- a/tests/contracts/context-tool-recall.test.ts +++ b/tests/contracts/context-tool-recall.test.ts @@ -127,13 +127,13 @@ describe("contracts/context recall scope", () => { assert.equal(recalled[0]?.tokensReadmitted, Math.ceil(BODY.length / 4)); }); - it("errors name the nearest valid ref", async () => { + it("errors list the refs that can be recalled", async () => { const { deps } = fakeSession(baseEntries()); const tool = createContextTool({ session: deps }); const notEvicted = await tool.run({ scope: "recall", ref: "t2" }, undefined); assert.equal(notEvicted.kind, "error"); if (notEvicted.kind === "error") - assert.match(notEvicted.message, /not evicted.*Nearest evicted ref: t1\. Evicted refs on the active path: t1\./); + assert.match(notEvicted.message, /not evicted.*Evicted refs on the active path: t1\.$/); const offPath = await tool.run({ scope: "recall", ref: "nope" }, undefined); assert.equal(offPath.kind, "error"); if (offPath.kind === "error") diff --git a/tests/contracts/working-set-recall.test.ts b/tests/contracts/working-set-recall.test.ts index 8c6be8e31..d552d57e9 100644 --- a/tests/contracts/working-set-recall.test.ts +++ b/tests/contracts/working-set-recall.test.ts @@ -149,7 +149,7 @@ test("recall: invalid refs", () => { assert.match(recallErrorMessage(outcome.error), /single turnId/); }); -test("recall: not_evicted names the nearest evicted ref by longest common prefix", () => { +test("recall: not_evicted lists the refs that are evicted", () => { const entries = [ user("u1", null), toolResult("turn-a1", "u1", "a"), @@ -160,26 +160,40 @@ test("recall: not_evicted names the nearest evicted ref by longest common prefix const view = foldWorkingSet(entries); const outcome = resolveRecall(entries, view, "turn-b1"); assert.ok(!outcome.ok); - assert.equal(outcome.error.kind, "not_evicted"); - // "turn-b1" shares "turn-" (5) with both; the tie keeps fold order. - assert.deepEqual(outcome.error, { kind: "not_evicted", ref: "turn-b1", nearest: "turn-a1" }); - assert.match(recallErrorMessage(outcome.error, entries), /not evicted.*Nearest evicted ref: turn-a1/); + assert.deepEqual(outcome.error, { kind: "not_evicted", ref: "turn-b1" }); + assert.match( + recallErrorMessage(outcome.error, entries, view), + /not evicted.*Evicted refs on the active path: turn-a1, turn-a2\.$/, + ); - // A closer prefix wins over an earlier one. - const closer = resolveRecall(entries, view, "turn-a2x"); - assert.ok(!closer.ok); - assert.equal(closer.error.kind, "not_on_active_path"); - assert.equal(closer.error.nearest, "turn-a2"); + const unknown = resolveRecall(entries, view, "turn-a2x"); + assert.ok(!unknown.ok); + assert.equal(unknown.error.kind, "not_on_active_path"); + assert.match(recallErrorMessage(unknown.error, entries, view), /Evicted refs on the active path: turn-a1, turn-a2\.$/); }); -test("recall: not_on_active_path for an unknown ref, nearest null when nothing shares a prefix", () => { - const entries = fixture(); +test("recall: not_on_active_path for an unknown ref says when nothing is evicted", () => { + const entries = fixture().filter((entry) => entry.kind !== "contextEviction"); const view = foldWorkingSet(entries); const outcome = resolveRecall(entries, view, "zzz"); assert.ok(!outcome.ok); - assert.deepEqual(outcome.error, { kind: "not_on_active_path", ref: "zzz", nearest: null }); - assert.match(recallErrorMessage(outcome.error), /not on the active path/); - assert.doesNotMatch(recallErrorMessage(outcome.error), /Nearest/); + assert.deepEqual(outcome.error, { kind: "not_on_active_path", ref: "zzz" }); + assert.match(recallErrorMessage(outcome.error, entries, view), /not on the active path.*No refs are evicted/); +}); + +test("recall: the listing is cut after eight refs", () => { + const refs = Array.from({ length: 10 }, (_, index) => `t${index}`); + const entries: SessionEntry[] = [user("u1", null)]; + let parent = "u1"; + for (const ref of refs) { + entries.push(toolResult(ref, parent, `body ${ref}`)); + parent = ref; + } + entries.push(eviction("e1", parent, refs)); + const view = foldWorkingSet(entries); + const outcome = resolveRecall(entries, view, "nope"); + assert.ok(!outcome.ok); + assert.match(recallErrorMessage(outcome.error, entries, view), /t0, t1, t2, t3, t4, t5, t6, t7, and 2 more\.$/); }); test("recall: a ref on an abandoned branch is not_on_active_path after a fork", () => { @@ -196,7 +210,7 @@ test("recall: a ref on an abandoned branch is not_on_active_path after a fork", assert.deepEqual([...view.evicted.keys()], ["t1b"]); const abandoned = resolveRecall(entries, view, "t1", "u2"); assert.ok(!abandoned.ok); - assert.deepEqual(abandoned.error, { kind: "not_on_active_path", ref: "t1", nearest: "t1b" }); + assert.deepEqual(abandoned.error, { kind: "not_on_active_path", ref: "t1" }); const live = resolveRecall(entries, view, "t1b", "u2"); assert.ok(live.ok); assert.equal(live.result.body, "live body"); From 6e558cccbc467c357e9d1161efca865e69f39c4d Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 13:12:38 -0500 Subject: [PATCH 35/45] fix(context): a thinking-only assistant keeps its thinking instead of projecting to empty content Local reasoning models close a turn with reasoning and no answer text. Projected, that turn reached the provider as an assistant with content: [] or vanished and left two user turns adjacent. The projection now leaves such a turn alone and planEviction prices it at zero, so no eviction is recorded for it. --- src/domains/context/working-set/project.ts | 14 ++++++++++++- tests/contracts/working-set-project.test.ts | 22 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/domains/context/working-set/project.ts b/src/domains/context/working-set/project.ts index 6ba5f05cb..70713754b 100644 --- a/src/domains/context/working-set/project.ts +++ b/src/domains/context/working-set/project.ts @@ -55,12 +55,18 @@ function projectToolResult(entry: MessageEntry, state: EvictedState): MessageEnt * marker would spend tokens to say that something the model cannot act on is * gone. Both persisted shapes go: `thinking` content blocks and the * payload-level string the local engine adapters write. + * + * A turn that was nothing but reasoning keeps it. Projected, it would reach + * the provider as an assistant message with no content, or vanish from the + * replay and leave two user messages adjacent; either is worse than the + * tokens. `planEviction` then prices such a turn at zero and records nothing. */ function projectAssistant(entry: MessageEntry): MessageEntry { const obj = isRecord(entry.payload) ? entry.payload : null; if (obj === null || !hasThinking(obj)) return entry; - const next = cloneEntry(entry); const content = withoutThinkingBlocks(obj.content); + if (!hasVisibleContent(obj, content)) return entry; + const next = cloneEntry(entry); next.payload = { ...obj, ...(content !== undefined ? { content } : {}), @@ -69,6 +75,12 @@ function projectAssistant(entry: MessageEntry): MessageEntry { return next; } +/** What the replay builder would still send: a payload-level text or at least one surviving block. */ +function hasVisibleContent(obj: Record, content: unknown[] | undefined): boolean { + if (typeof obj.text === "string" && obj.text.length > 0) return true; + return content !== undefined && content.length > 0; +} + /** * Usage recorded before the projection existed described a longer prompt than * the model will now receive. `calculateContextTokens` anchors on the newest diff --git a/tests/contracts/working-set-project.test.ts b/tests/contracts/working-set-project.test.ts index c9e2d0a83..69d9e8c0b 100644 --- a/tests/contracts/working-set-project.test.ts +++ b/tests/contracts/working-set-project.test.ts @@ -138,6 +138,28 @@ test("project: an evicted assistant loses both thinking shapes", () => { assert.equal(payloadOf(entries[1]).thinking, "payload-level reasoning"); }); +test("project: an assistant whose only content was thinking keeps it", () => { + // Local reasoning models close a turn with reasoning and no answer text. + // Projected to content: [] the provider would reject the message, and + // dropped entirely the replay would show two user turns back to back. + const entries = ledger(); + const onlyThinking = entries[1] as MessageEntry; + onlyThinking.payload = { + ...(onlyThinking.payload as object), + content: [{ type: "thinking", thinking: "only reasoning" }], + }; + const projected = projectWorkingSet(entries, foldWorkingSet(entries)); + const payload = payloadOf(projected[1]) as { + content?: unknown[]; + thinking?: unknown; + contextUsageInvalidated?: unknown; + }; + assert.deepEqual(payload.content, [{ type: "thinking", thinking: "only reasoning" }]); + assert.equal(payload.thinking, "payload-level reasoning"); + // Usage invalidation is the event's, not the eviction's, and still applies. + assert.equal(payload.contextUsageInvalidated, true); +}); + test("project: is idempotent", () => { const entries = ledger(); const view = foldWorkingSet(entries); From 6236afaaae8688cd2bda230226b4eb1e5a6e5946 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 13:16:24 -0500 Subject: [PATCH 36/45] fix(context): an eviction event reports one token population everywhere the operator sees it The ledger entry priced the visible ledger slice while the notice, the ContextPruned toast, and the overlay's last-compaction line priced the agent message list, about 15 percent apart for the same event. All of them now quote the plan's tokensBefore/tokensAfter; the live estimate stays what the meter and the post-eviction re-check read. The notice also says how to get a body back. --- src/interactive/turn-context.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/interactive/turn-context.ts b/src/interactive/turn-context.ts index 5f25b1b3a..ad0c1a47b 100644 --- a/src/interactive/turn-context.ts +++ b/src/interactive/turn-context.ts @@ -79,7 +79,7 @@ export interface TurnContextDeps { bus?: SafeEventBus | undefined; readSessionEntries?: (() => ReadonlyArray) | undefined; autoCompact?: ((instructions?: string, trigger?: CompactionTrigger) => Promise) | undefined; - /** Test seam for the pure Worker A planner; production uses planEviction. */ + /** Test seam for the eviction planner; production uses `planEviction` from the working-set engine. */ planEviction?: typeof planEviction; getMemorySection?: (() => string) | undefined; middleware: TurnMiddleware; @@ -513,18 +513,24 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { currentContextSnapshot = postEvictionSnapshot; persistContextSnapshot(postEvictionSnapshot); - const tokensAfterEviction = snapshotInputTokens(postEvictionSnapshot); + // Every surface that describes this event (the notice, the toast + // ContextPruned feeds, the overlay's last-compaction line, the + // ledger entry) quotes the plan: the same chars/4 pricing over the + // same visible slice. The live estimate prices the agent message + // list and differs by the tool schemas and replay text; it stays + // what the meter and the re-check below read, not what the event + // reports about itself. lastCompactionEvent = { stage: "working_set", - tokensBefore: estimate.tokens, - tokensAfter: tokensAfterEviction, + tokensBefore: planned.tokensBefore, + tokensAfter: planned.tokensAfter, trigger, }; deps.bus?.emit(BusChannels.ContextPruned, { stage: "working_set", pressure: verdict.pressure, - tokensBefore: estimate.tokens, - tokensAfter: tokensAfterEviction, + tokensBefore: planned.tokensBefore, + tokensAfter: planned.tokensAfter, trigger, snapshotIdBefore: beforeSnapshotId, snapshotIdAfter: postEvictionSnapshot.snapshotId, @@ -532,9 +538,10 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { evictedItems: planned.items.length, at: Date.now(), } satisfies ContextPrunedPayload); - emitCompactionActivity("completed", `${planned.items.length} working-set items evicted`); + const itemsWord = planned.items.length === 1 ? "item" : "items"; + emitCompactionActivity("completed", `${planned.items.length} working-set ${itemsWord} evicted`); deps.emitNotice( - `[context engine] working_set: ${planned.items.length} items evicted by ${planned.policyId}; ~${estimate.tokens} tokens -> ~${tokensAfterEviction} tokens`, + `[context engine] working set: ${planned.items.length} ${itemsWord} evicted by ${planned.policyId}; ~${planned.tokensBefore} -> ~${planned.tokensAfter} tokens, recall by ref with context(scope="recall")`, ); const after = liveContextEstimate(agentRuntime, pendingUserText); @@ -902,8 +909,9 @@ export function createTurnContext(deps: TurnContextDeps): TurnContext { consumeExpectedColdReasons(runtimeId: string): void { // Cache-disturbance honesty (T3.3): consume disturbances since // the last settled run. Only single-slot local backends lose their - // prefix cache to interleaved work, so only local-native targets - // stamp reasons and notify; other tiers just clear the set. + // prefix cache to interleaved work, so dispatch and compaction + // stamp only on local-native targets; a working-set eviction moved + // the prefix itself and stamps on every tier (see stampsOnTier). runExpectedColdReasons = []; nextAssistantColdReasons = []; if (pendingColdReasons.size > 0) { From a01c581c290dbe5a419a8962a9d18597d25f7c14 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 13:16:24 -0500 Subject: [PATCH 37/45] feat(context): a read marker names the file the call read The read tool records no details.paths, so every evicted read rendered without path= and the model had only a 120-character preview to decide between recall and re-read. The marker now falls back to the call's own path argument, as the model wrote it; policies price with the same map so the recorded marker is the one that was priced. --- src/domains/context/working-set/engine.ts | 21 ++++++++++----- src/domains/context/working-set/path-index.ts | 15 +++++++++++ src/domains/context/working-set/payload.ts | 6 ++--- .../working-set/policies/structural.ts | 11 ++++++-- .../context/working-set/replay/controls.ts | 5 ++-- tests/contracts/working-set-project.test.ts | 27 +++++++++++++++++++ 6 files changed, 72 insertions(+), 13 deletions(-) diff --git a/src/domains/context/working-set/engine.ts b/src/domains/context/working-set/engine.ts index 9a1a1e87a..24d4baccd 100644 --- a/src/domains/context/working-set/engine.ts +++ b/src/domains/context/working-set/engine.ts @@ -25,9 +25,14 @@ import type { } from "./contract.js"; import { refKey } from "./fold.js"; import { renderMarker } from "./marker.js"; +import { callPathsByToolCallId } from "./path-index.js"; import { hasThinking, offloadPathOf, primaryPathOf, toolResultPayload, toolResultText } from "./payload.js"; import { projectWorkingSet } from "./project.js"; +/** Paths of the tool calls whose results may be evicted: `callPathsByToolCallId` over the policy input. */ +export type CallPaths = ReadonlyMap; +const NO_CALL_PATHS: CallPaths = new Map(); + /** * The event has no turnId until `session.appendEntry` gives it one, and the * projection reads only `reason` and `marker`, so plan-time states carry this @@ -40,11 +45,12 @@ const PENDING_EVENT_TURN_ID = ""; * evict. Thinking eviction renders no marker at all: the reasoning simply * stops being replayed. */ -function markerFor(entry: SessionEntry, candidate: EvictionCandidate): string | null { +function markerFor(entry: SessionEntry, candidate: EvictionCandidate, callPaths: CallPaths): string | null { if (entry.kind !== "message") return null; if (entry.role === "assistant") return hasThinking(entry.payload) ? "" : null; if (entry.role !== "tool_result") return null; const payload = toolResultPayload(entry.payload); + const toolCallId = typeof payload.obj.toolCallId === "string" ? payload.obj.toolCallId : undefined; return renderMarker({ ref: candidate.ref, reason: candidate.reason, @@ -52,7 +58,7 @@ function markerFor(entry: SessionEntry, candidate: EvictionCandidate): string | toolName: payload.toolName, text: toolResultText(payload.result), offloadPath: offloadPathOf(payload), - path: primaryPathOf(payload), + path: primaryPathOf(payload) ?? (toolCallId === undefined ? undefined : callPaths.get(toolCallId)), }); } @@ -117,14 +123,16 @@ function sumTokens(entries: ReadonlyArray, estimate: (entry: Sessi * Exported so a policy can do headroom arithmetic (`structural-v1` rung 6 needs * to know when to stop) against the same numbers `planEviction` will record. * A policy that priced evictions its own way would report headroom the ledger - * then contradicts. + * then contradicts. Pass the same `callPaths` the plan will use, or the marker + * priced here is a few bytes shorter than the one recorded. */ export function tokensFreedByEviction( estimateTokens: (entry: SessionEntry) => number, entry: SessionEntry, candidate: EvictionCandidate, + callPaths: CallPaths = NO_CALL_PATHS, ): number { - const marker = markerFor(entry, candidate); + const marker = markerFor(entry, candidate, callPaths); if (marker === null) return 0; const key = refKey(candidate.ref); const projected = projectWorkingSet([entry], soloView(key, pendingState(candidate, marker, "")))[0] ?? entry; @@ -137,6 +145,7 @@ export function planEviction(policy: WorkingSetPolicy, input: PolicyInput): Evic const byTurnId = new Map(); for (const entry of input.entries) byTurnId.set(entry.turnId, entry); + const callPaths = callPathsByToolCallId(input.entries); const items: EvictedItem[] = []; const claimed = new Set(); @@ -148,12 +157,12 @@ export function planEviction(policy: WorkingSetPolicy, input: PolicyInput): Evic if (input.view.evicted.has(key) || claimed.has(key)) continue; const entry = byTurnId.get(key); if (entry === undefined) continue; - const marker = markerFor(entry, candidate); + const marker = markerFor(entry, candidate, callPaths); if (marker === null) continue; // A marker at least as long as the body it replaces is a cold turn bought // for nothing, whatever the policy's reason. Refused here so no policy can // record an eviction that freed nothing. - const tokensFreed = tokensFreedByEviction(input.estimateTokens, entry, candidate); + const tokensFreed = tokensFreedByEviction(input.estimateTokens, entry, candidate, callPaths); if (tokensFreed <= 0) continue; claimed.add(key); items.push({ diff --git a/src/domains/context/working-set/path-index.ts b/src/domains/context/working-set/path-index.ts index 3abeb90f1..7a4c63c36 100644 --- a/src/domains/context/working-set/path-index.ts +++ b/src/domains/context/working-set/path-index.ts @@ -195,6 +195,21 @@ function collectToolCalls(entries: ReadonlyArray): Map): ReadonlyMap { + const paths = new Map(); + for (const [id, call] of collectToolCalls(entries)) { + const named = stringField(isRecord(call.args) ? call.args : null, "path", "file_path", "filePath"); + if (named !== null) paths.set(id, named); + } + return paths; +} + /** * The path the call was about. Search tools default to the working directory, * which is what they actually searched, so a `grep` with no `path` argument is diff --git a/src/domains/context/working-set/payload.ts b/src/domains/context/working-set/payload.ts index b61383a27..7608d25d7 100644 --- a/src/domains/context/working-set/payload.ts +++ b/src/domains/context/working-set/payload.ts @@ -114,9 +114,9 @@ export function offloadPathOf(payload: ToolResultPayload): string | undefined { /** * The one file this result was about, when the tool named exactly one. * `edit`, `write`, and `artifact` record `details.paths`; a result naming - * several is left without a path rather than picking one arbitrarily. The - * structural policy's path index (slice 2) replaces this with the call's own - * arguments. + * several is left without a path rather than picking one arbitrarily. Read + * results carry no `paths`, so the marker falls back to the call's own + * argument (`callPathsByToolCallId` in path-index.ts). */ export function primaryPathOf(payload: ToolResultPayload): string | undefined { const details = nestedRecord(isRecord(payload.result) ? payload.result : null, "details"); diff --git a/src/domains/context/working-set/policies/structural.ts b/src/domains/context/working-set/policies/structural.ts index 0d728ff05..542a42f6d 100644 --- a/src/domains/context/working-set/policies/structural.ts +++ b/src/domains/context/working-set/policies/structural.ts @@ -24,7 +24,13 @@ import type { EvictionCandidate, EvictionReason, PolicyInput, WorkingSetPolicy } from "../contract.js"; import { tokensFreedByEviction } from "../engine.js"; import { protectionCutoffIndex } from "../horizon.js"; -import { buildPathIndex, type PathIndex, type PathObservation, type PathRange } from "../path-index.js"; +import { + buildPathIndex, + callPathsByToolCallId, + type PathIndex, + type PathObservation, + type PathRange, +} from "../path-index.js"; import { hasThinking } from "../payload.js"; import { findLaterSuccess, isProtected } from "../protect.js"; @@ -92,6 +98,7 @@ export const structuralPolicy: WorkingSetPolicy = { select(input: PolicyInput): ReadonlyArray { const { entries, view, settings, pressure, estimateTokens } = input; const index = buildPathIndex(entries, { cwd: input.cwd }); + const callPaths = callPathsByToolCallId(entries); const cutoffIndex = protectionCutoffIndex(entries, settings.protectLastTurns); const candidates: EvictionCandidate[] = []; const claimed = new Set(); @@ -113,7 +120,7 @@ export const structuralPolicy: WorkingSetPolicy = { const candidate: EvictionCandidate = { ref: { entry: turnId }, reason, ...(by === undefined ? {} : { by }) }; claimed.add(turnId); candidates.push(candidate); - freed += tokensFreedByEviction(estimateTokens, entry, candidate); + freed += tokensFreedByEviction(estimateTokens, entry, candidate, callPaths); return true; }; diff --git a/src/domains/context/working-set/replay/controls.ts b/src/domains/context/working-set/replay/controls.ts index 23a281a9f..c4f55bac1 100644 --- a/src/domains/context/working-set/replay/controls.ts +++ b/src/domains/context/working-set/replay/controls.ts @@ -2,7 +2,7 @@ import type { SessionEntry } from "../../../session/entries.js"; import type { EvictionCandidate, PolicyInput, WorkingSetPolicy, WorkingSetPolicyId } from "../contract.js"; import { tokensFreedByEviction } from "../engine.js"; import { protectionCutoffIndex } from "../horizon.js"; -import { buildPathIndex } from "../path-index.js"; +import { buildPathIndex, callPathsByToolCallId } from "../path-index.js"; import { isProtected } from "../protect.js"; import type { ReferenceGraph } from "./reference-graph.js"; import { countReplayTurns } from "./trace.js"; @@ -35,10 +35,11 @@ function takeToTarget(input: PolicyInput, entries: ReadonlyArray): let tokensNeeded = Math.max(0, input.pressure.tokens - input.pressure.target * input.pressure.contextWindow); if (tokensNeeded <= 0) return []; const selected: EvictionCandidate[] = []; + const callPaths = callPathsByToolCallId(input.entries); for (const entry of entries) { const candidate: EvictionCandidate = { ref: { entry: entry.turnId }, reason: "age_horizon" }; selected.push(candidate); - tokensNeeded -= tokensFreedByEviction(input.estimateTokens, entry, candidate); + tokensNeeded -= tokensFreedByEviction(input.estimateTokens, entry, candidate, callPaths); if (tokensNeeded <= 0) break; } return selected; diff --git a/tests/contracts/working-set-project.test.ts b/tests/contracts/working-set-project.test.ts index 69d9e8c0b..36f8a150c 100644 --- a/tests/contracts/working-set-project.test.ts +++ b/tests/contracts/working-set-project.test.ts @@ -1,8 +1,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { EMPTY_WORKING_SET_VIEW } from "../../src/domains/context/working-set/contract.js"; +import { planEviction } from "../../src/domains/context/working-set/engine.js"; import { foldWorkingSet } from "../../src/domains/context/working-set/fold.js"; import { renderMarker } from "../../src/domains/context/working-set/marker.js"; +import { ageHorizonPolicy } from "../../src/domains/context/working-set/policies/age-horizon.js"; import { projectWorkingSet } from "../../src/domains/context/working-set/project.js"; import type { MessageEntry, SessionEntry } from "../../src/domains/session/entries.js"; @@ -160,6 +162,31 @@ test("project: an assistant whose only content was thinking keeps it", () => { assert.equal(payload.contextUsageInvalidated, true); }); +test("plan: a read result without details.paths takes its marker path from the call's argument", () => { + // The read tool records no `paths`; the model still needs to know which + // file a marker stands for to choose between recall and re-read. + const entries = ledger().filter((entry) => entry.kind !== "contextEviction"); + const result = entries[3] as MessageEntry; + result.payload = { + toolCallId: "call-1", + toolName: "read", + result: { content: [{ type: "text", text: BODY }] }, + }; + const plan = planEviction(ageHorizonPolicy, { + entries, + view: EMPTY_WORKING_SET_VIEW, + cwd: null, + settings: { enabled: true, policy: "age-horizon", target: 0.6, protectLastTurns: 1, minEvictableTokens: 0 }, + pressure: { tokens: 1, contextWindow: 1, threshold: 0.8, target: 0.6 }, + estimateTokens: (entry) => JSON.stringify(entry).length / 4, + }); + const item = plan?.items.find((candidate) => candidate.ref.entry === "t1"); + assert.ok(item); + assert.match(item.marker, /^\[evicted ref=t1 reason=age_horizon tool=read path=src\/huge\.ts size=/); + // The recorded marker is the one that was priced. + assert.ok(item.tokensFreed > 0); +}); + test("project: is idempotent", () => { const entries = ledger(); const view = foldWorkingSet(entries); From f81db365ab7a6fced06d94ad051a8ac264fb8c5d Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 13:16:24 -0500 Subject: [PATCH 38/45] refactor(context): drop the replay churn column, which is one minus precision Replay churn counted evicted items the session referenced again, which is exactly the complement of eviction precision, and the name collided with live churn (recalls over items evicted). The table and the JSON lose the column; the test that pinned churn=1 already pinned precision=0. --- docs/commands-and-modes.md | 2 +- src/domains/context/working-set/replay/metrics.ts | 11 ++++------- src/domains/context/working-set/replay/report.ts | 7 +++---- tests/contracts/working-set-replay.test.ts | 1 - 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/docs/commands-and-modes.md b/docs/commands-and-modes.md index 4c3b18a38..94842f299 100644 --- a/docs/commands-and-modes.md +++ b/docs/commands-and-modes.md @@ -78,7 +78,7 @@ For process exit codes, stdout deliverable guarantees, and machine-readable JSON | `clio-coder context wiki [--update] [--status] [--depth auto\|simple\|medium\|detailed] [--target ] [--model ] [--thinking off\|low\|medium\|high]` | Generate, update, or inspect the agent-authored Markdown wiki under `.clio-coder/wiki/`. | | `clio-coder context reset [--all] [--yes]` | Clear accumulated project context artifacts; `--all` also removes `CLIO-CODER.md`. `--yes` (or `-y`) answers every confirmation and is required when stdin is not a terminal. | | `clio-coder context index [--json]` | Build the structural codewiki index without model calls; writes `.clio-coder/codewiki.json` and `.clio-coder/state.json` and prints coverage plus a structural hash. | -| `clio-coder context replay --sessions ... [--format clio\|claude-code\|auto] [--policies ] [--budgets ] [--threshold ] [--target ] [--protect-last-turns ] [--min-evictable-tokens ] [--seed ] [--no-filter] [--json ] [--md ]` | Replay working-set policies over Clio or Claude Code session ledgers and report retention, precision, token savings, saturation, churn, and summary headroom. | +| `clio-coder context replay --sessions ... [--format clio\|claude-code\|auto] [--policies ] [--budgets ] [--threshold ] [--target ] [--protect-last-turns ] [--min-evictable-tokens ] [--seed ] [--no-filter] [--json ] [--md ]` | Replay working-set policies over Clio or Claude Code session ledgers and report retention, precision, token savings, saturation, and summary headroom. | | `clio-coder context working-set --session ` | Inspect one session's durable working-set fold and path-index summary without modifying the ledger. | ## Headless Run Flags diff --git a/src/domains/context/working-set/replay/metrics.ts b/src/domains/context/working-set/replay/metrics.ts index 54cb4ad96..13e19780f 100644 --- a/src/domains/context/working-set/replay/metrics.ts +++ b/src/domains/context/working-set/replay/metrics.ts @@ -12,7 +12,6 @@ export interface ReplayMetrics { evictionEvents: number; /** Fraction of applied events that exhausted the policy's usable candidates. */ saturatedEvents: number; - churn: number; turnsToFirstSummary: number | null; } @@ -65,9 +64,11 @@ function measure(input: ReplayMeasurement): MeasuredTrace { } } + // Precision is the share of evicted items never referenced again; the + // complement (items the session came back to) is what live churn would + // count as recalls, so it is not reported as a second column. let evictedItems = 0; let safelyEvictedItems = 0; - let churnedItems = 0; let tokensEvicted = 0; let saturatedEventCount = 0; for (const event of input.replay.events) { @@ -76,9 +77,7 @@ function measure(input: ReplayMeasurement): MeasuredTrace { evictedItems += 1; tokensEvicted += item.tokensFreed; const future = input.graph.futureTurnsOf.get(item.ref.entry) ?? []; - const referencedAfter = future.some((turn) => turn > event.turnIndex); - if (referencedAfter) churnedItems += 1; - else safelyEvictedItems += 1; + if (!future.some((turn) => turn > event.turnIndex)) safelyEvictedItems += 1; } } @@ -91,7 +90,6 @@ function measure(input: ReplayMeasurement): MeasuredTrace { tokensEvicted, evictionEvents: input.replay.events.length, saturatedEvents: safeFraction(saturatedEventCount, input.replay.events.length, 0), - churn: safeFraction(churnedItems, evictedItems, 0), turnsToFirstSummary: input.replay.turnsToFirstSummary, }, pairs, @@ -129,7 +127,6 @@ export function aggregateReplayMetrics(inputs: ReadonlyArray) evictionEvents: mean(measured.map((entry) => entry.metrics.evictionEvents)), // Event-pooled: zero-event traces must not dilute the saturation rate. saturatedEvents: safeFraction(saturatedEvents, totalEvents, 0), - churn: mean(measured.map((entry) => entry.metrics.churn)), turnsToFirstSummary: summaries.length === 0 ? null : mean(summaries), }, turnsToFirstSummaryCount: summaries.length, diff --git a/src/domains/context/working-set/replay/report.ts b/src/domains/context/working-set/replay/report.ts index 656fe3ae8..7141bac04 100644 --- a/src/domains/context/working-set/replay/report.ts +++ b/src/domains/context/working-set/replay/report.ts @@ -36,7 +36,6 @@ function metricObject(metrics: ReplayMetrics, turnsToFirstSummaryCount: number): tokensEvicted: metrics.tokensEvicted, evictionEvents: metrics.evictionEvents, saturatedEvents: metrics.saturatedEvents, - churn: metrics.churn, turnsToFirstSummary: metrics.turnsToFirstSummary, turnsToFirstSummaryCount, }; @@ -117,15 +116,15 @@ export function renderReplayMarkdown(input: ReplayReportInput): string { "", `## Budget ${budget}`, "", - "| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) |", - "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + "| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | turns to first summary (mean) |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", ); for (const policy of input.config.policies) { const result = input.results.find((entry) => entry.budgetTokens === budget && entry.policyId === policy); if (result === undefined) continue; const metrics = result.metrics.mean; lines.push( - `| ${policy} | ${metrics.traces} | ${ratio(metrics.retention)} | ${ratio(result.metrics.pooledRetention)} | ${ratio(metrics.retentionAt10)} | ${ratio(metrics.evictionPrecision)} | ${quantity(metrics.tokensEvicted)} | ${quantity(metrics.evictionEvents)} | ${ratio(metrics.saturatedEvents)} | ${ratio(metrics.churn)} | ${metrics.turnsToFirstSummary === null ? "—" : quantity(metrics.turnsToFirstSummary)} (n=${result.metrics.turnsToFirstSummaryCount}) |`, + `| ${policy} | ${metrics.traces} | ${ratio(metrics.retention)} | ${ratio(result.metrics.pooledRetention)} | ${ratio(metrics.retentionAt10)} | ${ratio(metrics.evictionPrecision)} | ${quantity(metrics.tokensEvicted)} | ${quantity(metrics.evictionEvents)} | ${ratio(metrics.saturatedEvents)} | ${metrics.turnsToFirstSummary === null ? "—" : quantity(metrics.turnsToFirstSummary)} (n=${result.metrics.turnsToFirstSummaryCount}) |`, ); } } diff --git a/tests/contracts/working-set-replay.test.ts b/tests/contracts/working-set-replay.test.ts index c0e8d3c59..0fe0cb177 100644 --- a/tests/contracts/working-set-replay.test.ts +++ b/tests/contracts/working-set-replay.test.ts @@ -181,7 +181,6 @@ describe("contracts/working-set replay-lite", () => { assert.equal(metrics.tokensEvicted, 250); assert.equal(metrics.evictionEvents, 1); assert.equal(metrics.saturatedEvents, 1); - assert.equal(metrics.churn, 1); }); it("pools saturated events by event count rather than by trace", async () => { From a3d5d69a7aa34a8dcf3a7abdc58c53bc6b42074e Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 13:18:04 -0500 Subject: [PATCH 39/45] docs(context): the default is structural-v1 everywhere the docs say it context-working-set.md and the changelog still said age-horizon in the settings block and table; the guide listed Claude Code replay as not shipped and the cache-honesty paragraph said only local-native targets stamp reasons. Also: age-horizon's missing target stop is now stated as deliberate, the /context prose mentions recall, the guide gains a See also for context replay and context working-set, and the two policy header comments stop describing slice 1. --- CHANGELOG.md | 3 ++- docs/commands-and-modes.md | 10 +++++++--- docs/context-engine.md | 2 +- docs/context-working-set.md | 20 +++++++++++-------- .../working-set/policies/age-horizon.ts | 17 ++++++++-------- .../context/working-set/policies/index.ts | 7 ++++--- 6 files changed, 35 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6993072f..6f7001b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,12 @@ All notable changes to Clio Coder are documented in this file. The format follow - Two eviction policies. `structural-v1` is the default: it selects by what the session did since (`stale_after_mutation`, `superseded_read`, `failure_resolved`, `listing_consumed`, `thinking_turn_closed`) and falls back to age only under pressure. `age-horizon` reproduces the previous age-based selection, minus results whose body is below `context.workingSet.minEvictableTokens`. Replayed over 165 Claude Code transcripts at a 128k budget, `structural-v1` retained 0.831 of later-referenced results against 0.781 for `age-horizon` and 0.779 for random eviction; the tables are under `benchmarks/results/context-replay/`. - `/context` reports the working set: policy, evicted items, evicted tokens, events, recalls, and churn. Evicted tool rows carry a dim `evicted · ` tag in the transcript. - Cache-honesty attribution for eviction. An applied event stamps `working_set_evict` on the next assistant entry's `promptCache.expectedColdReasons`, and `/context` reports `last cold turn: working-set eviction (expected)` instead of warning about a cold backend it caused itself. +- `clio-coder context replay --sessions ...` replays Clio ledgers and Claude Code transcripts through the live eviction code with `none`, `random`, and `oracle` controls and reports retention, precision, tokens evicted, saturation, and turns to first summary; `clio-coder context working-set --session ` prints one session's working-set fold and path index. - New guide: `docs/context-working-set.md`. ### Changed - Session format version 4. The bump is additive: it adds the `contextEviction` and `contextRecall` records and changes no existing entry, so a version 3 session migrates to 4 in place on open with nothing rewritten. Only a session written by a newer build is refused. The bump is one-way for the operator, and a 0.3.3 binary cannot open a session this release wrote. -- New settings under `context.workingSet`: `enabled` (default `true`), `policy` (default `age-horizon`), `target` (default `0.6`), `protectLastTurns` (default `6`), and `minEvictableTokens` (default `200`). `compaction.excludeLastTurns` now governs only the legacy mask path. +- New settings under `context.workingSet`: `enabled` (default `true`), `policy` (default `structural-v1`), `target` (default `0.6`), `protectLastTurns` (default `6`), and `minEvictableTokens` (default `200`). `compaction.excludeLastTurns` now governs only the legacy mask path. - Compaction reports a `working_set` stage on `ContextPruned`, and the middleware `on_compaction` hook gains the `working_set_evict` and `working_set_recall` stages. ### Fixed diff --git a/docs/commands-and-modes.md b/docs/commands-and-modes.md index 94842f299..41afbe02d 100644 --- a/docs/commands-and-modes.md +++ b/docs/commands-and-modes.md @@ -171,9 +171,13 @@ The registry table below lists the available interactive slash commands. On a ba | `/fork` | `/fork` | Fork from an assistant turn | | `/export` | `/export [path]` | Export a self-contained HTML transcript by default; a `.md` path writes Markdown | -`/context` with no arguments opens the context-window ledger overlay. The -subcommands own the durable project-context noun: `compact` summarizes older -turns in the session window, `init` bootstraps or updates `CLIO-CODER.md` and the +`/context` with no arguments opens the context-window ledger overlay, including +the working-set section (policy, evicted items and tokens, events, recalls, churn). +The subcommands own the durable project-context noun: `compact` summarizes older +turns in the session window, `recall ` prints an evicted tool-result body +back into the transcript by the ref its `[evicted ...]` marker names (it never +enters model context; the model recalls with `context(scope="recall", ref=...)`), +`init` bootstraps or updates `CLIO-CODER.md` and the codewiki, `refresh` re-indexes the codewiki and refreshes `.clio-coder/state.json` without touching `CLIO-CODER.md`, and `reset` deletes accumulated context artifacts (`.clio-coder/codewiki.json`, `.clio-coder/state.json`, diff --git a/docs/context-engine.md b/docs/context-engine.md index 858bf7dd2..d9e6b405f 100644 --- a/docs/context-engine.md +++ b/docs/context-engine.md @@ -73,7 +73,7 @@ When the ledger is replayed to the model, compaction summaries, branch summaries Compaction and eviction both change the replayed history. On a local backend with a single prefix-cache slot, the next turn after either one is expected to be cold because the byte prefix moved. Dispatch traffic can disturb the same slot. -Clio records these disturbances once on the next assistant entry as `promptCache.expectedColdReasons`. The recorded reasons are `working_set_evict` for an applied eviction event, `compaction` for the summary path, and `dispatch` for interleaved worker traffic. Only `local-native` targets stamp reasons and notify, because they are the tier a single interleaved run actually costs. The user sees one dim notice, and the same reasons persist on that entry in the session ledger next to the per-call cache data. +Clio records these disturbances once on the next assistant entry as `promptCache.expectedColdReasons`. The recorded reasons are `working_set_evict` for an applied eviction event, `compaction` for the summary path, and `dispatch` for interleaved worker traffic. `compaction` and `dispatch` are stamped only on `local-native` targets, because a single-slot local cache is the one an interleaved run actually disturbs. `working_set_evict` is stamped on every tier: the eviction moved the byte prefix itself, so the cloud prefix cache is cold for the same reason. The user sees one dim notice, and the same reasons persist on that entry in the session ledger next to the per-call cache data. Per-call cache verdicts are `hot`, `partial`, `cold`, and `small`. They are derived from provider usage and persisted with `timing { ttftMs, apiMs }` and `promptCache { input, cacheRead, cacheWrite, backendVerdict }` when available. diff --git a/docs/context-working-set.md b/docs/context-working-set.md index 9edf8102a..ff540822c 100644 --- a/docs/context-working-set.md +++ b/docs/context-working-set.md @@ -50,7 +50,7 @@ Adding those kinds bumps the session format to version 4 (`CURRENT_SESSION_FORMA A marker is one line, its fields are in fixed order, and it carries no timestamp and no counter. That is not cosmetic. The marker is persisted inside the `contextEviction` entry and replayed on every subsequent request, so a marker whose bytes drifted between renders would cold-start the provider prefix cache on a turn that evicted nothing new. It would also make two replays of the same recorded ledger disagree. -Field order is `ref`, `reason`, `by`, `tool`, `path`, `size`, `offload`, `recall`, then the body tail. Undefined fields are omitted rather than rendered empty. Real output from `renderMarker` in `src/domains/context/working-set/marker.ts`: +Field order is `ref`, `reason`, `by`, `tool`, `path`, `size`, `offload`, `recall`, then the body tail. Undefined fields are omitted rather than rendered empty. `path` is the one file the result was about: `details.paths` when the tool recorded exactly one (`edit`, `write`, `artifact`), otherwise the `path` argument of the call as the model wrote it, which is how a `read` marker names its file. Real output from `renderMarker` in `src/domains/context/working-set/marker.ts`: ```text [evicted ref=0198f3c2-7a10-7c31-9d44-2b0c5f1e88a3 reason=stale_after_mutation by=0198f3c2-9b02-7f55-8e10-6d21ac9e4471 tool=read path=src/domains/context/working-set/engine.ts size=41 lines/3.8KB recall=context(scope="recall", ref="0198f3c2-7a10-7c31-9d44-2b0c5f1e88a3") preview="export function planEviction(policy: WorkingSetPolicy, input: PolicyInput): EvictionPlan | null { export function planEv"] @@ -90,9 +90,9 @@ The rule `maskStaleObservations` applied, recorded instead of destroyed. Every ` One skip condition is new, so this is today's selection minus small results rather than a byte-identical reproduction of it: a result whose estimated body is below `minEvictableTokens` (200 tokens by default) stays, whatever its age, because the marker would cost more than the body it replaces. The old mask had no such floor and masked those results too. Thinking has no size floor either way, because dropping it renders no marker. -Candidates arrive newest-safe-first, so a caller that stops early has evicted the newest safe unit rather than the oldest one. It ships as the default so this release changes one thing at a time: the ledger stops being rewritten while what the model receives stays what it received before. +`age-horizon` has no target stop. It evicts everything beyond the horizon in one event, exactly as the mask did, and ignores `context.workingSet.target`; the replay tables show this as `saturated events = 1.000` on every row. That is deliberate: the policy exists to reproduce the old selection through the ledger, and an operator who wants batching to a target wants `structural-v1`. Candidates arrive newest-safe-first, so a caller that stops early has evicted the newest safe unit rather than the oldest one. -Age is not a quality signal. A file read twenty turns ago and never touched since is more useful than a directory listing from two turns ago, which is the whole reason `structural-v1` exists. +Age is not a quality signal. A file read twenty turns ago and never touched since is more useful than a directory listing from two turns ago, which is the whole reason `structural-v1` exists and is the default. ### `structural-v1` (default) @@ -121,6 +121,8 @@ Recall is explicit and by ref. There is no auto-readmission: the marker tells th - `not_on_active_path` when the session has no such turn on this branch, which includes a ref from a branch `/tree` abandoned. - `not_evicted` when the unit is still in context. An assistant turn reports separately that thinking is not recallable. +Both messages end with the refs that are evicted on the active path (up to eight, then a count), because a failed recall is usually a mistyped ref and the listing is what the next call needs. + **A recall does not un-evict.** The key stays in `view.evicted`, the marker stays byte-identical at its original position, and the recalled body arrives at the tail of the working set inside the recall result. Readmitting it in place would duplicate the bytes and invalidate the provider prefix cache for everything after that point, which costs more than the recall saved. That also makes recall the churn signal. `churn = recalls / itemsEvicted` over the active path. A high churn number means the policy keeps evicting content the session still needs, which is a reason to change the policy rather than to raise the threshold. @@ -142,7 +144,7 @@ Both publish `BusChannels.ContextRecalled`, and both route through the middlewar context: workingSet: enabled: true - policy: age-horizon + policy: structural-v1 target: 0.6 protectLastTurns: 6 minEvictableTokens: 200 @@ -151,8 +153,8 @@ context: | Key | Default | Accepted | Meaning | | --- | --- | --- | --- | | `context.workingSet.enabled` | `true` | boolean | Master switch. `false` skips eviction and goes straight to summary compaction. It does not restore the destructive mask. | -| `context.workingSet.policy` | `age-horizon` | `age-horizon`, `structural-v1` | Candidate selection rule set. | -| `context.workingSet.target` | `0.6` | number greater than 0 and less than 1 | Used-over-window ratio an applied event batches down to. | +| `context.workingSet.policy` | `structural-v1` | `age-horizon`, `structural-v1` | Candidate selection rule set. | +| `context.workingSet.target` | `0.6` | number greater than 0 and less than 1 | Used-over-window ratio an applied `structural-v1` event batches down to. `age-horizon` ignores it. | | `context.workingSet.protectLastTurns` | `6` | integer ≥ 1 | Recent turns whose observations and thinking are never evicted. | | `context.workingSet.minEvictableTokens` | `200` | integer ≥ 0 | Results below this estimate are never evicted. | @@ -166,7 +168,7 @@ context: - **Transcript.** An evicted tool row keeps its full body and gains a dim `evicted · ` tag. The transcript shows the ledger, never the projection, so `/resume`, `/tree`, `/fork`, and the HTML export are unaffected by eviction. - **`/context recall `.** Prints the ref, why it was evicted, the token count, and the offload pointer when there is one, followed by the original body. Transcript only. - **Prompt cache line.** Every applied event stamps `working_set_evict` on the next assistant entry's `promptCache.expectedColdReasons`. When the last settled run came back cold for that reason, the overlay adds `last cold turn: working-set eviction (expected)` and drops the shell-reused-but-backend-cold warning, because the cold turn is explained rather than surprising. -- **Notice.** One line per applied event: `[context engine] working_set: N items evicted by ; ~X tokens -> ~Y tokens`. +- **Notice.** One line per applied event: `[context engine] working set: N items evicted by ; ~X -> ~Y tokens, recall by ref with context(scope="recall")`. The numbers are the plan's, priced over the visible ledger slice, and they are the same numbers the `contextEviction` entry, the `[Compaction] Reclaimed context` toast, and the overlay's `last compaction` line carry. The footer meter is a separate live estimate over the agent message list and can differ from them by the tool schemas and replay text it includes. ## Not in this release @@ -174,11 +176,13 @@ These are tracked follow-ups, not available behavior: - **Auto-readmission.** Nothing brings an evicted body back on its own. There are no path fingerprints and no registry of what the model is likely to need next. - **Cost model and deferred scheduling.** Pressure is the only trigger. There is no break-even horizon, no deferred eviction plan, and no piggybacking beyond the fact that the working-set stage already runs first inside `runAutoCompact`. -- **Claude Code transcript replay.** Replay reads Clio session ledgers only. There is no loader for other harnesses' transcript formats. +- **Intra-turn eviction.** Eviction runs before a request is sent. A single turn whose tool results overflow the window is handled by the observation envelope's caps and by summary compaction, not by this layer. +- **Worker runtimes.** Dispatched workers replay their own ledgers without the working-set stage. - **Digests.** A marker carries tool, size, and a first-line preview. The generated summaries from #165 are not embedded in it. ## See also +- `clio-coder context replay --sessions ...` replays Clio ledgers and Claude Code transcripts through the same fold, projection, and policy code with `none`, `random`, and `oracle` controls; `clio-coder context working-set --session ` prints one session's fold and path index. Both are described under [Working-set replay](commands-and-modes.md#working-set-replay), and the committed tables with the default-policy rule are under `benchmarks/results/context-replay/`. - [context-engine.md](context-engine.md) for context window resolution, token accounting, and how this stage sits ahead of summary compaction. - [session-lifecycle.md](session-lifecycle.md) for the ledger format, active-path lineage, and branching. - [glossary.md](glossary.md) for the one-line definitions of these terms. diff --git a/src/domains/context/working-set/policies/age-horizon.ts b/src/domains/context/working-set/policies/age-horizon.ts index 49db5430e..4502797a1 100644 --- a/src/domains/context/working-set/policies/age-horizon.ts +++ b/src/domains/context/working-set/policies/age-horizon.ts @@ -8,15 +8,16 @@ * skip conditions. The difference is that the bodies stay in the ledger and * come back with `context(scope="recall", ref=...)`. * - * It ships as the default so slice 1 changes one thing at a time: the ledger - * stops being rewritten, while what the model sees on the next request stays - * what it saw before. `structural-v1` replaces the age rule with typed - * structural ones once replay-lite shows it ahead on retention. + * It shipped as the default for one slice so the ledger could stop being + * rewritten without changing what the model saw; `structural-v1` is the default + * now and this policy stays as the recorded form of the old selection. It has + * no target stop on purpose: everything beyond the horizon leaves in one event, + * as the mask did, and `pressure.target` is ignored. * - * Age is not a quality signal, which is the whole reason for slice 2: a file - * read twenty turns ago and never touched since is more useful than a - * directory listing from two turns ago. Nothing here scores candidates by size - * or recency beyond that ordering; the only token input is the + * Age is not a quality signal, which is the whole reason `structural-v1` + * exists: a file read twenty turns ago and never touched since is more useful + * than a directory listing from two turns ago. Nothing here scores candidates + * by size or recency beyond that ordering; the only token input is the * `minEvictableTokens` floor, below which the marker costs more than the body. */ diff --git a/src/domains/context/working-set/policies/index.ts b/src/domains/context/working-set/policies/index.ts index 7c0e4e986..c471c7f81 100644 --- a/src/domains/context/working-set/policies/index.ts +++ b/src/domains/context/working-set/policies/index.ts @@ -3,9 +3,10 @@ * replay-lite runner resolve the same object from the same settings value and * cannot drift into running different selections. * - * `age-horizon` stays the default until the replay table says `structural-v1` - * is ahead on retention and ahead of the random control on precision. Both are - * resolvable now so the table can be produced. + * `structural-v1` is the default; the replay tables under + * benchmarks/results/context-replay/ put it ahead of `age-horizon` on + * retention and ahead of the random control on precision. `age-horizon` stays + * resolvable as the exact pre-layer selection. */ import type { WorkingSetPolicy, WorkingSetPolicyId } from "../contract.js"; From ca3f49b6ef14a27e4b67dfe6c7b060246e9be414 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 13:37:18 -0500 Subject: [PATCH 40/45] fix(context): the Claude Code loader merges the per-block records of one assistant message Claude Code writes thinking, text, and tool_use as separate JSONL records sharing message.id; the loader emitted one assistant entry per record, so in replay most thinking lived in entries of its own. With the projection now keeping a thinking-only turn, that shape protected nearly all replayed thinking and moved the tables. Records with the same id fold into one entry the way Clio persists a message; id-less records stay separate. --- .../claude-code-2026-08-21-protect-6.json | 703 +++++++++--------- .../claude-code-2026-08-21-protect-6.md | 51 +- .../working-set/replay/load-claude-code.ts | 37 +- .../working-set-replay-claude-code.test.ts | 83 +++ 4 files changed, 489 insertions(+), 385 deletions(-) diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json index bd62b37fd..5640a2ea5 100644 --- a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json @@ -1,356 +1,351 @@ { - "schema": "clio-context-replay-v1", - "config": { - "policies": ["none", "random", "age-horizon", "structural-v1", "oracle"], - "budgets": [32000, 64000, 128000], - "threshold": 0.8, - "target": 0.6, - "seed": 0, - "format": "auto", - "filter": "default", - "settings": { - "enabled": true, - "policy": "age-horizon", - "target": 0.6, - "protectLastTurns": 6, - "minEvictableTokens": 200 - } - }, - "provenance": { - "gitSha": "cb4d7a07b58344e8f8ece2a92990a12cfdccac45", - "commandLine": [ - "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", - "--import", - "tsx", - "/home/akougkas/iowarp/clio-coder-ws-wiring/src/cli/index.ts", - "context", - "replay", - "--sessions", - "/home/akougkas/.claude/projects/-home-akougkas-iowarp-clio-coder", - "--policies", - "none,random,age-horizon,structural-v1,oracle", - "--budgets", - "32000,64000,128000", - "--protect-last-turns", - "6", - "--md", - "/tmp/cc-project-p6.md", - "--json", - "/tmp/cc-project-p6.json" - ] - }, - "cascade": { - "found": 302, - "unreadable": 2, - "filtered": { - "no_file_reread": 100, - "sidechain_or_subagent": 17, - "summary_only": 0, - "tool_results_lt_8": 4, - "turns_lt_8": 14 - }, - "kept": 165 - }, - "results": [ - { - "budgetTokens": 32000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "churn": 0, - "turnsToFirstSummary": 18.426829268292682, - "turnsToFirstSummaryCount": 164 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 32000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.4833177066874543, - "retentionAt10": 0.9261196589198183, - "evictionPrecision": 0.9473257732807969, - "tokensEvicted": 66849.2787878788, - "evictionEvents": 57.7939393939394, - "saturatedEvents": 0.9826971476510067, - "churn": 0.052674226719202966, - "turnsToFirstSummary": 31.90740740740741, - "turnsToFirstSummaryCount": 162 - }, - "pooledRetention": 0.431244153414406, - "pooledRetentionAt10": 0.875882946518668 - } - }, - { - "budgetTokens": 32000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.4869686158647096, - "retentionAt10": 0.9351686139077687, - "evictionPrecision": 0.9725388619474085, - "tokensEvicted": 101707.84848484848, - "evictionEvents": 83.76969696969697, - "saturatedEvents": 1, - "churn": 0.02746113805259143, - "turnsToFirstSummary": 40.745222929936304, - "turnsToFirstSummaryCount": 157 - }, - "pooledRetention": 0.43358278765201125, - "pooledRetentionAt10": 0.8779011099899092 - } - }, - { - "budgetTokens": 32000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.48781054300941923, - "retentionAt10": 0.9273801344446482, - "evictionPrecision": 0.9729190426257791, - "tokensEvicted": 101485.41818181818, - "evictionEvents": 86.83636363636364, - "saturatedEvents": 0.9286711334450027, - "churn": 0.027080957374220775, - "turnsToFirstSummary": 38.80379746835443, - "turnsToFirstSummaryCount": 158 - }, - "pooledRetention": 0.4345182413470533, - "pooledRetentionAt10": 0.8789101917255298 - } - }, - { - "budgetTokens": 32000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 66884.45454545454, - "evictionEvents": 58.842424242424244, - "saturatedEvents": 0.9907302502832424, - "churn": 0, - "turnsToFirstSummary": 28.641975308641975, - "turnsToFirstSummaryCount": 162 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 64000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "churn": 0, - "turnsToFirstSummary": 37.57324840764331, - "turnsToFirstSummaryCount": 157 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 64000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.5882949630186936, - "retentionAt10": 0.9574288301148334, - "evictionPrecision": 0.9576639856600655, - "tokensEvicted": 65689.54545454546, - "evictionEvents": 41.83030303030303, - "saturatedEvents": 0.9588525065198493, - "churn": 0.04233601433993452, - "turnsToFirstSummary": 74.8972602739726, - "turnsToFirstSummaryCount": 146 - }, - "pooledRetention": 0.5346117867165575, - "pooledRetentionAt10": 0.9233097880928355 - } - }, - { - "budgetTokens": 64000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.5838286020312077, - "retentionAt10": 0.9722075189873377, - "evictionPrecision": 0.9772436541772238, - "tokensEvicted": 99935.21212121213, - "evictionEvents": 53.26060606060606, - "saturatedEvents": 1, - "churn": 0.02275634582277612, - "turnsToFirstSummary": 102.97692307692307, - "turnsToFirstSummaryCount": 130 - }, - "pooledRetention": 0.5280636108512629, - "pooledRetentionAt10": 0.9475277497477296 - } - }, - { - "budgetTokens": 64000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.604976770513591, - "retentionAt10": 0.9671820141891055, - "evictionPrecision": 0.9795901302078288, - "tokensEvicted": 99347.78787878787, - "evictionEvents": 58.121212121212125, - "saturatedEvents": 0.8663190823774766, - "churn": 0.02040986979217111, - "turnsToFirstSummary": 98.46969696969697, - "turnsToFirstSummaryCount": 132 - }, - "pooledRetention": 0.5594013096351731, - "pooledRetentionAt10": 0.9475277497477296 - } - }, - { - "budgetTokens": 64000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 65534.44242424242, - "evictionEvents": 42.68484848484849, - "saturatedEvents": 0.9752946187704103, - "churn": 0, - "turnsToFirstSummary": 68.71428571428571, - "turnsToFirstSummaryCount": 147 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 128000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "churn": 0, - "turnsToFirstSummary": 86.14285714285714, - "turnsToFirstSummaryCount": 140 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 128000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.7785849994792741, - "retentionAt10": 0.9898164966346785, - "evictionPrecision": 0.9768056012832772, - "tokensEvicted": 59476.357575757575, - "evictionEvents": 20.587878787878786, - "saturatedEvents": 0.9449514277303503, - "churn": 0.023194398716723085, - "turnsToFirstSummary": 151.2164948453608, - "turnsToFirstSummaryCount": 97 - }, - "pooledRetention": 0.7422825070159027, - "pooledRetentionAt10": 0.9818365287588294 - } - }, - { - "budgetTokens": 128000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.7805842537890286, - "retentionAt10": 0.9976396894846092, - "evictionPrecision": 0.9886698447282122, - "tokensEvicted": 89686.7696969697, - "evictionEvents": 18.915151515151514, - "saturatedEvents": 1, - "churn": 0.011330155271787852, - "turnsToFirstSummary": 206.03508771929825, - "turnsToFirstSummaryCount": 57 - }, - "pooledRetention": 0.7502338634237605, - "pooledRetentionAt10": 0.992936427850656 - } - }, - { - "budgetTokens": 128000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.8308409632691356, - "retentionAt10": 0.9972609016058214, - "evictionPrecision": 0.989808384521122, - "tokensEvicted": 84848.4303030303, - "evictionEvents": 22.44242424242424, - "saturatedEvents": 0.7709964893329733, - "churn": 0.010191615478877817, - "turnsToFirstSummary": 194.88524590163934, - "turnsToFirstSummaryCount": 61 - }, - "pooledRetention": 0.7694106641721234, - "pooledRetentionAt10": 0.9919273461150353 - } - }, - { - "budgetTokens": 128000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 59886.63636363636, - "evictionEvents": 21.315151515151516, - "saturatedEvents": 0.9550753483082173, - "churn": 0, - "turnsToFirstSummary": 146.659793814433, - "turnsToFirstSummaryCount": 97 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - } - ] + "schema": "clio-context-replay-v1", + "config": { + "policies": [ + "none", + "random", + "age-horizon", + "structural-v1", + "oracle" + ], + "budgets": [ + 32000, + 64000, + 128000 + ], + "threshold": 0.8, + "target": 0.6, + "seed": 0, + "format": "auto", + "filter": "default", + "settings": { + "enabled": true, + "policy": "structural-v1", + "target": 0.6, + "protectLastTurns": 6, + "minEvictableTokens": 200 + } + }, + "provenance": { + "gitSha": "a3d5d69a7aa34a8dcf3a7abdc58c53bc6b42074e", + "commandLine": [ + "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", + "--import", + "tsx", + "/home/akougkas/iowarp/clio-coder/src/cli/index.ts", + "context", + "replay", + "--sessions", + "/home/akougkas/.claude/projects/-home-akougkas-iowarp-clio-coder", + "--policies", + "none,random,age-horizon,structural-v1,oracle", + "--budgets", + "32000,64000,128000", + "--protect-last-turns", + "6", + "--md", + "benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md", + "--json", + "benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json" + ] + }, + "cascade": { + "found": 303, + "unreadable": 2, + "filtered": { + "no_file_reread": 102, + "sidechain_or_subagent": 17, + "summary_only": 0, + "tool_results_lt_8": 3, + "turns_lt_8": 14 + }, + "kept": 165 + }, + "results": [ + { + "budgetTokens": 32000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 18.426829268292682, + "turnsToFirstSummaryCount": 164 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 32000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.4833177066874543, + "retentionAt10": 0.9261196589198183, + "evictionPrecision": 0.9473257732807969, + "tokensEvicted": 66521.02424242425, + "evictionEvents": 57.92121212121212, + "saturatedEvents": 0.9830490739771895, + "turnsToFirstSummary": 31.64814814814815, + "turnsToFirstSummaryCount": 162 + }, + "pooledRetention": 0.431244153414406, + "pooledRetentionAt10": 0.875882946518668 + } + }, + { + "budgetTokens": 32000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.4792350637937497, + "retentionAt10": 0.9234988075489672, + "evictionPrecision": 0.9475340393836018, + "tokensEvicted": 66678.89090909091, + "evictionEvents": 58.02424242424242, + "saturatedEvents": 1, + "turnsToFirstSummary": 31.641975308641975, + "turnsToFirstSummaryCount": 162 + }, + "pooledRetention": 0.42656688493919553, + "pooledRetentionAt10": 0.8698284561049445 + } + }, + { + "budgetTokens": 32000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.481872499653399, + "retentionAt10": 0.9222500762165038, + "evictionPrecision": 0.9479608131999118, + "tokensEvicted": 66484.24848484849, + "evictionEvents": 58.018181818181816, + "saturatedEvents": 0.9708555311814479, + "turnsToFirstSummary": 31.333333333333332, + "turnsToFirstSummaryCount": 162 + }, + "pooledRetention": 0.42516370439663237, + "pooledRetentionAt10": 0.8657921291624622 + } + }, + { + "budgetTokens": 32000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 66556.03636363636, + "evictionEvents": 58.90909090909091, + "saturatedEvents": 0.9907407407407407, + "turnsToFirstSummary": 28.469135802469136, + "turnsToFirstSummaryCount": 162 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 37.57324840764331, + "turnsToFirstSummaryCount": 157 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.58807049612756, + "retentionAt10": 0.9574288301148334, + "evictionPrecision": 0.9577194857155654, + "tokensEvicted": 65388.933333333334, + "evictionEvents": 41.878787878787875, + "saturatedEvents": 0.9593342981186685, + "turnsToFirstSummary": 74.5958904109589, + "turnsToFirstSummaryCount": 146 + }, + "pooledRetention": 0.5341440598690365, + "pooledRetentionAt10": 0.9233097880928355 + } + }, + { + "budgetTokens": 64000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5689079008098213, + "retentionAt10": 0.9536157130606094, + "evictionPrecision": 0.9557283400464567, + "tokensEvicted": 65568.99393939394, + "evictionEvents": 41.43636363636364, + "saturatedEvents": 1, + "turnsToFirstSummary": 74.6917808219178, + "turnsToFirstSummaryCount": 146 + }, + "pooledRetention": 0.5060804490177736, + "pooledRetentionAt10": 0.9182643794147326 + } + }, + { + "budgetTokens": 64000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.56992623135722, + "retentionAt10": 0.9579578355845503, + "evictionPrecision": 0.9553262382608974, + "tokensEvicted": 65073.42424242424, + "evictionEvents": 42.35757575757576, + "saturatedEvents": 0.9429102875947918, + "turnsToFirstSummary": 73.92567567567568, + "turnsToFirstSummaryCount": 148 + }, + "pooledRetention": 0.5079513564078578, + "pooledRetentionAt10": 0.9212916246215943 + } + }, + { + "budgetTokens": 64000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 65221.06060606061, + "evictionEvents": 42.74545454545454, + "saturatedEvents": 0.9754714305969091, + "turnsToFirstSummary": 68.26530612244898, + "turnsToFirstSummaryCount": 147 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 86.14285714285714, + "turnsToFirstSummaryCount": 140 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7787533496476242, + "retentionAt10": 0.9898164966346785, + "evictionPrecision": 0.9769049554809919, + "tokensEvicted": 59286.70303030303, + "evictionEvents": 20.666666666666668, + "saturatedEvents": 0.9460410557184751, + "turnsToFirstSummary": 150.43298969072166, + "turnsToFirstSummaryCount": 97 + }, + "pooledRetention": 0.7427502338634238, + "pooledRetentionAt10": 0.9818365287588294 + } + }, + { + "budgetTokens": 128000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7643536981681742, + "retentionAt10": 0.9891090786812711, + "evictionPrecision": 0.9758884223172307, + "tokensEvicted": 60445.684848484845, + "evictionEvents": 20.24848484848485, + "saturatedEvents": 1, + "turnsToFirstSummary": 150.79166666666666, + "turnsToFirstSummaryCount": 96 + }, + "pooledRetention": 0.7160898035547241, + "pooledRetentionAt10": 0.9798183652875883 + } + }, + { + "budgetTokens": 128000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7791585352246793, + "retentionAt10": 0.9876244076778837, + "evictionPrecision": 0.9754222828389132, + "tokensEvicted": 59430.57575757576, + "evictionEvents": 21.618181818181817, + "saturatedEvents": 0.897112419400056, + "turnsToFirstSummary": 142.76, + "turnsToFirstSummaryCount": 100 + }, + "pooledRetention": 0.7184284377923292, + "pooledRetentionAt10": 0.9757820383451059 + } + }, + { + "budgetTokens": 128000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 59667.357575757575, + "evictionEvents": 21.375757575757575, + "saturatedEvents": 0.9554862489367735, + "turnsToFirstSummary": 146.17525773195877, + "turnsToFirstSummaryCount": 97 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + } + ] } diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md index ce0e7ae9f..aee5a4ebb 100644 --- a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md @@ -1,47 +1,44 @@ - - - # Clio working-set replay ## Inclusion cascade | stage | traces | | --- | ---: | -| found | 302 | +| found | 303 | | unreadable | 2 | | sidechain_or_subagent | 17 | | summary_only | 0 | | turns_lt_8 | 14 | -| tool_results_lt_8 | 4 | -| no_file_reread | 100 | +| tool_results_lt_8 | 3 | +| no_file_reread | 102 | | kept | 165 | ## Budget 32000 -| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 18.4 (n=164) | -| random | 165 | 0.483 | 0.431 | 0.926 | 0.947 | 66849.3 | 57.8 | 0.983 | 0.053 | 31.9 (n=162) | -| age-horizon | 165 | 0.487 | 0.434 | 0.935 | 0.973 | 101707.8 | 83.8 | 1.000 | 0.027 | 40.7 (n=157) | -| structural-v1 | 165 | 0.488 | 0.435 | 0.927 | 0.973 | 101485.4 | 86.8 | 0.929 | 0.027 | 38.8 (n=158) | -| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 66884.5 | 58.8 | 0.991 | 0.000 | 28.6 (n=162) | +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 18.4 (n=164) | +| random | 165 | 0.483 | 0.431 | 0.926 | 0.947 | 66521.0 | 57.9 | 0.983 | 31.6 (n=162) | +| age-horizon | 165 | 0.479 | 0.427 | 0.923 | 0.948 | 66678.9 | 58.0 | 1.000 | 31.6 (n=162) | +| structural-v1 | 165 | 0.482 | 0.425 | 0.922 | 0.948 | 66484.2 | 58.0 | 0.971 | 31.3 (n=162) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 66556.0 | 58.9 | 0.991 | 28.5 (n=162) | ## Budget 64000 -| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 37.6 (n=157) | -| random | 165 | 0.588 | 0.535 | 0.957 | 0.958 | 65689.5 | 41.8 | 0.959 | 0.042 | 74.9 (n=146) | -| age-horizon | 165 | 0.584 | 0.528 | 0.972 | 0.977 | 99935.2 | 53.3 | 1.000 | 0.023 | 103.0 (n=130) | -| structural-v1 | 165 | 0.605 | 0.559 | 0.967 | 0.980 | 99347.8 | 58.1 | 0.866 | 0.020 | 98.5 (n=132) | -| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 65534.4 | 42.7 | 0.975 | 0.000 | 68.7 (n=147) | +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 37.6 (n=157) | +| random | 165 | 0.588 | 0.534 | 0.957 | 0.958 | 65388.9 | 41.9 | 0.959 | 74.6 (n=146) | +| age-horizon | 165 | 0.569 | 0.506 | 0.954 | 0.956 | 65569.0 | 41.4 | 1.000 | 74.7 (n=146) | +| structural-v1 | 165 | 0.570 | 0.508 | 0.958 | 0.955 | 65073.4 | 42.4 | 0.943 | 73.9 (n=148) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 65221.1 | 42.7 | 0.975 | 68.3 (n=147) | ## Budget 128000 -| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 86.1 (n=140) | -| random | 165 | 0.779 | 0.742 | 0.990 | 0.977 | 59476.4 | 20.6 | 0.945 | 0.023 | 151.2 (n=97) | -| age-horizon | 165 | 0.781 | 0.750 | 0.998 | 0.989 | 89686.8 | 18.9 | 1.000 | 0.011 | 206.0 (n=57) | -| structural-v1 | 165 | 0.831 | 0.769 | 0.997 | 0.990 | 84848.4 | 22.4 | 0.771 | 0.010 | 194.9 (n=61) | -| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 59886.6 | 21.3 | 0.955 | 0.000 | 146.7 (n=97) | +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 86.1 (n=140) | +| random | 165 | 0.779 | 0.743 | 0.990 | 0.977 | 59286.7 | 20.7 | 0.946 | 150.4 (n=97) | +| age-horizon | 165 | 0.764 | 0.716 | 0.989 | 0.976 | 60445.7 | 20.2 | 1.000 | 150.8 (n=96) | +| structural-v1 | 165 | 0.779 | 0.718 | 0.988 | 0.975 | 59430.6 | 21.6 | 0.897 | 142.8 (n=100) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 59667.4 | 21.4 | 0.955 | 146.2 (n=97) | diff --git a/src/domains/context/working-set/replay/load-claude-code.ts b/src/domains/context/working-set/replay/load-claude-code.ts index 3daf84c6c..216a2704a 100644 --- a/src/domains/context/working-set/replay/load-claude-code.ts +++ b/src/domains/context/working-set/replay/load-claude-code.ts @@ -354,13 +354,43 @@ function normalizeTranscript(records: ReadonlyArray, source: strin const id = sessionIdOf(records, source); const cwd = cwdOf(records) ?? null; - for (const record of records) { - if (record.isSidechain === true) continue; + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + if (record === undefined || record.isSidechain === true) continue; if (record.type !== "user" && record.type !== "assistant") continue; if (!isRecord(record.message)) continue; const timestamp = timestampOf(record); if (record.type === "assistant") { - const { content, toolUses } = assistantBlocks(record.message); + // Claude Code writes one record per content block, all carrying the + // same message.id. Clio persists one assistant entry per message, and + // the thinking rule is written for that shape: a thinking block beside + // the answer it produced leaves, a turn that is only thinking stays. + // Split records would make every thinking block look like the latter. + const messages: Array> = [record.message]; + const messageId = stringValue(record.message.id); + while (messageId !== undefined) { + const next = records[index + 1]; + if ( + next === undefined || + next.isSidechain === true || + next.type !== "assistant" || + !isRecord(next.message) || + stringValue(next.message.id) !== messageId + ) { + break; + } + messages.push(next.message); + index += 1; + } + const content: Array> = []; + const toolUses: Array> = []; + let usage: Record | undefined; + for (const message of messages) { + const blocks = assistantBlocks(message); + content.push(...blocks.content); + toolUses.push(...blocks.toolUses); + usage = usagePayload(message.usage) ?? usage; + } const text = content .filter((block) => block.type === "text") .map((block) => block.text) @@ -369,7 +399,6 @@ function normalizeTranscript(records: ReadonlyArray, source: strin .filter((block) => block.type === "thinking") .map((block) => block.thinking) .join(""); - const usage = usagePayload(record.message.usage); appendMessage( "assistant", { diff --git a/tests/contracts/working-set-replay-claude-code.test.ts b/tests/contracts/working-set-replay-claude-code.test.ts index 7aaa8f2f8..68783eab1 100644 --- a/tests/contracts/working-set-replay-claude-code.test.ts +++ b/tests/contracts/working-set-replay-claude-code.test.ts @@ -377,6 +377,89 @@ describe("contracts/working-set Claude Code replay loader", () => { } }); + it("merges the per-block assistant records of one message into one assistant entry", async () => { + // Real transcripts write thinking, text, and tool_use as separate records + // sharing message.id; only a record without an id stays on its own. + const root = await mkdtemp(join(tmpdir(), "clio-replay-merge-")); + try { + const base = { sessionId: "merge-fixture", cwd: "/fixture/merge", timestamp: "2026-08-21T00:00:00.000Z" }; + const lines = [ + { ...base, type: "user", uuid: "u1", message: { role: "user", content: "read it" } }, + { + ...base, + type: "assistant", + uuid: "a1", + message: { + id: "msg_1", + role: "assistant", + content: [{ type: "thinking", thinking: "plan" }], + usage: { input_tokens: 10 }, + }, + }, + { + ...base, + type: "assistant", + uuid: "a2", + message: { + id: "msg_1", + role: "assistant", + content: [{ type: "text", text: "reading" }], + usage: { input_tokens: 12 }, + }, + }, + { + ...base, + type: "assistant", + uuid: "a3", + message: { + id: "msg_1", + role: "assistant", + content: [{ type: "tool_use", id: "toolu_1", name: "Read", input: { file_path: "/fixture/merge/a.ts" } }], + }, + }, + { + ...base, + type: "user", + uuid: "u2", + message: { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "body" }] }, + }, + { + ...base, + type: "assistant", + uuid: "a4", + message: { role: "assistant", content: [{ type: "thinking", thinking: "only reasoning" }] }, + }, + { + ...base, + type: "assistant", + uuid: "a5", + message: { role: "assistant", content: [{ type: "text", text: "done" }] }, + }, + ]; + const file = join(root, "merge.jsonl"); + await writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + const loaded = await loadClaudeCodeTraces([file], { filter: false }); + const trace = loaded.traces[0]; + assert.ok(trace); + const assistants = messages(trace, "assistant"); + assert.equal(assistants.length, 3); + const merged = payload(assistants[0] as MessageEntry); + assert.deepEqual(merged.content, [ + { type: "thinking", thinking: "plan" }, + { type: "text", text: "reading" }, + ]); + assert.equal(merged.text, "reading"); + assert.equal(merged.thinking, "plan"); + assert.equal(isRecord(merged.usage) && merged.usage.input, 12); + assert.equal(messages(trace, "tool_call").length, 1); + // Records without message.id are not merged, whatever their neighbours are. + assert.deepEqual(payload(assistants[1] as MessageEntry).content, [{ type: "thinking", thinking: "only reasoning" }]); + assert.deepEqual(payload(assistants[2] as MessageEntry).content, [{ type: "text", text: "done" }]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("auto-detects Claude Code and drives one live age-horizon eviction", async () => { const raw = await readFile(FIXTURE, "utf8"); assert.equal(detectReplayInputFormat(raw), "claude-code"); From c56030f33fd241bc5514cc0811446b2a9db3b4cd Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 13:59:47 -0500 Subject: [PATCH 41/45] docs(benchmarks): commit the replay tables from the merged-message loader with the default-policy rule Both protectLastTurns settings re-run at ca3f49b6. README carries the corpus, the metric definitions, the rule, the headline grid with the verdict per cell, and the one cell that misses it: precision 0.979 against random's 0.980 at 6/128k. Retention leads age-horizon on every budget at the shipped setting. The changelog and the defaults comment quote the new numbers. --- CHANGELOG.md | 2 +- benchmarks/results/context-replay/README.md | 64 ++ .../claude-code-2026-08-21-protect-2.json | 703 +++++++++--------- .../claude-code-2026-08-21-protect-2.md | 48 +- .../claude-code-2026-08-21-protect-6.json | 216 +++--- .../claude-code-2026-08-21-protect-6.md | 33 +- src/domains/context/working-set/defaults.ts | 7 +- 7 files changed, 568 insertions(+), 505 deletions(-) create mode 100644 benchmarks/results/context-replay/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7001b32..acc72aa15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to Clio Coder are documented in this file. The format follow ### Added - Non-destructive working-set eviction. When context pressure crosses `compaction.threshold`, Clio now records which tool-result bodies and closed-turn thinking blocks leave the model's working set instead of rewriting them out of the session. The bodies stay in the ledger, the transcript keeps showing them, and each one is replaced in model replay by a one-line marker naming the ref, the reason, the size, and the exact call that brings it back. - Exact recall by ref. The model reads an evicted body back with `context(scope="recall", ref="")`; the operator reads one into the transcript with `/context recall `, which never enters model context. A recall does not un-evict: the marker stays byte-identical so the provider prefix cache is untouched, and repeated recalls of one ref are the churn signal. -- Two eviction policies. `structural-v1` is the default: it selects by what the session did since (`stale_after_mutation`, `superseded_read`, `failure_resolved`, `listing_consumed`, `thinking_turn_closed`) and falls back to age only under pressure. `age-horizon` reproduces the previous age-based selection, minus results whose body is below `context.workingSet.minEvictableTokens`. Replayed over 165 Claude Code transcripts at a 128k budget, `structural-v1` retained 0.831 of later-referenced results against 0.781 for `age-horizon` and 0.779 for random eviction; the tables are under `benchmarks/results/context-replay/`. +- Two eviction policies. `structural-v1` is the default: it selects by what the session did since (`stale_after_mutation`, `superseded_read`, `failure_resolved`, `listing_consumed`, `thinking_turn_closed`) and falls back to age only under pressure. `age-horizon` reproduces the previous age-based selection, minus results whose body is below `context.workingSet.minEvictableTokens`. Replayed over 165 Claude Code transcripts at a 128k budget, `structural-v1` retained 0.812 of later-referenced results against 0.788 for `age-horizon` and 0.798 for random eviction, with the default-policy rule, the one precision cell it misses by 0.001, and the full grid under `benchmarks/results/context-replay/`. - `/context` reports the working set: policy, evicted items, evicted tokens, events, recalls, and churn. Evicted tool rows carry a dim `evicted · ` tag in the transcript. - Cache-honesty attribution for eviction. An applied event stamps `working_set_evict` on the next assistant entry's `promptCache.expectedColdReasons`, and `/context` reports `last cold turn: working-set eviction (expected)` instead of warning about a cold backend it caused itself. - `clio-coder context replay --sessions ...` replays Clio ledgers and Claude Code transcripts through the live eviction code with `none`, `random`, and `oracle` controls and reports retention, precision, tokens evicted, saturation, and turns to first summary; `clio-coder context working-set --session ` prints one session's working-set fold and path index. diff --git a/benchmarks/results/context-replay/README.md b/benchmarks/results/context-replay/README.md new file mode 100644 index 000000000..e241f5a2a --- /dev/null +++ b/benchmarks/results/context-replay/README.md @@ -0,0 +1,64 @@ +# Working-set replay tables + +Replay of the eviction policies in `src/domains/context/working-set/policies/` over the 165 Claude Code transcripts of this repository that pass the default inclusion filter, driven by `clio-coder context replay` through the same fold, projection, and planner the live session uses. The command line is the first comment of each Markdown table; the JSON beside it carries the configuration, the git revision, and the exact command. + +Source revision for every table in this directory: `ca3f49b6`. + +| File | `protectLastTurns` | Budgets | +| --- | ---: | --- | +| `claude-code-2026-08-21-protect-6.md` / `.json` | 6 | 32000, 64000, 128000 | +| `claude-code-2026-08-21-protect-2.md` / `.json` | 2 | 32000, 64000, 128000 | + +## Corpus + +`~/.claude/projects/-home-akougkas-iowarp-clio-coder`: 303 transcripts found, 2 unreadable, 17 sidechain or subagent, 14 with fewer than 8 turns, 3 with fewer than 8 tool results, 102 with no file re-read, **165 kept**. The loader folds Claude Code's per-block assistant records into one assistant entry per message, maps its tool names and argument keys onto Clio's, and keeps the recorded cwd for the path index. Clio's own ledgers on the machine were too short to measure anything (129 sessions, 123 under 8 turns, retention 0.997 for every policy), which is why the Claude Code loader exists. + +## Metrics + +- **retention**: of the (tool result, later turn that re-read or discovered the same file) pairs in the trace, the share whose result was still in the working set at that later turn. `mean` averages per trace; `pooled` counts pairs across all traces. Higher is better. +- **eviction precision**: share of evicted items the session never referenced again. Its complement is what live churn (recalls over items evicted) would count. +- **saturated events**: share of applied eviction events in which the policy exhausted its candidates before reaching `target`. `age-horizon` has no target stop by design, so it reads 1.000; a value below 1.0 means the policy chose, rather than ran out. +- **turns to first summary**: turns until the projection still exceeded the threshold after an eviction and the summary path would have run; `n` is the number of traces that ever reached that point. +- `none` evicts nothing and `oracle` evicts only what the future never references; `random` takes eligible results in seeded random order to the same target. + +## Default-policy rule + +`context.workingSet.policy` defaults to `structural-v1` if, at the shipped `protectLastTurns: 6`, every budget shows **(a)** `structural-v1` retention (mean) at or above `age-horizon`, **(b)** `structural-v1` precision above `random`, and **(c)** `structural-v1` saturated events below 1.0 at 64k and 128k, meaning the structural rungs and the target stop actually decided something. The `protectLastTurns: 2` table is a sensitivity check, not part of the rule. + +## Headline grid + +### `protectLastTurns: 6` + +| budget | retention mean: age-horizon | structural-v1 | random | retention pooled: age-horizon | structural-v1 | precision: random | structural-v1 | saturated: structural-v1 | rule | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| 32000 | 0.479 | **0.480** | 0.485 | 0.426 | **0.426** | 0.947 | **0.953** | 0.963 | holds | +| 64000 | 0.582 | **0.590** | 0.603 | 0.522 | **0.526** | 0.960 | **0.962** | 0.922 | holds | +| 128000 | 0.788 | **0.812** | 0.798 | 0.745 | **0.741** | 0.980 | **0.979** | 0.856 | fails: (b) | + +### `protectLastTurns: 2` + +| budget | retention mean: age-horizon | structural-v1 | random | retention pooled: age-horizon | structural-v1 | precision: random | structural-v1 | saturated: structural-v1 | rule | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| 32000 | 0.387 | **0.389** | 0.397 | 0.309 | **0.309** | 0.942 | **0.949** | 0.945 | holds | +| 64000 | 0.529 | **0.524** | 0.546 | 0.442 | **0.445** | 0.954 | **0.959** | 0.911 | fails: (a) | +| 128000 | 0.759 | **0.787** | 0.781 | 0.714 | **0.710** | 0.979 | **0.977** | 0.849 | fails: (b) | + +## Reading the grid + +At the shipped `protectLastTurns: 6` the rule holds at 32k and 64k and fails one cell at 128k: `structural-v1` precision 0.979 against random 0.980, a difference of one thousandth on the budget where its retention lead is largest (+0.024 over `age-horizon`, +0.014 over random). Retention (mean), the primary metric, is at or above `age-horizon` on all six cells of both tables except 64k at `protectLastTurns: 2` (0.524 vs 0.529). The default stays `structural-v1` on that basis, and the cell is recorded here rather than the rule being rewritten around it. + +Two things the grid says that the rule did not ask about. First, random eviction to target retains more than either real policy at 32k and 64k (0.485 and 0.603 against 0.480 and 0.590 at `protectLastTurns: 6`); both real policies evict about 13 percent more tokens than random because `age-horizon` has no target stop and `structural-v1` runs rungs 1 to 5 whatever the pressure, and every extra eviction is a chance to lose a pair. Second, the retention metric counts every (result, later re-read) pair without asking whether a newer copy of the same file was still in the working set, so a `superseded_read` eviction is charged when the file is read a third time even though the model held the second copy. Both are follow-ups on #179: a cost model that decides whether rungs 1 to 5 should run below threshold, and a retention variant that credits a surviving newer copy. + +Tables committed before `ca3f49b6` were produced with a loader that emitted one assistant entry per Claude Code JSONL record, three per message, and priced each with the per-message overhead; those numbers (0.831 / 0.781 / 0.779 at 128k) are superseded by this directory. + +## Reproducing + +```bash +node --import tsx src/cli/index.ts context replay \ + --sessions ~/.claude/projects/ \ + --policies none,random,age-horizon,structural-v1,oracle \ + --budgets 32000,64000,128000 --protect-last-turns 6 \ + --md benchmarks/results/context-replay/.md --json benchmarks/results/context-replay/.json +``` + +The matrix is deterministic for a given corpus, revision, and `--seed` (default 0). The 165-trace matrix takes roughly 35 minutes per `protectLastTurns` value on the operator machine; the two settings can run in parallel. diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json index 71561d525..2397cfbc9 100644 --- a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json @@ -1,356 +1,351 @@ { - "schema": "clio-context-replay-v1", - "config": { - "policies": ["none", "random", "age-horizon", "structural-v1", "oracle"], - "budgets": [32000, 64000, 128000], - "threshold": 0.8, - "target": 0.6, - "seed": 0, - "format": "auto", - "filter": "default", - "settings": { - "enabled": true, - "policy": "age-horizon", - "target": 0.6, - "protectLastTurns": 2, - "minEvictableTokens": 200 - } - }, - "provenance": { - "gitSha": "cb4d7a07b58344e8f8ece2a92990a12cfdccac45", - "commandLine": [ - "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", - "--import", - "tsx", - "/home/akougkas/iowarp/clio-coder-ws-wiring/src/cli/index.ts", - "context", - "replay", - "--sessions", - "/home/akougkas/.claude/projects/-home-akougkas-iowarp-clio-coder", - "--policies", - "none,random,age-horizon,structural-v1,oracle", - "--budgets", - "32000,64000,128000", - "--protect-last-turns", - "2", - "--md", - "/tmp/cc-project-p2.md", - "--json", - "/tmp/cc-project-p2.json" - ] - }, - "cascade": { - "found": 302, - "unreadable": 2, - "filtered": { - "no_file_reread": 101, - "sidechain_or_subagent": 17, - "summary_only": 0, - "tool_results_lt_8": 3, - "turns_lt_8": 14 - }, - "kept": 165 - }, - "results": [ - { - "budgetTokens": 32000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "churn": 0, - "turnsToFirstSummary": 18.426829268292682, - "turnsToFirstSummaryCount": 164 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 32000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.3891266274168913, - "retentionAt10": 0.7624751586495949, - "evictionPrecision": 0.9420974870639153, - "tokensEvicted": 67601.84848484848, - "evictionEvents": 54.7030303030303, - "saturatedEvents": 0.9643252825171726, - "churn": 0.057902512936084984, - "turnsToFirstSummary": 39.725, - "turnsToFirstSummaryCount": 160 - }, - "pooledRetention": 0.3166510757717493, - "pooledRetentionAt10": 0.6326942482341069 - } - }, - { - "budgetTokens": 32000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.4052406300503513, - "retentionAt10": 0.7874741634028641, - "evictionPrecision": 0.9700931215762035, - "tokensEvicted": 102990.87878787878, - "evictionEvents": 78.67272727272727, - "saturatedEvents": 1, - "churn": 0.029906878423796013, - "turnsToFirstSummary": 54.81578947368421, - "turnsToFirstSummaryCount": 152 - }, - "pooledRetention": 0.3246024321796071, - "pooledRetentionAt10": 0.6417759838546923 - } - }, - { - "budgetTokens": 32000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.4170611362028952, - "retentionAt10": 0.7928038450665378, - "evictionPrecision": 0.970769190402395, - "tokensEvicted": 102754.73333333334, - "evictionEvents": 82.55757575757576, - "saturatedEvents": 0.9066950521215681, - "churn": 0.029230809597604917, - "turnsToFirstSummary": 52.666666666666664, - "turnsToFirstSummaryCount": 153 - }, - "pooledRetention": 0.3292797006548176, - "pooledRetentionAt10": 0.6397578203834511 - } - }, - { - "budgetTokens": 32000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 67602.67878787879, - "evictionEvents": 55.67878787878788, - "saturatedEvents": 0.9794274518341134, - "churn": 0, - "turnsToFirstSummary": 34.34375, - "turnsToFirstSummaryCount": 160 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 64000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "churn": 0, - "turnsToFirstSummary": 37.57324840764331, - "turnsToFirstSummaryCount": 157 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 64000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.5295844426253361, - "retentionAt10": 0.8531465075995417, - "evictionPrecision": 0.9536416483402413, - "tokensEvicted": 66268.01818181819, - "evictionEvents": 40.7030303030303, - "saturatedEvents": 0.9541393686718285, - "churn": 0.04635835165975869, - "turnsToFirstSummary": 77.75862068965517, - "turnsToFirstSummaryCount": 145 - }, - "pooledRetention": 0.4499532273152479, - "pooledRetentionAt10": 0.7487386478304743 - } - }, - { - "budgetTokens": 64000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.5396326704729105, - "retentionAt10": 0.8919924416262478, - "evictionPrecision": 0.9767721779142171, - "tokensEvicted": 101194.2606060606, - "evictionEvents": 50.878787878787875, - "saturatedEvents": 1, - "churn": 0.023227822085782862, - "turnsToFirstSummary": 110.024, - "turnsToFirstSummaryCount": 125 - }, - "pooledRetention": 0.4616463985032741, - "pooledRetentionAt10": 0.805247225025227 - } - }, - { - "budgetTokens": 64000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.5670056686801388, - "retentionAt10": 0.8941705143350112, - "evictionPrecision": 0.9782494942999655, - "tokensEvicted": 100305.41212121212, - "evictionEvents": 56.32121212121212, - "saturatedEvents": 0.8517163456365006, - "churn": 0.021750505700034426, - "turnsToFirstSummary": 102.96899224806202, - "turnsToFirstSummaryCount": 129 - }, - "pooledRetention": 0.49859681945743684, - "pooledRetentionAt10": 0.8062563067608476 - } - }, - { - "budgetTokens": 64000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 66376.7696969697, - "evictionEvents": 41.339393939393936, - "saturatedEvents": 0.9673068465034452, - "churn": 0, - "turnsToFirstSummary": 71.6896551724138, - "turnsToFirstSummaryCount": 145 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 128000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "churn": 0, - "turnsToFirstSummary": 86.14285714285714, - "turnsToFirstSummaryCount": 140 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 128000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.7548640102774834, - "retentionAt10": 0.9484446069013612, - "evictionPrecision": 0.9751224764528812, - "tokensEvicted": 59933.569696969695, - "evictionEvents": 20.006060606060608, - "saturatedEvents": 0.9403211148136928, - "churn": 0.024877523547118807, - "turnsToFirstSummary": 152.67708333333334, - "turnsToFirstSummaryCount": 96 - }, - "pooledRetention": 0.7043966323666978, - "pooledRetentionAt10": 0.9142280524722503 - } - }, - { - "budgetTokens": 128000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.7710672132375215, - "retentionAt10": 0.9834813967108783, - "evictionPrecision": 0.9877803351462859, - "tokensEvicted": 90092.87878787878, - "evictionEvents": 18.145454545454545, - "saturatedEvents": 1, - "churn": 0.01221966485371426, - "turnsToFirstSummary": 210.38181818181818, - "turnsToFirstSummaryCount": 55 - }, - "pooledRetention": 0.7301216089803555, - "pooledRetentionAt10": 0.9616548940464178 - } - }, - { - "budgetTokens": 128000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.8169060925178981, - "retentionAt10": 0.9757320945306298, - "evictionPrecision": 0.9892505292068529, - "tokensEvicted": 85565.81212121212, - "evictionEvents": 22.048484848484847, - "saturatedEvents": 0.7553600879604178, - "churn": 0.010749470793147019, - "turnsToFirstSummary": 202.05084745762713, - "turnsToFirstSummaryCount": 59 - }, - "pooledRetention": 0.7483629560336763, - "pooledRetentionAt10": 0.9535822401614531 - } - }, - { - "budgetTokens": 128000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 60099.96363636364, - "evictionEvents": 20.55757575757576, - "saturatedEvents": 0.9498820754716981, - "churn": 0, - "turnsToFirstSummary": 147.84375, - "turnsToFirstSummaryCount": 96 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - } - ] + "schema": "clio-context-replay-v1", + "config": { + "policies": [ + "none", + "random", + "age-horizon", + "structural-v1", + "oracle" + ], + "budgets": [ + 32000, + 64000, + 128000 + ], + "threshold": 0.8, + "target": 0.6, + "seed": 0, + "format": "auto", + "filter": "default", + "settings": { + "enabled": true, + "policy": "structural-v1", + "target": 0.6, + "protectLastTurns": 2, + "minEvictableTokens": 200 + } + }, + "provenance": { + "gitSha": "ca3f49b6ef14a27e4b67dfe6c7b060246e9be414", + "commandLine": [ + "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", + "--import", + "tsx", + "/home/akougkas/iowarp/clio-coder/src/cli/index.ts", + "context", + "replay", + "--sessions", + "/home/akougkas/.claude/projects/-home-akougkas-iowarp-clio-coder", + "--policies", + "none,random,age-horizon,structural-v1,oracle", + "--budgets", + "32000,64000,128000", + "--protect-last-turns", + "2", + "--md", + "benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md", + "--json", + "benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json" + ] + }, + "cascade": { + "found": 303, + "unreadable": 2, + "filtered": { + "no_file_reread": 102, + "sidechain_or_subagent": 17, + "summary_only": 0, + "tool_results_lt_8": 3, + "turns_lt_8": 14 + }, + "kept": 165 + }, + "results": [ + { + "budgetTokens": 32000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 18.890243902439025, + "turnsToFirstSummaryCount": 164 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 32000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.39736045282440036, + "retentionAt10": 0.7796271806042483, + "evictionPrecision": 0.9420574036904985, + "tokensEvicted": 67237.44848484849, + "evictionEvents": 53.70909090909091, + "saturatedEvents": 0.960731211916046, + "turnsToFirstSummary": 42.09493670886076, + "turnsToFirstSummaryCount": 158 + }, + "pooledRetention": 0.3246024321796071, + "pooledRetentionAt10": 0.6437941473259334 + } + }, + { + "budgetTokens": 32000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.38734720867396777, + "retentionAt10": 0.7630094420606726, + "evictionPrecision": 0.9481324810379269, + "tokensEvicted": 76253.64242424243, + "evictionEvents": 61.412121212121214, + "saturatedEvents": 1, + "turnsToFirstSummary": 44.05696202531646, + "turnsToFirstSummaryCount": 158 + }, + "pooledRetention": 0.3086997193638915, + "pooledRetentionAt10": 0.6135216952573158 + } + }, + { + "budgetTokens": 32000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.3885318187107285, + "retentionAt10": 0.7501152403028344, + "evictionPrecision": 0.9485123933274446, + "tokensEvicted": 76032.92121212122, + "evictionEvents": 62.339393939393936, + "saturatedEvents": 0.9452654092941862, + "turnsToFirstSummary": 43.15822784810127, + "turnsToFirstSummaryCount": 158 + }, + "pooledRetention": 0.3086997193638915, + "pooledRetentionAt10": 0.6044399596367306 + } + }, + { + "budgetTokens": 32000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 67216.38181818182, + "evictionEvents": 54.806060606060605, + "saturatedEvents": 0.9773305319031295, + "turnsToFirstSummary": 36.537974683544306, + "turnsToFirstSummaryCount": 158 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 38.84615384615385, + "turnsToFirstSummaryCount": 156 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5458401662289053, + "retentionAt10": 0.8690588780573669, + "evictionPrecision": 0.9544114724418871, + "tokensEvicted": 65668.67878787879, + "evictionEvents": 39.45454545454545, + "saturatedEvents": 0.9509984639016897, + "turnsToFirstSummary": 82.80281690140845, + "turnsToFirstSummaryCount": 142 + }, + "pooledRetention": 0.4644527595884004, + "pooledRetentionAt10": 0.7699293642785066 + } + }, + { + "budgetTokens": 64000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5290339282445672, + "retentionAt10": 0.8798954909394343, + "evictionPrecision": 0.9595138223923505, + "tokensEvicted": 74726.32121212121, + "evictionEvents": 44.224242424242426, + "saturatedEvents": 1, + "turnsToFirstSummary": 88.66428571428571, + "turnsToFirstSummaryCount": 140 + }, + "pooledRetention": 0.44200187090739007, + "pooledRetentionAt10": 0.7729566094853683 + } + }, + { + "budgetTokens": 64000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5237889755623402, + "retentionAt10": 0.8612024561890871, + "evictionPrecision": 0.9589541485392247, + "tokensEvicted": 74391.9696969697, + "evictionEvents": 46.557575757575755, + "saturatedEvents": 0.911481385055975, + "turnsToFirstSummary": 83.0354609929078, + "turnsToFirstSummaryCount": 141 + }, + "pooledRetention": 0.44480823199251635, + "pooledRetentionAt10": 0.7507568113017155 + } + }, + { + "budgetTokens": 64000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 65699.73333333334, + "evictionEvents": 40.13939393939394, + "saturatedEvents": 0.9637626453268912, + "turnsToFirstSummary": 76.73239436619718, + "turnsToFirstSummaryCount": 142 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 89.33576642335767, + "turnsToFirstSummaryCount": 137 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7810314888232291, + "retentionAt10": 0.9590172833831285, + "evictionPrecision": 0.9787066458508404, + "tokensEvicted": 58993.32121212121, + "evictionEvents": 18.048484848484847, + "saturatedEvents": 0.9335124244459369, + "turnsToFirstSummary": 162.46511627906978, + "turnsToFirstSummaryCount": 86 + }, + "pooledRetention": 0.7174929840972872, + "pooledRetentionAt10": 0.9283551967709385 + } + }, + { + "budgetTokens": 128000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7589941116539988, + "retentionAt10": 0.9596122128149566, + "evictionPrecision": 0.9798995775797211, + "tokensEvicted": 68054.01212121212, + "evictionEvents": 18.89090909090909, + "saturatedEvents": 1, + "turnsToFirstSummary": 175.9375, + "turnsToFirstSummaryCount": 80 + }, + "pooledRetention": 0.7137511693171188, + "pooledRetentionAt10": 0.9334006054490414 + } + }, + { + "budgetTokens": 128000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7870428546660476, + "retentionAt10": 0.9581048194359052, + "evictionPrecision": 0.9771884835906713, + "tokensEvicted": 65938.32121212121, + "evictionEvents": 21.01212121212121, + "saturatedEvents": 0.8491491202768965, + "turnsToFirstSummary": 168.5487804878049, + "turnsToFirstSummaryCount": 82 + }, + "pooledRetention": 0.7104770813844715, + "pooledRetentionAt10": 0.9243188698284561 + } + }, + { + "budgetTokens": 128000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 59018.721212121214, + "evictionEvents": 18.587878787878786, + "saturatedEvents": 0.9439191392239974, + "turnsToFirstSummary": 156.75581395348837, + "turnsToFirstSummaryCount": 86 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + } + ] } diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md index 10428c4b9..43e9739df 100644 --- a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md @@ -1,5 +1,5 @@ - + # Clio working-set replay @@ -7,41 +7,41 @@ | stage | traces | | --- | ---: | -| found | 302 | +| found | 303 | | unreadable | 2 | | sidechain_or_subagent | 17 | | summary_only | 0 | | turns_lt_8 | 14 | | tool_results_lt_8 | 3 | -| no_file_reread | 101 | +| no_file_reread | 102 | | kept | 165 | ## Budget 32000 -| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 18.4 (n=164) | -| random | 165 | 0.389 | 0.317 | 0.762 | 0.942 | 67601.8 | 54.7 | 0.964 | 0.058 | 39.7 (n=160) | -| age-horizon | 165 | 0.405 | 0.325 | 0.787 | 0.970 | 102990.9 | 78.7 | 1.000 | 0.030 | 54.8 (n=152) | -| structural-v1 | 165 | 0.417 | 0.329 | 0.793 | 0.971 | 102754.7 | 82.6 | 0.907 | 0.029 | 52.7 (n=153) | -| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 67602.7 | 55.7 | 0.979 | 0.000 | 34.3 (n=160) | +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 18.9 (n=164) | +| random | 165 | 0.397 | 0.325 | 0.780 | 0.942 | 67237.4 | 53.7 | 0.961 | 42.1 (n=158) | +| age-horizon | 165 | 0.387 | 0.309 | 0.763 | 0.948 | 76253.6 | 61.4 | 1.000 | 44.1 (n=158) | +| structural-v1 | 165 | 0.389 | 0.309 | 0.750 | 0.949 | 76032.9 | 62.3 | 0.945 | 43.2 (n=158) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 67216.4 | 54.8 | 0.977 | 36.5 (n=158) | ## Budget 64000 -| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 37.6 (n=157) | -| random | 165 | 0.530 | 0.450 | 0.853 | 0.954 | 66268.0 | 40.7 | 0.954 | 0.046 | 77.8 (n=145) | -| age-horizon | 165 | 0.540 | 0.462 | 0.892 | 0.977 | 101194.3 | 50.9 | 1.000 | 0.023 | 110.0 (n=125) | -| structural-v1 | 165 | 0.567 | 0.499 | 0.894 | 0.978 | 100305.4 | 56.3 | 0.852 | 0.022 | 103.0 (n=129) | -| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 66376.8 | 41.3 | 0.967 | 0.000 | 71.7 (n=145) | +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 38.8 (n=156) | +| random | 165 | 0.546 | 0.464 | 0.869 | 0.954 | 65668.7 | 39.5 | 0.951 | 82.8 (n=142) | +| age-horizon | 165 | 0.529 | 0.442 | 0.880 | 0.960 | 74726.3 | 44.2 | 1.000 | 88.7 (n=140) | +| structural-v1 | 165 | 0.524 | 0.445 | 0.861 | 0.959 | 74392.0 | 46.6 | 0.911 | 83.0 (n=141) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 65699.7 | 40.1 | 0.964 | 76.7 (n=142) | ## Budget 128000 -| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | churn (mean) | turns to first summary (mean) | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 0.000 | 86.1 (n=140) | -| random | 165 | 0.755 | 0.704 | 0.948 | 0.975 | 59933.6 | 20.0 | 0.940 | 0.025 | 152.7 (n=96) | -| age-horizon | 165 | 0.771 | 0.730 | 0.983 | 0.988 | 90092.9 | 18.1 | 1.000 | 0.012 | 210.4 (n=55) | -| structural-v1 | 165 | 0.817 | 0.748 | 0.976 | 0.989 | 85565.8 | 22.0 | 0.755 | 0.011 | 202.1 (n=59) | -| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 60100.0 | 20.6 | 0.950 | 0.000 | 147.8 (n=96) | +| policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | turns to first summary (mean) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 89.3 (n=137) | +| random | 165 | 0.781 | 0.717 | 0.959 | 0.979 | 58993.3 | 18.0 | 0.934 | 162.5 (n=86) | +| age-horizon | 165 | 0.759 | 0.714 | 0.960 | 0.980 | 68054.0 | 18.9 | 1.000 | 175.9 (n=80) | +| structural-v1 | 165 | 0.787 | 0.710 | 0.958 | 0.977 | 65938.3 | 21.0 | 0.849 | 168.5 (n=82) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 59018.7 | 18.6 | 0.944 | 156.8 (n=86) | diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json index 5640a2ea5..0dc385edb 100644 --- a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json @@ -27,7 +27,7 @@ } }, "provenance": { - "gitSha": "a3d5d69a7aa34a8dcf3a7abdc58c53bc6b42074e", + "gitSha": "ca3f49b6ef14a27e4b67dfe6c7b060246e9be414", "commandLine": [ "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", "--import", @@ -74,7 +74,7 @@ "tokensEvicted": 0, "evictionEvents": 0, "saturatedEvents": 0, - "turnsToFirstSummary": 18.426829268292682, + "turnsToFirstSummary": 18.890243902439025, "turnsToFirstSummaryCount": 164 }, "pooledRetention": 1, @@ -87,17 +87,17 @@ "metrics": { "mean": { "traces": 165, - "retention": 0.4833177066874543, - "retentionAt10": 0.9261196589198183, - "evictionPrecision": 0.9473257732807969, - "tokensEvicted": 66521.02424242425, - "evictionEvents": 57.92121212121212, - "saturatedEvents": 0.9830490739771895, - "turnsToFirstSummary": 31.64814814814815, - "turnsToFirstSummaryCount": 162 + "retention": 0.48484498875354787, + "retentionAt10": 0.9245789700764979, + "evictionPrecision": 0.947321719905957, + "tokensEvicted": 66522.63030303031, + "evictionEvents": 56.72121212121212, + "saturatedEvents": 0.979378138690031, + "turnsToFirstSummary": 33.59627329192546, + "turnsToFirstSummaryCount": 161 }, - "pooledRetention": 0.431244153414406, - "pooledRetentionAt10": 0.875882946518668 + "pooledRetention": 0.43405051449953225, + "pooledRetentionAt10": 0.8748738647830474 } }, { @@ -106,17 +106,17 @@ "metrics": { "mean": { "traces": 165, - "retention": 0.4792350637937497, - "retentionAt10": 0.9234988075489672, - "evictionPrecision": 0.9475340393836018, - "tokensEvicted": 66678.89090909091, - "evictionEvents": 58.02424242424242, + "retention": 0.47860094757297494, + "retentionAt10": 0.9202608227798817, + "evictionPrecision": 0.9526857053976399, + "tokensEvicted": 75283.95757575758, + "evictionEvents": 64.92121212121212, "saturatedEvents": 1, - "turnsToFirstSummary": 31.641975308641975, - "turnsToFirstSummaryCount": 162 + "turnsToFirstSummary": 34.50625, + "turnsToFirstSummaryCount": 160 }, - "pooledRetention": 0.42656688493919553, - "pooledRetentionAt10": 0.8698284561049445 + "pooledRetention": 0.4260991580916745, + "pooledRetentionAt10": 0.863773965691221 } }, { @@ -125,17 +125,17 @@ "metrics": { "mean": { "traces": 165, - "retention": 0.481872499653399, - "retentionAt10": 0.9222500762165038, - "evictionPrecision": 0.9479608131999118, - "tokensEvicted": 66484.24848484849, - "evictionEvents": 58.018181818181816, - "saturatedEvents": 0.9708555311814479, - "turnsToFirstSummary": 31.333333333333332, - "turnsToFirstSummaryCount": 162 + "retention": 0.48034138908908536, + "retentionAt10": 0.9161332690470648, + "evictionPrecision": 0.9532525845958315, + "tokensEvicted": 75073.7696969697, + "evictionEvents": 65.53333333333333, + "saturatedEvents": 0.9630074909830759, + "turnsToFirstSummary": 33.69375, + "turnsToFirstSummaryCount": 160 }, - "pooledRetention": 0.42516370439663237, - "pooledRetentionAt10": 0.8657921291624622 + "pooledRetention": 0.4256314312441534, + "pooledRetentionAt10": 0.8668012108980827 } }, { @@ -147,11 +147,11 @@ "retention": 1, "retentionAt10": 1, "evictionPrecision": 1, - "tokensEvicted": 66556.03636363636, - "evictionEvents": 58.90909090909091, - "saturatedEvents": 0.9907407407407407, - "turnsToFirstSummary": 28.469135802469136, - "turnsToFirstSummaryCount": 162 + "tokensEvicted": 66506.84242424242, + "evictionEvents": 58.1030303030303, + "saturatedEvents": 0.9885261291332013, + "turnsToFirstSummary": 30.1055900621118, + "turnsToFirstSummaryCount": 161 }, "pooledRetention": 1, "pooledRetentionAt10": 1 @@ -169,8 +169,8 @@ "tokensEvicted": 0, "evictionEvents": 0, "saturatedEvents": 0, - "turnsToFirstSummary": 37.57324840764331, - "turnsToFirstSummaryCount": 157 + "turnsToFirstSummary": 38.84615384615385, + "turnsToFirstSummaryCount": 156 }, "pooledRetention": 1, "pooledRetentionAt10": 1 @@ -182,17 +182,17 @@ "metrics": { "mean": { "traces": 165, - "retention": 0.58807049612756, - "retentionAt10": 0.9574288301148334, - "evictionPrecision": 0.9577194857155654, - "tokensEvicted": 65388.933333333334, - "evictionEvents": 41.878787878787875, - "saturatedEvents": 0.9593342981186685, - "turnsToFirstSummary": 74.5958904109589, - "turnsToFirstSummaryCount": 146 + "retention": 0.6031162930617947, + "retentionAt10": 0.9614498990766137, + "evictionPrecision": 0.9598482899583711, + "tokensEvicted": 65192.92727272727, + "evictionEvents": 40.448484848484846, + "saturatedEvents": 0.9574468085106383, + "turnsToFirstSummary": 78.86111111111111, + "turnsToFirstSummaryCount": 144 }, - "pooledRetention": 0.5341440598690365, - "pooledRetentionAt10": 0.9233097880928355 + "pooledRetention": 0.5481758652946679, + "pooledRetentionAt10": 0.9313824419778002 } }, { @@ -201,17 +201,17 @@ "metrics": { "mean": { "traces": 165, - "retention": 0.5689079008098213, - "retentionAt10": 0.9536157130606094, - "evictionPrecision": 0.9557283400464567, - "tokensEvicted": 65568.99393939394, - "evictionEvents": 41.43636363636364, + "retention": 0.5818565083140609, + "retentionAt10": 0.9603327136708908, + "evictionPrecision": 0.9619785119963982, + "tokensEvicted": 73872.11515151516, + "evictionEvents": 45.557575757575755, "saturatedEvents": 1, - "turnsToFirstSummary": 74.6917808219178, - "turnsToFirstSummaryCount": 146 + "turnsToFirstSummary": 83.43661971830986, + "turnsToFirstSummaryCount": 142 }, - "pooledRetention": 0.5060804490177736, - "pooledRetentionAt10": 0.9182643794147326 + "pooledRetention": 0.5219831618334893, + "pooledRetentionAt10": 0.9283551967709385 } }, { @@ -220,17 +220,17 @@ "metrics": { "mean": { "traces": 165, - "retention": 0.56992623135722, - "retentionAt10": 0.9579578355845503, - "evictionPrecision": 0.9553262382608974, - "tokensEvicted": 65073.42424242424, - "evictionEvents": 42.35757575757576, - "saturatedEvents": 0.9429102875947918, - "turnsToFirstSummary": 73.92567567567568, - "turnsToFirstSummaryCount": 148 + "retention": 0.5896915833607682, + "retentionAt10": 0.9524678710036767, + "evictionPrecision": 0.9618101484947162, + "tokensEvicted": 73522.18787878788, + "evictionEvents": 47.53939393939394, + "saturatedEvents": 0.9217236104028557, + "turnsToFirstSummary": 79.26573426573427, + "turnsToFirstSummaryCount": 143 }, - "pooledRetention": 0.5079513564078578, - "pooledRetentionAt10": 0.9212916246215943 + "pooledRetention": 0.5261927034611786, + "pooledRetentionAt10": 0.9243188698284561 } }, { @@ -242,11 +242,11 @@ "retention": 1, "retentionAt10": 1, "evictionPrecision": 1, - "tokensEvicted": 65221.06060606061, - "evictionEvents": 42.74545454545454, - "saturatedEvents": 0.9754714305969091, - "turnsToFirstSummary": 68.26530612244898, - "turnsToFirstSummaryCount": 147 + "tokensEvicted": 65207.30303030303, + "evictionEvents": 41.39393939393939, + "saturatedEvents": 0.9724743777452416, + "turnsToFirstSummary": 72.56551724137931, + "turnsToFirstSummaryCount": 145 }, "pooledRetention": 1, "pooledRetentionAt10": 1 @@ -264,8 +264,8 @@ "tokensEvicted": 0, "evictionEvents": 0, "saturatedEvents": 0, - "turnsToFirstSummary": 86.14285714285714, - "turnsToFirstSummaryCount": 140 + "turnsToFirstSummary": 89.33576642335767, + "turnsToFirstSummaryCount": 137 }, "pooledRetention": 1, "pooledRetentionAt10": 1 @@ -277,17 +277,17 @@ "metrics": { "mean": { "traces": 165, - "retention": 0.7787533496476242, - "retentionAt10": 0.9898164966346785, - "evictionPrecision": 0.9769049554809919, - "tokensEvicted": 59286.70303030303, - "evictionEvents": 20.666666666666668, - "saturatedEvents": 0.9460410557184751, - "turnsToFirstSummary": 150.43298969072166, - "turnsToFirstSummaryCount": 97 + "retention": 0.7984476960436251, + "retentionAt10": 0.9955901421810512, + "evictionPrecision": 0.9797552622041499, + "tokensEvicted": 58484.69090909091, + "evictionEvents": 18.454545454545453, + "saturatedEvents": 0.9376026272577996, + "turnsToFirstSummary": 161, + "turnsToFirstSummaryCount": 88 }, - "pooledRetention": 0.7427502338634238, - "pooledRetentionAt10": 0.9818365287588294 + "pooledRetention": 0.7530402245088869, + "pooledRetentionAt10": 0.9858728557013118 } }, { @@ -296,17 +296,17 @@ "metrics": { "mean": { "traces": 165, - "retention": 0.7643536981681742, - "retentionAt10": 0.9891090786812711, - "evictionPrecision": 0.9758884223172307, - "tokensEvicted": 60445.684848484845, - "evictionEvents": 20.24848484848485, + "retention": 0.7877046085525273, + "retentionAt10": 0.9964447505356595, + "evictionPrecision": 0.9816552198334042, + "tokensEvicted": 67286.4909090909, + "evictionEvents": 19.412121212121214, "saturatedEvents": 1, - "turnsToFirstSummary": 150.79166666666666, - "turnsToFirstSummaryCount": 96 + "turnsToFirstSummary": 173.96296296296296, + "turnsToFirstSummaryCount": 81 }, - "pooledRetention": 0.7160898035547241, - "pooledRetentionAt10": 0.9798183652875883 + "pooledRetention": 0.744621141253508, + "pooledRetentionAt10": 0.987891019172553 } }, { @@ -315,17 +315,17 @@ "metrics": { "mean": { "traces": 165, - "retention": 0.7791585352246793, - "retentionAt10": 0.9876244076778837, - "evictionPrecision": 0.9754222828389132, - "tokensEvicted": 59430.57575757576, - "evictionEvents": 21.618181818181817, - "saturatedEvents": 0.897112419400056, - "turnsToFirstSummary": 142.76, - "turnsToFirstSummaryCount": 100 + "retention": 0.8117532769721052, + "retentionAt10": 0.9955524946434037, + "evictionPrecision": 0.9791781930037826, + "tokensEvicted": 65404.242424242424, + "evictionEvents": 21.484848484848484, + "saturatedEvents": 0.8558533145275036, + "turnsToFirstSummary": 167.91764705882352, + "turnsToFirstSummaryCount": 85 }, - "pooledRetention": 0.7184284377923292, - "pooledRetentionAt10": 0.9757820383451059 + "pooledRetention": 0.7408793264733395, + "pooledRetentionAt10": 0.9858728557013118 } }, { @@ -337,11 +337,11 @@ "retention": 1, "retentionAt10": 1, "evictionPrecision": 1, - "tokensEvicted": 59667.357575757575, - "evictionEvents": 21.375757575757575, - "saturatedEvents": 0.9554862489367735, - "turnsToFirstSummary": 146.17525773195877, - "turnsToFirstSummaryCount": 97 + "tokensEvicted": 58524.242424242424, + "evictionEvents": 19.296969696969697, + "saturatedEvents": 0.9494346733668342, + "turnsToFirstSummary": 155.72727272727272, + "turnsToFirstSummaryCount": 88 }, "pooledRetention": 1, "pooledRetentionAt10": 1 diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md index aee5a4ebb..959623710 100644 --- a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md @@ -1,3 +1,6 @@ + + + # Clio working-set replay ## Inclusion cascade @@ -17,28 +20,28 @@ | policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | turns to first summary (mean) | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 18.4 (n=164) | -| random | 165 | 0.483 | 0.431 | 0.926 | 0.947 | 66521.0 | 57.9 | 0.983 | 31.6 (n=162) | -| age-horizon | 165 | 0.479 | 0.427 | 0.923 | 0.948 | 66678.9 | 58.0 | 1.000 | 31.6 (n=162) | -| structural-v1 | 165 | 0.482 | 0.425 | 0.922 | 0.948 | 66484.2 | 58.0 | 0.971 | 31.3 (n=162) | -| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 66556.0 | 58.9 | 0.991 | 28.5 (n=162) | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 18.9 (n=164) | +| random | 165 | 0.485 | 0.434 | 0.925 | 0.947 | 66522.6 | 56.7 | 0.979 | 33.6 (n=161) | +| age-horizon | 165 | 0.479 | 0.426 | 0.920 | 0.953 | 75284.0 | 64.9 | 1.000 | 34.5 (n=160) | +| structural-v1 | 165 | 0.480 | 0.426 | 0.916 | 0.953 | 75073.8 | 65.5 | 0.963 | 33.7 (n=160) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 66506.8 | 58.1 | 0.989 | 30.1 (n=161) | ## Budget 64000 | policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | turns to first summary (mean) | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 37.6 (n=157) | -| random | 165 | 0.588 | 0.534 | 0.957 | 0.958 | 65388.9 | 41.9 | 0.959 | 74.6 (n=146) | -| age-horizon | 165 | 0.569 | 0.506 | 0.954 | 0.956 | 65569.0 | 41.4 | 1.000 | 74.7 (n=146) | -| structural-v1 | 165 | 0.570 | 0.508 | 0.958 | 0.955 | 65073.4 | 42.4 | 0.943 | 73.9 (n=148) | -| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 65221.1 | 42.7 | 0.975 | 68.3 (n=147) | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 38.8 (n=156) | +| random | 165 | 0.603 | 0.548 | 0.961 | 0.960 | 65192.9 | 40.4 | 0.957 | 78.9 (n=144) | +| age-horizon | 165 | 0.582 | 0.522 | 0.960 | 0.962 | 73872.1 | 45.6 | 1.000 | 83.4 (n=142) | +| structural-v1 | 165 | 0.590 | 0.526 | 0.952 | 0.962 | 73522.2 | 47.5 | 0.922 | 79.3 (n=143) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 65207.3 | 41.4 | 0.972 | 72.6 (n=145) | ## Budget 128000 | policy | n | retention (mean) | retention (pooled) | retention@10 (mean) | eviction precision (mean) | tokens evicted (mean) | eviction events (mean) | saturated events | turns to first summary (mean) | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 86.1 (n=140) | -| random | 165 | 0.779 | 0.743 | 0.990 | 0.977 | 59286.7 | 20.7 | 0.946 | 150.4 (n=97) | -| age-horizon | 165 | 0.764 | 0.716 | 0.989 | 0.976 | 60445.7 | 20.2 | 1.000 | 150.8 (n=96) | -| structural-v1 | 165 | 0.779 | 0.718 | 0.988 | 0.975 | 59430.6 | 21.6 | 0.897 | 142.8 (n=100) | -| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 59667.4 | 21.4 | 0.955 | 146.2 (n=97) | +| none | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 0 | 0 | 0.000 | 89.3 (n=137) | +| random | 165 | 0.798 | 0.753 | 0.996 | 0.980 | 58484.7 | 18.5 | 0.938 | 161 (n=88) | +| age-horizon | 165 | 0.788 | 0.745 | 0.996 | 0.982 | 67286.5 | 19.4 | 1.000 | 174.0 (n=81) | +| structural-v1 | 165 | 0.812 | 0.741 | 0.996 | 0.979 | 65404.2 | 21.5 | 0.856 | 167.9 (n=85) | +| oracle | 165 | 1.000 | 1.000 | 1.000 | 1.000 | 58524.2 | 19.3 | 0.949 | 155.7 (n=88) | diff --git a/src/domains/context/working-set/defaults.ts b/src/domains/context/working-set/defaults.ts index 268427969..ac522cb94 100644 --- a/src/domains/context/working-set/defaults.ts +++ b/src/domains/context/working-set/defaults.ts @@ -6,9 +6,10 @@ * * `structural-v1` is the default: typed path-keyed rules first, the age * rule last and batched to `target`. On 165 Claude Code transcripts it held - * retention 0.831 against 0.781 for `age-horizon` and 0.779 for random at a - * 128k budget (benchmarks/results/context-replay/). `age-horizon` stays - * available as the exact pre-layer selection recorded through the ledger. + * retention 0.812 against 0.788 for `age-horizon` and 0.798 for random at a + * 128k budget (benchmarks/results/context-replay/README.md has the rule and + * the grid). `age-horizon` stays available as the exact pre-layer selection + * recorded through the ledger. */ import type { WorkingSetSettings } from "../../../core/defaults.js"; From 446bbf9da0ecc6bf6c248200eb087cc8817e73ed Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 14:01:10 -0500 Subject: [PATCH 42/45] fix(cli): context replay fails when nothing is kept instead of printing an empty table A missing path or a corpus the filter rejects entirely produced a full Markdown report of zeros and exit 0. It now writes the cascade to stderr and exits 1. --- src/cli/context-working-set.ts | 4 ++++ tests/contracts/cli-context-replay.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/cli/context-working-set.ts b/src/cli/context-working-set.ts index dc4df5774..f8cd7221f 100644 --- a/src/cli/context-working-set.ts +++ b/src/cli/context-working-set.ts @@ -263,6 +263,10 @@ export async function runContextReplayCommand(args: string[]): Promise { } try { const loaded = await loadReplayTraces(parsed.sessions, parsed.format, { filter: !parsed.noFilter }); + if (loaded.traces.length === 0) { + process.stderr.write(`clio-coder context replay: no traces to replay (${cascadeLine(loaded.cascade)})\n`); + return 1; + } const indexed = loaded.traces.map((trace) => { const index = buildPathIndex(trace.entries, { cwd: trace.cwd }); return { trace, index, graph: buildReferenceGraph(trace, index) }; diff --git a/tests/contracts/cli-context-replay.test.ts b/tests/contracts/cli-context-replay.test.ts index 9cb50339a..6dfadb1b4 100644 --- a/tests/contracts/cli-context-replay.test.ts +++ b/tests/contracts/cli-context-replay.test.ts @@ -31,6 +31,15 @@ describe("contracts/cli context replay overrides", () => { }); } + it("fails instead of printing an empty table when nothing is kept", async () => { + const result = await runCli(["context", "replay", "--sessions", join(tmpdir(), "clio-replay-missing-input")], { + env: scratch.env, + }); + assert.equal(result.code, 1, `stdout=${result.stdout}\nstderr=${result.stderr}`); + assert.match(result.stderr, /no traces to replay \(cascade found=0 unreadable=1/); + assert.equal(result.stdout, ""); + }); + it("records valid replay-only overrides and the saturation metric", async () => { const output = await mkdtemp(join(tmpdir(), "clio-context-replay-output-")); outputs.push(output); From f4792681497172ff799e1f705ad1053fa1973d0b Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 14:11:49 -0500 Subject: [PATCH 43/45] fix(context): recall failures list only the refs a recall can bring back The listing included evicted assistant turns, whose thinking is not recallable, so a caller following it hit a second failure. Tool results only, and the wording says so. --- docs/context-working-set.md | 2 +- src/domains/context/working-set/recall.ts | 24 ++++++++++++-------- tests/contracts/context-recall-slash.test.ts | 2 +- tests/contracts/context-tool-recall.test.ts | 4 ++-- tests/contracts/working-set-recall.test.ts | 24 +++++++++++++++++--- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/docs/context-working-set.md b/docs/context-working-set.md index ff540822c..a57d812f1 100644 --- a/docs/context-working-set.md +++ b/docs/context-working-set.md @@ -121,7 +121,7 @@ Recall is explicit and by ref. There is no auto-readmission: the marker tells th - `not_on_active_path` when the session has no such turn on this branch, which includes a ref from a branch `/tree` abandoned. - `not_evicted` when the unit is still in context. An assistant turn reports separately that thinking is not recallable. -Both messages end with the refs that are evicted on the active path (up to eight, then a count), because a failed recall is usually a mistyped ref and the listing is what the next call needs. +Both messages end with the refs that can be recalled on the active path (tool results only, up to eight, then a count), because a failed recall is usually a mistyped ref and the listing is what the next call needs. **A recall does not un-evict.** The key stays in `view.evicted`, the marker stays byte-identical at its original position, and the recalled body arrives at the tail of the working set inside the recall result. Readmitting it in place would duplicate the bytes and invalidate the provider prefix cache for everything after that point, which costs more than the recall saved. diff --git a/src/domains/context/working-set/recall.ts b/src/domains/context/working-set/recall.ts index 3f9ea4d9e..0de893041 100644 --- a/src/domains/context/working-set/recall.ts +++ b/src/domains/context/working-set/recall.ts @@ -106,29 +106,35 @@ export function buildRecallFields( const MAX_LISTED_REFS = 8; /** - * The refs that are actually out, so the next call can name one of them. A - * guessed "nearest" ref was tried first and dropped: over time-ordered ids a - * prefix match names an unrelated result, and the listing is what helps. + * The refs a recall can actually bring back, so the next call can name one of + * them. Thinking refs are evicted too but are not recallable, so listing them + * would hand the caller a ref that fails for a different reason. A guessed + * "nearest" ref was tried first and dropped: over time-ordered ids a prefix + * match names an unrelated result, and the listing is what helps. */ -function evictedRefListing(view: WorkingSetView): string { - const refs = [...view.evicted.keys()]; - if (refs.length === 0) return "No refs are evicted on the active path."; +function recallableRefListing(entries: ReadonlyArray, view: WorkingSetView): string { + const refs = [...view.evicted.keys()].filter((key) => { + const entry = entries.find((candidate) => candidate.turnId === key); + return entry !== undefined && isToolResultEntry(entry); + }); + if (refs.length === 0) return "No recallable refs on the active path."; const shown = refs.slice(0, MAX_LISTED_REFS).join(", "); const more = refs.length > MAX_LISTED_REFS ? `, and ${refs.length - MAX_LISTED_REFS} more` : ""; - return `Evicted refs on the active path: ${shown}${more}.`; + return `Recallable refs on the active path: ${shown}${more}.`; } /** * One-line operator/model-facing message for a recall failure. Says why an * assistant turn is refused instead of calling it "not evicted", and ends with - * the refs that can be recalled. + * the refs that can be recalled. `entries` is the active path the view was + * folded over; without it the listing is empty. */ export function recallErrorMessage( error: RecallError, entries: ReadonlyArray = [], view: WorkingSetView = EMPTY_WORKING_SET_VIEW, ): string { - const listing = ` ${evictedRefListing(view)}`; + const listing = ` ${recallableRefListing(entries, view)}`; switch (error.kind) { case "invalid_ref": return `recall ref must be a single turnId without whitespace; got '${error.ref}'.`; diff --git a/tests/contracts/context-recall-slash.test.ts b/tests/contracts/context-recall-slash.test.ts index f496a7f00..2f2199080 100644 --- a/tests/contracts/context-recall-slash.test.ts +++ b/tests/contracts/context-recall-slash.test.ts @@ -234,7 +234,7 @@ describe("contracts//context recall", () => { h.runtime.dispatchCommand("/context recall zzz"); const transcript = h.transcript(); ok(transcript.includes("is not on the active path"), transcript); - ok(transcript.includes("Evicted refs on the active path: t1."), transcript); + ok(transcript.includes("Recallable refs on the active path: t1."), transcript); deepStrictEqual(h.appended, []); deepStrictEqual(h.recalled, []); diff --git a/tests/contracts/context-tool-recall.test.ts b/tests/contracts/context-tool-recall.test.ts index d4f33e673..076abc1cb 100644 --- a/tests/contracts/context-tool-recall.test.ts +++ b/tests/contracts/context-tool-recall.test.ts @@ -133,11 +133,11 @@ describe("contracts/context recall scope", () => { const notEvicted = await tool.run({ scope: "recall", ref: "t2" }, undefined); assert.equal(notEvicted.kind, "error"); if (notEvicted.kind === "error") - assert.match(notEvicted.message, /not evicted.*Evicted refs on the active path: t1\.$/); + assert.match(notEvicted.message, /not evicted.*Recallable refs on the active path: t1\.$/); const offPath = await tool.run({ scope: "recall", ref: "nope" }, undefined); assert.equal(offPath.kind, "error"); if (offPath.kind === "error") - assert.match(offPath.message, /not on the active path.*Evicted refs on the active path: t1\./); + assert.match(offPath.message, /not on the active path.*Recallable refs on the active path: t1\./); const missing = await tool.run({ scope: "recall" }, undefined); assert.equal(missing.kind, "error"); if (missing.kind === "error") assert.match(missing.message, /requires ref=/); diff --git a/tests/contracts/working-set-recall.test.ts b/tests/contracts/working-set-recall.test.ts index d552d57e9..b08d9f594 100644 --- a/tests/contracts/working-set-recall.test.ts +++ b/tests/contracts/working-set-recall.test.ts @@ -163,13 +163,16 @@ test("recall: not_evicted lists the refs that are evicted", () => { assert.deepEqual(outcome.error, { kind: "not_evicted", ref: "turn-b1" }); assert.match( recallErrorMessage(outcome.error, entries, view), - /not evicted.*Evicted refs on the active path: turn-a1, turn-a2\.$/, + /not evicted.*Recallable refs on the active path: turn-a1, turn-a2\.$/, ); const unknown = resolveRecall(entries, view, "turn-a2x"); assert.ok(!unknown.ok); assert.equal(unknown.error.kind, "not_on_active_path"); - assert.match(recallErrorMessage(unknown.error, entries, view), /Evicted refs on the active path: turn-a1, turn-a2\.$/); + assert.match( + recallErrorMessage(unknown.error, entries, view), + /Recallable refs on the active path: turn-a1, turn-a2\.$/, + ); }); test("recall: not_on_active_path for an unknown ref says when nothing is evicted", () => { @@ -178,7 +181,22 @@ test("recall: not_on_active_path for an unknown ref says when nothing is evicted const outcome = resolveRecall(entries, view, "zzz"); assert.ok(!outcome.ok); assert.deepEqual(outcome.error, { kind: "not_on_active_path", ref: "zzz" }); - assert.match(recallErrorMessage(outcome.error, entries, view), /not on the active path.*No refs are evicted/); + assert.match(recallErrorMessage(outcome.error, entries, view), /not on the active path.*No recallable refs/); +}); + +test("recall: the listing names tool results only, never evicted thinking", () => { + const entries: SessionEntry[] = [ + user("u1", null), + assistant("a1", "u1"), + toolResult("t1", "a1", "body"), + user("u2", "t1"), + eviction("e1", "u2", ["a1", "t1"]), + ]; + const view = foldWorkingSet(entries); + assert.deepEqual([...view.evicted.keys()], ["a1", "t1"]); + const outcome = resolveRecall(entries, view, "nope"); + assert.ok(!outcome.ok); + assert.match(recallErrorMessage(outcome.error, entries, view), /Recallable refs on the active path: t1\.$/); }); test("recall: the listing is cut after eight refs", () => { From ba0c311d38a9a8f78f8ece807c94e00c738b11d8 Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 14:13:20 -0500 Subject: [PATCH 44/45] feat(context): a recall failure names what each recallable ref was After a summary compaction the markers before the cut leave the working set, and the listing was the model's only way to learn which ref held which file; with bare ids it recalled five bodies to find one. Each ref now carries the tool and the path the call named. --- src/domains/context/working-set/recall.ts | 22 +++++++++--- tests/contracts/context-recall-slash.test.ts | 2 +- tests/contracts/context-tool-recall.test.ts | 4 +-- tests/contracts/working-set-recall.test.ts | 35 +++++++++++++++++--- 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/domains/context/working-set/recall.ts b/src/domains/context/working-set/recall.ts index 0de893041..31b65ad75 100644 --- a/src/domains/context/working-set/recall.ts +++ b/src/domains/context/working-set/recall.ts @@ -28,7 +28,8 @@ import { type WorkingSetView, } from "./contract.js"; import { parseRefKey, refKey } from "./fold.js"; -import { offloadPathOf, toolResultPayload, toolResultText } from "./payload.js"; +import { callPathsByToolCallId } from "./path-index.js"; +import { offloadPathOf, primaryPathOf, toolResultPayload, toolResultText } from "./payload.js"; export type RecallOutcome = { ok: true; result: RecallResult } | { ok: false; error: RecallError }; @@ -113,10 +114,21 @@ const MAX_LISTED_REFS = 8; * match names an unrelated result, and the listing is what helps. */ function recallableRefListing(entries: ReadonlyArray, view: WorkingSetView): string { - const refs = [...view.evicted.keys()].filter((key) => { - const entry = entries.find((candidate) => candidate.turnId === key); - return entry !== undefined && isToolResultEntry(entry); - }); + const byTurnId = new Map(); + for (const entry of entries) byTurnId.set(entry.turnId, entry); + const callPaths = callPathsByToolCallId(entries); + const refs: string[] = []; + for (const key of view.evicted.keys()) { + const entry = byTurnId.get(key); + if (entry === undefined || !isToolResultEntry(entry)) continue; + // After a summary compaction the markers before the cut are gone from + // the working set, so the listing is the only place the caller learns + // what a ref was. Tool and path are what it needs to pick one. + const payload = toolResultPayload(entry.payload); + const toolCallId = typeof payload.obj.toolCallId === "string" ? payload.obj.toolCallId : undefined; + const path = primaryPathOf(payload) ?? (toolCallId === undefined ? undefined : callPaths.get(toolCallId)); + refs.push(`${key} (${payload.toolName}${path === undefined ? "" : ` ${path}`})`); + } if (refs.length === 0) return "No recallable refs on the active path."; const shown = refs.slice(0, MAX_LISTED_REFS).join(", "); const more = refs.length > MAX_LISTED_REFS ? `, and ${refs.length - MAX_LISTED_REFS} more` : ""; diff --git a/tests/contracts/context-recall-slash.test.ts b/tests/contracts/context-recall-slash.test.ts index 2f2199080..aa2272237 100644 --- a/tests/contracts/context-recall-slash.test.ts +++ b/tests/contracts/context-recall-slash.test.ts @@ -234,7 +234,7 @@ describe("contracts//context recall", () => { h.runtime.dispatchCommand("/context recall zzz"); const transcript = h.transcript(); ok(transcript.includes("is not on the active path"), transcript); - ok(transcript.includes("Recallable refs on the active path: t1."), transcript); + ok(transcript.includes("Recallable refs on the active path: t1 (read)."), transcript); deepStrictEqual(h.appended, []); deepStrictEqual(h.recalled, []); diff --git a/tests/contracts/context-tool-recall.test.ts b/tests/contracts/context-tool-recall.test.ts index 076abc1cb..d5441d2cd 100644 --- a/tests/contracts/context-tool-recall.test.ts +++ b/tests/contracts/context-tool-recall.test.ts @@ -133,11 +133,11 @@ describe("contracts/context recall scope", () => { const notEvicted = await tool.run({ scope: "recall", ref: "t2" }, undefined); assert.equal(notEvicted.kind, "error"); if (notEvicted.kind === "error") - assert.match(notEvicted.message, /not evicted.*Recallable refs on the active path: t1\.$/); + assert.match(notEvicted.message, /not evicted.*Recallable refs on the active path: t1 \(read\)\.$/); const offPath = await tool.run({ scope: "recall", ref: "nope" }, undefined); assert.equal(offPath.kind, "error"); if (offPath.kind === "error") - assert.match(offPath.message, /not on the active path.*Recallable refs on the active path: t1\./); + assert.match(offPath.message, /not on the active path.*Recallable refs on the active path: t1 \(read\)\./); const missing = await tool.run({ scope: "recall" }, undefined); assert.equal(missing.kind, "error"); if (missing.kind === "error") assert.match(missing.message, /requires ref=/); diff --git a/tests/contracts/working-set-recall.test.ts b/tests/contracts/working-set-recall.test.ts index b08d9f594..02b65e54e 100644 --- a/tests/contracts/working-set-recall.test.ts +++ b/tests/contracts/working-set-recall.test.ts @@ -163,7 +163,7 @@ test("recall: not_evicted lists the refs that are evicted", () => { assert.deepEqual(outcome.error, { kind: "not_evicted", ref: "turn-b1" }); assert.match( recallErrorMessage(outcome.error, entries, view), - /not evicted.*Recallable refs on the active path: turn-a1, turn-a2\.$/, + /not evicted.*Recallable refs on the active path: turn-a1 \(read\), turn-a2 \(read\)\.$/, ); const unknown = resolveRecall(entries, view, "turn-a2x"); @@ -171,7 +171,7 @@ test("recall: not_evicted lists the refs that are evicted", () => { assert.equal(unknown.error.kind, "not_on_active_path"); assert.match( recallErrorMessage(unknown.error, entries, view), - /Recallable refs on the active path: turn-a1, turn-a2\.$/, + /Recallable refs on the active path: turn-a1 \(read\), turn-a2 \(read\)\.$/, ); }); @@ -196,7 +196,31 @@ test("recall: the listing names tool results only, never evicted thinking", () = assert.deepEqual([...view.evicted.keys()], ["a1", "t1"]); const outcome = resolveRecall(entries, view, "nope"); assert.ok(!outcome.ok); - assert.match(recallErrorMessage(outcome.error, entries, view), /Recallable refs on the active path: t1\.$/); + assert.match(recallErrorMessage(outcome.error, entries, view), /Recallable refs on the active path: t1 \(read\)\.$/); +}); + +test("recall: the listing names the file the call read", () => { + const entries: SessionEntry[] = [ + user("u1", null), + { + kind: "message", + turnId: "c1", + parentTurnId: "u1", + timestamp: stamp(), + role: "tool_call", + payload: { toolCallId: "call-t1", name: "read", args: { path: "src/a.ts" } }, + }, + toolResult("t1", "c1", "body"), + user("u2", "t1"), + eviction("e1", "u2", ["t1"]), + ]; + const view = foldWorkingSet(entries); + const outcome = resolveRecall(entries, view, "nope"); + assert.ok(!outcome.ok); + assert.match( + recallErrorMessage(outcome.error, entries, view), + /Recallable refs on the active path: t1 \(read src\/a\.ts\)\.$/, + ); }); test("recall: the listing is cut after eight refs", () => { @@ -211,7 +235,10 @@ test("recall: the listing is cut after eight refs", () => { const view = foldWorkingSet(entries); const outcome = resolveRecall(entries, view, "nope"); assert.ok(!outcome.ok); - assert.match(recallErrorMessage(outcome.error, entries, view), /t0, t1, t2, t3, t4, t5, t6, t7, and 2 more\.$/); + assert.match( + recallErrorMessage(outcome.error, entries, view), + /t0 \(read\), t1 \(read\), .*t7 \(read\), and 2 more\.$/, + ); }); test("recall: a ref on an abandoned branch is not_on_active_path after a fork", () => { From 9073e395953074f2833b3002a32e6f1331d5e1fa Mon Sep 17 00:00:00 2001 From: akougkas Date: Fri, 21 Aug 2026 14:18:41 -0500 Subject: [PATCH 45/45] fix(context): the replay JSON artifact is written in the repository's indent style biome rejected the committed artifacts because the CLI emitted two-space JSON; tab indentation keeps a fresh run lint-clean without a reformat step. --- .../claude-code-2026-08-21-protect-2.json | 688 +++++++++--------- .../claude-code-2026-08-21-protect-6.json | 688 +++++++++--------- .../context/working-set/replay/report.ts | 2 +- 3 files changed, 679 insertions(+), 699 deletions(-) diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json index 2397cfbc9..c963eeeea 100644 --- a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json @@ -1,351 +1,341 @@ { - "schema": "clio-context-replay-v1", - "config": { - "policies": [ - "none", - "random", - "age-horizon", - "structural-v1", - "oracle" - ], - "budgets": [ - 32000, - 64000, - 128000 - ], - "threshold": 0.8, - "target": 0.6, - "seed": 0, - "format": "auto", - "filter": "default", - "settings": { - "enabled": true, - "policy": "structural-v1", - "target": 0.6, - "protectLastTurns": 2, - "minEvictableTokens": 200 - } - }, - "provenance": { - "gitSha": "ca3f49b6ef14a27e4b67dfe6c7b060246e9be414", - "commandLine": [ - "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", - "--import", - "tsx", - "/home/akougkas/iowarp/clio-coder/src/cli/index.ts", - "context", - "replay", - "--sessions", - "/home/akougkas/.claude/projects/-home-akougkas-iowarp-clio-coder", - "--policies", - "none,random,age-horizon,structural-v1,oracle", - "--budgets", - "32000,64000,128000", - "--protect-last-turns", - "2", - "--md", - "benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md", - "--json", - "benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json" - ] - }, - "cascade": { - "found": 303, - "unreadable": 2, - "filtered": { - "no_file_reread": 102, - "sidechain_or_subagent": 17, - "summary_only": 0, - "tool_results_lt_8": 3, - "turns_lt_8": 14 - }, - "kept": 165 - }, - "results": [ - { - "budgetTokens": 32000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "turnsToFirstSummary": 18.890243902439025, - "turnsToFirstSummaryCount": 164 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 32000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.39736045282440036, - "retentionAt10": 0.7796271806042483, - "evictionPrecision": 0.9420574036904985, - "tokensEvicted": 67237.44848484849, - "evictionEvents": 53.70909090909091, - "saturatedEvents": 0.960731211916046, - "turnsToFirstSummary": 42.09493670886076, - "turnsToFirstSummaryCount": 158 - }, - "pooledRetention": 0.3246024321796071, - "pooledRetentionAt10": 0.6437941473259334 - } - }, - { - "budgetTokens": 32000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.38734720867396777, - "retentionAt10": 0.7630094420606726, - "evictionPrecision": 0.9481324810379269, - "tokensEvicted": 76253.64242424243, - "evictionEvents": 61.412121212121214, - "saturatedEvents": 1, - "turnsToFirstSummary": 44.05696202531646, - "turnsToFirstSummaryCount": 158 - }, - "pooledRetention": 0.3086997193638915, - "pooledRetentionAt10": 0.6135216952573158 - } - }, - { - "budgetTokens": 32000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.3885318187107285, - "retentionAt10": 0.7501152403028344, - "evictionPrecision": 0.9485123933274446, - "tokensEvicted": 76032.92121212122, - "evictionEvents": 62.339393939393936, - "saturatedEvents": 0.9452654092941862, - "turnsToFirstSummary": 43.15822784810127, - "turnsToFirstSummaryCount": 158 - }, - "pooledRetention": 0.3086997193638915, - "pooledRetentionAt10": 0.6044399596367306 - } - }, - { - "budgetTokens": 32000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 67216.38181818182, - "evictionEvents": 54.806060606060605, - "saturatedEvents": 0.9773305319031295, - "turnsToFirstSummary": 36.537974683544306, - "turnsToFirstSummaryCount": 158 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 64000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "turnsToFirstSummary": 38.84615384615385, - "turnsToFirstSummaryCount": 156 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 64000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.5458401662289053, - "retentionAt10": 0.8690588780573669, - "evictionPrecision": 0.9544114724418871, - "tokensEvicted": 65668.67878787879, - "evictionEvents": 39.45454545454545, - "saturatedEvents": 0.9509984639016897, - "turnsToFirstSummary": 82.80281690140845, - "turnsToFirstSummaryCount": 142 - }, - "pooledRetention": 0.4644527595884004, - "pooledRetentionAt10": 0.7699293642785066 - } - }, - { - "budgetTokens": 64000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.5290339282445672, - "retentionAt10": 0.8798954909394343, - "evictionPrecision": 0.9595138223923505, - "tokensEvicted": 74726.32121212121, - "evictionEvents": 44.224242424242426, - "saturatedEvents": 1, - "turnsToFirstSummary": 88.66428571428571, - "turnsToFirstSummaryCount": 140 - }, - "pooledRetention": 0.44200187090739007, - "pooledRetentionAt10": 0.7729566094853683 - } - }, - { - "budgetTokens": 64000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.5237889755623402, - "retentionAt10": 0.8612024561890871, - "evictionPrecision": 0.9589541485392247, - "tokensEvicted": 74391.9696969697, - "evictionEvents": 46.557575757575755, - "saturatedEvents": 0.911481385055975, - "turnsToFirstSummary": 83.0354609929078, - "turnsToFirstSummaryCount": 141 - }, - "pooledRetention": 0.44480823199251635, - "pooledRetentionAt10": 0.7507568113017155 - } - }, - { - "budgetTokens": 64000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 65699.73333333334, - "evictionEvents": 40.13939393939394, - "saturatedEvents": 0.9637626453268912, - "turnsToFirstSummary": 76.73239436619718, - "turnsToFirstSummaryCount": 142 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 128000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "turnsToFirstSummary": 89.33576642335767, - "turnsToFirstSummaryCount": 137 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 128000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.7810314888232291, - "retentionAt10": 0.9590172833831285, - "evictionPrecision": 0.9787066458508404, - "tokensEvicted": 58993.32121212121, - "evictionEvents": 18.048484848484847, - "saturatedEvents": 0.9335124244459369, - "turnsToFirstSummary": 162.46511627906978, - "turnsToFirstSummaryCount": 86 - }, - "pooledRetention": 0.7174929840972872, - "pooledRetentionAt10": 0.9283551967709385 - } - }, - { - "budgetTokens": 128000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.7589941116539988, - "retentionAt10": 0.9596122128149566, - "evictionPrecision": 0.9798995775797211, - "tokensEvicted": 68054.01212121212, - "evictionEvents": 18.89090909090909, - "saturatedEvents": 1, - "turnsToFirstSummary": 175.9375, - "turnsToFirstSummaryCount": 80 - }, - "pooledRetention": 0.7137511693171188, - "pooledRetentionAt10": 0.9334006054490414 - } - }, - { - "budgetTokens": 128000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.7870428546660476, - "retentionAt10": 0.9581048194359052, - "evictionPrecision": 0.9771884835906713, - "tokensEvicted": 65938.32121212121, - "evictionEvents": 21.01212121212121, - "saturatedEvents": 0.8491491202768965, - "turnsToFirstSummary": 168.5487804878049, - "turnsToFirstSummaryCount": 82 - }, - "pooledRetention": 0.7104770813844715, - "pooledRetentionAt10": 0.9243188698284561 - } - }, - { - "budgetTokens": 128000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 59018.721212121214, - "evictionEvents": 18.587878787878786, - "saturatedEvents": 0.9439191392239974, - "turnsToFirstSummary": 156.75581395348837, - "turnsToFirstSummaryCount": 86 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - } - ] + "schema": "clio-context-replay-v1", + "config": { + "policies": ["none", "random", "age-horizon", "structural-v1", "oracle"], + "budgets": [32000, 64000, 128000], + "threshold": 0.8, + "target": 0.6, + "seed": 0, + "format": "auto", + "filter": "default", + "settings": { + "enabled": true, + "policy": "structural-v1", + "target": 0.6, + "protectLastTurns": 2, + "minEvictableTokens": 200 + } + }, + "provenance": { + "gitSha": "ca3f49b6ef14a27e4b67dfe6c7b060246e9be414", + "commandLine": [ + "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", + "--import", + "tsx", + "/home/akougkas/iowarp/clio-coder/src/cli/index.ts", + "context", + "replay", + "--sessions", + "/home/akougkas/.claude/projects/-home-akougkas-iowarp-clio-coder", + "--policies", + "none,random,age-horizon,structural-v1,oracle", + "--budgets", + "32000,64000,128000", + "--protect-last-turns", + "2", + "--md", + "benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.md", + "--json", + "benchmarks/results/context-replay/claude-code-2026-08-21-protect-2.json" + ] + }, + "cascade": { + "found": 303, + "unreadable": 2, + "filtered": { + "no_file_reread": 102, + "sidechain_or_subagent": 17, + "summary_only": 0, + "tool_results_lt_8": 3, + "turns_lt_8": 14 + }, + "kept": 165 + }, + "results": [ + { + "budgetTokens": 32000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 18.890243902439025, + "turnsToFirstSummaryCount": 164 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 32000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.39736045282440036, + "retentionAt10": 0.7796271806042483, + "evictionPrecision": 0.9420574036904985, + "tokensEvicted": 67237.44848484849, + "evictionEvents": 53.70909090909091, + "saturatedEvents": 0.960731211916046, + "turnsToFirstSummary": 42.09493670886076, + "turnsToFirstSummaryCount": 158 + }, + "pooledRetention": 0.3246024321796071, + "pooledRetentionAt10": 0.6437941473259334 + } + }, + { + "budgetTokens": 32000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.38734720867396777, + "retentionAt10": 0.7630094420606726, + "evictionPrecision": 0.9481324810379269, + "tokensEvicted": 76253.64242424243, + "evictionEvents": 61.412121212121214, + "saturatedEvents": 1, + "turnsToFirstSummary": 44.05696202531646, + "turnsToFirstSummaryCount": 158 + }, + "pooledRetention": 0.3086997193638915, + "pooledRetentionAt10": 0.6135216952573158 + } + }, + { + "budgetTokens": 32000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.3885318187107285, + "retentionAt10": 0.7501152403028344, + "evictionPrecision": 0.9485123933274446, + "tokensEvicted": 76032.92121212122, + "evictionEvents": 62.339393939393936, + "saturatedEvents": 0.9452654092941862, + "turnsToFirstSummary": 43.15822784810127, + "turnsToFirstSummaryCount": 158 + }, + "pooledRetention": 0.3086997193638915, + "pooledRetentionAt10": 0.6044399596367306 + } + }, + { + "budgetTokens": 32000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 67216.38181818182, + "evictionEvents": 54.806060606060605, + "saturatedEvents": 0.9773305319031295, + "turnsToFirstSummary": 36.537974683544306, + "turnsToFirstSummaryCount": 158 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 38.84615384615385, + "turnsToFirstSummaryCount": 156 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5458401662289053, + "retentionAt10": 0.8690588780573669, + "evictionPrecision": 0.9544114724418871, + "tokensEvicted": 65668.67878787879, + "evictionEvents": 39.45454545454545, + "saturatedEvents": 0.9509984639016897, + "turnsToFirstSummary": 82.80281690140845, + "turnsToFirstSummaryCount": 142 + }, + "pooledRetention": 0.4644527595884004, + "pooledRetentionAt10": 0.7699293642785066 + } + }, + { + "budgetTokens": 64000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5290339282445672, + "retentionAt10": 0.8798954909394343, + "evictionPrecision": 0.9595138223923505, + "tokensEvicted": 74726.32121212121, + "evictionEvents": 44.224242424242426, + "saturatedEvents": 1, + "turnsToFirstSummary": 88.66428571428571, + "turnsToFirstSummaryCount": 140 + }, + "pooledRetention": 0.44200187090739007, + "pooledRetentionAt10": 0.7729566094853683 + } + }, + { + "budgetTokens": 64000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5237889755623402, + "retentionAt10": 0.8612024561890871, + "evictionPrecision": 0.9589541485392247, + "tokensEvicted": 74391.9696969697, + "evictionEvents": 46.557575757575755, + "saturatedEvents": 0.911481385055975, + "turnsToFirstSummary": 83.0354609929078, + "turnsToFirstSummaryCount": 141 + }, + "pooledRetention": 0.44480823199251635, + "pooledRetentionAt10": 0.7507568113017155 + } + }, + { + "budgetTokens": 64000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 65699.73333333334, + "evictionEvents": 40.13939393939394, + "saturatedEvents": 0.9637626453268912, + "turnsToFirstSummary": 76.73239436619718, + "turnsToFirstSummaryCount": 142 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 89.33576642335767, + "turnsToFirstSummaryCount": 137 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7810314888232291, + "retentionAt10": 0.9590172833831285, + "evictionPrecision": 0.9787066458508404, + "tokensEvicted": 58993.32121212121, + "evictionEvents": 18.048484848484847, + "saturatedEvents": 0.9335124244459369, + "turnsToFirstSummary": 162.46511627906978, + "turnsToFirstSummaryCount": 86 + }, + "pooledRetention": 0.7174929840972872, + "pooledRetentionAt10": 0.9283551967709385 + } + }, + { + "budgetTokens": 128000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7589941116539988, + "retentionAt10": 0.9596122128149566, + "evictionPrecision": 0.9798995775797211, + "tokensEvicted": 68054.01212121212, + "evictionEvents": 18.89090909090909, + "saturatedEvents": 1, + "turnsToFirstSummary": 175.9375, + "turnsToFirstSummaryCount": 80 + }, + "pooledRetention": 0.7137511693171188, + "pooledRetentionAt10": 0.9334006054490414 + } + }, + { + "budgetTokens": 128000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7870428546660476, + "retentionAt10": 0.9581048194359052, + "evictionPrecision": 0.9771884835906713, + "tokensEvicted": 65938.32121212121, + "evictionEvents": 21.01212121212121, + "saturatedEvents": 0.8491491202768965, + "turnsToFirstSummary": 168.5487804878049, + "turnsToFirstSummaryCount": 82 + }, + "pooledRetention": 0.7104770813844715, + "pooledRetentionAt10": 0.9243188698284561 + } + }, + { + "budgetTokens": 128000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 59018.721212121214, + "evictionEvents": 18.587878787878786, + "saturatedEvents": 0.9439191392239974, + "turnsToFirstSummary": 156.75581395348837, + "turnsToFirstSummaryCount": 86 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + } + ] } diff --git a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json index 0dc385edb..2a374bb39 100644 --- a/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json +++ b/benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json @@ -1,351 +1,341 @@ { - "schema": "clio-context-replay-v1", - "config": { - "policies": [ - "none", - "random", - "age-horizon", - "structural-v1", - "oracle" - ], - "budgets": [ - 32000, - 64000, - 128000 - ], - "threshold": 0.8, - "target": 0.6, - "seed": 0, - "format": "auto", - "filter": "default", - "settings": { - "enabled": true, - "policy": "structural-v1", - "target": 0.6, - "protectLastTurns": 6, - "minEvictableTokens": 200 - } - }, - "provenance": { - "gitSha": "ca3f49b6ef14a27e4b67dfe6c7b060246e9be414", - "commandLine": [ - "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", - "--import", - "tsx", - "/home/akougkas/iowarp/clio-coder/src/cli/index.ts", - "context", - "replay", - "--sessions", - "/home/akougkas/.claude/projects/-home-akougkas-iowarp-clio-coder", - "--policies", - "none,random,age-horizon,structural-v1,oracle", - "--budgets", - "32000,64000,128000", - "--protect-last-turns", - "6", - "--md", - "benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md", - "--json", - "benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json" - ] - }, - "cascade": { - "found": 303, - "unreadable": 2, - "filtered": { - "no_file_reread": 102, - "sidechain_or_subagent": 17, - "summary_only": 0, - "tool_results_lt_8": 3, - "turns_lt_8": 14 - }, - "kept": 165 - }, - "results": [ - { - "budgetTokens": 32000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "turnsToFirstSummary": 18.890243902439025, - "turnsToFirstSummaryCount": 164 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 32000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.48484498875354787, - "retentionAt10": 0.9245789700764979, - "evictionPrecision": 0.947321719905957, - "tokensEvicted": 66522.63030303031, - "evictionEvents": 56.72121212121212, - "saturatedEvents": 0.979378138690031, - "turnsToFirstSummary": 33.59627329192546, - "turnsToFirstSummaryCount": 161 - }, - "pooledRetention": 0.43405051449953225, - "pooledRetentionAt10": 0.8748738647830474 - } - }, - { - "budgetTokens": 32000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.47860094757297494, - "retentionAt10": 0.9202608227798817, - "evictionPrecision": 0.9526857053976399, - "tokensEvicted": 75283.95757575758, - "evictionEvents": 64.92121212121212, - "saturatedEvents": 1, - "turnsToFirstSummary": 34.50625, - "turnsToFirstSummaryCount": 160 - }, - "pooledRetention": 0.4260991580916745, - "pooledRetentionAt10": 0.863773965691221 - } - }, - { - "budgetTokens": 32000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.48034138908908536, - "retentionAt10": 0.9161332690470648, - "evictionPrecision": 0.9532525845958315, - "tokensEvicted": 75073.7696969697, - "evictionEvents": 65.53333333333333, - "saturatedEvents": 0.9630074909830759, - "turnsToFirstSummary": 33.69375, - "turnsToFirstSummaryCount": 160 - }, - "pooledRetention": 0.4256314312441534, - "pooledRetentionAt10": 0.8668012108980827 - } - }, - { - "budgetTokens": 32000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 66506.84242424242, - "evictionEvents": 58.1030303030303, - "saturatedEvents": 0.9885261291332013, - "turnsToFirstSummary": 30.1055900621118, - "turnsToFirstSummaryCount": 161 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 64000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "turnsToFirstSummary": 38.84615384615385, - "turnsToFirstSummaryCount": 156 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 64000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.6031162930617947, - "retentionAt10": 0.9614498990766137, - "evictionPrecision": 0.9598482899583711, - "tokensEvicted": 65192.92727272727, - "evictionEvents": 40.448484848484846, - "saturatedEvents": 0.9574468085106383, - "turnsToFirstSummary": 78.86111111111111, - "turnsToFirstSummaryCount": 144 - }, - "pooledRetention": 0.5481758652946679, - "pooledRetentionAt10": 0.9313824419778002 - } - }, - { - "budgetTokens": 64000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.5818565083140609, - "retentionAt10": 0.9603327136708908, - "evictionPrecision": 0.9619785119963982, - "tokensEvicted": 73872.11515151516, - "evictionEvents": 45.557575757575755, - "saturatedEvents": 1, - "turnsToFirstSummary": 83.43661971830986, - "turnsToFirstSummaryCount": 142 - }, - "pooledRetention": 0.5219831618334893, - "pooledRetentionAt10": 0.9283551967709385 - } - }, - { - "budgetTokens": 64000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.5896915833607682, - "retentionAt10": 0.9524678710036767, - "evictionPrecision": 0.9618101484947162, - "tokensEvicted": 73522.18787878788, - "evictionEvents": 47.53939393939394, - "saturatedEvents": 0.9217236104028557, - "turnsToFirstSummary": 79.26573426573427, - "turnsToFirstSummaryCount": 143 - }, - "pooledRetention": 0.5261927034611786, - "pooledRetentionAt10": 0.9243188698284561 - } - }, - { - "budgetTokens": 64000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 65207.30303030303, - "evictionEvents": 41.39393939393939, - "saturatedEvents": 0.9724743777452416, - "turnsToFirstSummary": 72.56551724137931, - "turnsToFirstSummaryCount": 145 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 128000, - "policyId": "none", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 0, - "evictionEvents": 0, - "saturatedEvents": 0, - "turnsToFirstSummary": 89.33576642335767, - "turnsToFirstSummaryCount": 137 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - }, - { - "budgetTokens": 128000, - "policyId": "random", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.7984476960436251, - "retentionAt10": 0.9955901421810512, - "evictionPrecision": 0.9797552622041499, - "tokensEvicted": 58484.69090909091, - "evictionEvents": 18.454545454545453, - "saturatedEvents": 0.9376026272577996, - "turnsToFirstSummary": 161, - "turnsToFirstSummaryCount": 88 - }, - "pooledRetention": 0.7530402245088869, - "pooledRetentionAt10": 0.9858728557013118 - } - }, - { - "budgetTokens": 128000, - "policyId": "age-horizon", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.7877046085525273, - "retentionAt10": 0.9964447505356595, - "evictionPrecision": 0.9816552198334042, - "tokensEvicted": 67286.4909090909, - "evictionEvents": 19.412121212121214, - "saturatedEvents": 1, - "turnsToFirstSummary": 173.96296296296296, - "turnsToFirstSummaryCount": 81 - }, - "pooledRetention": 0.744621141253508, - "pooledRetentionAt10": 0.987891019172553 - } - }, - { - "budgetTokens": 128000, - "policyId": "structural-v1", - "metrics": { - "mean": { - "traces": 165, - "retention": 0.8117532769721052, - "retentionAt10": 0.9955524946434037, - "evictionPrecision": 0.9791781930037826, - "tokensEvicted": 65404.242424242424, - "evictionEvents": 21.484848484848484, - "saturatedEvents": 0.8558533145275036, - "turnsToFirstSummary": 167.91764705882352, - "turnsToFirstSummaryCount": 85 - }, - "pooledRetention": 0.7408793264733395, - "pooledRetentionAt10": 0.9858728557013118 - } - }, - { - "budgetTokens": 128000, - "policyId": "oracle", - "metrics": { - "mean": { - "traces": 165, - "retention": 1, - "retentionAt10": 1, - "evictionPrecision": 1, - "tokensEvicted": 58524.242424242424, - "evictionEvents": 19.296969696969697, - "saturatedEvents": 0.9494346733668342, - "turnsToFirstSummary": 155.72727272727272, - "turnsToFirstSummaryCount": 88 - }, - "pooledRetention": 1, - "pooledRetentionAt10": 1 - } - } - ] + "schema": "clio-context-replay-v1", + "config": { + "policies": ["none", "random", "age-horizon", "structural-v1", "oracle"], + "budgets": [32000, 64000, 128000], + "threshold": 0.8, + "target": 0.6, + "seed": 0, + "format": "auto", + "filter": "default", + "settings": { + "enabled": true, + "policy": "structural-v1", + "target": 0.6, + "protectLastTurns": 6, + "minEvictableTokens": 200 + } + }, + "provenance": { + "gitSha": "ca3f49b6ef14a27e4b67dfe6c7b060246e9be414", + "commandLine": [ + "/home/akougkas/.nvm/versions/node/v24.9.0/bin/node", + "--import", + "tsx", + "/home/akougkas/iowarp/clio-coder/src/cli/index.ts", + "context", + "replay", + "--sessions", + "/home/akougkas/.claude/projects/-home-akougkas-iowarp-clio-coder", + "--policies", + "none,random,age-horizon,structural-v1,oracle", + "--budgets", + "32000,64000,128000", + "--protect-last-turns", + "6", + "--md", + "benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.md", + "--json", + "benchmarks/results/context-replay/claude-code-2026-08-21-protect-6.json" + ] + }, + "cascade": { + "found": 303, + "unreadable": 2, + "filtered": { + "no_file_reread": 102, + "sidechain_or_subagent": 17, + "summary_only": 0, + "tool_results_lt_8": 3, + "turns_lt_8": 14 + }, + "kept": 165 + }, + "results": [ + { + "budgetTokens": 32000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 18.890243902439025, + "turnsToFirstSummaryCount": 164 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 32000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.48484498875354787, + "retentionAt10": 0.9245789700764979, + "evictionPrecision": 0.947321719905957, + "tokensEvicted": 66522.63030303031, + "evictionEvents": 56.72121212121212, + "saturatedEvents": 0.979378138690031, + "turnsToFirstSummary": 33.59627329192546, + "turnsToFirstSummaryCount": 161 + }, + "pooledRetention": 0.43405051449953225, + "pooledRetentionAt10": 0.8748738647830474 + } + }, + { + "budgetTokens": 32000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.47860094757297494, + "retentionAt10": 0.9202608227798817, + "evictionPrecision": 0.9526857053976399, + "tokensEvicted": 75283.95757575758, + "evictionEvents": 64.92121212121212, + "saturatedEvents": 1, + "turnsToFirstSummary": 34.50625, + "turnsToFirstSummaryCount": 160 + }, + "pooledRetention": 0.4260991580916745, + "pooledRetentionAt10": 0.863773965691221 + } + }, + { + "budgetTokens": 32000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.48034138908908536, + "retentionAt10": 0.9161332690470648, + "evictionPrecision": 0.9532525845958315, + "tokensEvicted": 75073.7696969697, + "evictionEvents": 65.53333333333333, + "saturatedEvents": 0.9630074909830759, + "turnsToFirstSummary": 33.69375, + "turnsToFirstSummaryCount": 160 + }, + "pooledRetention": 0.4256314312441534, + "pooledRetentionAt10": 0.8668012108980827 + } + }, + { + "budgetTokens": 32000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 66506.84242424242, + "evictionEvents": 58.1030303030303, + "saturatedEvents": 0.9885261291332013, + "turnsToFirstSummary": 30.1055900621118, + "turnsToFirstSummaryCount": 161 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 38.84615384615385, + "turnsToFirstSummaryCount": 156 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 64000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.6031162930617947, + "retentionAt10": 0.9614498990766137, + "evictionPrecision": 0.9598482899583711, + "tokensEvicted": 65192.92727272727, + "evictionEvents": 40.448484848484846, + "saturatedEvents": 0.9574468085106383, + "turnsToFirstSummary": 78.86111111111111, + "turnsToFirstSummaryCount": 144 + }, + "pooledRetention": 0.5481758652946679, + "pooledRetentionAt10": 0.9313824419778002 + } + }, + { + "budgetTokens": 64000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5818565083140609, + "retentionAt10": 0.9603327136708908, + "evictionPrecision": 0.9619785119963982, + "tokensEvicted": 73872.11515151516, + "evictionEvents": 45.557575757575755, + "saturatedEvents": 1, + "turnsToFirstSummary": 83.43661971830986, + "turnsToFirstSummaryCount": 142 + }, + "pooledRetention": 0.5219831618334893, + "pooledRetentionAt10": 0.9283551967709385 + } + }, + { + "budgetTokens": 64000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.5896915833607682, + "retentionAt10": 0.9524678710036767, + "evictionPrecision": 0.9618101484947162, + "tokensEvicted": 73522.18787878788, + "evictionEvents": 47.53939393939394, + "saturatedEvents": 0.9217236104028557, + "turnsToFirstSummary": 79.26573426573427, + "turnsToFirstSummaryCount": 143 + }, + "pooledRetention": 0.5261927034611786, + "pooledRetentionAt10": 0.9243188698284561 + } + }, + { + "budgetTokens": 64000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 65207.30303030303, + "evictionEvents": 41.39393939393939, + "saturatedEvents": 0.9724743777452416, + "turnsToFirstSummary": 72.56551724137931, + "turnsToFirstSummaryCount": 145 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "none", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 0, + "evictionEvents": 0, + "saturatedEvents": 0, + "turnsToFirstSummary": 89.33576642335767, + "turnsToFirstSummaryCount": 137 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + }, + { + "budgetTokens": 128000, + "policyId": "random", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7984476960436251, + "retentionAt10": 0.9955901421810512, + "evictionPrecision": 0.9797552622041499, + "tokensEvicted": 58484.69090909091, + "evictionEvents": 18.454545454545453, + "saturatedEvents": 0.9376026272577996, + "turnsToFirstSummary": 161, + "turnsToFirstSummaryCount": 88 + }, + "pooledRetention": 0.7530402245088869, + "pooledRetentionAt10": 0.9858728557013118 + } + }, + { + "budgetTokens": 128000, + "policyId": "age-horizon", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.7877046085525273, + "retentionAt10": 0.9964447505356595, + "evictionPrecision": 0.9816552198334042, + "tokensEvicted": 67286.4909090909, + "evictionEvents": 19.412121212121214, + "saturatedEvents": 1, + "turnsToFirstSummary": 173.96296296296296, + "turnsToFirstSummaryCount": 81 + }, + "pooledRetention": 0.744621141253508, + "pooledRetentionAt10": 0.987891019172553 + } + }, + { + "budgetTokens": 128000, + "policyId": "structural-v1", + "metrics": { + "mean": { + "traces": 165, + "retention": 0.8117532769721052, + "retentionAt10": 0.9955524946434037, + "evictionPrecision": 0.9791781930037826, + "tokensEvicted": 65404.242424242424, + "evictionEvents": 21.484848484848484, + "saturatedEvents": 0.8558533145275036, + "turnsToFirstSummary": 167.91764705882352, + "turnsToFirstSummaryCount": 85 + }, + "pooledRetention": 0.7408793264733395, + "pooledRetentionAt10": 0.9858728557013118 + } + }, + { + "budgetTokens": 128000, + "policyId": "oracle", + "metrics": { + "mean": { + "traces": 165, + "retention": 1, + "retentionAt10": 1, + "evictionPrecision": 1, + "tokensEvicted": 58524.242424242424, + "evictionEvents": 19.296969696969697, + "saturatedEvents": 0.9494346733668342, + "turnsToFirstSummary": 155.72727272727272, + "turnsToFirstSummaryCount": 88 + }, + "pooledRetention": 1, + "pooledRetentionAt10": 1 + } + } + ] } diff --git a/src/domains/context/working-set/replay/report.ts b/src/domains/context/working-set/replay/report.ts index 7141bac04..d20c5f259 100644 --- a/src/domains/context/working-set/replay/report.ts +++ b/src/domains/context/working-set/replay/report.ts @@ -81,7 +81,7 @@ export function renderReplayJson(input: ReplayReportInput): string { }, })), }; - return `${JSON.stringify(artifact, null, 2)}\n`; + return `${JSON.stringify(artifact, null, "\t")}\n`; } function ratio(value: number): string {