pi-condense 2.10.0 → 2.10.2
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 +8 -0
- package/PRUNING.md +2 -2
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/commands.ts +1 -1
- package/src/config.test.ts +16 -0
- package/src/oversized-spill.integration.test.ts +49 -1
- package/src/protected.test.ts +22 -0
- package/src/spill.test.ts +71 -1
- package/src/spill.ts +13 -1
- package/src/types.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ 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.2] - 2026-09-06
|
|
11
|
+
|
|
12
|
+
- Protect `gauntlet-overrides.md` reads by default alongside skill files, including `.pi/` and `doc/` paths, so per-repo harness contracts survive context pruning. User-supplied `protectedPaths` still replaces the defaults; path matching remains limited to `args.path`.
|
|
13
|
+
|
|
14
|
+
## [2.10.1] - 2026-09-02
|
|
15
|
+
|
|
16
|
+
- **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).
|
|
17
|
+
|
|
10
18
|
## [2.10.0] - 2026-09-01
|
|
11
19
|
|
|
12
20
|
- **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
|
@@ -618,7 +618,7 @@ A tool call is protected if **either** its `toolName` is in `protectedTools` **o
|
|
|
618
618
|
|
|
619
619
|
**`protectedTools: string[]`** (default `[]`) - allowlist of tool names. Covers tools whose output is a small handle that must be reused byte-for-byte (e.g. a session-id) or planning tools like `todowrite` / `todoread`.
|
|
620
620
|
|
|
621
|
-
**`protectedPaths: string[]`** (default `["**/skills/**/*.md"]`) - glob list matched against `args.path`. Designed for skill files that carry multi-step workflow gates; summarizing them is categorically lossy. Non-string or missing `path` arguments never match. Set `[]` to disable. Edit with `/pruner protected-paths`.
|
|
621
|
+
**`protectedPaths: string[]`** (default `["**/skills/**/*.md", "**/gauntlet-overrides.md"]`) - glob list matched against `args.path`. Designed for skill files that carry multi-step workflow gates; summarizing them is categorically lossy. Non-string or missing `path` arguments never match. Set `[]` to disable. Edit with `/pruner protected-paths`.
|
|
622
622
|
|
|
623
623
|
Glob contract: full-path match against the raw `args.path` string with `\` normalized to `/`. `*` and `?` match within a segment (no `/`); `**` crosses segments; `**/` also matches zero directories (so `**/SKILL.md` matches a bare relative `SKILL.md`). Case-sensitive. All other characters are regex-escaped literals.
|
|
624
624
|
|
|
@@ -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/README.md
CHANGED
|
@@ -169,9 +169,11 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
|
|
|
169
169
|
| `pruneOn` | `agent-message` | Trigger mode - see Architecture above |
|
|
170
170
|
| `autoBudgetThreshold` | `null` | Fraction (e.g. `0.8`) of the context window that force-flushes everything regardless of `pruneOn`; the trigger point is capped at 300k tokens |
|
|
171
171
|
| `frontierGapThresholdTokens` | `null` | Opt-in absolute-token flush trigger: fires at `turn_end` once the un-pruned tail past the prune frontier reaches N tokens, regardless of window size; recommended starting value `80000` |
|
|
172
|
-
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md"]` | Tool names / path globs that are never pruned |
|
|
172
|
+
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md", "**/gauntlet-overrides.md"]` | Tool names / path globs that are never pruned |
|
|
173
173
|
| `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
|
|
174
174
|
|
|
175
|
+
The default also protects reads of [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet)'s per-repo `gauntlet-overrides.md` so the repo's harness contract stays available for gate decisions after pruning.
|
|
176
|
+
|
|
175
177
|
The full settings JSON, every key, the commands table, footer widget states, spilled-output details, and the summarizer-model-by-plan table live in **[doc/configuration.md](doc/configuration.md)**.
|
|
176
178
|
|
|
177
179
|
## Relationship to the rest of the platform
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.2",
|
|
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",
|
package/src/commands.ts
CHANGED
|
@@ -256,7 +256,7 @@ function protectedToolsDescription(config: ContextPruneConfig): string {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
function protectedPathsDescription(config: ContextPruneConfig): string {
|
|
259
|
-
return `Glob patterns matched against a tool call's \`args.path\`; matching outputs are NEVER pruned. Currently: ${protectedToolsDisplay(config.protectedPaths)}. Edit via \`/pruner protected-paths\` (interactive) or \`/pruner protected-paths <comma-separated globs>\`. Set to 'none' to disable (kill switch). Default protects skill files: **/skills/**/*.md`;
|
|
259
|
+
return `Glob patterns matched against a tool call's \`args.path\`; matching outputs are NEVER pruned. Currently: ${protectedToolsDisplay(config.protectedPaths)}. Edit via \`/pruner protected-paths\` (interactive) or \`/pruner protected-paths <comma-separated globs>\`. Set to 'none' to disable (kill switch). Default protects skill files and per-repo gauntlet overrides: **/skills/**/*.md, **/gauntlet-overrides.md`;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
const HELP_TEXT = `pruner — automatically summarizes tool-call outputs to keep context lean.
|
package/src/config.test.ts
CHANGED
|
@@ -35,6 +35,22 @@ async function writeContextPrune(overrides: Record<string, unknown>): Promise<vo
|
|
|
35
35
|
await writeFile(settingsPath(), JSON.stringify({ contextPrune: overrides }));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
describe("loadConfig protectedPaths", () => {
|
|
39
|
+
it("uses the defaults when unset", async () => {
|
|
40
|
+
await writeContextPrune({});
|
|
41
|
+
const config = await loadConfig();
|
|
42
|
+
expect(config.protectedPaths).toEqual(DEFAULT_CONFIG.protectedPaths);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("replaces defaults with user-supplied paths, including an empty list", async () => {
|
|
46
|
+
for (const protectedPaths of [["**/custom.md"], []]) {
|
|
47
|
+
await writeContextPrune({ protectedPaths });
|
|
48
|
+
const config = await loadConfig();
|
|
49
|
+
expect(config.protectedPaths).toEqual(protectedPaths);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
38
54
|
describe("loadConfig recoveryGraceTurns normalization", () => {
|
|
39
55
|
it("preserves an explicit 0", async () => {
|
|
40
56
|
await writeContextPrune({ recoveryGraceTurns: 0 });
|
|
@@ -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/protected.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { globToRegExp, isProtected } from "./protected.js";
|
|
3
|
+
import { DEFAULT_CONFIG } from "./types.js";
|
|
3
4
|
|
|
4
5
|
describe("globToRegExp", () => {
|
|
5
6
|
test("** crosses path segments", () => {
|
|
@@ -33,6 +34,27 @@ describe("globToRegExp", () => {
|
|
|
33
34
|
});
|
|
34
35
|
});
|
|
35
36
|
|
|
37
|
+
describe("isProtected defaults", () => {
|
|
38
|
+
test.each([".pi/gauntlet-overrides.md", "doc/gauntlet-overrides.md"])(
|
|
39
|
+
"protects reads of %s",
|
|
40
|
+
(path) => {
|
|
41
|
+
expect(isProtected("read", { path }, DEFAULT_CONFIG)).toBe(true);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
test("protects skill reads", () => {
|
|
46
|
+
expect(isProtected("read", { path: "/h/skills/x/SKILL.md" }, DEFAULT_CONFIG)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("does not protect sibling settings reads", () => {
|
|
50
|
+
expect(isProtected("read", { path: ".pi/settings.json" }, DEFAULT_CONFIG)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("does not infer paths from bash commands", () => {
|
|
54
|
+
expect(isProtected("bash", { command: "cat .pi/gauntlet-overrides.md" }, DEFAULT_CONFIG)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
36
58
|
describe("isProtected", () => {
|
|
37
59
|
const cfg = { protectedTools: ["todowrite"], protectedPaths: ["**/skills/**/*.md"] };
|
|
38
60
|
|
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
|
-
|
|
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. */
|
package/src/types.ts
CHANGED
|
@@ -352,7 +352,8 @@ export interface ContextPruneConfig {
|
|
|
352
352
|
/**
|
|
353
353
|
* Glob patterns matched against a tool call's `args.path`. Matching calls are
|
|
354
354
|
* protected with identical semantics to protectedTools. Default protects
|
|
355
|
-
* skill files and their sibling reference docs under any `skills/` dir
|
|
355
|
+
* skill files and their sibling reference docs under any `skills/` dir,
|
|
356
|
+
* plus per-repo `gauntlet-overrides.md` files.
|
|
356
357
|
* Kill switch: set to [] in settings.json (`contextPrune.protectedPaths`).
|
|
357
358
|
*/
|
|
358
359
|
protectedPaths: string[];
|
|
@@ -562,7 +563,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
562
563
|
summarizerIdleTimeoutMs: 20000,
|
|
563
564
|
summarizerMaxTimeoutMs: 180000,
|
|
564
565
|
protectedTools: [],
|
|
565
|
-
protectedPaths: ["**/skills/**/*.md"],
|
|
566
|
+
protectedPaths: ["**/skills/**/*.md", "**/gauntlet-overrides.md"],
|
|
566
567
|
chainCompression: {
|
|
567
568
|
enabled: true,
|
|
568
569
|
rollingWindow: 3,
|