pi-condense 2.4.3 → 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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/PRUNING.md +96 -54
  3. package/README.md +6 -1
  4. package/index.ts +28 -42
  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 -35
  16. package/src/config.test.ts +27 -1
  17. package/src/diagnostics.test.ts +114 -0
  18. package/src/diagnostics.ts +46 -0
  19. package/src/frontier.test.ts +138 -16
  20. package/src/frontier.ts +0 -1
  21. package/src/id-collision.integration.test.ts +251 -0
  22. package/src/indexer.test.ts +336 -0
  23. package/src/indexer.ts +168 -55
  24. package/src/occurrence-key.test.ts +57 -0
  25. package/src/occurrence-key.ts +36 -0
  26. package/src/orphan-sweep.test.ts +67 -0
  27. package/src/orphan-sweep.ts +40 -0
  28. package/src/oversized-spill.integration.test.ts +7 -2
  29. package/src/pruner.test.ts +471 -64
  30. package/src/pruner.ts +84 -54
  31. package/src/query-tool.test.ts +117 -0
  32. package/src/query-tool.ts +47 -31
  33. package/src/range-compression.integration.test.ts +7 -44
  34. package/src/recovery-grace.test.ts +13 -0
  35. package/src/recovery-grace.ts +12 -3
  36. package/src/spill.test.ts +108 -1
  37. package/src/spill.ts +5 -3
  38. package/src/summary-refs.test.ts +51 -1
  39. package/src/summary-refs.ts +15 -4
  40. package/src/test-support.ts +54 -0
  41. package/src/tree-browser.ts +2 -1
  42. package/src/types.ts +56 -49
  43. package/src/thinking-strip.test.ts +0 -257
  44. package/src/thinking-strip.ts +0 -83
@@ -1,257 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { stripOldThinking, computeThinkingBoundary } from "./thinking-strip.js";
3
- import type { ThinkingStripConfig } from "./types.js";
4
-
5
- const cfg = (enabled: boolean, keepLastTurns: number): ThinkingStripConfig => ({ enabled, keepLastTurns });
6
-
7
- function userMsg(ts: number): any {
8
- return { role: "user", content: [{ type: "text", text: "go" }], timestamp: ts };
9
- }
10
-
11
- function assistantToolsThinking(ts: number, toolCallIds: string[], thinkingBlocks = 1): any {
12
- const content: any[] = [];
13
- for (let i = 0; i < thinkingBlocks; i++) {
14
- content.push({ type: "thinking", thinking: `t${ts}-${i}`, thinkingSignature: `sig${ts}-${i}` });
15
- }
16
- content.push({ type: "text", text: "working" });
17
- for (const id of toolCallIds) content.push({ type: "toolCall", id, name: "bash", arguments: { cmd: "ls" } });
18
- return { role: "assistant", content, timestamp: ts, usage: {}, stopReason: "toolUse" };
19
- }
20
-
21
- function assistantTextThinking(ts: number): any {
22
- return {
23
- role: "assistant",
24
- content: [
25
- { type: "thinking", thinking: "final reasoning", thinkingSignature: "sigf" },
26
- { type: "text", text: "done" },
27
- ],
28
- timestamp: ts,
29
- usage: {},
30
- stopReason: "stop",
31
- };
32
- }
33
-
34
- function toolResult(ts: number, toolCallId: string): any {
35
- return {
36
- role: "toolResult",
37
- toolCallId,
38
- toolName: "bash",
39
- content: [{ type: "text", text: "out" }],
40
- isError: false,
41
- timestamp: ts,
42
- };
43
- }
44
-
45
- function hasThinking(msg: any): boolean {
46
- return Array.isArray(msg.content) && msg.content.some((c: any) => c.type === "thinking");
47
- }
48
-
49
- function countThinking(msg: any): number {
50
- return Array.isArray(msg.content) ? msg.content.filter((c: any) => c.type === "thinking").length : 0;
51
- }
52
-
53
- /** user, then (n-1) tool-using assistant turns each followed by a toolResult, then 1 final text assistant. */
54
- function convo(nAssistantTurns: number): any[] {
55
- const msgs: any[] = [userMsg(1)];
56
- let ts = 2;
57
- for (let i = 0; i < nAssistantTurns - 1; i++) {
58
- const id = `tc${i}`;
59
- msgs.push(assistantToolsThinking(ts++, [id]));
60
- msgs.push(toolResult(ts++, id));
61
- }
62
- msgs.push(assistantTextThinking(ts++));
63
- return msgs;
64
- }
65
-
66
- describe("stripOldThinking", () => {
67
- test("disabled → same reference", () => {
68
- const msgs = convo(20);
69
- expect(stripOldThinking(msgs, cfg(false, 16))).toBe(msgs);
70
- });
71
-
72
- test("fewer assistant turns than keepLastTurns → same reference", () => {
73
- const msgs = convo(10);
74
- expect(stripOldThinking(msgs, cfg(true, 16))).toBe(msgs);
75
- });
76
-
77
- test("exactly keepLastTurns assistant turns → same reference (nothing older)", () => {
78
- const msgs = convo(16);
79
- expect(stripOldThinking(msgs, cfg(true, 16))).toBe(msgs);
80
- });
81
-
82
- test("strips thinking from turns older than the last K, keeps the last K", () => {
83
- const msgs = convo(20);
84
- const out = stripOldThinking(msgs, cfg(true, 16));
85
- expect(out).not.toBe(msgs);
86
- const assistants = out.filter((m) => m.role === "assistant");
87
- expect(assistants.length).toBe(20);
88
- for (const a of assistants.slice(-16)) expect(hasThinking(a)).toBe(true);
89
- for (const a of assistants.slice(0, 4)) expect(hasThinking(a)).toBe(false);
90
- });
91
-
92
- test("keepLastTurns=1 keeps only the most-recent assistant turn's thinking", () => {
93
- const msgs = convo(5);
94
- const out = stripOldThinking(msgs, cfg(true, 1));
95
- const assistants = out.filter((m) => m.role === "assistant");
96
- expect(hasThinking(assistants[assistants.length - 1])).toBe(true);
97
- for (const a of assistants.slice(0, -1)) expect(hasThinking(a)).toBe(false);
98
- });
99
-
100
- test("keepLastTurns=0 is clamped to 1 (never strips the last assistant turn)", () => {
101
- const msgs = convo(5);
102
- const out = stripOldThinking(msgs, cfg(true, 0));
103
- const assistants = out.filter((m) => m.role === "assistant");
104
- expect(hasThinking(assistants[assistants.length - 1])).toBe(true);
105
- expect(hasThinking(assistants[0])).toBe(false);
106
- });
107
-
108
- test("trailing tool-use assistant awaiting results keeps its thinking", () => {
109
- const msgs: any[] = [userMsg(1)];
110
- let ts = 2;
111
- for (let i = 0; i < 4; i++) {
112
- const id = `x${i}`;
113
- msgs.push(assistantToolsThinking(ts++, [id]));
114
- msgs.push(toolResult(ts++, id));
115
- }
116
- const out = stripOldThinking(msgs, cfg(true, 1));
117
- const assistants = out.filter((m) => m.role === "assistant");
118
- const last = assistants[assistants.length - 1];
119
- expect(hasThinking(last)).toBe(true);
120
- expect(last.content.some((c: any) => c.type === "toolCall")).toBe(true);
121
- });
122
-
123
- test("stripped assistant keeps its text and toolCall blocks", () => {
124
- const msgs = convo(20);
125
- const out = stripOldThinking(msgs, cfg(true, 16));
126
- const firstAssistant = out.find((m) => m.role === "assistant");
127
- expect(hasThinking(firstAssistant)).toBe(false);
128
- expect(firstAssistant.content.some((c: any) => c.type === "text")).toBe(true);
129
- expect(firstAssistant.content.some((c: any) => c.type === "toolCall")).toBe(true);
130
- });
131
-
132
- test("strips all thinking blocks from a message (all-or-nothing)", () => {
133
- const msgs: any[] = [userMsg(1), assistantToolsThinking(2, ["a"], 2), toolResult(3, "a")];
134
- let ts = 4;
135
- for (let i = 0; i < 3; i++) {
136
- const id = `b${i}`;
137
- msgs.push(assistantToolsThinking(ts++, [id], 2));
138
- msgs.push(toolResult(ts++, id));
139
- }
140
- msgs.push(assistantTextThinking(ts++));
141
- const out = stripOldThinking(msgs, cfg(true, 2));
142
- expect(countThinking(out[1])).toBe(0);
143
- });
144
-
145
- test("no thinking anywhere → same reference", () => {
146
- const msgs: any[] = [userMsg(1)];
147
- let ts = 2;
148
- for (let i = 0; i < 20; i++) {
149
- const id = `n${i}`;
150
- msgs.push({
151
- role: "assistant",
152
- content: [{ type: "text", text: "x" }, { type: "toolCall", id, name: "bash", arguments: {} }],
153
- timestamp: ts++,
154
- usage: {},
155
- stopReason: "toolUse",
156
- });
157
- msgs.push(toolResult(ts++, id));
158
- }
159
- expect(stripOldThinking(msgs, cfg(true, 4))).toBe(msgs);
160
- });
161
-
162
- test("idempotent: second pass returns same reference", () => {
163
- const msgs = convo(20);
164
- const once = stripOldThinking(msgs, cfg(true, 16));
165
- const twice = stripOldThinking(once, cfg(true, 16));
166
- expect(twice).toBe(once);
167
- });
168
-
169
- test("preserves message order and length", () => {
170
- const msgs = convo(20);
171
- const out = stripOldThinking(msgs, cfg(true, 16));
172
- expect(out.length).toBe(msgs.length);
173
- out.forEach((m, i) => expect(m.role).toBe(msgs[i].role));
174
- });
175
- });
176
-
177
- describe("stripOldThinking (boundaryTimestamp path)", () => {
178
- test("strips assistants older than boundary, keeps boundary and newer", () => {
179
- const msgs = convo(20);
180
- const assistantTs = msgs.filter((m) => m.role === "assistant").map((m) => m.timestamp);
181
- const boundary = assistantTs[4];
182
- const out = stripOldThinking(msgs, cfg(true, 16), boundary);
183
- expect(out).not.toBe(msgs);
184
- const assistants = out.filter((m) => m.role === "assistant");
185
- for (const a of assistants) {
186
- if (a.timestamp < boundary) expect(hasThinking(a)).toBe(false);
187
- else expect(hasThinking(a)).toBe(true);
188
- }
189
- });
190
-
191
- test("prefix is byte-stable across a growing tail at a fixed boundary (the AC)", () => {
192
- const msgs = convo(20);
193
- const boundary = msgs.filter((m) => m.role === "assistant").map((m) => m.timestamp)[4];
194
- const first = stripOldThinking(msgs, cfg(true, 16), boundary);
195
- const prefixLen = first.length;
196
- const grown = [...msgs, assistantToolsThinking(100, ["tcNew"]), toolResult(101, "tcNew")];
197
- const second = stripOldThinking(grown, cfg(true, 16), boundary);
198
- expect(JSON.stringify(second.slice(0, prefixLen))).toBe(JSON.stringify(first));
199
- });
200
-
201
- test("undefined boundary falls back to live-count (same as 2-arg)", () => {
202
- const msgs = convo(20);
203
- const viaUndefined = stripOldThinking(msgs, cfg(true, 16), undefined);
204
- const viaTwoArg = stripOldThinking(msgs, cfg(true, 16));
205
- expect(JSON.stringify(viaUndefined)).toBe(JSON.stringify(viaTwoArg));
206
- });
207
-
208
- test("assistant without a timestamp is kept, never stripped", () => {
209
- const noTs: any = { role: "assistant", content: [{ type: "thinking", thinking: "x", thinkingSignature: "s" }, { type: "text", text: "y" }], usage: {}, stopReason: "stop" };
210
- const msgs = [userMsg(1), noTs, ...convo(20).slice(1)];
211
- const out = stripOldThinking(msgs, cfg(true, 16), 9999);
212
- const kept = out.find((m) => m.role === "assistant" && m.timestamp === undefined);
213
- expect(hasThinking(kept)).toBe(true);
214
- });
215
-
216
- test("post-chain-drop survivor array: surviving older turns stripped, boundary honored", () => {
217
- const full = convo(20);
218
- const assistantTs = full.filter((m) => m.role === "assistant").map((m) => m.timestamp);
219
- const boundary = assistantTs[10];
220
- const survivor = [...full.slice(0, 6), ...full.slice(8)];
221
- const out = stripOldThinking(survivor, cfg(true, 16), boundary);
222
- for (const a of out.filter((m) => m.role === "assistant")) {
223
- if (a.timestamp < boundary) expect(hasThinking(a)).toBe(false);
224
- }
225
- });
226
- });
227
-
228
- describe("computeThinkingBoundary", () => {
229
- const ts = Array.from({ length: 40 }, (_, i) => (i + 1) * 10);
230
-
231
- test("count <= keep returns prev unchanged", () => {
232
- expect(computeThinkingBoundary(ts.slice(0, 16), 16, undefined)).toBeUndefined();
233
- expect(computeThinkingBoundary(ts.slice(0, 10), 16, 123)).toBe(123);
234
- });
235
-
236
- test("count > keep returns the (count-keep)-th timestamp", () => {
237
- expect(computeThinkingBoundary(ts.slice(0, 20), 16, undefined)).toBe(ts[4]);
238
- });
239
-
240
- test("keepLastTurns=0 is clamped to 1 (no out-of-bounds)", () => {
241
- expect(computeThinkingBoundary(ts.slice(0, 20), 0, undefined)).toBe(ts[19]);
242
- });
243
-
244
- test("monotonic clamp: never regresses when keepLastTurns increases", () => {
245
- const first = computeThinkingBoundary(ts.slice(0, 40), 16, undefined);
246
- expect(first).toBe(ts[24]);
247
- const second = computeThinkingBoundary(ts.slice(0, 40), 32, first);
248
- expect(second).toBe(first);
249
- });
250
-
251
- test("an added trailing turn (e.g. closingMessage) advances the boundary by one", () => {
252
- const before = computeThinkingBoundary(ts.slice(0, 20), 16, undefined);
253
- const after = computeThinkingBoundary(ts.slice(0, 21), 16, before);
254
- expect(after).toBe(ts[5]);
255
- expect(after).toBeGreaterThan(before as number);
256
- });
257
- });
@@ -1,83 +0,0 @@
1
- import { withoutThinkingBlocks } from "./chain-range-prune.js";
2
- import type { ThinkingStripConfig } from "./types.js";
3
-
4
- /**
5
- * Rolling main-loop thinking strip.
6
- *
7
- * Keeps `thinking` blocks on the last `keepLastTurns` assistant turns and
8
- * strips them from all older assistant messages, preserving each message's
9
- * `text` and `toolCall` blocks. "Turn" counts ASSISTANT messages, not
10
- * user-bounded spans — the target failure mode is a single long open chain
11
- * (zero subagents, near-zero user turns) where a span-based window keeps
12
- * everything.
13
- *
14
- * Provider safety (Anthropic): during tool use only the LAST assistant turn's
15
- * thinking is required; prior turns may be omitted, and a message's thinking
16
- * blocks must be dropped all-or-nothing. `keepLastTurns` is clamped to >= 1 so
17
- * the most-recent assistant turn always keeps its thinking. Stripping reuses
18
- * `withoutThinkingBlocks` (drops the whole block incl. signature).
19
- *
20
- * Returns the original array reference unchanged when nothing is stripped, so
21
- * `pruneMessages` can skip reconstruction.
22
- */
23
- export function stripOldThinking(
24
- messages: any[],
25
- config: ThinkingStripConfig,
26
- boundaryTimestamp?: number,
27
- ): any[] {
28
- if (!config.enabled) return messages;
29
-
30
- // Flush-gated path: strip by the persisted timestamp boundary. Fixed between
31
- // flushes, so consecutive renders produce a byte-identical historical prefix.
32
- // `!(ts < boundary)` keeps a timestamp-less assistant (undefined < n === false),
33
- // which is the provider-safe default (never over-strip an unknown-age turn).
34
- if (boundaryTimestamp !== undefined && boundaryTimestamp !== null) {
35
- let changed = false;
36
- const out = messages.map((msg) => {
37
- if (msg?.role !== "assistant" || !(msg.timestamp < boundaryTimestamp)) return msg;
38
- if (!Array.isArray(msg.content) || !msg.content.some((c: any) => c.type === "thinking")) return msg;
39
- changed = true;
40
- return withoutThinkingBlocks(msg);
41
- });
42
- return changed ? out : messages;
43
- }
44
-
45
- // Fallback: live-count window (pre-first-flush / pre-feature sessions).
46
- const keep = Math.max(1, config.keepLastTurns);
47
-
48
- const assistantIdx: number[] = [];
49
- for (let i = 0; i < messages.length; i++) {
50
- if (messages[i]?.role === "assistant") assistantIdx.push(i);
51
- }
52
- if (assistantIdx.length <= keep) return messages;
53
-
54
- const firstKeptAssistant = assistantIdx[assistantIdx.length - keep];
55
- let changed = false;
56
- const out = messages.map((msg, i) => {
57
- if (i >= firstKeptAssistant || msg?.role !== "assistant") return msg;
58
- if (!Array.isArray(msg.content) || !msg.content.some((c: any) => c.type === "thinking")) return msg;
59
- changed = true;
60
- return withoutThinkingBlocks(msg);
61
- });
62
- return changed ? out : messages;
63
- }
64
-
65
- /**
66
- * Flush-time computation of the thinking-strip boundary: the timestamp of the
67
- * (count - keepLastTurns)-th assistant message, monotonically clamped so the
68
- * boundary never moves backward (a mid-session `keepLastTurns` increase must not
69
- * re-add thinking to an already-stripped message). Stateless recompute - no
70
- * running counter. `keepLastTurns` is clamped to >= 1 to match `stripOldThinking`
71
- * and avoid an out-of-bounds index.
72
- */
73
- export function computeThinkingBoundary(
74
- assistantTimestamps: number[],
75
- keepLastTurns: number,
76
- prev?: number,
77
- ): number | undefined {
78
- const keep = Math.max(1, keepLastTurns);
79
- const count = assistantTimestamps.length;
80
- if (count <= keep) return prev;
81
- const candidate = assistantTimestamps[count - keep];
82
- return Math.max(prev ?? candidate, candidate);
83
- }