pi-condense 2.12.0 → 2.13.1

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.
@@ -7,9 +7,9 @@ import {
7
7
  findSuperseded,
8
8
  lowerFloor,
9
9
  supersededStub,
10
- type SupersedeState,
11
10
  } from "./supersede.js";
12
11
  import { isProtected } from "./protected.js";
12
+ import { expectNoOrphanToolResults } from "./test-support.js";
13
13
  import { occKey } from "./occurrence-key.js";
14
14
 
15
15
  const cfg = { protectedTools: ["phase_tracker"], protectedPaths: ["**/skills/**/*.md"] };
@@ -42,9 +42,15 @@ describe("findSuperseded", () => {
42
42
  expect(findSuperseded(msgs, prot).map((c) => c.toolCallId)).toEqual(["a", "b"]);
43
43
  });
44
44
 
45
- test("different offset/limit slices of one path -> older is a candidate", () => {
45
+ test("different offset/limit slices of one path -> neither is a candidate", () => {
46
46
  const msgs = [...call("a", 10, { path: SKILL, offset: 1, limit: 400 }), ...call("b", 20, { path: SKILL, offset: 400, limit: 50 })];
47
- expect(findSuperseded(msgs, prot).map((c) => c.toolCallId)).toEqual(["a"]);
47
+ expectNoOrphanToolResults(msgs);
48
+ expect(findSuperseded(msgs, prot)).toEqual([]);
49
+ const state = createSupersedeState();
50
+ state.floor = 0;
51
+ const out = applySupersede(msgs, state, prot);
52
+ expect(out).toBe(msgs);
53
+ expectNoOrphanToolResults(out);
48
54
  });
49
55
 
50
56
  test("two paths interleaved -> per-path winners and candidates", () => {
@@ -153,6 +159,70 @@ describe("findSuperseded", () => {
153
159
  });
154
160
  });
155
161
 
162
+ type RangeRead = { offset?: unknown; limit?: unknown; cap?: string; text?: string; path?: string };
163
+ const oversizedLine = `[Line 1 is 61.2KB, exceeds 50.0KB limit. Use bash: sed -n '1p' ${SKILL} | head -c 51200]`;
164
+ const cap1800 = "[Showing lines 1-1800 of 3000 (50.0KB limit). Use offset=1801 to continue.]";
165
+ const cap2000 = "[Showing lines 1-2000 of 3000. Use offset=2001 to continue.]";
166
+
167
+ describe("delivered range coverage", () => {
168
+ const cases: [string, RangeRead[], string[]][] = [
169
+ ["full/full", [{}, {}], ["r0"]],
170
+ ["tail then oversized-first-line warning", [{ offset: 2001 }, { text: oversizedLine }], []],
171
+ ["oversized-first-line warning then full", [{ text: oversizedLine }, {}], ["r0"]],
172
+ ["disjoint chunks", [{ limit: 2000 }, { offset: 2001 }], []],
173
+ ["chunk then capped full", [{ limit: 2000 }, { cap: cap2000 }], ["r0"]],
174
+ ["tail then capped full", [{ offset: 2001, limit: 1000 }, { cap: cap2000 }], []],
175
+ ["full then chunk", [{}, { limit: 50 }], []],
176
+ ["chunk1/chunk2/chunk1", [{ limit: 2000 }, { offset: 2001 }, { limit: 2000 }], ["r0"]],
177
+ ["equal capped full reads", [{ cap: cap2000 }, { cap: cap2000 }], ["r0"]],
178
+ ["byte-capped limit then capped full", [{ limit: 2500, cap: cap1800 }, { cap: cap2000 }], ["r0"]],
179
+ ["capped full then byte-capped limit", [{ cap: cap2000 }, { limit: 2500, cap: cap1800 }], []],
180
+ ["zero offset", [{ offset: 0, limit: 100 }, {}], ["r0"]],
181
+ ["null offset full then chunk", [{ offset: null }, { limit: 50 }], []],
182
+ ["null offset limited", [{ limit: 50 }, { offset: null, limit: 50 }], ["r0"]],
183
+ ["negative offset", [{ offset: -10, limit: 50 }, { limit: 50 }], ["r0"]],
184
+ ["null limit is open", [{ limit: null }, { limit: 50 }], []],
185
+ ["string values are open", [{ offset: "2001", limit: "50" }, { limit: 50 }], []],
186
+ ["later string values cover full", [{}, { offset: "2001", limit: "50" }], ["r0"]],
187
+ ["later null limit covers full", [{}, { limit: null }], ["r0"]],
188
+ ["zero limit covers nothing", [{ limit: 50 }, { limit: 0 }], []],
189
+ ["negative limit covers nothing", [{ limit: 50 }, { limit: -1 }], []],
190
+ ["empty cannot cover empty", [{ limit: 0 }, { limit: -1 }], []],
191
+ ["nonempty covers empty", [{ offset: 2001, limit: 0 }, { limit: 50 }], ["r0"]],
192
+ ["partial overlap", [{ offset: 10, limit: 50 }, { offset: 20, limit: 50 }], []],
193
+ ["equal end with earlier start", [{ offset: 10, limit: 41 }, { limit: 50 }], ["r0"]],
194
+ ["different paths", [{ limit: 50 }, { path: OTHER }], []],
195
+ ["intermediate covering read", [{ limit: 50 }, { limit: 100 }, { offset: 2001 }], ["r0"]],
196
+ ["offset byte cap", [{ offset: 1001, limit: 2500, cap: "[Showing lines 1001-2800 of 4000 (50.0KB limit). Use offset=2801 to continue.]" }, { offset: 1001, limit: 1800 }], ["r0"]],
197
+ ["ordinary continuation keeps limit", [{ limit: 50, cap: "[2950 more lines in file. Use offset=51 to continue.]" }, { limit: 50 }], ["r0"]],
198
+ ["nonterminal notice is body text", [{ cap: cap2000 + "\nmore body" }, { limit: 2000 }], []],
199
+ ];
200
+
201
+ test.each(cases)("%s", (_name, reads, expected) => {
202
+ const msgs = reads.flatMap(({ cap, text, ...args }, i) => {
203
+ const pair = call(`r${i}`, i * 10, { path: SKILL, ...args });
204
+ if (text) pair[1].content[0].text = text;
205
+ if (cap) pair[1].content[0].text += `\n\n${cap}`;
206
+ return pair;
207
+ });
208
+ expectNoOrphanToolResults(msgs);
209
+ expect(findSuperseded(msgs, prot).map((c) => c.toolCallId)).toEqual(expected);
210
+ const state = createSupersedeState();
211
+ expect(applySupersede(msgs, state, prot)).toBe(msgs);
212
+ state.floor = 0;
213
+ const out = applySupersede(msgs, state, prot);
214
+ expectNoOrphanToolResults(out);
215
+ if (expected.length === 0) expect(out).toBe(msgs);
216
+ for (let i = 0; i < reads.length; i++) {
217
+ if (expected.includes(`r${i}`)) expect(out[i * 2 + 1].content[0].text).toBe(supersededStub(SKILL));
218
+ else expect(out[i * 2 + 1]).toBe(msgs[i * 2 + 1]);
219
+ }
220
+ const sticky = applySupersede(msgs, state, prot);
221
+ expect(sticky).toEqual(out);
222
+ expectNoOrphanToolResults(sticky);
223
+ });
224
+ });
225
+
156
226
  describe("floor helpers", () => {
157
227
  test("lowerFloor sets when undefined, lowers monotonically, ignores undefined input", () => {
158
228
  const s = createSupersedeState();
package/src/supersede.ts CHANGED
@@ -1,11 +1,7 @@
1
1
  import { normalizePath } from "./protected.js";
2
2
  import { occKey, resultTimestampOf } from "./occurrence-key.js";
3
3
 
4
- /**
5
- * Protected reads are never indexed, so nothing else in the pipeline ever
6
- * collapses a re-read of the same skill file. This module keeps only the
7
- * newest protected read per `args.path` verbatim (spec 2026-09-07).
8
- */
4
+ /** Protected reads stay verbatim until a later same-path read covers their delivered lines. */
9
5
  export interface SupersededCandidate {
10
6
  toolCallId: string;
11
7
  path: string;
@@ -49,11 +45,34 @@ export function supersededStub(path: string): string {
49
45
 
50
46
  export type IsProtectedFn = (toolName: string, args: unknown) => boolean;
51
47
 
48
+ interface DeliveredRange {
49
+ start: number;
50
+ end: number;
51
+ }
52
+
53
+ function deliveredRange(args: Record<string, unknown>, result: any): DeliveredRange {
54
+ const start = typeof args.offset === "number" ? Math.max(1, args.offset) : 1;
55
+ const limit = args.limit;
56
+ let end = typeof limit === "number" ? (limit <= 0 ? start - 1 : start + limit - 1) : Infinity;
57
+ for (const block of result.content ?? []) {
58
+ if (block?.type !== "text" || typeof block.text !== "string") continue;
59
+ if (/^\[Line \d+ is [^,]+, exceeds [^ ]+ limit\. Use bash: .*\]$/.test(block.text)) return { start, end: start - 1 };
60
+ const cap = block.text.match(/(?:^|\n)\[Showing lines \d+-(\d+) of \d+(?: \([^)]*limit\))?\. Use offset=\d+ to continue\.\]$/);
61
+ if (cap) end = Math.min(end, Number(cap[1]));
62
+ }
63
+ return { start, end };
64
+ }
65
+
66
+ function covers(later: DeliveredRange, earlier: DeliveredRange): boolean {
67
+ if (later.end < later.start) return false;
68
+ return earlier.end < earlier.start || (later.start <= earlier.start && later.end >= earlier.end);
69
+ }
70
+
52
71
  export function findSuperseded(messages: any[], isProtected: IsProtectedFn): SupersededCandidate[] {
53
72
  // Provider ids repeat across turns and an aborted call has no result, so pairing
54
73
  // uses the same per-turn open-set model as orphan-sweep, not a global per-id cursor.
55
74
  let open = new Map<string, any>();
56
- const byPath = new Map<string, SupersededCandidate[]>();
75
+ const byPath = new Map<string, { candidate: SupersededCandidate; range: DeliveredRange }[]>();
57
76
 
58
77
  for (let i = 0; i < messages.length; i++) {
59
78
  const m = messages[i];
@@ -77,16 +96,25 @@ export function findSuperseded(messages: any[], isProtected: IsProtectedFn): Sup
77
96
  timestamp: resultTimestampOf(m.timestamp),
78
97
  resultIndex: i,
79
98
  };
99
+ const occurrence = { candidate: cand, range: deliveredRange(args, m) };
80
100
  const list = byPath.get(path);
81
- if (list) list.push(cand);
82
- else byPath.set(path, [cand]);
101
+ if (list) list.push(occurrence);
102
+ else byPath.set(path, [occurrence]);
83
103
  continue;
84
104
  }
85
105
  open = new Map();
86
106
  }
87
107
 
88
108
  const out: SupersededCandidate[] = [];
89
- for (const list of byPath.values()) for (let i = 0; i < list.length - 1; i++) out.push(list[i]);
109
+ for (const list of byPath.values()) {
110
+ for (let i = 0; i < list.length - 1; i++) {
111
+ for (let j = i + 1; j < list.length; j++) {
112
+ if (!covers(list[j].range, list[i].range)) continue;
113
+ out.push(list[i].candidate);
114
+ break;
115
+ }
116
+ }
117
+ }
90
118
  out.sort((a, b) => a.resultIndex - b.resultIndex);
91
119
  return out;
92
120
  }
package/src/types.ts CHANGED
@@ -219,8 +219,8 @@ export const RECOVERY_GRACE_PRESETS: { value: string; label: string }[] = [
219
219
  export const SUMMARIZER_IDLE_TIMEOUT_PRESETS: { value: string; label: string }[] = [
220
220
  { value: "0", label: "0 (disabled)" },
221
221
  { value: "10000", label: "10s" },
222
- { value: "20000", label: "20s (default)" },
223
- { value: "45000", label: "45s" },
222
+ { value: "20000", label: "20s" },
223
+ { value: "45000", label: "45s (default)" },
224
224
  { value: "90000", label: "90s" },
225
225
  ];
226
226
 
@@ -321,7 +321,7 @@ export interface ContextPruneConfig {
321
321
  * Reset on every received stream event; armed before the first event so it
322
322
  * also bounds time-to-first-token. If no event arrives within this window
323
323
  * the call is aborted and classified transient (feeds the outage-fallback
324
- * retry). 0 disables the idle timer. Default 20000.
324
+ * retry). 0 disables the idle timer. Default 45000.
325
325
  */
326
326
  summarizerIdleTimeoutMs: number;
327
327
  /**
@@ -569,7 +569,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
569
569
  quietOversizedSkips: false,
570
570
  minBatchChars: 2000,
571
571
  recoveryGraceTurns: 3,
572
- summarizerIdleTimeoutMs: 20000,
572
+ summarizerIdleTimeoutMs: 45000,
573
573
  summarizerMaxTimeoutMs: 180000,
574
574
  protectedTools: [],
575
575
  protectedPaths: ["**/skills/**/*.md", "**/gauntlet-overrides.md"],