pi-condense 2.0.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 (45) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +22 -0
  3. package/PRUNING.md +1028 -0
  4. package/README.md +243 -0
  5. package/index.ts +858 -0
  6. package/package.json +56 -0
  7. package/src/batch-capture.ts +226 -0
  8. package/src/block-refs.test.ts +42 -0
  9. package/src/block-refs.ts +16 -0
  10. package/src/budget.test.ts +66 -0
  11. package/src/budget.ts +39 -0
  12. package/src/chain-compressor.test.ts +283 -0
  13. package/src/chain-compressor.ts +132 -0
  14. package/src/chain-detector.test.ts +302 -0
  15. package/src/chain-detector.ts +128 -0
  16. package/src/chain-range-prune.test.ts +522 -0
  17. package/src/chain-range-prune.ts +128 -0
  18. package/src/commands.test.ts +67 -0
  19. package/src/commands.ts +1207 -0
  20. package/src/config.ts +126 -0
  21. package/src/content-hash.ts +35 -0
  22. package/src/error-purge.test.ts +186 -0
  23. package/src/error-purge.ts +71 -0
  24. package/src/frontier.ts +62 -0
  25. package/src/indexer.ts +393 -0
  26. package/src/nested-placeholders.test.ts +82 -0
  27. package/src/nested-placeholders.ts +20 -0
  28. package/src/oversized-spill.integration.test.ts +73 -0
  29. package/src/protected.test.ts +62 -0
  30. package/src/protected.ts +51 -0
  31. package/src/pruner.test.ts +508 -0
  32. package/src/pruner.ts +156 -0
  33. package/src/query-tool.ts +78 -0
  34. package/src/range-compression.integration.test.ts +252 -0
  35. package/src/spill.test.ts +102 -0
  36. package/src/spill.ts +90 -0
  37. package/src/stats.test.ts +114 -0
  38. package/src/stats.ts +190 -0
  39. package/src/summarizer.test.ts +17 -0
  40. package/src/summarizer.ts +262 -0
  41. package/src/summary-refs.ts +61 -0
  42. package/src/thinking-strip.test.ts +175 -0
  43. package/src/thinking-strip.ts +42 -0
  44. package/src/tree-browser.ts +382 -0
  45. package/src/types.ts +764 -0
@@ -0,0 +1,283 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { selectEligible, compressEligible } from "./chain-compressor.js";
3
+ import type { ChainCompressorIndexerDeps } from "./chain-compressor.js";
4
+ import type { ChainRange, ChainCompressionEntry } from "./types.js";
5
+ import { CUSTOM_TYPE_CHAIN } from "./types.js";
6
+
7
+ function closed(startUserTimestamp: number, toolCallIds: string[] = [`tc-${startUserTimestamp}`]): ChainRange {
8
+ return { startUserTimestamp, middleToolCallIds: toolCallIds, finalAssistantTimestamp: startUserTimestamp + 100 };
9
+ }
10
+
11
+ function emptyMiddle(startUserTimestamp: number): ChainRange {
12
+ return { startUserTimestamp, middleToolCallIds: [], finalAssistantTimestamp: startUserTimestamp + 100 };
13
+ }
14
+
15
+ function open(startUserTimestamp: number): ChainRange {
16
+ return { startUserTimestamp, middleToolCallIds: [], finalAssistantTimestamp: null };
17
+ }
18
+
19
+ describe("selectEligible", () => {
20
+ test("empty input → empty output", () => {
21
+ expect(selectEligible([], 3, new Set())).toEqual([]);
22
+ });
23
+
24
+ test("chains.length < K → empty", () => {
25
+ expect(selectEligible([closed(100), closed(300)], 3, new Set())).toHaveLength(0);
26
+ });
27
+
28
+ test("chains.length === K → empty (window exactly full)", () => {
29
+ expect(selectEligible([closed(100), closed(300), closed(500)], 3, new Set())).toHaveLength(0);
30
+ });
31
+
32
+ test("chains.length === K+1 → 1 chain (oldest)", () => {
33
+ const chains = [closed(100), closed(300), closed(500), closed(700)];
34
+ const result = selectEligible(chains, 3, new Set());
35
+ expect(result).toHaveLength(1);
36
+ expect(result[0].startUserTimestamp).toBe(100);
37
+ });
38
+
39
+ test("chains.length === K+3 → 3 chains (3 oldest, in input order)", () => {
40
+ const chains = [100, 300, 500, 700, 900, 1100].map((t) => closed(t));
41
+ const result = selectEligible(chains, 3, new Set());
42
+ expect(result).toHaveLength(3);
43
+ expect(result.map((c) => c.startUserTimestamp)).toEqual([100, 300, 500]);
44
+ });
45
+
46
+ test("open chains are never returned regardless of position", () => {
47
+ // 4 closed + 1 open; K=3 → only 1 closed oldest eligible (open doesn't count toward window)
48
+ const chains = [closed(100), open(200), closed(500), closed(700), closed(900)];
49
+ const result = selectEligible(chains, 3, new Set());
50
+ expect(result).toHaveLength(1);
51
+ expect(result[0].startUserTimestamp).toBe(100);
52
+ });
53
+
54
+ test("already-compressed chains are excluded and don't count toward window", () => {
55
+ // closed: [100, 300, 500, 700], K=1, already={100,300}
56
+ // not-already-compressed closed: [500, 700]; 2 chains, K=1 → take 1 → [500]
57
+ const chains = [closed(100), closed(300), closed(500), closed(700)];
58
+ const result = selectEligible(chains, 1, new Set([100, 300]));
59
+ expect(result).toHaveLength(1);
60
+ expect(result[0].startUserTimestamp).toBe(500);
61
+ });
62
+
63
+ test("K=0 → all closed not-already-compressed chains returned", () => {
64
+ const chains = [closed(100), closed(300), closed(500)];
65
+ expect(selectEligible(chains, 0, new Set())).toHaveLength(3);
66
+ });
67
+
68
+ test("K=0 with already-compressed → only not-yet-compressed", () => {
69
+ const chains = [closed(100), closed(300), closed(500)];
70
+ const result = selectEligible(chains, 0, new Set([100]));
71
+ expect(result).toHaveLength(2);
72
+ expect(result.map((c) => c.startUserTimestamp)).toEqual([300, 500]);
73
+ });
74
+
75
+ test("empty-middle chains never selected regardless of K", () => {
76
+ // Conversational exchanges (no tool calls) must never occupy rolling-window slots.
77
+ const withTools = closed(300, ["tc1", "tc2"]);
78
+ const withTools2 = closed(400, ["tc3"]);
79
+ // K=0 means compress everything eligible; empty-middle chains should still be excluded.
80
+ const result = selectEligible([emptyMiddle(100), emptyMiddle(200), withTools, withTools2], 0, new Set());
81
+ expect(result).toHaveLength(2);
82
+ expect(result.map((c) => c.startUserTimestamp)).toEqual([300, 400]);
83
+ // K=1 — only withTools2 stays in window; withTools is oldest eligible.
84
+ const result2 = selectEligible([emptyMiddle(100), emptyMiddle(200), withTools, withTools2], 1, new Set());
85
+ expect(result2).toHaveLength(1);
86
+ expect(result2[0].startUserTimestamp).toBe(300);
87
+ });
88
+ });
89
+
90
+ describe("compressEligible", () => {
91
+ function makeIndexer(opts: {
92
+ chainEntries?: ChainCompressionEntry[];
93
+ hasSummary?: boolean;
94
+ toolRefs?: string[];
95
+ perBatchSummaries?: string[];
96
+ } = {}): ChainCompressorIndexerDeps {
97
+ return {
98
+ getChainEntries: () => opts.chainEntries ?? [],
99
+ hasPerBatchSummaryCoveringAny: (_ids: string[]) => opts.hasSummary ?? true,
100
+ getPerBatchSummariesForToolCallIds: (_ids: string[]) => opts.perBatchSummaries ?? [],
101
+ getToolRefsForToolCallIds: (_ids: string[]) => opts.toolRefs ?? [],
102
+ registerChain: (_entry: ChainCompressionEntry) => {},
103
+ } satisfies ChainCompressorIndexerDeps;
104
+ }
105
+
106
+ function makeBlockRefs(ids: string[] = ["b1", "b2", "b3"]) {
107
+ let i = 0;
108
+ return { issue: () => ids[i++] ?? `b${i}` } satisfies Pick<import("./block-refs.js").BlockRefIssuer, "issue">;
109
+ }
110
+
111
+ test("compresses eligible chains and returns entries", async () => {
112
+ const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)];
113
+ const appended: unknown[] = [];
114
+ const result = await compressEligible(chains, 3, {
115
+ indexer: makeIndexer({ hasSummary: true }),
116
+ blockRefs: makeBlockRefs(["b1"]),
117
+ appendEntry: (_type, data) => appended.push(data),
118
+ now: () => 9999,
119
+ });
120
+ expect(result.compressedEntries).toHaveLength(1);
121
+ expect(result.compressedEntries[0].blockId).toBe("b1");
122
+ expect(result.compressedEntries[0].startUserTimestamp).toBe(100);
123
+ expect(result.compressedEntries[0].compressedAt).toBe(9999);
124
+ expect(appended).toHaveLength(1);
125
+ });
126
+
127
+ test("skips chain with no summary and records reason", async () => {
128
+ const chains = [closed(100, ["tc1"]), closed(300, ["tc2"]), closed(500), closed(700)];
129
+ const result = await compressEligible(chains, 3, {
130
+ indexer: makeIndexer({ hasSummary: false }),
131
+ blockRefs: makeBlockRefs(),
132
+ appendEntry: () => {},
133
+ now: () => 1,
134
+ });
135
+ expect(result.compressedEntries).toHaveLength(0);
136
+ expect(result.skipped).toHaveLength(1);
137
+ expect(result.skipped[0]).toEqual({ startUserTimestamp: 100, reason: "no-summary" });
138
+ });
139
+
140
+ test("reports already-compressed chains in skipped list", async () => {
141
+ const existing: ChainCompressionEntry = {
142
+ blockId: "b1",
143
+ startUserTimestamp: 100,
144
+ droppedToolCallIds: ["tc-100"],
145
+ finalAssistantTimestamp: 200,
146
+ toolRefs: [],
147
+ compressedAt: 0,
148
+ };
149
+ // 4 closed chains, K=3, chain@100 already compressed → none newly eligible
150
+ const chains = [closed(100), closed(300), closed(500), closed(700)];
151
+ const result = await compressEligible(chains, 3, {
152
+ indexer: makeIndexer({ chainEntries: [existing] }),
153
+ blockRefs: makeBlockRefs(),
154
+ appendEntry: () => {},
155
+ now: () => 1,
156
+ });
157
+ // Primary contract: already-compressed chains must never be double-compressed.
158
+ expect(result.compressedEntries).toHaveLength(0);
159
+ expect(result.skipped).toHaveLength(1);
160
+ expect(result.skipped[0]).toEqual({ startUserTimestamp: 100, reason: "already-compressed" });
161
+ });
162
+
163
+ test("appendEntry is called with CUSTOM_TYPE_CHAIN as the type argument", async () => {
164
+ const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)];
165
+ const calls: Array<{ type: string; data: unknown }> = [];
166
+ await compressEligible(chains, 3, {
167
+ indexer: makeIndexer({ hasSummary: true }),
168
+ blockRefs: makeBlockRefs(["b1"]),
169
+ appendEntry: (type, data) => calls.push({ type, data }),
170
+ now: () => 0,
171
+ });
172
+ expect(calls).toHaveLength(1);
173
+ expect(calls[0].type).toBe(CUSTOM_TYPE_CHAIN);
174
+ });
175
+
176
+ test("fuses range summary when >=2 per-batch summaries and fuseRange is provided", async () => {
177
+ const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)];
178
+ const fuseCalls: string[] = [];
179
+ const result = await compressEligible(chains, 3, {
180
+ indexer: makeIndexer({ hasSummary: true, perBatchSummaries: ["s1", "s2"] }),
181
+ blockRefs: makeBlockRefs(["b1"]),
182
+ appendEntry: () => {},
183
+ now: () => 1,
184
+ fuseRange: async (text) => {
185
+ fuseCalls.push(text);
186
+ return "FUSED";
187
+ },
188
+ });
189
+ expect(fuseCalls).toEqual(["s1\n\ns2"]);
190
+ expect(result.compressedEntries[0].rangeSummaryText).toBe("FUSED");
191
+ });
192
+
193
+ test("does not fuse a single per-batch summary", async () => {
194
+ const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)];
195
+ let fuseCalled = false;
196
+ const result = await compressEligible(chains, 3, {
197
+ indexer: makeIndexer({ hasSummary: true, perBatchSummaries: ["only-one"] }),
198
+ blockRefs: makeBlockRefs(["b1"]),
199
+ appendEntry: () => {},
200
+ now: () => 1,
201
+ fuseRange: async () => {
202
+ fuseCalled = true;
203
+ return "FUSED";
204
+ },
205
+ });
206
+ expect(fuseCalled).toBe(false);
207
+ expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined();
208
+ });
209
+
210
+ test("fusion returning null falls back to no rangeSummaryText (still compresses)", async () => {
211
+ const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)];
212
+ const result = await compressEligible(chains, 3, {
213
+ indexer: makeIndexer({ hasSummary: true, perBatchSummaries: ["s1", "s2"] }),
214
+ blockRefs: makeBlockRefs(["b1"]),
215
+ appendEntry: () => {},
216
+ now: () => 1,
217
+ fuseRange: async () => null,
218
+ });
219
+ expect(result.compressedEntries).toHaveLength(1);
220
+ expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined();
221
+ });
222
+
223
+ test("fusion throwing is non-fatal — chain still compresses via fallback", async () => {
224
+ const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)];
225
+ const result = await compressEligible(chains, 3, {
226
+ indexer: makeIndexer({ hasSummary: true, perBatchSummaries: ["s1", "s2"] }),
227
+ blockRefs: makeBlockRefs(["b1"]),
228
+ appendEntry: () => {},
229
+ now: () => 1,
230
+ fuseRange: async () => {
231
+ throw new Error("boom");
232
+ },
233
+ });
234
+ expect(result.compressedEntries).toHaveLength(1);
235
+ expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined();
236
+ });
237
+
238
+ test("copies protectedToolCallIds from the range onto the entry when non-empty", async () => {
239
+ const chainWithProtected: ChainRange = {
240
+ startUserTimestamp: 100,
241
+ middleToolCallIds: ["tc1"],
242
+ finalAssistantTimestamp: 200,
243
+ protectedToolCallIds: ["b"],
244
+ };
245
+ const appended: unknown[] = [];
246
+ const result = await compressEligible(
247
+ [chainWithProtected, closed(300), closed(500), closed(700)],
248
+ 3,
249
+ {
250
+ indexer: makeIndexer({ hasSummary: true }),
251
+ blockRefs: makeBlockRefs(["b1"]),
252
+ appendEntry: (_type, data) => appended.push(data),
253
+ now: () => 1,
254
+ },
255
+ );
256
+ expect(result.compressedEntries).toHaveLength(1);
257
+ expect(result.compressedEntries[0].protectedToolCallIds).toEqual(["b"]);
258
+ expect((appended[0] as ChainCompressionEntry).protectedToolCallIds).toEqual(["b"]);
259
+ });
260
+
261
+ test("omits protectedToolCallIds field entirely when range has none", async () => {
262
+ const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)];
263
+ const result = await compressEligible(chains, 3, {
264
+ indexer: makeIndexer({ hasSummary: true }),
265
+ blockRefs: makeBlockRefs(["b1"]),
266
+ appendEntry: () => {},
267
+ now: () => 1,
268
+ });
269
+ expect(result.compressedEntries).toHaveLength(1);
270
+ expect("protectedToolCallIds" in result.compressedEntries[0]).toBe(false);
271
+ });
272
+
273
+ test("no fuseRange provided → no rangeSummaryText (concat fallback at render)", async () => {
274
+ const chains = [closed(100, ["tc1"]), closed(300), closed(500), closed(700)];
275
+ const result = await compressEligible(chains, 3, {
276
+ indexer: makeIndexer({ hasSummary: true, perBatchSummaries: ["s1", "s2"] }),
277
+ blockRefs: makeBlockRefs(["b1"]),
278
+ appendEntry: () => {},
279
+ now: () => 1,
280
+ });
281
+ expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined();
282
+ });
283
+ });
@@ -0,0 +1,132 @@
1
+ import { CUSTOM_TYPE_CHAIN } from "./types.js";
2
+ import type { ChainRange, ChainCompressionEntry } from "./types.js";
3
+ import type { ToolCallIndexer } from "./indexer.js";
4
+ import type { BlockRefIssuer } from "./block-refs.js";
5
+
6
+ /**
7
+ * Pure eligibility filter: given all detected chains, return the subset
8
+ * that should be compressed — closed, not already compressed, and older
9
+ * than the rolling window.
10
+ *
11
+ * Extracted for unit testing without needing a real indexer or appendEntry.
12
+ *
13
+ * @param chains Must be in chronological order (oldest first), as emitted by
14
+ * chain-detector. Ordering is not validated here; out-of-order input silently
15
+ * picks wrong chains because the rolling-window slice is positional.
16
+ */
17
+ export function selectEligible(
18
+ chains: ChainRange[],
19
+ rollingWindow: number,
20
+ alreadyCompressed: Set<number>,
21
+ ): ChainRange[] {
22
+ const candidates = chains.filter(
23
+ (c) =>
24
+ c.finalAssistantTimestamp !== null &&
25
+ !alreadyCompressed.has(c.startUserTimestamp) &&
26
+ c.middleToolCallIds.length > 0,
27
+ );
28
+ return candidates.slice(0, Math.max(0, candidates.length - rollingWindow));
29
+ }
30
+
31
+ /**
32
+ * The subset of ToolCallIndexer that compressEligible actually uses.
33
+ * Accepting this narrower interface keeps the function testable without a full indexer
34
+ * and documents its real dependency surface.
35
+ */
36
+ export interface ChainCompressorIndexerDeps {
37
+ getChainEntries(): import("./types.js").ChainCompressionEntry[];
38
+ hasPerBatchSummaryCoveringAny(toolCallIds: string[]): boolean;
39
+ getPerBatchSummariesForToolCallIds(toolCallIds: string[]): string[];
40
+ getToolRefsForToolCallIds(toolCallIds: string[]): string[];
41
+ registerChain(entry: import("./types.js").ChainCompressionEntry): void;
42
+ }
43
+
44
+ export interface CompressEligibleDeps {
45
+ indexer: ChainCompressorIndexerDeps;
46
+ blockRefs: BlockRefIssuer;
47
+ /** pi.appendEntry binding — routes to session or runtime depending on caller context */
48
+ appendEntry: (customType: string, data: unknown) => void;
49
+ /** Injectable clock for deterministic tests */
50
+ now: () => number;
51
+ /**
52
+ * Optional range-summary fuser (B). When present, a span with >= 2 per-batch
53
+ * summaries gets one LLM call fusing them into a cohesive `rangeSummaryText`.
54
+ * Returning null (or throwing) is non-fatal: the chain still compresses and
55
+ * the renderer falls back to the per-batch concatenation.
56
+ */
57
+ fuseRange?: (perBatchSummaryText: string) => Promise<string | null>;
58
+ }
59
+
60
+ export interface CompressEligibleResult {
61
+ compressedEntries: ChainCompressionEntry[];
62
+ skipped: Array<{ startUserTimestamp: number; reason: "no-summary" | "already-compressed" }>;
63
+ }
64
+
65
+ /**
66
+ * Compresses all chains that are outside the rolling window.
67
+ * Reads existing chain state from the indexer so calls are safe to repeat
68
+ * (already-compressed chains are detected and reported, not double-compressed).
69
+ */
70
+ export async function compressEligible(
71
+ chains: ChainRange[],
72
+ rollingWindow: number,
73
+ deps: CompressEligibleDeps,
74
+ ): Promise<CompressEligibleResult> {
75
+ const alreadyCompressedTimestamps = new Set(
76
+ deps.indexer.getChainEntries().map((e) => e.startUserTimestamp),
77
+ );
78
+
79
+ const skipped: CompressEligibleResult["skipped"] = [];
80
+
81
+ // Report already-compressed closed chains for observability.
82
+ for (const chain of chains) {
83
+ if (chain.finalAssistantTimestamp !== null && alreadyCompressedTimestamps.has(chain.startUserTimestamp)) {
84
+ skipped.push({ startUserTimestamp: chain.startUserTimestamp, reason: "already-compressed" });
85
+ }
86
+ }
87
+
88
+ const eligible = selectEligible(chains, rollingWindow, alreadyCompressedTimestamps);
89
+
90
+ const compressedEntries: ChainCompressionEntry[] = [];
91
+ for (const chain of eligible) {
92
+ if (!deps.indexer.hasPerBatchSummaryCoveringAny(chain.middleToolCallIds)) {
93
+ skipped.push({ startUserTimestamp: chain.startUserTimestamp, reason: "no-summary" });
94
+ continue;
95
+ }
96
+
97
+ const blockId = deps.blockRefs.issue();
98
+ const toolRefs = deps.indexer.getToolRefsForToolCallIds(chain.middleToolCallIds);
99
+
100
+ // B: fuse this span's per-batch summaries into one cohesive summary.
101
+ // Gated on >= 2 summaries (nothing to fuse otherwise). Non-fatal.
102
+ let rangeSummaryText: string | undefined;
103
+ if (deps.fuseRange) {
104
+ const summaries = deps.indexer.getPerBatchSummariesForToolCallIds(chain.middleToolCallIds);
105
+ if (summaries.length >= 2) {
106
+ try {
107
+ const fused = await deps.fuseRange(summaries.join("\n\n"));
108
+ if (fused && fused.trim()) rangeSummaryText = fused;
109
+ } catch {
110
+ // fall back to the per-batch concatenation at render time
111
+ }
112
+ }
113
+ }
114
+
115
+ const entry: ChainCompressionEntry = {
116
+ blockId,
117
+ startUserTimestamp: chain.startUserTimestamp,
118
+ droppedToolCallIds: chain.middleToolCallIds,
119
+ finalAssistantTimestamp: chain.finalAssistantTimestamp,
120
+ toolRefs,
121
+ compressedAt: deps.now(),
122
+ ...(rangeSummaryText ? { rangeSummaryText } : {}),
123
+ ...(chain.protectedToolCallIds?.length ? { protectedToolCallIds: chain.protectedToolCallIds } : {}),
124
+ };
125
+
126
+ deps.appendEntry(CUSTOM_TYPE_CHAIN, entry);
127
+ deps.indexer.registerChain(entry);
128
+ compressedEntries.push(entry);
129
+ }
130
+
131
+ return { compressedEntries, skipped };
132
+ }