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
@@ -3,6 +3,8 @@ import { selectEligible, compressEligible } from "./chain-compressor.js";
3
3
  import type { ChainCompressorIndexerDeps } from "./chain-compressor.js";
4
4
  import type { ChainRange, ChainCompressionEntry } from "./types.js";
5
5
  import { CUSTOM_TYPE_CHAIN } from "./types.js";
6
+ import { detectChains } from "./chain-detector.js";
7
+ import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
6
8
 
7
9
  function closed(startUserTimestamp: number, toolCallIds: string[] = [`tc-${startUserTimestamp}`]): ChainRange {
8
10
  return { startUserTimestamp, middleToolCallIds: toolCallIds, finalAssistantTimestamp: startUserTimestamp + 100 };
@@ -313,3 +315,115 @@ describe("selectEligible - recovery grace deferral", () => {
313
315
  expect(eligible.map((c) => c.startUserTimestamp)).toEqual([1, 2, 3]);
314
316
  });
315
317
  });
318
+
319
+ describe("occurrence keys in compression", () => {
320
+ const chain = {
321
+ startUserTimestamp: 1000,
322
+ middleToolCallIds: ["bash_23"],
323
+ middleOccurrenceKeys: ["bash_23@1150"],
324
+ protectedToolCallIds: [],
325
+ finalAssistantTimestamp: 1200,
326
+ };
327
+
328
+ test("summary/toolRef lookups receive occurrence keys, not bare ids", async () => {
329
+ const asked: string[][] = [];
330
+ const deps = {
331
+ indexer: {
332
+ getChainEntries: () => [],
333
+ hasPerBatchSummaryCoveringAny: (ids: string[]) => (asked.push(ids), true),
334
+ getPerBatchSummariesForToolCallIds: (ids: string[]) => (asked.push(ids), ["s1"]),
335
+ getToolRefsForToolCallIds: (ids: string[]) => (asked.push(ids), ["t1"]),
336
+ registerChain: () => {},
337
+ },
338
+ blockRefs: { issue: () => "b1" },
339
+ appendEntry: () => {},
340
+ now: () => 5000,
341
+ };
342
+ await compressEligible([chain as any], 0, deps as any);
343
+ expect(asked).toEqual([["bash_23@1150"], ["bash_23@1150"]]);
344
+ });
345
+
346
+ test("persists droppedOccurrenceKeys alongside droppedToolCallIds", async () => {
347
+ const appended: any[] = [];
348
+ const deps = {
349
+ indexer: {
350
+ getChainEntries: () => [],
351
+ hasPerBatchSummaryCoveringAny: () => true,
352
+ getPerBatchSummariesForToolCallIds: () => ["s1"],
353
+ getToolRefsForToolCallIds: () => ["t1"],
354
+ registerChain: () => {},
355
+ },
356
+ blockRefs: { issue: () => "b1" },
357
+ appendEntry: (_t: string, data: unknown) => appended.push(data),
358
+ now: () => 5000,
359
+ };
360
+ const { compressedEntries } = await compressEligible([chain as any], 0, deps as any);
361
+ expect(compressedEntries[0].droppedToolCallIds).toEqual(["bash_23"]);
362
+ expect(compressedEntries[0].droppedOccurrenceKeys).toEqual(["bash_23@1150"]);
363
+ expect((appended[0] as any).droppedOccurrenceKeys).toEqual(["bash_23@1150"]);
364
+ });
365
+
366
+ test("a chain without middleOccurrenceKeys falls back to bare ids", async () => {
367
+ const deps = {
368
+ indexer: {
369
+ getChainEntries: () => [],
370
+ hasPerBatchSummaryCoveringAny: () => true,
371
+ getPerBatchSummariesForToolCallIds: () => ["s1"],
372
+ getToolRefsForToolCallIds: () => ["t1"],
373
+ registerChain: () => {},
374
+ },
375
+ blockRefs: { issue: () => "b1" },
376
+ appendEntry: () => {},
377
+ now: () => 5000,
378
+ };
379
+ const { compressedEntries } = await compressEligible(
380
+ [{ ...chain, middleOccurrenceKeys: undefined } as any],
381
+ 0,
382
+ deps as any,
383
+ );
384
+ expect(compressedEntries[0].droppedOccurrenceKeys).toBeUndefined();
385
+ });
386
+ });
387
+
388
+ describe("selectEligible - recovery grace wiring (production boundary)", () => {
389
+ // Crosses the real boundary: real detectChains() + real inGraceRecoveryToolCallIds()
390
+ // feed real selectEligible(). Hand-injecting bare ids (as the suite above does) hides
391
+ // the format mismatch this test pins down.
392
+ it("does not compress a chain whose recovery-query result is still in grace", () => {
393
+ const messages = [
394
+ { role: "user", timestamp: 1, content: [{ type: "text", text: "u1" }] },
395
+ {
396
+ role: "assistant",
397
+ timestamp: 1,
398
+ content: [{ type: "toolCall", id: "t1", name: "context_tree_query", input: {} }],
399
+ },
400
+ {
401
+ role: "toolResult",
402
+ toolCallId: "t1",
403
+ toolName: "context_tree_query",
404
+ timestamp: 100,
405
+ content: [{ type: "text", text: "recovered" }],
406
+ },
407
+ { role: "assistant", timestamp: 2, content: [{ type: "text", text: "done" }] },
408
+ { role: "user", timestamp: 3, content: [{ type: "text", text: "u2" }] },
409
+ { role: "assistant", timestamp: 3, content: [{ type: "toolCall", id: "b", name: "bash", input: {} }] },
410
+ {
411
+ role: "toolResult",
412
+ toolCallId: "b",
413
+ toolName: "bash",
414
+ timestamp: 200,
415
+ content: [{ type: "text", text: "ok" }],
416
+ },
417
+ { role: "assistant", timestamp: 4, content: [{ type: "text", text: "done2" }] },
418
+ ];
419
+
420
+ const chains = detectChains(messages);
421
+ expect(chains.map((c) => c.startUserTimestamp)).toEqual([1, 3]);
422
+
423
+ const inGrace = inGraceRecoveryToolCallIds(messages, 3);
424
+ expect([...inGrace]).toEqual(["t1@100"]);
425
+
426
+ const eligible = selectEligible(chains, 0, new Set(), inGrace);
427
+ expect(eligible.map((c) => c.startUserTimestamp)).toEqual([3]);
428
+ });
429
+ });
@@ -2,6 +2,26 @@ import { CUSTOM_TYPE_CHAIN } from "./types.js";
2
2
  import type { ChainRange, ChainCompressionEntry } from "./types.js";
3
3
  import type { ToolCallIndexer } from "./indexer.js";
4
4
  import type { BlockRefIssuer } from "./block-refs.js";
5
+ import { bareToolCallId, parseOccKey } from "./occurrence-key.js";
6
+
7
+ /**
8
+ * Grace ids are keyed the same way `recovery-grace.ts` keys them: occurrence
9
+ * (`id@timestamp`) when the recovery message carried a timestamp, bare id
10
+ * otherwise. A chain's middles are compared occurrence-first (exact match on
11
+ * `middleOccurrenceKeys`, falling back to `middleToolCallIds` for chains built
12
+ * before the field existed), so a graced occurrence never defers a chain
13
+ * holding a DIFFERENT occurrence of the same reused provider id. The only
14
+ * bare-to-bare fallback is for grace entries that themselves have no
15
+ * timestamp discriminant — there is no exact key to compare in that case.
16
+ */
17
+ function chainMatchesGrace(chain: ChainRange, inGraceToolCallIds: Set<string>): boolean {
18
+ const keys = chain.middleOccurrenceKeys?.length ? chain.middleOccurrenceKeys : chain.middleToolCallIds;
19
+ if (keys.some((k) => inGraceToolCallIds.has(k))) return true;
20
+ for (const g of inGraceToolCallIds) {
21
+ if (parseOccKey(g).resultTimestamp === undefined && keys.some((k) => bareToolCallId(k) === g)) return true;
22
+ }
23
+ return false;
24
+ }
5
25
 
6
26
  /**
7
27
  * Pure eligibility filter: given all detected chains, return the subset
@@ -31,7 +51,7 @@ export function selectEligible(
31
51
  c.middleToolCallIds.length > 0,
32
52
  );
33
53
  const toCompress = candidates.slice(0, Math.max(0, candidates.length - rollingWindow));
34
- return toCompress.filter((c) => !c.middleToolCallIds.some((id) => inGraceToolCallIds.has(id)));
54
+ return toCompress.filter((c) => !chainMatchesGrace(c, inGraceToolCallIds));
35
55
  }
36
56
 
37
57
  /**
@@ -96,19 +116,23 @@ export async function compressEligible(
96
116
 
97
117
  const compressedEntries: ChainCompressionEntry[] = [];
98
118
  for (const chain of eligible) {
99
- if (!deps.indexer.hasPerBatchSummaryCoveringAny(chain.middleToolCallIds)) {
119
+ // summaryBodies / toolRefs live in occurrence-key space (src/indexer.ts).
120
+ // Bare ids would match nothing and silently skip every chain.
121
+ const lookupKeys = chain.middleOccurrenceKeys?.length ? chain.middleOccurrenceKeys : chain.middleToolCallIds;
122
+
123
+ if (!deps.indexer.hasPerBatchSummaryCoveringAny(lookupKeys)) {
100
124
  skipped.push({ startUserTimestamp: chain.startUserTimestamp, reason: "no-summary" });
101
125
  continue;
102
126
  }
103
127
 
104
128
  const blockId = deps.blockRefs.issue();
105
- const toolRefs = deps.indexer.getToolRefsForToolCallIds(chain.middleToolCallIds);
129
+ const toolRefs = deps.indexer.getToolRefsForToolCallIds(lookupKeys);
106
130
 
107
131
  // B: fuse this span's per-batch summaries into one cohesive summary.
108
132
  // Gated on >= 2 summaries (nothing to fuse otherwise). Non-fatal.
109
133
  let rangeSummaryText: string | undefined;
110
134
  if (deps.fuseRange) {
111
- const summaries = deps.indexer.getPerBatchSummariesForToolCallIds(chain.middleToolCallIds);
135
+ const summaries = deps.indexer.getPerBatchSummariesForToolCallIds(lookupKeys);
112
136
  if (summaries.length >= 2) {
113
137
  try {
114
138
  const fused = await deps.fuseRange(summaries.join("\n\n"));
@@ -128,6 +152,7 @@ export async function compressEligible(
128
152
  compressedAt: deps.now(),
129
153
  ...(rangeSummaryText ? { rangeSummaryText } : {}),
130
154
  ...(chain.protectedToolCallIds?.length ? { protectedToolCallIds: chain.protectedToolCallIds } : {}),
155
+ ...(chain.middleOccurrenceKeys?.length ? { droppedOccurrenceKeys: chain.middleOccurrenceKeys } : {}),
131
156
  };
132
157
 
133
158
  deps.appendEntry(CUSTOM_TYPE_CHAIN, entry);
@@ -259,6 +259,55 @@ describe("detectChains protectedToolCallIds", () => {
259
259
  });
260
260
  });
261
261
 
262
+ describe("middleOccurrenceKeys", () => {
263
+ test("emits one occurrence key per middle tool result", () => {
264
+ const messages = [
265
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 },
266
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_1", name: "bash", input: {} }], timestamp: 1100 },
267
+ { role: "toolResult", toolCallId: "bash_1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1150 },
268
+ { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1200 },
269
+ ];
270
+ const [chain] = detectChains(messages);
271
+ expect(chain.middleToolCallIds).toEqual(["bash_1"]);
272
+ expect(chain.middleOccurrenceKeys).toEqual(["bash_1@1150"]);
273
+ });
274
+
275
+ test("a tool call with no result contributes no occurrence key", () => {
276
+ const messages = [
277
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 },
278
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_1", name: "bash", input: {} }], timestamp: 1100 },
279
+ { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1200 },
280
+ ];
281
+ const [chain] = detectChains(messages);
282
+ expect(chain.middleToolCallIds).toEqual(["bash_1"]);
283
+ expect(chain.middleOccurrenceKeys).toEqual([]);
284
+ });
285
+
286
+ test("two chains reusing one id get distinct occurrence keys", () => {
287
+ const turn = (base: number) => [
288
+ { role: "user", content: [{ type: "text", text: "go" }], timestamp: base },
289
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: base + 100 },
290
+ { role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: base + 150 },
291
+ { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: base + 200 },
292
+ ];
293
+ const chains = detectChains([...turn(1000), ...turn(2000)]);
294
+ expect(chains.map((c) => c.middleOccurrenceKeys)).toEqual([["bash_23@1150"], ["bash_23@2150"]]);
295
+ });
296
+
297
+ test("an interrupted chain still reports the keys it collected", () => {
298
+ const messages = [
299
+ { role: "user", content: [{ type: "text", text: "a" }], timestamp: 1000 },
300
+ { role: "assistant", content: [{ type: "toolCall", id: "bash_1", name: "bash", input: {} }], timestamp: 1100 },
301
+ { role: "toolResult", toolCallId: "bash_1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1150 },
302
+ { role: "user", content: [{ type: "text", text: "b" }], timestamp: 2000 },
303
+ { role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 2200 },
304
+ ];
305
+ const [interrupted] = detectChains(messages);
306
+ expect(interrupted.finalAssistantTimestamp).toBeNull();
307
+ expect(interrupted.middleOccurrenceKeys).toEqual(["bash_1@1150"]);
308
+ });
309
+ });
310
+
262
311
  describe("withClosingMessage", () => {
263
312
  test("undefined closing returns the same array reference", () => {
264
313
  const msgs = [userMsg(100)];
@@ -1,3 +1,4 @@
1
+ import { occKey, resultTimestampOf } from "./occurrence-key.js";
1
2
  import type { ChainRange } from "./types.js";
2
3
 
3
4
  /** Prefix that identifies a synthetic chain-compression user message. */
@@ -45,6 +46,7 @@ export function detectChains(
45
46
  let state: State = "idle";
46
47
  let chainStart: { timestamp: number } | null = null;
47
48
  let middleIds = new Set<string>();
49
+ let middleKeys = new Set<string>();
48
50
  let protectedIds = new Set<string>();
49
51
 
50
52
  const emitInterrupted = () => {
@@ -52,6 +54,7 @@ export function detectChains(
52
54
  ranges.push({
53
55
  startUserTimestamp: chainStart.timestamp,
54
56
  middleToolCallIds: [...middleIds],
57
+ middleOccurrenceKeys: [...middleKeys],
55
58
  protectedToolCallIds: [...protectedIds],
56
59
  finalAssistantTimestamp: null,
57
60
  });
@@ -64,6 +67,7 @@ export function detectChains(
64
67
  emitInterrupted();
65
68
  chainStart = { timestamp: msg.timestamp };
66
69
  middleIds = new Set();
70
+ middleKeys = new Set();
67
71
  protectedIds = new Set();
68
72
  state = "inChain";
69
73
  continue;
@@ -82,6 +86,7 @@ export function detectChains(
82
86
  if (msg.role === "toolResult") {
83
87
  if (msg.toolCallId) {
84
88
  middleIds.add(msg.toolCallId);
89
+ middleKeys.add(occKey(msg.toolCallId, resultTimestampOf(msg.timestamp)));
85
90
  // toolResult fallback — results carry no args; name-only by design,
86
91
  // the assistant block always precedes its result so no protection is lost
87
92
  if (isProtected(msg.toolName, undefined)) protectedIds.add(msg.toolCallId);
@@ -93,11 +98,13 @@ export function detectChains(
93
98
  ranges.push({
94
99
  startUserTimestamp: chainStart!.timestamp,
95
100
  middleToolCallIds: [...middleIds],
101
+ middleOccurrenceKeys: [...middleKeys],
96
102
  protectedToolCallIds: [...protectedIds],
97
103
  finalAssistantTimestamp: msg.timestamp,
98
104
  });
99
105
  chainStart = null;
100
106
  middleIds = new Set();
107
+ middleKeys = new Set();
101
108
  protectedIds = new Set();
102
109
  state = "idle";
103
110
  }