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
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "pi-condense",
3
+ "version": "2.0.0",
4
+ "description": "Pi extension that summarizes completed tool-call batches, replaces raw outputs with short stubs in future context, and recovers any original on demand via context_tree_query.",
5
+ "author": "Jacek Juraszek",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jjuraszek/pi-condense.git"
11
+ },
12
+ "homepage": "https://github.com/jjuraszek/pi-condense#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/jjuraszek/pi-condense/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "pi-coding-agent",
20
+ "context",
21
+ "context-management",
22
+ "pruning",
23
+ "summarization",
24
+ "ai"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "files": [
30
+ "index.ts",
31
+ "src/**/*.ts",
32
+ "README.md",
33
+ "CHANGELOG.md",
34
+ "PRUNING.md"
35
+ ],
36
+ "scripts": {
37
+ "test": "bun test src/"
38
+ },
39
+ "pi": {
40
+ "extensions": [
41
+ "./index.ts"
42
+ ]
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "@earendil-works/pi-ai": "*",
47
+ "@earendil-works/pi-tui": "*",
48
+ "@sinclair/typebox": "*"
49
+ },
50
+ "devDependencies": {
51
+ "@earendil-works/pi-ai": "^0.78.0",
52
+ "@earendil-works/pi-coding-agent": "^0.78.0",
53
+ "@earendil-works/pi-tui": "^0.78.0",
54
+ "@sinclair/typebox": "^0.34.49"
55
+ }
56
+ }
@@ -0,0 +1,226 @@
1
+ import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
2
+
3
+ /** Joins the text blocks of a ToolResultMessage into a single string. */
4
+ export function extractToolResultText(msg: any): string {
5
+ const content: any[] = Array.isArray(msg?.content) ? msg.content : [];
6
+ return content
7
+ .filter((c: any) => c.type === "text")
8
+ .map((c: any) => c.text)
9
+ .join("\n");
10
+ }
11
+
12
+ /**
13
+ * Converts turn_end event data into a CapturedBatch.
14
+ * @param message AssistantMessage (content: Array of TextContent|ThinkingContent|ToolCall)
15
+ * @param toolResults ToolResultMessage[]
16
+ */
17
+ export function captureBatch(
18
+ message: any,
19
+ toolResults: any[],
20
+ turnIndex: number,
21
+ timestamp: number
22
+ ): CapturedBatch {
23
+ const content: any[] = Array.isArray(message?.content) ? message.content : [];
24
+
25
+ // Collect assistant prose text
26
+ const assistantText = content
27
+ .filter((block: any) => block.type === "text")
28
+ .map((block: any) => block.text)
29
+ .join("\n")
30
+ .trim();
31
+
32
+ // Collect tool calls, matching each to its result
33
+ const toolCalls: CapturedToolCall[] = content
34
+ .filter((block: any) => block.type === "toolCall")
35
+ .map((block: any) => {
36
+ const match = toolResults.find((result: any) => result.toolCallId === block.id);
37
+
38
+ let resultText = "(no result)";
39
+ let isError = false;
40
+
41
+ if (match) {
42
+ resultText = extractToolResultText(match);
43
+ isError = match.isError ?? false;
44
+ }
45
+
46
+ return {
47
+ toolCallId: block.id,
48
+ toolName: block.name,
49
+ args: block.input ?? block.args ?? block.arguments ?? {},
50
+ resultText,
51
+ isError,
52
+ } satisfies CapturedToolCall;
53
+ });
54
+
55
+ return { turnIndex, timestamp, assistantText, toolCalls };
56
+ }
57
+
58
+ /**
59
+ * Scans a session branch for unsummarized tool results and groups them into CapturedBatches.
60
+ * Useful for capturing results from the current in-progress turn when a prune is triggered.
61
+ *
62
+ * @param branch The session message branch (from ctx.sessionManager.getBranch())
63
+ * @param indexer The pruner indexer to check for already-summarized IDs
64
+ * @param exclude Optional predicate; matching tool calls are skipped (user-protected tools/paths)
65
+ */
66
+ export function captureUnindexedBatchesFromSession(
67
+ branch: any[],
68
+ indexer: { isSummarized(id: string): boolean },
69
+ exclude: (toolName: string, args: unknown) => boolean = () => false
70
+ ): CapturedBatch[] {
71
+ // branch is SessionEntry[]. Each message entry has { type: "message", message: AgentMessage }.
72
+ // We must unwrap the SessionEntry wrapper before accessing role/toolCallId.
73
+ const resultMap = new Map<string, any>();
74
+ for (const entry of branch) {
75
+ if (entry.type !== "message") continue;
76
+ const m = entry.message;
77
+ if (m.role === "toolResult" && m.toolCallId) {
78
+ resultMap.set(m.toolCallId, m);
79
+ }
80
+ }
81
+
82
+ const batches: CapturedBatch[] = [];
83
+ // turnCounter increments for EVERY assistant message (not just prunable ones).
84
+ // This makes turnIndex stable across multiple prune cycles: pruning removes
85
+ // ToolResultMessages from the context event but leaves AssistantMessages in the
86
+ // session branch, so the count of all assistant messages never decreases and
87
+ // always matches Pi's own event.turnIndex numbering.
88
+ let turnCounter = 0;
89
+
90
+ // userTurnGroup increments on every user message seen while walking the branch.
91
+ // All assistant tool-call batches between two consecutive user messages share the
92
+ // same userTurnGroup. This is used by groupBatchesByMode to merge turns within
93
+ // a single user → final-agent-message span when batchingMode === "agent-message".
94
+ let userTurnGroup = 0;
95
+
96
+ for (const entry of branch) {
97
+ if (entry.type !== "message") continue;
98
+ const msg = entry.message;
99
+
100
+ // Advance userTurnGroup on every user message so all subsequent assistant
101
+ // batches get a new group number.
102
+ if (msg.role === "user") {
103
+ userTurnGroup++;
104
+ continue;
105
+ }
106
+
107
+ if (msg.role !== "assistant") continue;
108
+
109
+ // Stable turn index: count every assistant message regardless of pruning state
110
+ const currentTurnIndex = turnCounter++;
111
+
112
+ const content = Array.isArray(msg.content) ? msg.content : [];
113
+ const toolCallBlocks = content.filter((c: any) => c.type === "toolCall");
114
+
115
+ // Find tool calls that have results in this branch and are not yet summarized
116
+ const readyToPrune = toolCallBlocks.filter((tc: any) => {
117
+ const id = tc.id;
118
+ if (!id) return false;
119
+ if (indexer.isSummarized(id)) return false;
120
+ if (exclude(tc.name, tc.input ?? tc.arguments)) return false;
121
+ return resultMap.has(id);
122
+ });
123
+
124
+ if (readyToPrune.length > 0) {
125
+ const results = readyToPrune.map((tc: any) => resultMap.get(tc.id));
126
+ const readyIds = new Set(readyToPrune.map((tc: any) => tc.id));
127
+ // We pass the full message but then trim back down to only the tool calls
128
+ // whose results already exist in the session. This lets a flush prune
129
+ // an intermediate completed subset in the middle of a longer tool chain
130
+ // without accidentally capturing later unresolved calls from the same
131
+ // assistant message as "(no result)" placeholders.
132
+ const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : (msg.timestamp ?? Date.now());
133
+ const batch = captureBatch(msg, results, currentTurnIndex, ts);
134
+ batches.push({
135
+ ...batch,
136
+ toolCalls: batch.toolCalls.filter((tc) => readyIds.has(tc.toolCallId)),
137
+ // Tag with the current group so flushPending can merge by mode
138
+ userTurnGroup,
139
+ });
140
+ }
141
+ }
142
+
143
+ return batches;
144
+ }
145
+
146
+ /** Serializes a single CapturedBatch into readable text for the summarizer LLM. */
147
+ export function serializeBatchForSummarizer(batch: CapturedBatch): string {
148
+ const parts: string[] = [];
149
+
150
+ if (batch.assistantText) {
151
+ parts.push(`Assistant said: ${batch.assistantText}\n`);
152
+ }
153
+
154
+ const toolParts = batch.toolCalls.map((tc) => {
155
+ const status = tc.isError ? "ERROR" : "OK";
156
+ const argsJson = JSON.stringify(tc.args, null, 2);
157
+
158
+ let resultText = tc.resultText;
159
+ const MAX_CHARS = 2000;
160
+ if (resultText.length > MAX_CHARS) {
161
+ const remaining = resultText.length - MAX_CHARS;
162
+ resultText = resultText.slice(0, MAX_CHARS) + ` ...[${remaining} chars truncated]`;
163
+ }
164
+
165
+ return `Tool: ${tc.toolName}(${argsJson})\nResult (${status}): ${resultText}`;
166
+ });
167
+
168
+ parts.push(toolParts.join("\n---\n"));
169
+
170
+ return parts.join("\n");
171
+ }
172
+
173
+ /**
174
+ * Groups CapturedBatches according to the chosen batching mode.
175
+ *
176
+ * - "turn" : returns the input array unchanged (one summary per assistant turn).
177
+ * - "agent-message" : merges all consecutive batches that share the same `userTurnGroup`
178
+ * into a single CapturedBatch, producing one summary per
179
+ * user → final-agent-message span.
180
+ *
181
+ * Batches without a `userTurnGroup` (e.g. from the live `turn_end` capture path) are
182
+ * always passed through one-per-batch regardless of mode — grouping only applies to
183
+ * batches captured from the session branch scan.
184
+ *
185
+ * Merge rules:
186
+ * - `assistantText` = non-empty values joined with "\n\n"
187
+ * - `toolCalls` = concatenation in original order
188
+ * - `turnIndex` = last batch's turnIndex (latest turn in the group)
189
+ * - `timestamp` = last batch's timestamp
190
+ * - `userTurnGroup` = shared group value of the merged batches
191
+ */
192
+ export function groupBatchesByMode(batches: CapturedBatch[], mode: BatchingMode): CapturedBatch[] {
193
+ if (mode !== "agent-message") return batches;
194
+
195
+ const out: CapturedBatch[] = [];
196
+ // current tracks the mutable merged batch being built for the current group.
197
+ // We spread into a plain object so we can mutate it without affecting the source.
198
+ let current: CapturedBatch & { userTurnGroup: number } | null = null;
199
+
200
+ for (const batch of batches) {
201
+ // Batches without a group key are passed through individually; they break
202
+ // any open merge group too since we can't confidently assign them a span.
203
+ if (batch.userTurnGroup === undefined) {
204
+ current = null;
205
+ out.push(batch);
206
+ continue;
207
+ }
208
+
209
+ if (current !== null && current.userTurnGroup === batch.userTurnGroup) {
210
+ // Same span — merge into the current accumulated batch
211
+ const textParts = [current.assistantText, batch.assistantText].filter(Boolean);
212
+ current.assistantText = textParts.join("\n\n");
213
+ current.toolCalls = current.toolCalls.concat(batch.toolCalls);
214
+ // Advance to the latest turn metadata
215
+ current.turnIndex = batch.turnIndex;
216
+ current.timestamp = batch.timestamp;
217
+ } else {
218
+ // New group — create a fresh accumulated batch (shallow copy so mutations
219
+ // to `current` do not bleed back into the original `batch` object)
220
+ current = { ...batch, userTurnGroup: batch.userTurnGroup };
221
+ out.push(current);
222
+ }
223
+ }
224
+
225
+ return out;
226
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BlockRefIssuer } from "./block-refs.js";
3
+
4
+ describe("BlockRefIssuer", () => {
5
+ test("first issue() returns b1", () => {
6
+ const issuer = new BlockRefIssuer();
7
+ expect(issuer.issue()).toBe("b1");
8
+ });
9
+
10
+ test("subsequent calls are monotonic", () => {
11
+ const issuer = new BlockRefIssuer();
12
+ expect(issuer.issue()).toBe("b1");
13
+ expect(issuer.issue()).toBe("b2");
14
+ expect(issuer.issue()).toBe("b3");
15
+ });
16
+
17
+ test("rebuildFrom([]) → next is b1", () => {
18
+ const issuer = new BlockRefIssuer();
19
+ issuer.rebuildFrom([]);
20
+ expect(issuer.issue()).toBe("b1");
21
+ });
22
+
23
+ test("rebuildFrom([b1, b2]) → next is b3", () => {
24
+ const issuer = new BlockRefIssuer();
25
+ issuer.rebuildFrom(["b1", "b2"]);
26
+ expect(issuer.issue()).toBe("b3");
27
+ });
28
+
29
+ test("rebuildFrom gapped sequence → max+1, not gap-fill", () => {
30
+ const issuer = new BlockRefIssuer();
31
+ issuer.rebuildFrom(["b1", "b3"]);
32
+ expect(issuer.issue()).toBe("b4");
33
+ });
34
+
35
+ test("rebuildFrom resets counter even after prior issues", () => {
36
+ const issuer = new BlockRefIssuer();
37
+ issuer.issue(); // b1
38
+ issuer.issue(); // b2
39
+ issuer.rebuildFrom(["b5"]);
40
+ expect(issuer.issue()).toBe("b6");
41
+ });
42
+ });
@@ -0,0 +1,16 @@
1
+ export class BlockRefIssuer {
2
+ private next = 1;
3
+
4
+ issue(): string {
5
+ return `b${this.next++}`;
6
+ }
7
+
8
+ rebuildFrom(existingBlockIds: string[]): void {
9
+ if (existingBlockIds.length === 0) {
10
+ this.next = 1;
11
+ return;
12
+ }
13
+ const max = Math.max(...existingBlockIds.map((id) => parseInt(id.slice(1), 10)));
14
+ this.next = max + 1;
15
+ }
16
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./budget.js";
3
+
4
+ const usage = (tokens: number | null, contextWindow: number) =>
5
+ ({ tokens, contextWindow, percent: null }) as any;
6
+
7
+ describe("shouldBudgetFlush", () => {
8
+ it("is false when threshold is null", () => {
9
+ expect(shouldBudgetFlush(usage(900, 1000), null)).toBe(false);
10
+ });
11
+ it("is false for non-positive or >1 thresholds", () => {
12
+ expect(shouldBudgetFlush(usage(900, 1000), 0)).toBe(false);
13
+ expect(shouldBudgetFlush(usage(900, 1000), 1.5)).toBe(false);
14
+ });
15
+ it("is false when usage is undefined", () => {
16
+ expect(shouldBudgetFlush(undefined, 0.8)).toBe(false);
17
+ });
18
+ it("is false when tokens is null (post-compaction)", () => {
19
+ expect(shouldBudgetFlush(usage(null, 1000), 0.8)).toBe(false);
20
+ });
21
+ it("is false when contextWindow is non-positive", () => {
22
+ expect(shouldBudgetFlush(usage(900, 0), 0.8)).toBe(false);
23
+ });
24
+ it("is true at or over the threshold, false under", () => {
25
+ expect(shouldBudgetFlush(usage(800, 1000), 0.8)).toBe(true);
26
+ expect(shouldBudgetFlush(usage(900, 1000), 0.8)).toBe(true);
27
+ expect(shouldBudgetFlush(usage(799, 1000), 0.8)).toBe(false);
28
+ });
29
+
30
+ it("treats threshold of exactly 1.0 as valid (flush only at 100%)", () => {
31
+ expect(shouldBudgetFlush(usage(1000, 1000), 1)).toBe(true);
32
+ expect(shouldBudgetFlush(usage(999, 1000), 1)).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe("usageFraction", () => {
37
+ it("returns null for undefined / null tokens / non-positive window", () => {
38
+ expect(usageFraction(undefined)).toBeNull();
39
+ expect(usageFraction(usage(null, 1000))).toBeNull();
40
+ expect(usageFraction(usage(900, 0))).toBeNull();
41
+ });
42
+ it("returns the 0–1 fraction", () => {
43
+ expect(usageFraction(usage(750, 1000))).toBe(0.75);
44
+ });
45
+ });
46
+
47
+ describe("shouldDeltaFlush", () => {
48
+ it("is false when delta is null, non-positive, or >1", () => {
49
+ expect(shouldDeltaFlush(usage(900, 1000), 0.5, null)).toBe(false);
50
+ expect(shouldDeltaFlush(usage(900, 1000), 0.5, 0)).toBe(false);
51
+ expect(shouldDeltaFlush(usage(900, 1000), 0.5, 1.5)).toBe(false);
52
+ });
53
+ it("is false when previousFraction is null (first turn / post-restart)", () => {
54
+ expect(shouldDeltaFlush(usage(900, 1000), null, 0.15)).toBe(false);
55
+ });
56
+ it("is false when usage missing or tokens null", () => {
57
+ expect(shouldDeltaFlush(undefined, 0.5, 0.15)).toBe(false);
58
+ expect(shouldDeltaFlush(usage(null, 1000), 0.5, 0.15)).toBe(false);
59
+ });
60
+ it("fires when the jump meets the delta, not below", () => {
61
+ expect(shouldDeltaFlush(usage(700, 1000), 0.5, 0.15)).toBe(true); // 0.20 >= 0.15
62
+ expect(shouldDeltaFlush(usage(650, 1000), 0.5, 0.15)).toBe(true); // 0.15 exactly
63
+ expect(shouldDeltaFlush(usage(640, 1000), 0.5, 0.15)).toBe(false); // 0.14 < 0.15
64
+ expect(shouldDeltaFlush(usage(600, 1000), 0.5, 0.15)).toBe(false); // 0.10 < 0.15
65
+ });
66
+ });
package/src/budget.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { ContextUsage } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * True iff a budget-triggered flush should fire. Computes the ratio ourselves
5
+ * (tokens / contextWindow, a 0–1 fraction) rather than using ContextUsage.percent
6
+ * (a 0–100 value, null when tokens is null). tokens is also null right after a
7
+ * compaction — guarded here.
8
+ */
9
+ export function shouldBudgetFlush(
10
+ usage: ContextUsage | undefined,
11
+ threshold: number | null,
12
+ ): boolean {
13
+ if (threshold == null || threshold <= 0 || threshold > 1) return false;
14
+ if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return false;
15
+ return usage.tokens / usage.contextWindow >= threshold;
16
+ }
17
+
18
+ /** 0–1 usage fraction, or null when usage is missing / tokens null / window non-positive. */
19
+ export function usageFraction(usage: ContextUsage | undefined): number | null {
20
+ if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return null;
21
+ return usage.tokens / usage.contextWindow;
22
+ }
23
+
24
+ /**
25
+ * True iff this turn's usage fraction rose by at least `delta` versus the previous turn.
26
+ * Mirrors shouldBudgetFlush's guards. previousFraction === null (first turn or post-restart)
27
+ * never fires; the absolute autoBudgetThreshold covers that gap.
28
+ */
29
+ export function shouldDeltaFlush(
30
+ usage: ContextUsage | undefined,
31
+ previousFraction: number | null,
32
+ delta: number | null,
33
+ ): boolean {
34
+ if (delta == null || delta <= 0 || delta > 1) return false;
35
+ if (previousFraction == null) return false;
36
+ const current = usageFraction(usage);
37
+ if (current == null) return false;
38
+ return current - previousFraction >= delta;
39
+ }