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,302 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { detectChains, withClosingMessage } from "./chain-detector.js";
3
+
4
+ // ── Minimal message factories ──────────────────────────────────────────────
5
+
6
+ function userMsg(timestamp: number, text = "do the thing"): any {
7
+ return { role: "user", content: [{ type: "text", text }], timestamp };
8
+ }
9
+
10
+ function syntheticChainMsg(timestamp: number, blockId = "b1"): any {
11
+ return {
12
+ role: "user",
13
+ content: [{ type: "text", text: `<compressed-chain id="${blockId}" tools="t1">summary</compressed-chain>` }],
14
+ timestamp,
15
+ };
16
+ }
17
+
18
+ function assistantWithTools(timestamp: number, toolCallIds: string[]): any {
19
+ return {
20
+ role: "assistant",
21
+ content: [
22
+ { type: "text", text: "working..." },
23
+ ...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })),
24
+ ],
25
+ timestamp,
26
+ usage: {},
27
+ stopReason: "toolUse",
28
+ };
29
+ }
30
+
31
+ function toolResult(timestamp: number, toolCallId: string): any {
32
+ return {
33
+ role: "toolResult",
34
+ toolCallId,
35
+ toolName: "bash",
36
+ content: [{ type: "text", text: "output" }],
37
+ isError: false,
38
+ timestamp,
39
+ };
40
+ }
41
+
42
+ function assistantText(timestamp: number, text = "done"): any {
43
+ return {
44
+ role: "assistant",
45
+ content: [{ type: "text", text }, { type: "thinking", thinking: "thoughts", thinkingSignature: "sig" }],
46
+ timestamp,
47
+ usage: {},
48
+ stopReason: "stop",
49
+ };
50
+ }
51
+
52
+ // ── Tests ──────────────────────────────────────────────────────────────────
53
+
54
+ describe("detectChains", () => {
55
+ test("empty input returns empty array", () => {
56
+ expect(detectChains([])).toEqual([]);
57
+ });
58
+
59
+ test("single complete chain produces one range", () => {
60
+ const msgs = [
61
+ userMsg(100),
62
+ assistantWithTools(200, ["tc1", "tc2"]),
63
+ toolResult(300, "tc1"),
64
+ toolResult(310, "tc2"),
65
+ assistantText(400),
66
+ ];
67
+ const ranges = detectChains(msgs);
68
+ expect(ranges).toHaveLength(1);
69
+ expect(ranges[0].startUserTimestamp).toBe(100);
70
+ expect(ranges[0].middleToolCallIds).toContain("tc1");
71
+ expect(ranges[0].middleToolCallIds).toContain("tc2");
72
+ expect(ranges[0].finalAssistantTimestamp).toBe(400);
73
+ });
74
+
75
+ test("multi-chain sequence produces N ranges in order", () => {
76
+ const msgs = [
77
+ userMsg(100),
78
+ assistantWithTools(200, ["tc1"]),
79
+ toolResult(300, "tc1"),
80
+ assistantText(400),
81
+ userMsg(500),
82
+ assistantWithTools(600, ["tc2"]),
83
+ toolResult(700, "tc2"),
84
+ assistantText(800),
85
+ ];
86
+ const ranges = detectChains(msgs);
87
+ expect(ranges).toHaveLength(2);
88
+ expect(ranges[0].startUserTimestamp).toBe(100);
89
+ expect(ranges[0].finalAssistantTimestamp).toBe(400);
90
+ expect(ranges[1].startUserTimestamp).toBe(500);
91
+ expect(ranges[1].finalAssistantTimestamp).toBe(800);
92
+ });
93
+
94
+ test("open chain (no text-only close) is not emitted", () => {
95
+ const msgs = [
96
+ userMsg(100),
97
+ assistantWithTools(200, ["tc1"]),
98
+ toolResult(300, "tc1"),
99
+ // no text-only assistant close
100
+ ];
101
+ expect(detectChains(msgs)).toHaveLength(0);
102
+ });
103
+
104
+ test("synthetic chain message is not treated as a chain start", () => {
105
+ const msgs = [
106
+ syntheticChainMsg(50), // should be skipped
107
+ assistantText(200), // text-only but no prior real chain start
108
+ ];
109
+ expect(detectChains(msgs)).toHaveLength(0);
110
+ });
111
+
112
+ test("synthetic chain message in a real multi-chain sequence is a passthrough", () => {
113
+ // Represents a session after one chain was already compressed:
114
+ // synthetic summary, then the next real chain
115
+ const msgs = [
116
+ syntheticChainMsg(50),
117
+ userMsg(100),
118
+ assistantWithTools(200, ["tc1"]),
119
+ toolResult(300, "tc1"),
120
+ assistantText(400),
121
+ ];
122
+ const ranges = detectChains(msgs);
123
+ expect(ranges).toHaveLength(1);
124
+ expect(ranges[0].startUserTimestamp).toBe(100);
125
+ });
126
+
127
+ test("interrupted chain (user interrupts before text-only close) emits with null final", () => {
128
+ const msgs = [
129
+ userMsg(100),
130
+ assistantWithTools(200, ["tc1"]),
131
+ toolResult(300, "tc1"),
132
+ userMsg(400), // second user message interrupts before chain close
133
+ assistantText(500),
134
+ ];
135
+ const ranges = detectChains(msgs);
136
+ // First chain is interrupted → emitted with null finalAssistantTimestamp
137
+ expect(ranges).toHaveLength(2);
138
+ expect(ranges[0].startUserTimestamp).toBe(100);
139
+ expect(ranges[0].finalAssistantTimestamp).toBeNull();
140
+ // Second "chain" opened at ts=400, but text-only at 500 closes it (no tool calls needed)
141
+ // Actually, the second user at 400 starts a chain, but its assistant turn has no toolCalls
142
+ // so middleToolCallIds is empty and it immediately closes with the text-only assistant.
143
+ expect(ranges[1].startUserTimestamp).toBe(400);
144
+ expect(ranges[1].middleToolCallIds).toEqual([]);
145
+ expect(ranges[1].finalAssistantTimestamp).toBe(500);
146
+ });
147
+
148
+ test("agent-message flush: closing assistant absent from branch → newest chain reads as open", () => {
149
+ // Reproduces the runtime state at message_end: pi emits the event to extensions
150
+ // BEFORE appending event.message, so the just-closed assistant is missing here.
151
+ const branch = [
152
+ userMsg(100),
153
+ assistantWithTools(200, ["tc1"]),
154
+ toolResult(300, "tc1"),
155
+ assistantText(400),
156
+ userMsg(500),
157
+ assistantWithTools(600, ["tc2"]),
158
+ toolResult(700, "tc2"),
159
+ // closing assistant for chain 2 not yet in branch
160
+ ];
161
+ const ranges = detectChains(branch);
162
+ expect(ranges).toHaveLength(1); // only chain 1 closed; chain 2 open → dropped
163
+ });
164
+
165
+ test("middleToolCallIds contains all toolCallIds from assistant and toolResult messages", () => {
166
+ const msgs = [
167
+ userMsg(100),
168
+ assistantWithTools(200, ["tc1", "tc2"]),
169
+ toolResult(300, "tc1"),
170
+ toolResult(310, "tc2"),
171
+ assistantWithTools(400, ["tc3"]),
172
+ toolResult(500, "tc3"),
173
+ assistantText(600),
174
+ ];
175
+ const [range] = detectChains(msgs);
176
+ expect(range.middleToolCallIds.sort()).toEqual(["tc1", "tc2", "tc3"].sort());
177
+ });
178
+ });
179
+
180
+ describe("detectChains protectedToolCallIds", () => {
181
+ const chainMsgs = () => [
182
+ { role: "user", timestamp: 1 },
183
+ {
184
+ role: "assistant",
185
+ timestamp: 2,
186
+ content: [
187
+ { type: "toolCall", id: "tc-read", name: "read" },
188
+ { type: "toolCall", id: "tc-todo", name: "todowrite" },
189
+ ],
190
+ },
191
+ { role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "x" }] },
192
+ { role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "plan" }] },
193
+ { role: "assistant", timestamp: 5, content: [{ type: "text", text: "done" }] },
194
+ ];
195
+
196
+ test("is empty when no protectedTools are given", () => {
197
+ const [chain] = detectChains(chainMsgs());
198
+ expect(chain.protectedToolCallIds).toEqual([]);
199
+ expect(chain.middleToolCallIds.sort()).toEqual(["tc-read", "tc-todo"]);
200
+ });
201
+
202
+ test("collects protected ids by tool name from both branches", () => {
203
+ const [chain] = detectChains(chainMsgs(), (name) => name === "todowrite");
204
+ expect(chain.protectedToolCallIds).toEqual(["tc-todo"]);
205
+ expect(chain.middleToolCallIds.sort()).toEqual(["tc-read", "tc-todo"]);
206
+ });
207
+
208
+ test("does not leak protected ids across consecutive chains", () => {
209
+ const msgs = [
210
+ // chain 1: has a protected todowrite
211
+ { role: "user", timestamp: 1 },
212
+ { role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "tc-todo", name: "todowrite" }] },
213
+ { role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "p" }] },
214
+ { role: "assistant", timestamp: 4, content: [{ type: "text", text: "done 1" }] },
215
+ // chain 2: only an unprotected read
216
+ { role: "user", timestamp: 5 },
217
+ { role: "assistant", timestamp: 6, content: [{ type: "toolCall", id: "tc-read", name: "read" }] },
218
+ { role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "x" }] },
219
+ { role: "assistant", timestamp: 8, content: [{ type: "text", text: "done 2" }] },
220
+ ];
221
+ const chains = detectChains(msgs, (name) => name === "todowrite");
222
+ expect(chains.length).toBe(2);
223
+ expect(chains[0].protectedToolCallIds).toEqual(["tc-todo"]);
224
+ expect(chains[1].protectedToolCallIds).toEqual([]);
225
+ expect(chains[1].middleToolCallIds).toEqual(["tc-read"]);
226
+ });
227
+
228
+ test("collects protected ids by args.path via predicate", () => {
229
+ const msgs = [
230
+ { role: "user", timestamp: 1 },
231
+ {
232
+ role: "assistant",
233
+ timestamp: 2,
234
+ content: [
235
+ { type: "toolCall", id: "tc-skill", name: "read", input: { path: "/h/skills/x/SKILL.md" } },
236
+ { type: "toolCall", id: "tc-src", name: "read", input: { path: "/h/src/app.ts" } },
237
+ ],
238
+ },
239
+ { role: "toolResult", toolCallId: "tc-skill", toolName: "read", timestamp: 3, content: [] },
240
+ { role: "toolResult", toolCallId: "tc-src", toolName: "read", timestamp: 4, content: [] },
241
+ { role: "assistant", timestamp: 5, content: [{ type: "text", text: "done" }] },
242
+ ];
243
+ const pred = (name: string, args: unknown) =>
244
+ typeof (args as any)?.path === "string" && (args as any).path.includes("/skills/");
245
+ const [chain] = detectChains(msgs, pred);
246
+ expect(chain.protectedToolCallIds).toEqual(["tc-skill"]);
247
+ });
248
+
249
+ test("populates protectedToolCallIds on an interrupted (open→new user) chain", () => {
250
+ const msgs = [
251
+ { role: "user", timestamp: 1 },
252
+ { role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "tc-todo", name: "todowrite" }] },
253
+ { role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "p" }] },
254
+ { role: "user", timestamp: 4 },
255
+ ];
256
+ const [interrupted] = detectChains(msgs, (name) => name === "todowrite");
257
+ expect(interrupted.finalAssistantTimestamp).toBeNull();
258
+ expect(interrupted.protectedToolCallIds).toEqual(["tc-todo"]);
259
+ });
260
+ });
261
+
262
+ describe("withClosingMessage", () => {
263
+ test("undefined closing returns the same array reference", () => {
264
+ const msgs = [userMsg(100)];
265
+ expect(withClosingMessage(msgs, undefined)).toBe(msgs);
266
+ });
267
+
268
+ test("appends closing when branch does not already end with it", () => {
269
+ const branch = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")];
270
+ const closing = assistantText(400);
271
+ const merged = withClosingMessage(branch, closing);
272
+ expect(merged).toHaveLength(4);
273
+ expect(merged[3]).toBe(closing);
274
+ expect(branch).toHaveLength(3); // original not mutated
275
+ });
276
+
277
+ test("does not double-append when branch already ends with the closing message (role+timestamp)", () => {
278
+ const closing = assistantText(400);
279
+ const branch = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1"), closing];
280
+ expect(withClosingMessage(branch, closing)).toBe(branch);
281
+ // also dedups a distinct object with the same role+timestamp
282
+ const branch2 = [userMsg(100), assistantText(400)];
283
+ expect(withClosingMessage(branch2, assistantText(400))).toBe(branch2);
284
+ });
285
+
286
+ test("threading the closing message makes the newest chain close (effective window = K)", () => {
287
+ const branch = [
288
+ userMsg(100),
289
+ assistantWithTools(200, ["tc1"]),
290
+ toolResult(300, "tc1"),
291
+ assistantText(400),
292
+ userMsg(500),
293
+ assistantWithTools(600, ["tc2"]),
294
+ toolResult(700, "tc2"),
295
+ ];
296
+ const closing = assistantText(800);
297
+ const ranges = detectChains(withClosingMessage(branch, closing));
298
+ expect(ranges).toHaveLength(2);
299
+ expect(ranges[1].startUserTimestamp).toBe(500);
300
+ expect(ranges[1].finalAssistantTimestamp).toBe(800);
301
+ });
302
+ });
@@ -0,0 +1,128 @@
1
+ import type { ChainRange } from "./types.js";
2
+
3
+ /** Prefix that identifies a synthetic chain-compression user message. */
4
+ const COMPRESSED_CHAIN_PREFIX = "<compressed-chain";
5
+
6
+ function isSyntheticChainMessage(msg: any): boolean {
7
+ const content = msg.content;
8
+ if (typeof content === "string") return content.trimStart().startsWith(COMPRESSED_CHAIN_PREFIX);
9
+ if (!Array.isArray(content)) return false;
10
+ const first = content[0];
11
+ return first?.type === "text" && typeof first.text === "string" && first.text.trimStart().startsWith(COMPRESSED_CHAIN_PREFIX);
12
+ }
13
+
14
+ function hasToolCalls(msg: any): boolean {
15
+ return Array.isArray(msg.content) && msg.content.some((b: any) => b.type === "toolCall");
16
+ }
17
+
18
+ function collectToolCalls(msg: any): { id: string; name: string; args: unknown }[] {
19
+ if (!Array.isArray(msg.content)) return [];
20
+ return msg.content
21
+ .filter((b: any) => b.type === "toolCall" && b.id && b.name)
22
+ .map((b: any) => ({ id: b.id as string, name: b.name as string, args: b.input ?? b.arguments }));
23
+ }
24
+
25
+ type State = "idle" | "inChain";
26
+
27
+ /**
28
+ * Walks an AgentMessage array and emits ChainRange records for each detectable chain.
29
+ *
30
+ * A chain is: [user message] → [assistant+toolResult turns...] → [text-only assistant].
31
+ * Synthetic chain messages (injected by chain-range-prune) are treated as passthroughs —
32
+ * not chain starts. This is defensive; the detector normally runs pre-compression.
33
+ *
34
+ * NOTE: Message identity uses `timestamp` (for user / final text-only assistant) and
35
+ * `toolCallId` sets (for middle tool-using turns). AgentMessage has no `.id` field.
36
+ *
37
+ * @param isProtected Predicate over (toolName, args); matching calls are never pruned
38
+ * and their outputs are relocated verbatim into compressed chains.
39
+ */
40
+ export function detectChains(
41
+ messages: any[],
42
+ isProtected: (toolName: string, args: unknown) => boolean = () => false,
43
+ ): ChainRange[] {
44
+ const ranges: ChainRange[] = [];
45
+ let state: State = "idle";
46
+ let chainStart: { timestamp: number } | null = null;
47
+ let middleIds = new Set<string>();
48
+ let protectedIds = new Set<string>();
49
+
50
+ const emitInterrupted = () => {
51
+ if (state === "inChain" && chainStart) {
52
+ ranges.push({
53
+ startUserTimestamp: chainStart.timestamp,
54
+ middleToolCallIds: [...middleIds],
55
+ protectedToolCallIds: [...protectedIds],
56
+ finalAssistantTimestamp: null,
57
+ });
58
+ }
59
+ };
60
+
61
+ for (const msg of messages) {
62
+ if (msg.role === "user") {
63
+ if (isSyntheticChainMessage(msg)) continue; // passthrough — not a chain start
64
+ emitInterrupted();
65
+ chainStart = { timestamp: msg.timestamp };
66
+ middleIds = new Set();
67
+ protectedIds = new Set();
68
+ state = "inChain";
69
+ continue;
70
+ }
71
+
72
+ if (state !== "inChain") continue;
73
+
74
+ if (msg.role === "assistant" && hasToolCalls(msg)) {
75
+ for (const { id, name, args } of collectToolCalls(msg)) {
76
+ middleIds.add(id);
77
+ if (isProtected(name, args)) protectedIds.add(id);
78
+ }
79
+ continue;
80
+ }
81
+
82
+ if (msg.role === "toolResult") {
83
+ if (msg.toolCallId) {
84
+ middleIds.add(msg.toolCallId);
85
+ // toolResult fallback — results carry no args; name-only by design,
86
+ // the assistant block always precedes its result so no protection is lost
87
+ if (isProtected(msg.toolName, undefined)) protectedIds.add(msg.toolCallId);
88
+ }
89
+ continue;
90
+ }
91
+
92
+ if (msg.role === "assistant" && !hasToolCalls(msg)) {
93
+ ranges.push({
94
+ startUserTimestamp: chainStart!.timestamp,
95
+ middleToolCallIds: [...middleIds],
96
+ protectedToolCallIds: [...protectedIds],
97
+ finalAssistantTimestamp: msg.timestamp,
98
+ });
99
+ chainStart = null;
100
+ middleIds = new Set();
101
+ protectedIds = new Set();
102
+ state = "idle";
103
+ }
104
+ }
105
+
106
+ // Open chain at end of input is intentionally dropped (in-flight).
107
+
108
+ return ranges;
109
+ }
110
+
111
+ /**
112
+ * Appends `closing` to a copy of `branchMessages` unless the array already ends with it.
113
+ *
114
+ * pi emits `message_end` to extensions BEFORE persisting the message to the session
115
+ * (agent-session.js `_processAgentEvent` runs `_emitExtensionEvent` ahead of
116
+ * `sessionManager.appendMessage`). At the agent-message flush boundary the just-closed
117
+ * final assistant is therefore still missing from `getBranch()`; without threading it
118
+ * in, the newest chain reads as open and the rolling window over-retains by one
119
+ * (effective K+1 instead of K). Identity is role+timestamp (AgentMessage has no id,
120
+ * matching the detector's own identity model), so a future pi that persists before
121
+ * emitting keeps this a no-op.
122
+ */
123
+ export function withClosingMessage(branchMessages: any[], closing: any): any[] {
124
+ if (!closing) return branchMessages;
125
+ const last = branchMessages[branchMessages.length - 1];
126
+ if (last && last.role === closing.role && last.timestamp === closing.timestamp) return branchMessages;
127
+ return [...branchMessages, closing];
128
+ }