pi-condense 2.10.0 → 2.10.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,10 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
7
7
  Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
8
8
  publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
9
9
 
10
+ ## [2.10.1] - 2026-09-02
11
+
12
+ - **Spill sidecar basenames capped at 255 bytes ([#14](https://github.com/jjuraszek/pi-condense/issues/14)).** Providers emitting 300+ char tool-call ids drove `blobPathFor` past the filesystem basename limit: eager spill failed silently (`ENAMETOOLONG` caught, oversized result stayed inline and bloated context) and the deterministic backfill aborted fail-closed. Fitting names stay byte-identical; over-limit names become `<234-byte sanitized prefix>.<16-hex sha1 of the unsanitized occurrence key>.txt` (exactly 255 bytes). The `.` separator is unreachable by `sanitizeId`, so capped names are namespace-disjoint from short-key names by construction - no probe, no migration, persisted `spillPath` read-back unchanged. Spec: `doc/specs/2026-09-02-gh-14-spill-filename-cap.md` (partially supersedes the 2026-06-02 spill spec's filename derivation).
13
+
10
14
  ## [2.10.0] - 2026-09-01
11
15
 
12
16
  - **Custom-message chain anchors ([#13](https://github.com/jjuraszek/pi-condense/issues/13)).** A non-pruner `role: "custom"` message (`customType` not prefixed `context-prune-`) can now open a chain, but only while the chain detector is idle - a non-pruner custom seen mid-chain stays passthrough, not a new anchor. `resolveRange` accepts these as start anchors fail-closed; persisted `custom_message` steers reach chain detection through a shared projection (`src/batch-capture.ts` `projectBranchMessages`); in `agent-message` batching, eligible customs also bound summary groups.
package/PRUNING.md CHANGED
@@ -630,7 +630,7 @@ Names and patterns that don't match any captured tool call are silently ignored.
630
630
 
631
631
  `spillThreshold: number` (default `65536`) is a capture-time safeguard for outsized single tool results (e.g. a 1 MB web fetch, a full binary diff). When a single `ToolResultMessage`'s `resultText.length` reaches the threshold, the result is spilled immediately at `turn_end` — before the pending-queue trim and before any LLM call.
632
632
 
633
- **Sidecar location:** `<sessionDir>/<sessionId>-blobs/<sanitizedToolCallId>.txt`.
633
+ **Sidecar location:** `<sessionDir>/<sessionId>-blobs/<sanitizedToolCallId>.txt`. When that basename would exceed 255 bytes (very long provider tool-call ids), it is capped to exactly 255 bytes as `<first 234 bytes of the sanitized key>.<16-hex sha1 of the unsanitized occurrence key>.txt`; names that already fit are unchanged.
634
634
 
635
635
  **Index entry:** `addBatch` is called synchronously with the spilled body (no LLM round-trip). The record is immediately `isSummarized = true`; the pruner emits a mechanical file-pointer stub:
636
636
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.10.0",
3
+ "version": "2.10.1",
4
4
  "description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -1,8 +1,9 @@
1
1
  import { describe, it, expect } from "bun:test";
2
2
  import { mkdtemp, readFile, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, basename } from "node:path";
5
5
  import { ToolCallIndexer } from "./indexer.js";
6
+ import { occKey } from "./occurrence-key.js";
6
7
  import { spillOversizedBatch, blobPathFor } from "./spill.js";
7
8
  import { pruneMessages } from "./pruner.js";
8
9
  import type { CapturedBatch } from "./types.js";
@@ -75,4 +76,51 @@ describe("oversized spill end-to-end", () => {
75
76
  await rm(dir, { recursive: true, force: true });
76
77
  }
77
78
  });
79
+
80
+ it("long-id record survives the backfill -> restart round trip (AC4)", async () => {
81
+ const dir = await mkdtemp(join(tmpdir(), "spill-e2e-"));
82
+ try {
83
+ const indexer = new ToolCallIndexer();
84
+ const entries: any[] = [];
85
+ const appendEntry = (customType: string, data?: unknown) => {
86
+ entries.push({ type: "custom", customType, data });
87
+ };
88
+ const longId = "toolu_" + "q".repeat(494); // 500 chars
89
+ const body = "BACKFILL BODY\n".repeat(200);
90
+ const rec: any = {
91
+ toolCallId: longId, toolName: "bash", args: { command: "ls" },
92
+ resultText: body, isError: false, turnIndex: -1, timestamp: 1000, resultTimestamp: 1000,
93
+ };
94
+
95
+ // 1. backfill write: must not throw (fail-closed path), basename capped
96
+ await indexer.backfillChainRecords([rec], {
97
+ spillThreshold: 10, spillPreviewBytes: 16, sessionDir: dir, sessionId: "sid", appendEntry,
98
+ });
99
+ expect(rec.spillPath).toBeTruthy();
100
+ expect(Buffer.byteLength(basename(rec.spillPath), "utf8")).toBeLessThanOrEqual(255);
101
+
102
+ // 2. persisted index entry exists (backfilled shape)
103
+ expect(entries.some((e) => e.customType === CUSTOM_TYPE_INDEX && e.data.backfilled)).toBe(true);
104
+
105
+ // 3. restart: fresh indexer reconstructs from persisted entries only
106
+ const rebuilt = new ToolCallIndexer();
107
+ rebuilt.reconstructFromSession({ sessionManager: { getBranch: () => entries } } as any);
108
+ const restored = rebuilt.getRecord(occKey(longId, 1000))!;
109
+ expect(restored.spillPath).toBe(rec.spillPath);
110
+
111
+ // 4. read-back of the restored persisted path equals the original body
112
+ expect(await readFile(restored.spillPath!, "utf-8")).toBe(body);
113
+ } finally {
114
+ await rm(dir, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ it("capped names are namespace-disjoint from short-key names", () => {
119
+ const cappedPath = blobPathFor("/s", "sid", "a".repeat(300));
120
+ const stem = basename(cappedPath).slice(0, -".txt".length);
121
+ // sanitizeId can never emit ".", so no short key maps onto a capped name -
122
+ // even the short key spelled exactly like the capped stem.
123
+ expect(stem).toContain(".");
124
+ expect(blobPathFor("/s", "sid", stem)).not.toBe(cappedPath);
125
+ });
78
126
  });
package/src/spill.test.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from "bun:test";
2
2
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, basename } from "node:path";
5
5
  import { sanitizeId, blobDirFor, blobPathFor, headPreview, spillOversizedBatch } from "./spill.js";
6
6
  import { ToolCallIndexer } from "./indexer.js";
7
7
  import { registerQueryTool } from "./query-tool.js";
@@ -24,6 +24,41 @@ describe("blobDirFor / blobPathFor", () => {
24
24
  });
25
25
  });
26
26
 
27
+ describe("blobPathFor byte cap (gh-14)", () => {
28
+ const nameBytes = (p: string) => Buffer.byteLength(basename(p), "utf8");
29
+
30
+ it("251-byte sanitized base keeps today's formula (AC5 boundary, just-under)", () => {
31
+ const id = "a".repeat(251);
32
+ const p = blobPathFor("/s", "sid", id);
33
+ expect(p).toBe(join("/s", "sid-blobs", `${id}.txt`));
34
+ expect(nameBytes(p)).toBe(255);
35
+ });
36
+
37
+ it("252-byte sanitized base is capped to exactly 255 bytes (AC5 boundary, just-over)", () => {
38
+ const p = blobPathFor("/s", "sid", "a".repeat(252));
39
+ expect(nameBytes(p)).toBe(255);
40
+ expect(basename(p)).toMatch(/^a{234}\.[0-9a-f]{16}\.txt$/);
41
+ });
42
+
43
+ it("is deterministic: same long key -> identical path", () => {
44
+ const key = "x".repeat(500);
45
+ expect(blobPathFor("/s", "sid", key)).toBe(blobPathFor("/s", "sid", key));
46
+ });
47
+
48
+ it("two long ids sharing the first 300 chars map to distinct filenames (AC3)", () => {
49
+ const a = "t".repeat(300) + "A".repeat(200);
50
+ const b = "t".repeat(300) + "B".repeat(200);
51
+ expect(blobPathFor("/s", "sid", a)).not.toBe(blobPathFor("/s", "sid", b));
52
+ });
53
+
54
+ it("hashes the unsanitized key: long ids that sanitize identically stay distinct", () => {
55
+ const a = "p".repeat(300) + "/x";
56
+ const b = "p".repeat(300) + "\\x";
57
+ expect(sanitizeId(a)).toBe(sanitizeId(b));
58
+ expect(blobPathFor("/s", "sid", a)).not.toBe(blobPathFor("/s", "sid", b));
59
+ });
60
+ });
61
+
27
62
  describe("headPreview", () => {
28
63
  it("returns the whole string when under the byte cap", () => {
29
64
  expect(headPreview("hello", 1024)).toBe("hello");
@@ -206,4 +241,39 @@ describe("spillOversizedBatch", () => {
206
241
  await expect(readFile(blobPathFor(dir, "sid", "tc2"), "utf-8")).rejects.toBeDefined();
207
242
  } finally { await rm(dir, { recursive: true, force: true }); }
208
243
  });
244
+
245
+ it("spills a 500-char tool-call id: file created, capped basename, record mutated (AC1)", async () => {
246
+ const dir = await mkdtemp(join(tmpdir(), "spill-"));
247
+ try {
248
+ const indexer = new ToolCallIndexer();
249
+ const longId = "toolu_" + "k".repeat(494); // 500 chars
250
+ const body = "LONG-ID BODY ".repeat(10);
251
+ const batch = mkBatch([{ toolCallId: longId, toolName: "fetch", args: {}, resultText: body, isError: false, resultTimestamp: 1150 }]);
252
+ const spilled = await spillOversizedBatch({ batch, indexer, config: cfg, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
253
+ expect(spilled.has(longId)).toBe(true);
254
+ const rec = indexer.getRecord(occKey(longId, 1150))!;
255
+ expect(rec.resultText).toBe("");
256
+ expect(rec.resultPreview!.length).toBeGreaterThan(0);
257
+ expect(Buffer.byteLength(basename(rec.spillPath!), "utf8")).toBeLessThanOrEqual(255);
258
+ expect(await readFile(rec.spillPath!, "utf-8")).toBe(body);
259
+ } finally { await rm(dir, { recursive: true, force: true }); }
260
+ });
261
+
262
+ it("same 500-char id at two occurrences spills to two distinct files (AC2)", async () => {
263
+ const dir = await mkdtemp(join(tmpdir(), "spill-"));
264
+ try {
265
+ const indexer = new ToolCallIndexer();
266
+ const longId = "toolu_" + "k".repeat(494);
267
+ const noDedup = { ...cfg, dedupByContentHash: false };
268
+ const b1 = mkBatch([{ toolCallId: longId, toolName: "bash", args: {}, resultText: "FIRST".repeat(20), isError: false, resultTimestamp: 1150 }]);
269
+ const b2 = mkBatch([{ toolCallId: longId, toolName: "bash", args: {}, resultText: "SECOND".repeat(20), isError: false, resultTimestamp: 3150 }]);
270
+ await spillOversizedBatch({ batch: b1, indexer, config: noDedup, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
271
+ await spillOversizedBatch({ batch: b2, indexer, config: noDedup, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
272
+ const rec1 = indexer.getRecord(occKey(longId, 1150))!;
273
+ const rec2 = indexer.getRecord(occKey(longId, 3150))!;
274
+ expect(rec1.spillPath).not.toBe(rec2.spillPath);
275
+ expect(await readFile(rec1.spillPath!, "utf-8")).toBe("FIRST".repeat(20));
276
+ expect(await readFile(rec2.spillPath!, "utf-8")).toBe("SECOND".repeat(20));
277
+ } finally { await rm(dir, { recursive: true, force: true }); }
278
+ });
209
279
  });
package/src/spill.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { createHash } from "node:crypto";
3
4
  import type { CapturedBatch, CapturedToolCall } from "./types.js";
4
5
  import type { ToolCallIndexer } from "./indexer.js";
5
6
  import { hashToolResult } from "./content-hash.js";
@@ -15,7 +16,18 @@ export function blobDirFor(sessionDir: string, sessionId: string): string {
15
16
  }
16
17
 
17
18
  export function blobPathFor(sessionDir: string, sessionId: string, toolCallId: string): string {
18
- return join(blobDirFor(sessionDir, sessionId), `${sanitizeId(toolCallId)}.txt`);
19
+ const base = sanitizeId(toolCallId);
20
+ // 255-byte basename cap (gh-14). Uncapped budget: 255 - ".txt" = 251.
21
+ // Capped: 234-byte prefix + "." + 16-hex sha1 + ".txt" = 255 exactly.
22
+ // sanitizeId output is ASCII, so slice counts bytes. The "." separator is
23
+ // unreachable by sanitizeId, keeping capped names disjoint from short-key
24
+ // names. The hash covers the UNsanitized key so ids that sanitize
25
+ // identically stay distinct.
26
+ const name =
27
+ Buffer.byteLength(base, "utf8") <= 251
28
+ ? `${base}.txt`
29
+ : `${base.slice(0, 234)}.${createHash("sha1").update(toolCallId).digest("hex").slice(0, 16)}.txt`;
30
+ return join(blobDirFor(sessionDir, sessionId), name);
19
31
  }
20
32
 
21
33
  /** Head of `text` capped at `maxBytes` (UTF-8 safe), preferring a line boundary. */