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.
- package/CHANGELOG.md +10 -0
- package/PRUNING.md +111 -23
- package/README.md +6 -1
- package/index.ts +23 -13
- package/package.json +1 -1
- package/src/batch-capture.test.ts +75 -1
- package/src/batch-capture.ts +22 -13
- package/src/chain-compressor.test.ts +114 -0
- package/src/chain-compressor.ts +29 -4
- package/src/chain-detector.test.ts +49 -0
- package/src/chain-detector.ts +7 -0
- package/src/chain-range-prune.test.ts +342 -7
- package/src/chain-range-prune.ts +161 -48
- package/src/commands.test.ts +31 -2
- package/src/commands.ts +25 -10
- package/src/diagnostics.test.ts +114 -0
- package/src/diagnostics.ts +46 -0
- package/src/frontier.test.ts +1 -0
- package/src/id-collision.integration.test.ts +251 -0
- package/src/indexer.test.ts +336 -0
- package/src/indexer.ts +168 -55
- package/src/occurrence-key.test.ts +57 -0
- package/src/occurrence-key.ts +36 -0
- package/src/orphan-sweep.test.ts +67 -0
- package/src/orphan-sweep.ts +40 -0
- package/src/oversized-spill.integration.test.ts +7 -2
- package/src/pruner.test.ts +456 -25
- package/src/pruner.ts +84 -36
- package/src/query-tool.test.ts +117 -0
- package/src/query-tool.ts +47 -31
- package/src/range-compression.integration.test.ts +6 -1
- package/src/recovery-grace.test.ts +13 -0
- package/src/recovery-grace.ts +12 -3
- package/src/spill.test.ts +108 -1
- package/src/spill.ts +5 -3
- package/src/summary-refs.test.ts +51 -1
- package/src/summary-refs.ts +15 -4
- package/src/test-support.ts +54 -0
- package/src/tree-browser.ts +2 -1
- 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
|
+
});
|
package/src/chain-compressor.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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)];
|
package/src/chain-detector.ts
CHANGED
|
@@ -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
|
}
|