pi-condense 2.4.3 → 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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/PRUNING.md +96 -54
  3. package/README.md +6 -1
  4. package/index.ts +28 -42
  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 -35
  16. package/src/config.test.ts +27 -1
  17. package/src/diagnostics.test.ts +114 -0
  18. package/src/diagnostics.ts +46 -0
  19. package/src/frontier.test.ts +138 -16
  20. package/src/frontier.ts +0 -1
  21. package/src/id-collision.integration.test.ts +251 -0
  22. package/src/indexer.test.ts +336 -0
  23. package/src/indexer.ts +168 -55
  24. package/src/occurrence-key.test.ts +57 -0
  25. package/src/occurrence-key.ts +36 -0
  26. package/src/orphan-sweep.test.ts +67 -0
  27. package/src/orphan-sweep.ts +40 -0
  28. package/src/oversized-spill.integration.test.ts +7 -2
  29. package/src/pruner.test.ts +471 -64
  30. package/src/pruner.ts +84 -54
  31. package/src/query-tool.test.ts +117 -0
  32. package/src/query-tool.ts +47 -31
  33. package/src/range-compression.integration.test.ts +7 -44
  34. package/src/recovery-grace.test.ts +13 -0
  35. package/src/recovery-grace.ts +12 -3
  36. package/src/spill.test.ts +108 -1
  37. package/src/spill.ts +5 -3
  38. package/src/summary-refs.test.ts +51 -1
  39. package/src/summary-refs.ts +15 -4
  40. package/src/test-support.ts +54 -0
  41. package/src/tree-browser.ts +2 -1
  42. package/src/types.ts +56 -49
  43. package/src/thinking-strip.test.ts +0 -257
  44. package/src/thinking-strip.ts +0 -83
package/src/pruner.ts CHANGED
@@ -1,23 +1,26 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { ToolCallIndexer } from "./indexer.js";
2
- import type { ChainCompressionConfig, ErrorPurgeConfig, ThinkingStripConfig } from "./types.js";
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
- import { stripOldThinking } from "./thinking-strip.js";
7
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";
8
11
 
9
12
  /**
10
13
  * Estimate of a message array's context weight. Serializing the whole array
11
14
  * (not just visible text) is deliberate: it counts tool-call argument bodies
12
- * (error-purge), thinking blocks (thinking-strip), and tool-result arrays
13
- * (stub-replace / chain-range) so all reclaim mechanisms register.
15
+ * (error-purge) and tool-result arrays (stub-replace / chain-range) so all
16
+ * reclaim mechanisms register.
14
17
  */
15
18
  export function sizeMessages(messages: any[]): number {
16
19
  return JSON.stringify(messages).length;
17
20
  }
18
21
 
19
22
  /**
20
- * Transforms the `context` event message array in two passes:
23
+ * Transforms the `context` event message array in four phases:
21
24
  *
22
25
  * Phase 1 — stub-replace: ToolResultMessages for summarized tool calls are
23
26
  * replaced with short stubs pointing the model at `context_tree_query`.
@@ -43,11 +46,11 @@ export function sizeMessages(messages: any[]): number {
43
46
  * synthetic user message wrapping the existing per-batch summary text.
44
47
  * Only runs when `chainCompression.enabled` and chain entries exist.
45
48
  *
46
- * Phase 4 — thinking strip: keep `thinking` blocks only on the last
47
- * `keepLastTurns` assistant turns; strip them from older assistant messages
48
- * (preserving text + toolCall). Runs last so the window counts the assistant
49
- * turns that actually survive to the LLM. Only runs when
50
- * `thinkingStrip.enabled`.
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.
51
54
  *
52
55
  * Return shape:
53
56
  * - `pruned: true` — at least one change happened; the returned
@@ -71,50 +74,63 @@ export function pruneMessages(
71
74
  indexer: ToolCallIndexer,
72
75
  chainCompression?: ChainCompressionConfig,
73
76
  errorPurge?: ErrorPurgeConfig,
74
- thinkingStrip?: ThinkingStripConfig,
75
77
  protection?: ProtectionConfig,
76
78
  recoveryGraceTurns: number = 0,
77
- thinkingBoundaryTimestamp?: number,
79
+ diagnostics?: DiagnosticSink,
78
80
  ): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
79
81
  // Phase 1: stub-replace summarized tool results
80
82
  let pruned = false;
81
83
  const inGrace = inGraceRecoveryToolCallIds(messages, recoveryGraceTurns);
82
84
  const next = messages.map((msg) => {
83
- if (msg.role === "toolResult" && indexer.isSummarized(msg.toolCallId)) {
84
- const record = indexer.getRecord(msg.toolCallId);
85
- // Render-time re-check: a record summarized before protectedPaths
86
- // covered it is repaired here the raw toolResult still lives in the
87
- // session JSONL, so skipping the stub restores it verbatim.
88
- // Dedup aliases resolve to the original record, so an alias whose own
89
- // path is protected but whose original isn't stays stubbed (edge case).
90
- if (protection && record && isProtected(record.toolName, record.args, protection)) {
91
- return msg;
92
- }
93
- if (inGrace.has(msg.toolCallId)) {
94
- return msg;
95
- }
96
- pruned = true;
97
- const ref = indexer.getShortRefForToolCallId(msg.toolCallId) ?? msg.toolCallId;
98
- const text = record?.spillPath
99
- ? [
100
- `[Oversized output spilled to file — ${record.spillBytes ?? "?"} bytes.]`,
101
- `Tool: ${record.toolName}`,
102
- `Preview (head):`,
103
- record.resultPreview ?? "",
104
- `Full output read this file (offset/limit supported): ${record.spillPath}`,
105
- `Or use context_tree_query with ref \`${ref}\`.`,
106
- ].join("\n")
107
- : `[Summarized in pruner summary, ref \`${ref}\`. Use context_tree_query to retrieve full output.]`;
108
- return {
109
- role: "toolResult",
110
- toolCallId: msg.toolCallId,
111
- toolName: msg.toolName,
112
- content: [{ type: "text", text }],
113
- isError: false,
114
- timestamp: msg.timestamp,
115
- };
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;
116
113
  }
117
- 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
+ };
118
134
  });
119
135
 
120
136
  let current: any[] = pruned ? next : messages;
@@ -135,7 +151,8 @@ export function pruneMessages(
135
151
  // Prefer the cohesive LLM range summary (B) when present; fall back to the
136
152
  // per-batch concatenation for spans compressed before fusion / on failure.
137
153
  const chainSummaryText = (entry: typeof chainEntries[number]): string =>
138
- entry.rangeSummaryText ?? indexer.getPerBatchSummaryTextForToolCallIds(entry.droppedToolCallIds);
154
+ entry.rangeSummaryText ??
155
+ indexer.getPerBatchSummaryTextForToolCallIds(entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds);
139
156
  const blockSummaryLookup = (blockId: string): string | undefined => {
140
157
  const entry = indexer.findChainEntryByBlockId(blockId);
141
158
  if (!entry) return undefined;
@@ -147,6 +164,7 @@ export function pruneMessages(
147
164
  chainSummaryText,
148
165
  chainCompression.stripFinalAssistantThinking,
149
166
  blockSummaryLookup,
167
+ diagnostics,
150
168
  );
151
169
  if (compressed !== current) {
152
170
  current = compressed;
@@ -155,13 +173,25 @@ export function pruneMessages(
155
173
  }
156
174
  }
157
175
 
158
- // Phase 4: thinking stripkeep thinking only on the last K assistant turns
159
- if (thinkingStrip?.enabled) {
160
- const afterStrip = stripOldThinking(current, thinkingStrip, thinkingBoundaryTimestamp);
161
- if (afterStrip !== current) {
162
- current = afterStrip;
163
- pruned = true;
164
- }
176
+ // Phase 4: orphan sweepstructural 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
+ );
165
195
  }
166
196
 
167
197
  return pruned
@@ -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,7 +5,8 @@ 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 type { ChainRange, ChainCompressionConfig, ThinkingStripConfig } from "./types.js";
8
+ import { bareToolCallId } from "./occurrence-key.js";
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
11
12
  // the shared runSummarization already exercised live): a span's per-batch
@@ -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,
@@ -249,46 +254,4 @@ describe("range compression integration", () => {
249
254
  expect(synthetic.content[0].text).toContain("batch one body");
250
255
  expect(synthetic.content[0].text).toContain("batch two body");
251
256
  });
252
-
253
- test("boundary strips thinking on survivors after a real phase-3 chain drop", async () => {
254
- const indexer = new ToolCallIndexer();
255
- const blockRefs = new BlockRefIssuer();
256
- indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tc1" }]);
257
- indexer.registerSummaryBody(["tc1"], "summary of batch 1");
258
-
259
- const chain: ChainRange = {
260
- startUserTimestamp: 100,
261
- middleToolCallIds: ["tc1"],
262
- finalAssistantTimestamp: 400,
263
- };
264
- const { compressedEntries } = await compressEligible([chain], 0, {
265
- indexer,
266
- blockRefs,
267
- appendEntry: () => {},
268
- now: () => 999,
269
- });
270
- expect(compressedEntries).toHaveLength(1);
271
-
272
- // A later assistant turn (ts 500) that carries thinking and sits OLDER than the boundary.
273
- const messages: any[] = [
274
- { role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
275
- { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
276
- { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "o1" }], isError: false, timestamp: 210 },
277
- { role: "assistant", content: [{ type: "text", text: "mid" }], timestamp: 400, usage: {}, stopReason: "end_turn" },
278
- { role: "assistant", content: [{ type: "thinking", thinking: "old-think", thinkingSignature: "s" }, { type: "text", text: "after" }], timestamp: 500, usage: {}, stopReason: "stop" },
279
- ];
280
-
281
- const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false };
282
- const strip: ThinkingStripConfig = { enabled: true, keepLastTurns: 16 };
283
- // Boundary 600: the ts=500 assistant is older -> its thinking must be stripped,
284
- // even though phase 3 has dropped the tc1 chain from the array first.
285
- const { messages: out, pruned } = pruneMessages(messages, indexer, cc, undefined, strip, undefined, 0, 600);
286
- expect(pruned).toBe(true);
287
- // Chain middle dropped:
288
- expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0);
289
- // Surviving ts=500 assistant older than boundary 600 -> thinking stripped:
290
- const late = out.find((m: any) => m.role === "assistant" && m.timestamp === 500);
291
- expect(late).toBeDefined();
292
- expect(late.content.some((c: any) => c.type === "thinking")).toBe(false);
293
- });
294
257
  });
@@ -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;