pi-condense 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/PRUNING.md +111 -23
  3. package/README.md +6 -1
  4. package/index.ts +23 -13
  5. package/package.json +1 -1
  6. package/src/batch-capture.test.ts +75 -1
  7. package/src/batch-capture.ts +22 -13
  8. package/src/chain-compressor.test.ts +114 -0
  9. package/src/chain-compressor.ts +29 -4
  10. package/src/chain-detector.test.ts +49 -0
  11. package/src/chain-detector.ts +7 -0
  12. package/src/chain-range-prune.test.ts +342 -7
  13. package/src/chain-range-prune.ts +161 -48
  14. package/src/commands.test.ts +31 -2
  15. package/src/commands.ts +25 -10
  16. package/src/diagnostics.test.ts +114 -0
  17. package/src/diagnostics.ts +46 -0
  18. package/src/frontier.test.ts +1 -0
  19. package/src/id-collision.integration.test.ts +251 -0
  20. package/src/indexer.test.ts +336 -0
  21. package/src/indexer.ts +168 -55
  22. package/src/occurrence-key.test.ts +57 -0
  23. package/src/occurrence-key.ts +36 -0
  24. package/src/orphan-sweep.test.ts +67 -0
  25. package/src/orphan-sweep.ts +40 -0
  26. package/src/oversized-spill.integration.test.ts +7 -2
  27. package/src/pruner.test.ts +456 -25
  28. package/src/pruner.ts +84 -36
  29. package/src/query-tool.test.ts +117 -0
  30. package/src/query-tool.ts +47 -31
  31. package/src/range-compression.integration.test.ts +6 -1
  32. package/src/recovery-grace.test.ts +13 -0
  33. package/src/recovery-grace.ts +12 -3
  34. package/src/spill.test.ts +108 -1
  35. package/src/spill.ts +5 -3
  36. package/src/summary-refs.test.ts +51 -1
  37. package/src/summary-refs.ts +15 -4
  38. package/src/test-support.ts +54 -0
  39. package/src/tree-browser.ts +2 -1
  40. package/src/types.ts +56 -10
package/src/pruner.ts CHANGED
@@ -1,9 +1,13 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { ToolCallIndexer } from "./indexer.js";
2
3
  import type { ChainCompressionConfig, ErrorPurgeConfig } from "./types.js";
3
4
  import { isProtected, type ProtectionConfig } from "./protected.js";
4
5
  import { applyChainCompressions } from "./chain-range-prune.js";
5
6
  import { purgeErroredArgs } from "./error-purge.js";
6
7
  import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
8
+ import { occKey } from "./occurrence-key.js";
9
+ import { sweepOrphanToolResults } from "./orphan-sweep.js";
10
+ import type { DiagnosticSink } from "./diagnostics.js";
7
11
 
8
12
  /**
9
13
  * Estimate of a message array's context weight. Serializing the whole array
@@ -16,7 +20,7 @@ export function sizeMessages(messages: any[]): number {
16
20
  }
17
21
 
18
22
  /**
19
- * Transforms the `context` event message array in three phases:
23
+ * Transforms the `context` event message array in four phases:
20
24
  *
21
25
  * Phase 1 — stub-replace: ToolResultMessages for summarized tool calls are
22
26
  * replaced with short stubs pointing the model at `context_tree_query`.
@@ -42,6 +46,12 @@ export function sizeMessages(messages: any[]): number {
42
46
  * synthetic user message wrapping the existing per-batch summary text.
43
47
  * Only runs when `chainCompression.enabled` and chain entries exist.
44
48
  *
49
+ * Phase 4 — orphan sweep: structural post-condition run unconditionally over
50
+ * the final array. Removes any toolResult whose matching toolCall id was not
51
+ * opened by the immediately preceding assistant turn (per-turn open set, not
52
+ * cumulative — see src/orphan-sweep.ts). Reference-preserving when nothing is
53
+ * swept, so a clean render still returns the identical input array.
54
+ *
45
55
  * Return shape:
46
56
  * - `pruned: true` — at least one change happened; the returned
47
57
  * `messages` is a freshly allocated array.
@@ -66,46 +76,61 @@ export function pruneMessages(
66
76
  errorPurge?: ErrorPurgeConfig,
67
77
  protection?: ProtectionConfig,
68
78
  recoveryGraceTurns: number = 0,
79
+ diagnostics?: DiagnosticSink,
69
80
  ): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
70
81
  // Phase 1: stub-replace summarized tool results
71
82
  let pruned = false;
72
83
  const inGrace = inGraceRecoveryToolCallIds(messages, recoveryGraceTurns);
73
84
  const next = messages.map((msg) => {
74
- if (msg.role === "toolResult" && indexer.isSummarized(msg.toolCallId)) {
75
- const record = indexer.getRecord(msg.toolCallId);
76
- // Render-time re-check: a record summarized before protectedPaths
77
- // covered it is repaired here the raw toolResult still lives in the
78
- // session JSONL, so skipping the stub restores it verbatim.
79
- // Dedup aliases resolve to the original record, so an alias whose own
80
- // path is protected but whose original isn't stays stubbed (edge case).
81
- if (protection && record && isProtected(record.toolName, record.args, protection)) {
82
- return msg;
83
- }
84
- if (inGrace.has(msg.toolCallId)) {
85
- return msg;
86
- }
87
- pruned = true;
88
- const ref = indexer.getShortRefForToolCallId(msg.toolCallId) ?? msg.toolCallId;
89
- const text = record?.spillPath
90
- ? [
91
- `[Oversized output spilled to file — ${record.spillBytes ?? "?"} bytes.]`,
92
- `Tool: ${record.toolName}`,
93
- `Preview (head):`,
94
- record.resultPreview ?? "",
95
- `Full output read this file (offset/limit supported): ${record.spillPath}`,
96
- `Or use context_tree_query with ref \`${ref}\`.`,
97
- ].join("\n")
98
- : `[Summarized in pruner summary, ref \`${ref}\`. Use context_tree_query to retrieve full output.]`;
99
- return {
100
- role: "toolResult",
101
- toolCallId: msg.toolCallId,
102
- toolName: msg.toolName,
103
- content: [{ type: "text", text }],
104
- isError: false,
105
- timestamp: msg.timestamp,
106
- };
85
+ if (msg.role !== "toolResult") return msg;
86
+
87
+ // Fail-closed: when the message carries a timestamp, the occurrence key
88
+ // is tried first. The bare id is consulted only as a fallback, and only
89
+ // when `hasLegacyBareRecord` confirms it is LEGACY-ONLY (no occurrence
90
+ // siblings) - a mixed bare+occurrence id fails closed there too, since a
91
+ // live result under a reused id is not the legacy one. A permissive
92
+ // bare-id fallback would stub a live result because an older occurrence
93
+ // (or the legacy record) of the same provider id was summarized.
94
+ const key = typeof msg.timestamp === "number" ? occKey(msg.toolCallId, msg.timestamp) : msg.toolCallId;
95
+ const lookupKey = indexer.isSummarized(key)
96
+ ? key
97
+ : indexer.hasLegacyBareRecord(msg.toolCallId)
98
+ ? msg.toolCallId
99
+ : undefined;
100
+ if (lookupKey === undefined) return msg;
101
+
102
+ const record = indexer.getRecord(lookupKey);
103
+ // Render-time re-check: a record summarized before protectedPaths
104
+ // covered it is repaired here — the raw toolResult still lives in the
105
+ // session JSONL, so skipping the stub restores it verbatim.
106
+ // Dedup aliases resolve to the original record, so an alias whose own
107
+ // path is protected but whose original isn't stays stubbed (edge case).
108
+ if (protection && record && isProtected(record.toolName, record.args, protection)) {
109
+ return msg;
110
+ }
111
+ if (inGrace.has(key)) {
112
+ return msg;
107
113
  }
108
- return msg;
114
+ pruned = true;
115
+ const ref = indexer.getShortRefForToolCallId(lookupKey) ?? msg.toolCallId;
116
+ const text = record?.spillPath
117
+ ? [
118
+ `[Oversized output spilled to file — ${record.spillBytes ?? "?"} bytes.]`,
119
+ `Tool: ${record.toolName}`,
120
+ `Preview (head):`,
121
+ record.resultPreview ?? "",
122
+ `Full output — read this file (offset/limit supported): ${record.spillPath}`,
123
+ `Or use context_tree_query with ref \`${ref}\`.`,
124
+ ].join("\n")
125
+ : `[Summarized in pruner summary, ref \`${ref}\`. Use context_tree_query to retrieve full output.]`;
126
+ return {
127
+ role: "toolResult",
128
+ toolCallId: msg.toolCallId,
129
+ toolName: msg.toolName,
130
+ content: [{ type: "text", text }],
131
+ isError: false,
132
+ timestamp: msg.timestamp,
133
+ };
109
134
  });
110
135
 
111
136
  let current: any[] = pruned ? next : messages;
@@ -126,7 +151,8 @@ export function pruneMessages(
126
151
  // Prefer the cohesive LLM range summary (B) when present; fall back to the
127
152
  // per-batch concatenation for spans compressed before fusion / on failure.
128
153
  const chainSummaryText = (entry: typeof chainEntries[number]): string =>
129
- entry.rangeSummaryText ?? indexer.getPerBatchSummaryTextForToolCallIds(entry.droppedToolCallIds);
154
+ entry.rangeSummaryText ??
155
+ indexer.getPerBatchSummaryTextForToolCallIds(entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds);
130
156
  const blockSummaryLookup = (blockId: string): string | undefined => {
131
157
  const entry = indexer.findChainEntryByBlockId(blockId);
132
158
  if (!entry) return undefined;
@@ -138,6 +164,7 @@ export function pruneMessages(
138
164
  chainSummaryText,
139
165
  chainCompression.stripFinalAssistantThinking,
140
166
  blockSummaryLookup,
167
+ diagnostics,
141
168
  );
142
169
  if (compressed !== current) {
143
170
  current = compressed;
@@ -146,6 +173,27 @@ export function pruneMessages(
146
173
  }
147
174
  }
148
175
 
176
+ // Phase 4: orphan sweep — structural post-condition. Reference-preserving
177
+ // when clean, so a no-op render leaves the prompt-cache prefix untouched.
178
+ const swept = sweepOrphanToolResults(current);
179
+ if (swept.messages !== current) {
180
+ current = swept.messages;
181
+ pruned = true;
182
+ const sortedIds = [...swept.sweptIds].sort();
183
+ // Hash the id list into a short, stable dedup key instead of the raw
184
+ // sorted join: a growing orphan set would otherwise write ever-longer
185
+ // keys ("a", "a,b", "a,b,c", ...), and DiagnosticSink.seen retains every
186
+ // prefix forever - O(n^2) characters over a session's lifetime.
187
+ const dedupKey = createHash("sha1").update(sortedIds.join(",")).digest("hex").slice(0, 16);
188
+ const shown = sortedIds.slice(0, 5);
189
+ const more = sortedIds.length > shown.length ? ` ... +${sortedIds.length - shown.length} more` : "";
190
+ diagnostics?.report(
191
+ "orphan-sweep",
192
+ dedupKey,
193
+ `swept ${swept.sweptIds.length} orphan toolResult(s): ${shown.join(", ")}${more}`,
194
+ );
195
+ }
196
+
149
197
  return pruned
150
198
  ? { messages: current, pruned, beforeChars: sizeMessages(messages), afterChars: sizeMessages(current) }
151
199
  : { messages, pruned, beforeChars: 0, afterChars: 0 };
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { registerQueryTool } from "./query-tool.js";
3
+ import { ToolCallIndexer } from "./indexer.js";
4
+ import type { CapturedBatch } from "./types.js";
5
+
6
+ const capture = (idx: ToolCallIndexer, id: string, ts: number, text: string, turnIndex: number) => {
7
+ const batch: CapturedBatch = {
8
+ turnIndex,
9
+ timestamp: ts - 50,
10
+ assistantText: "",
11
+ toolCalls: [{ toolCallId: id, toolName: "bash", args: { cmd: "ls" }, resultText: text, isError: false, resultTimestamp: ts }],
12
+ };
13
+ idx.addBatch(batch, () => {});
14
+ };
15
+
16
+ const captureLegacy = (idx: ToolCallIndexer, id: string, timestamp: number, text: string, turnIndex: number) => {
17
+ const batch: CapturedBatch = {
18
+ turnIndex,
19
+ timestamp,
20
+ assistantText: "",
21
+ toolCalls: [{ toolCallId: id, toolName: "bash", args: { cmd: "ls" }, resultText: text, isError: false }],
22
+ };
23
+ idx.addBatch(batch, () => {});
24
+ };
25
+
26
+ // execute returns { content: [{ type: "text", text }], details } (src/query-tool.ts:73-76)
27
+ const runTool = async (indexer: ToolCallIndexer, toolCallIds: string[]): Promise<string> => {
28
+ let registered: any;
29
+ registerQueryTool({ registerTool: (def: any) => (registered = def) } as any, indexer);
30
+ const result = await registered.execute("call-1", { toolCallIds }, undefined, undefined, undefined);
31
+ return result.content[0].text as string;
32
+ };
33
+
34
+ describe("context_tree_query occurrence handling", () => {
35
+ test("a bare id with two occurrences returns both blocks, chronologically", async () => {
36
+ const idx = new ToolCallIndexer();
37
+ capture(idx, "bash_23", 2150, "SECOND", 1);
38
+ capture(idx, "bash_23", 1150, "FIRST", 0);
39
+ const text = await runTool(idx, ["bash_23"]);
40
+ expect(text.indexOf("FIRST")).toBeGreaterThan(-1);
41
+ expect(text.indexOf("SECOND")).toBeGreaterThan(-1);
42
+ expect(text.indexOf("FIRST")).toBeLessThan(text.indexOf("SECOND"));
43
+ expect(text.match(/## toolRef:/g)).toHaveLength(2);
44
+ });
45
+
46
+ test("an occurrence key returns exactly one block", async () => {
47
+ const idx = new ToolCallIndexer();
48
+ capture(idx, "bash_23", 1150, "FIRST", 0);
49
+ capture(idx, "bash_23", 2150, "SECOND", 1);
50
+ const text = await runTool(idx, ["bash_23@2150"]);
51
+ expect(text.match(/## toolRef:/g)).toHaveLength(1);
52
+ expect(text).toContain("SECOND");
53
+ });
54
+
55
+ test("an unknown id still reports not-found once", async () => {
56
+ const idx = new ToolCallIndexer();
57
+ const text = await runTool(idx, ["nope"]);
58
+ expect(text).toContain("not found in index");
59
+ });
60
+
61
+ test("a single match still labels with the caller's input and has no @ suffix", async () => {
62
+ const idx = new ToolCallIndexer();
63
+ capture(idx, "bash_23", 1150, "FIRST", 0);
64
+ const text = await runTool(idx, ["bash_23"]);
65
+ expect(text.match(/## toolRef:/g)).toHaveLength(1);
66
+ expect(text).toContain("## toolRef: bash_23\n");
67
+ expect(text).not.toContain("bash_23@");
68
+ });
69
+
70
+ test("G5 conformance: a bare id whose collision was content-deduplicated still returns both occurrences via context_tree_query", async () => {
71
+ const idx = new ToolCallIndexer();
72
+ capture(idx, "bash_23", 1150, "SAME", 0);
73
+ // Same bare id, later occurrence, content-deduplicated against the original.
74
+ idx.registerDuplicate("bash_23@9150", "bash_23@1150", () => {});
75
+
76
+ const records = idx.getRecordsForId("bash_23");
77
+ expect(records).toHaveLength(2);
78
+
79
+ const text = await runTool(idx, ["bash_23"]);
80
+ expect(text.match(/## toolRef:/g)).toHaveLength(2);
81
+ expect(text).toContain("bash_23@1150");
82
+ expect(text).toContain("bash_23@9150");
83
+ });
84
+
85
+ test("G4/C1: colliding short refs each resolve to their OWN batch through the registered tool", async () => {
86
+ const idx = new ToolCallIndexer();
87
+ capture(idx, "bash_23", 1150, "FIRST", 0);
88
+ capture(idx, "bash_23", 2150, "SECOND", 1);
89
+ idx.registerSummaryRefs([
90
+ { shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 },
91
+ { shortId: "t2", toolCallId: "bash_23", resultTimestamp: 2150 },
92
+ ]);
93
+
94
+ const firstText = await runTool(idx, ["t1"]);
95
+ expect(firstText.match(/## toolRef:/g)).toHaveLength(1);
96
+ expect(firstText).toContain("FIRST");
97
+ expect(firstText).not.toContain("SECOND");
98
+
99
+ const secondText = await runTool(idx, ["t2"]);
100
+ expect(secondText.match(/## toolRef:/g)).toHaveLength(1);
101
+ expect(secondText).toContain("SECOND");
102
+ expect(secondText).not.toContain("FIRST");
103
+ });
104
+
105
+ test("mixed legacy + new occurrence under the same bare id: legacy record labels with the bare id plus an explicit note, not a fake @legacy key", async () => {
106
+ const idx = new ToolCallIndexer();
107
+ captureLegacy(idx, "bash_23", 1000, "OLD", 0);
108
+ capture(idx, "bash_23", 2150, "NEW", 1);
109
+ const text = await runTool(idx, ["bash_23"]);
110
+ expect(text.match(/## toolRef:/g)).toHaveLength(2);
111
+ expect(text).toContain("## toolRef: bash_23\n");
112
+ expect(text).toContain("Occurrence: legacy (no resultTimestamp)");
113
+ expect(text).toContain("bash_23@2150");
114
+ expect(text).not.toContain("undefined");
115
+ expect(text).not.toContain("@legacy");
116
+ });
117
+ });
package/src/query-tool.ts CHANGED
@@ -5,19 +5,28 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import type { ToolCallIndexer } from "./indexer.js";
6
6
  import { QUERY_TOOL_NAME } from "./types.js";
7
7
 
8
+ // Legacy records (persisted before resultTimestamp existed) have no timestamp
9
+ // to disambiguate. Do NOT mint an `id@legacy` string: it is occurrence-key-
10
+ // SHAPED but not a real key — parseOccKey would treat "legacy" as part of a
11
+ // literal id, so echoing it back into context_tree_query would fail lookup.
12
+ // Label with the bare id and call out the ambiguity in the block body instead.
13
+ function occurrenceLabel(id: string, resultTimestamp: number | undefined): string {
14
+ return resultTimestamp === undefined ? id : `${id}@${resultTimestamp}`;
15
+ }
16
+
8
17
  export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): void {
9
18
  pi.registerTool({
10
19
  name: QUERY_TOOL_NAME,
11
20
  label: "Query Original Tool History",
12
21
  description:
13
- "Retrieve original tool call results that have been pruned from active context. Pass the short refs listed in a pruner-summary message, e.g. context_tree_query({ toolCallIds: [\"t12\", \"t3\"] }), to get back the full original outputs.",
22
+ "Retrieve original tool call results that have been pruned from active context. Pass the short refs listed in a pruner-summary message, e.g. context_tree_query({ toolCallIds: [\"t12\", \"t3\"] }), to get back the full original outputs. A raw id that was reused returns every occurrence, each labelled id@timestamp.",
14
23
  promptSnippet: "Retrieve original pruned tool outputs by short ref",
15
24
  promptGuidelines: [
16
25
  "When you need the full output of a tool call that was summarized and pruned from context, use context_tree_query with the short refs listed in the relevant pruner-summary message.",
17
26
  ],
18
27
  parameters: Type.Object({
19
28
  toolCallIds: Type.Array(Type.String(), {
20
- description: 'Required. One or more short refs (e.g. "t12") or raw tool call IDs from a pruner-summary message.',
29
+ description: 'Required. One or more short refs (e.g. "t12") or raw tool call IDs from a pruner-summary message. A reused raw id returns every occurrence, each labelled id@timestamp.',
21
30
  }),
22
31
  }),
23
32
 
@@ -26,46 +35,53 @@ export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): v
26
35
  const blocks: string[] = [];
27
36
 
28
37
  for (const id of params.toolCallIds) {
29
- const record = indexer.getRecord(id);
38
+ const records = indexer.getRecordsForId(id);
30
39
 
31
- if (!record) {
40
+ if (records.length === 0) {
32
41
  blocks.push(`## toolRef: ${id}\n(not found in index — may not have been summarized yet)`);
33
42
  continue;
34
43
  }
35
44
 
36
- foundRecords[id] = record;
45
+ for (const record of records) {
46
+ // A reused provider id denotes several occurrences; return all of
47
+ // them rather than silently picking one. `tN` refs stay 1:1.
48
+ const label = records.length > 1 ? occurrenceLabel(id, record.resultTimestamp) : id;
37
49
 
38
- const status = record.isError ? "ERROR" : "OK";
39
- const header = [
40
- `## toolRef: ${id}`,
41
- `Tool: ${record.toolName}`,
42
- `Args: ${JSON.stringify(record.args, null, 2)}`,
43
- `Status: ${status}`,
44
- `Turn: ${record.turnIndex}`,
45
- "",
46
- ].join("\n");
50
+ foundRecords[label] = record;
47
51
 
48
- let raw = record.resultText;
49
- if (record.spillPath) {
50
- try {
51
- raw = await readFile(record.spillPath, "utf-8");
52
- } catch (err) {
53
- console.error(`context_tree_query: failed to read spilled output at ${record.spillPath}:`, err);
54
- raw = record.resultPreview ?? "(spilled output unavailable — sidecar file missing)";
52
+ const status = record.isError ? "ERROR" : "OK";
53
+ const header = [
54
+ `## toolRef: ${label}`,
55
+ ...(records.length > 1 && record.resultTimestamp === undefined ? ["Occurrence: legacy (no resultTimestamp)"] : []),
56
+ `Tool: ${record.toolName}`,
57
+ `Args: ${JSON.stringify(record.args, null, 2)}`,
58
+ `Status: ${status}`,
59
+ `Turn: ${record.turnIndex}`,
60
+ "",
61
+ ].join("\n");
62
+
63
+ let raw = record.resultText;
64
+ if (record.spillPath) {
65
+ try {
66
+ raw = await readFile(record.spillPath, "utf-8");
67
+ } catch (err) {
68
+ console.error(`context_tree_query: failed to read spilled output at ${record.spillPath}:`, err);
69
+ raw = record.resultPreview ?? "(spilled output unavailable — sidecar file missing)";
70
+ }
55
71
  }
56
- }
57
72
 
58
- const t = truncateHead(raw, {
59
- maxLines: DEFAULT_MAX_LINES,
60
- maxBytes: DEFAULT_MAX_BYTES,
61
- });
73
+ const t = truncateHead(raw, {
74
+ maxLines: DEFAULT_MAX_LINES,
75
+ maxBytes: DEFAULT_MAX_BYTES,
76
+ });
62
77
 
63
- let body = t.content;
64
- if (t.truncated) {
65
- body += `\n[Output truncated: ${t.outputLines}/${t.totalLines} lines shown]`;
66
- }
78
+ let body = t.content;
79
+ if (t.truncated) {
80
+ body += `\n[Output truncated: ${t.outputLines}/${t.totalLines} lines shown]`;
81
+ }
67
82
 
68
- blocks.push(`${header}\n${body}`);
83
+ blocks.push(`${header}\n${body}`);
84
+ }
69
85
  }
70
86
 
71
87
  const combined = blocks.join("\n\n---\n\n");
@@ -5,6 +5,7 @@ import { compressEligible } from "./chain-compressor.js";
5
5
  import { pruneMessages } from "./pruner.js";
6
6
  import { detectChains } from "./chain-detector.js";
7
7
  import { isProtected } from "./protected.js";
8
+ import { bareToolCallId } from "./occurrence-key.js";
8
9
  import type { ChainRange, ChainCompressionConfig } from "./types.js";
9
10
 
10
11
  // End-to-end of the in-memory B path (everything except the LLM call, which is
@@ -185,8 +186,12 @@ describe("range compression integration", () => {
185
186
  expect(chains[0].protectedToolCallIds).toEqual(["tc2"]);
186
187
 
187
188
  // Only unprotected tc1 has a per-batch summary; tc2 is protected, no short ref.
189
+ // Keyed by the occurrence key detectChains actually produced for tc1's result
190
+ // (id + resultTimestamp), not the bare toolCallId, so this can't silently drift.
191
+ const tc1OccKey = chains[0].middleOccurrenceKeys!.find((k) => bareToolCallId(k) === "tc1")!;
192
+ expect(tc1OccKey).toBeDefined();
188
193
  indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tc1" }]);
189
- indexer.registerSummaryBody(["tc1"], "read app.ts summary");
194
+ indexer.registerSummaryBody([tc1OccKey], "read app.ts summary");
190
195
 
191
196
  const { compressedEntries } = await compressEligible(chains, 0, {
192
197
  indexer,
@@ -3,6 +3,7 @@ import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
3
3
 
4
4
  const user = () => ({ role: "user", content: [{ type: "text", text: "u" }] });
5
5
  const ctq = (id: string) => ({ role: "toolResult", toolCallId: id, toolName: "context_tree_query", content: [{ type: "text", text: "x" }] });
6
+ const ctqAt = (id: string, timestamp: number) => ({ role: "toolResult", toolCallId: id, toolName: "context_tree_query", content: [{ type: "text", text: "x" }], timestamp });
6
7
  const bash = (id: string) => ({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "x" }] });
7
8
 
8
9
  describe("inGraceRecoveryToolCallIds", () => {
@@ -32,4 +33,16 @@ describe("inGraceRecoveryToolCallIds", () => {
32
33
  expect(set.has("t1")).toBe(false);
33
34
  expect(set.has("t2")).toBe(true);
34
35
  });
36
+ it("keys a timestamped recovery output by occurrence (id@timestamp), not the bare id", () => {
37
+ const msgs = [user(), ctqAt("reused", 100)];
38
+ const set = inGraceRecoveryToolCallIds(msgs, 3);
39
+ expect(set.has("reused@100")).toBe(true);
40
+ expect(set.has("reused")).toBe(false);
41
+ });
42
+ it("does not conflate two occurrences of the same reused bare id", () => {
43
+ const msgs = [user(), ctqAt("reused", 100), user(), user(), user(), user(), ctqAt("reused", 200)];
44
+ const set = inGraceRecoveryToolCallIds(msgs, 3);
45
+ expect(set.has("reused@100")).toBe(false);
46
+ expect(set.has("reused@200")).toBe(true);
47
+ });
35
48
  });
@@ -1,8 +1,16 @@
1
1
  import { QUERY_TOOL_NAME } from "./types.js";
2
+ import { occKey } from "./occurrence-key.js";
2
3
 
3
4
  /**
4
- * Set of `context_tree_query` toolCallIds still inside the recovery grace
5
- * window, computed positionally from the message array (no stored metadata).
5
+ * Set of `context_tree_query` occurrence keys (or bare ids when the message
6
+ * carries no timestamp) still inside the recovery grace window, computed
7
+ * positionally from the message array (no stored metadata).
8
+ *
9
+ * Keyed by occurrence, not bare id: the pruner's fail-closed ladder in
10
+ * `pruneMessages` looks this set up with the SAME `occKey(id, timestamp)` it
11
+ * computed for the message under test, so a graced occurrence never leaks
12
+ * protection onto a different, later occurrence that happens to reuse the
13
+ * same provider id.
6
14
  *
7
15
  * A recovery output's "user-turn-group" is the count of `role === "user"`
8
16
  * messages at or before its position; its age is `nowUTG - that count`, where
@@ -26,7 +34,8 @@ export function inGraceRecoveryToolCallIds(messages: any[], graceTurns: number):
26
34
  continue;
27
35
  }
28
36
  if (m?.role === "toolResult" && m.toolName === QUERY_TOOL_NAME && typeof m.toolCallId === "string") {
29
- if (nowUTG - seen <= graceTurns) result.add(m.toolCallId);
37
+ const key = typeof m.timestamp === "number" ? occKey(m.toolCallId, m.timestamp) : m.toolCallId;
38
+ if (nowUTG - seen <= graceTurns) result.add(key);
30
39
  }
31
40
  }
32
41
  return result;
package/src/spill.test.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { sanitizeId, blobDirFor, blobPathFor, headPreview, spillOversizedBatch } from "./spill.js";
6
6
  import { ToolCallIndexer } from "./indexer.js";
7
+ import { registerQueryTool } from "./query-tool.js";
8
+ import { occKey } from "./occurrence-key.js";
9
+ import { CUSTOM_TYPE_INDEX } from "./types.js";
7
10
  import type { CapturedBatch } from "./types.js";
8
11
 
9
12
  describe("sanitizeId", () => {
@@ -36,6 +39,94 @@ describe("headPreview", () => {
36
39
  });
37
40
  });
38
41
 
42
+ describe("occurrence-keyed spill", () => {
43
+ it("blobPathFor distinguishes two occurrences of one id", () => {
44
+ const a = blobPathFor("/tmp/s", "sess", occKey("bash_23", 1150));
45
+ const b = blobPathFor("/tmp/s", "sess", occKey("bash_23", 3150));
46
+ expect(a).not.toBe(b);
47
+ expect(a.endsWith("bash_23_1150.txt")).toBe(true);
48
+ });
49
+
50
+ it("legacy bare-id sidecar path is unchanged", () => {
51
+ expect(blobPathFor("/tmp/s", "sess", "bash_23").endsWith("bash_23.txt")).toBe(true);
52
+ });
53
+
54
+ it("registerDuplicate is called with occurrence keys on both sides", async () => {
55
+ const calls: string[][] = [];
56
+ const indexer = {
57
+ lookupByContent: () => "bash_1@1000",
58
+ registerDuplicate: (a: string, b: string) => calls.push([a, b]),
59
+ } as any;
60
+ const batch = {
61
+ turnIndex: 0,
62
+ timestamp: 2000,
63
+ assistantText: "",
64
+ toolCalls: [
65
+ { toolCallId: "bash_2", toolName: "bash", args: {}, resultText: "x".repeat(100), isError: false, resultTimestamp: 2150 },
66
+ ],
67
+ };
68
+ await spillOversizedBatch({
69
+ batch: batch as any,
70
+ indexer,
71
+ config: { spillThreshold: 10, spillPreviewBytes: 10, dedupByContentHash: true },
72
+ sessionDir: "/tmp/s",
73
+ sessionId: "sess",
74
+ appendEntry: () => {},
75
+ });
76
+ expect(calls).toEqual([["bash_2@2150", "bash_1@1000"]]);
77
+ });
78
+ });
79
+
80
+ describe("G4/C4: legacy bare-id sidecar recovery", () => {
81
+ it("a pre-upgrade legacy record whose spillPath points at a bare-id-named sidecar still resolves through context_tree_query", async () => {
82
+ const dir = await mkdtemp(join(tmpdir(), "spill-legacy-"));
83
+ try {
84
+ // A pre-occurrence-key sidecar, written and named exactly the way a
85
+ // pre-upgrade session would have (bare id, no resultTimestamp suffix).
86
+ const sidecarPath = blobPathFor(dir, "sid", "bash_7");
87
+ await mkdir(blobDirFor(dir, "sid"), { recursive: true });
88
+ await writeFile(sidecarPath, "OLD SPILLED BODY".repeat(20));
89
+
90
+ const indexEntry = {
91
+ type: "custom",
92
+ customType: CUSTOM_TYPE_INDEX,
93
+ data: {
94
+ toolCalls: [
95
+ {
96
+ toolCallId: "bash_7",
97
+ toolName: "fetch",
98
+ args: { url: "https://x" },
99
+ resultText: "",
100
+ resultPreview: "OLD SPILLED",
101
+ spillPath: sidecarPath,
102
+ spillBytes: 340,
103
+ isError: false,
104
+ turnIndex: 0,
105
+ timestamp: 500,
106
+ },
107
+ ],
108
+ },
109
+ };
110
+ // No matching ToolResultMessage in the branch (a genuinely pre-upgrade,
111
+ // truncated session) - an index entry persisted without resultTimestamp
112
+ // stays bare-keyed (no migration), so its sidecar keeps its bare-id
113
+ // filename and still resolves via the persisted spillPath.
114
+ const indexer = new ToolCallIndexer();
115
+ indexer.reconstructFromSession({ sessionManager: { getBranch: () => [indexEntry] } } as any);
116
+ expect(indexer.hasLegacyBareRecord("bash_7")).toBe(true);
117
+
118
+ let registered: any;
119
+ registerQueryTool({ registerTool: (def: any) => (registered = def) } as any, indexer);
120
+ const result = await registered.execute("call-1", { toolCallIds: ["bash_7"] }, undefined, undefined, undefined);
121
+ const text = result.content[0].text as string;
122
+
123
+ expect(text).toContain("OLD SPILLED BODY");
124
+ } finally {
125
+ await rm(dir, { recursive: true, force: true });
126
+ }
127
+ });
128
+ });
129
+
39
130
  describe("spillOversizedBatch", () => {
40
131
  const cfg = { spillThreshold: 10, spillPreviewBytes: 8, dedupByContentHash: true };
41
132
  const mkBatch = (toolCalls: any[]): CapturedBatch => ({ turnIndex: 0, timestamp: 1, assistantText: "", toolCalls });
@@ -85,6 +176,22 @@ describe("spillOversizedBatch", () => {
85
176
  }
86
177
  });
87
178
 
179
+ it("two occurrences of one toolCallId spill to distinct sidecar files", async () => {
180
+ const dir = await mkdtemp(join(tmpdir(), "spill-"));
181
+ try {
182
+ const indexer = new ToolCallIndexer();
183
+ const batch1 = mkBatch([{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "FIRST".repeat(20), isError: false, resultTimestamp: 1150 }]);
184
+ const batch2 = mkBatch([{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "SECOND".repeat(20), isError: false, resultTimestamp: 3150 }]);
185
+ await spillOversizedBatch({ batch: batch1, indexer, config: { ...cfg, dedupByContentHash: false }, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
186
+ await spillOversizedBatch({ batch: batch2, indexer, config: { ...cfg, dedupByContentHash: false }, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
187
+ const rec1 = indexer.getRecord("bash_23@1150")!;
188
+ const rec2 = indexer.getRecord("bash_23@3150")!;
189
+ expect(rec1.spillPath).not.toBe(rec2.spillPath);
190
+ expect(await readFile(rec1.spillPath!, "utf-8")).toBe("FIRST".repeat(20));
191
+ expect(await readFile(rec2.spillPath!, "utf-8")).toBe("SECOND".repeat(20));
192
+ } finally { await rm(dir, { recursive: true, force: true }); }
193
+ });
194
+
88
195
  it("dedups an oversized duplicate to the original without a second file", async () => {
89
196
  const dir = await mkdtemp(join(tmpdir(), "spill-"));
90
197
  try {
package/src/spill.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import type { CapturedBatch, CapturedToolCall } from "./types.js";
4
4
  import type { ToolCallIndexer } from "./indexer.js";
5
5
  import { hashToolResult } from "./content-hash.js";
6
+ import { occKey } from "./occurrence-key.js";
6
7
 
7
8
  /** Replace anything outside [A-Za-z0-9_-] so the id can't escape the blob dir. */
8
9
  export function sanitizeId(toolCallId: string): string {
@@ -50,18 +51,19 @@ export async function spillOversizedBatch(args: {
50
51
  for (const tc of batch.toolCalls) {
51
52
  if (tc.resultText.length < config.spillThreshold) continue;
52
53
 
54
+ const key = occKey(tc.toolCallId, tc.resultTimestamp);
53
55
  const hash = hashToolResult(tc.toolName, tc.resultText);
54
56
 
55
57
  if (config.dedupByContentHash) {
56
58
  const original = indexer.lookupByContent(tc.toolName, tc.resultText);
57
- if (original && original !== tc.toolCallId) {
58
- indexer.registerDuplicate(tc.toolCallId, original, appendEntry);
59
+ if (original && original !== key) {
60
+ indexer.registerDuplicate(key, original, appendEntry);
59
61
  handled.add(tc.toolCallId);
60
62
  continue;
61
63
  }
62
64
  }
63
65
 
64
- const path = blobPathFor(sessionDir, sessionId, tc.toolCallId);
66
+ const path = blobPathFor(sessionDir, sessionId, key);
65
67
  try {
66
68
  await mkdir(blobDirFor(sessionDir, sessionId), { recursive: true });
67
69
  await writeFile(path, tc.resultText, "utf-8");