pi-multimodal-proxy 1.7.1 → 1.11.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.
@@ -1,127 +1,127 @@
1
- # Security Review — pi-vision-proxy v1.4.0
2
-
3
- **Reviewer**: AI-assisted code audit
4
- **Date**: 2026-05-03 (updated after fixes)
5
- **Scope**: Full codebase — `extensions/internal.ts`, `extensions/vision-proxy.ts`, `package.json`
6
- **Classification**: Extension runs locally in user's Pi agent — not a network service
7
-
8
- ---
9
-
10
- ## Executive Summary
11
-
12
- All identified issues have been fixed and verified with dedicated test coverage.
13
- 238 tests passing (180 unit + 33 integration + 25 security-specific).
14
-
15
- ---
16
-
17
- ## Findings
18
-
19
- ### CRITICAL → ✅ FIXED
20
-
21
- #### SEC-1: Interactive consent toggle missing `provider` field
22
-
23
- **File**: `vision-proxy.ts:1734`
24
- **Fix**: Added `provider: effective.provider` to the `appendEntry` call.
25
- **Test**: `Security: consent integrity > consent entry without provider does not satisfy per-provider check`
26
-
27
- ---
28
-
29
- ### HIGH → ✅ FIXED
30
-
31
- #### SEC-2: `extractCandidateImagePaths` auto-reads files from `before_agent_start` without `..` check
32
-
33
- **File**: `vision-proxy.ts:857`
34
- **Fix**: Added `if (fp.includes("..")) continue;` before `readImageFileWithReason()`.
35
- **Test**: `Security: path traversal rejection`
36
-
37
- ---
38
-
39
- ### MEDIUM → ✅ FIXED
40
-
41
- #### SEC-3: `reason` parameter stored unsanitized in session entries
42
-
43
- **File**: `vision-proxy.ts:648-649,778-779,1600`
44
- **Fix**: All telemetry fields now pass through `sanitizeForLog()` which strips control characters and enforces length limits.
45
- **Test**: `Security: telemetry sanitization (SEC-3)`
46
-
47
- ---
48
-
49
- ### LOW → ✅ FIXED
50
-
51
- #### SEC-4: `readPersistentFile` trusts file content without schema validation
52
-
53
- **File**: `internal.ts:400-416`
54
- **Fix**: Added `PERSISTED_CONFIG_KEYS` allowlist filter. Only known config keys pass through; everything else is dropped.
55
- **Test**: `Security: persistent config key filtering (SEC-4)`
56
-
57
- ---
58
-
59
- #### SEC-5: `stripImagePaths` uses user-controlled path strings as regex input
60
-
61
- **File**: `internal.ts:759-768`
62
- **Status**: SAFE — metacharacters properly escaped. No fix needed.
63
- **Test**: `Security: path traversal rejection > stripImagePaths escapes regex metacharacters safely`
64
-
65
- ---
66
-
67
- #### SEC-6: Null bytes in filenames not escaped by `escapeAttr`
68
-
69
- **File**: `internal.ts:799-806`
70
- **Fix**: `escapeAttr` now replaces `\x00` with `\uFFFD` (Unicode replacement character) before escaping other special chars.
71
- **Test**: `Security: attribute escaping > escapeAttr neutralises null bytes`
72
-
73
- ---
74
-
75
- #### SEC-7: No rate limiting on `analyze_image` tool calls
76
-
77
- **File**: `vision-proxy.ts:808-811`
78
- **Fix**: Added `MAX_TOOL_CALLS_PER_TURN = 10` counter. Counter resets on each `session_start`. Tool returns error when exceeded.
79
- **Test**: Verified via integration test pattern.
80
-
81
- ---
82
-
83
- ### ADDITIONAL: Image decode bomb protection
84
-
85
- **File**: `internal.ts:1053-1063` (cropImage), `internal.ts:60-64` (MAX_IMAGE_DIMENSION)
86
- **Fix**: Added `MAX_IMAGE_DIMENSION = 16384` (16K). Both `cropImage()` and `storeImageMeta()` check dimensions before full decode. Images exceeding 16K × 16K are rejected.
87
- **Test**: `Security: image decode bomb protection`
88
-
89
- ---
90
-
91
- ## Summary Table
92
-
93
- | ID | Severity | Finding | Status |
94
- |----|----------|---------|--------|
95
- | SEC-1 | CRITICAL | Interactive consent missing `provider` | ✅ Fixed |
96
- | SEC-2 | HIGH | `before_agent_start` lacks `..` check | ✅ Fixed |
97
- | SEC-3 | MEDIUM | `reason`/`question` stored unsanitized | ✅ Fixed |
98
- | SEC-4 | LOW | Persistent config lacks key filtering | ✅ Fixed |
99
- | SEC-5 | LOW | Regex from path strings | ✅ Safe |
100
- | SEC-6 | INFO | Null bytes in filenames | ✅ Fixed |
101
- | SEC-7 | INFO | No tool call rate limit | ✅ Fixed |
102
- | BOMB | LOW | Image decode bomb | ✅ Fixed |
103
-
104
- ## Positive Security Observations
105
-
106
- 1. **Defense-in-depth on file reads**: Three layers — `..` check, `isPathAllowed()` with `realpath()` canonicalization, and file extension filtering.
107
- 2. **Fence neutralisation**: `fenceUntrusted()` properly breaks closing tags for all three fence types.
108
- 3. **Consent per-provider**: `hasConsent()` validates provider field, preventing cross-provider consent leakage.
109
- 4. **Attribute escaping**: `escapeAttr()` covers `&`, `"`, `<`, `>`, and null bytes.
110
- 5. **Sanitized user prompts**: `sanitizeXml()` wraps all user/model input with proper angle-bracket escaping.
111
- 6. **Memory bounds**: `_imageMeta` capped at 500 entries, `_toolCache` bounded by configurable `cacheSize`.
112
- 7. **File size limits**: `maxImageFileBytes()` defaults to 10 MB.
113
- 8. **Persistent file path**: Hardcoded to `~/.pi/agent/vision-proxy.json` — no path injection.
114
- 9. **Input validation**: All numeric config values range-checked. Provider/model strings validated against allowlist regex.
115
- 10. **No network surface**: Zero listening ports, no webhooks, no HTTP server.
116
- 11. **Telemetry sanitization**: All log fields stripped of control characters and length-limited.
117
- 12. **Config key filtering**: Persistent config only loads known keys; prototype pollution blocked.
118
- 13. **Rate limiting**: Max 10 tool calls per turn prevents cost runaway.
119
- 14. **Decode bomb protection**: Max 16K × 16K pixel dimensions prevent memory exhaustion.
120
-
121
- ## Dependency Audit
122
-
123
- | Package | Version | Risk |
124
- |---------|---------|------|
125
- | `imagescript` | ^1.3.1 | Pure JS. No native deps. Decode bomb mitigated by dimension limit. AGPL-3.0. |
126
- | `imghash` | ^1.1.4 | Pure JS. Lazy-loaded, wrapped in try/catch. |
127
- | `image-size` | ^2.0.2 | Header-only extraction. Minimal attack surface. |
1
+ # Security Review — pi-vision-proxy v1.4.0
2
+
3
+ **Reviewer**: AI-assisted code audit
4
+ **Date**: 2026-05-03 (updated after fixes)
5
+ **Scope**: Full codebase — `extensions/internal.ts`, `extensions/vision-proxy.ts`, `package.json`
6
+ **Classification**: Extension runs locally in user's Pi agent — not a network service
7
+
8
+ ---
9
+
10
+ ## Executive Summary
11
+
12
+ All identified issues have been fixed and verified with dedicated test coverage.
13
+ 238 tests passing (180 unit + 33 integration + 25 security-specific).
14
+
15
+ ---
16
+
17
+ ## Findings
18
+
19
+ ### CRITICAL → ✅ FIXED
20
+
21
+ #### SEC-1: Interactive consent toggle missing `provider` field
22
+
23
+ **File**: `vision-proxy.ts:1734`
24
+ **Fix**: Added `provider: effective.provider` to the `appendEntry` call.
25
+ **Test**: `Security: consent integrity > consent entry without provider does not satisfy per-provider check`
26
+
27
+ ---
28
+
29
+ ### HIGH → ✅ FIXED
30
+
31
+ #### SEC-2: `extractCandidateImagePaths` auto-reads files from `before_agent_start` without `..` check
32
+
33
+ **File**: `vision-proxy.ts:857`
34
+ **Fix**: Added `if (fp.includes("..")) continue;` before `readImageFileWithReason()`.
35
+ **Test**: `Security: path traversal rejection`
36
+
37
+ ---
38
+
39
+ ### MEDIUM → ✅ FIXED
40
+
41
+ #### SEC-3: `reason` parameter stored unsanitized in session entries
42
+
43
+ **File**: `vision-proxy.ts:648-649,778-779,1600`
44
+ **Fix**: All telemetry fields now pass through `sanitizeForLog()` which strips control characters and enforces length limits.
45
+ **Test**: `Security: telemetry sanitization (SEC-3)`
46
+
47
+ ---
48
+
49
+ ### LOW → ✅ FIXED
50
+
51
+ #### SEC-4: `readPersistentFile` trusts file content without schema validation
52
+
53
+ **File**: `internal.ts:400-416`
54
+ **Fix**: Added `PERSISTED_CONFIG_KEYS` allowlist filter. Only known config keys pass through; everything else is dropped.
55
+ **Test**: `Security: persistent config key filtering (SEC-4)`
56
+
57
+ ---
58
+
59
+ #### SEC-5: `stripImagePaths` uses user-controlled path strings as regex input
60
+
61
+ **File**: `internal.ts:759-768`
62
+ **Status**: SAFE — metacharacters properly escaped. No fix needed.
63
+ **Test**: `Security: path traversal rejection > stripImagePaths escapes regex metacharacters safely`
64
+
65
+ ---
66
+
67
+ #### SEC-6: Null bytes in filenames not escaped by `escapeAttr`
68
+
69
+ **File**: `internal.ts:799-806`
70
+ **Fix**: `escapeAttr` now replaces `\x00` with `\uFFFD` (Unicode replacement character) before escaping other special chars.
71
+ **Test**: `Security: attribute escaping > escapeAttr neutralises null bytes`
72
+
73
+ ---
74
+
75
+ #### SEC-7: No rate limiting on `analyze_image` tool calls
76
+
77
+ **File**: `vision-proxy.ts:808-811`
78
+ **Fix**: Added `MAX_TOOL_CALLS_PER_TURN = 10` counter. Counter resets on each `session_start`. Tool returns error when exceeded.
79
+ **Test**: Verified via integration test pattern.
80
+
81
+ ---
82
+
83
+ ### ADDITIONAL: Image decode bomb protection
84
+
85
+ **File**: `internal.ts:1053-1063` (cropImage), `internal.ts:60-64` (MAX_IMAGE_DIMENSION)
86
+ **Fix**: Added `MAX_IMAGE_DIMENSION = 16384` (16K). Both `cropImage()` and `storeImageMeta()` check dimensions before full decode. Images exceeding 16K × 16K are rejected.
87
+ **Test**: `Security: image decode bomb protection`
88
+
89
+ ---
90
+
91
+ ## Summary Table
92
+
93
+ | ID | Severity | Finding | Status |
94
+ |----|----------|---------|--------|
95
+ | SEC-1 | CRITICAL | Interactive consent missing `provider` | ✅ Fixed |
96
+ | SEC-2 | HIGH | `before_agent_start` lacks `..` check | ✅ Fixed |
97
+ | SEC-3 | MEDIUM | `reason`/`question` stored unsanitized | ✅ Fixed |
98
+ | SEC-4 | LOW | Persistent config lacks key filtering | ✅ Fixed |
99
+ | SEC-5 | LOW | Regex from path strings | ✅ Safe |
100
+ | SEC-6 | INFO | Null bytes in filenames | ✅ Fixed |
101
+ | SEC-7 | INFO | No tool call rate limit | ✅ Fixed |
102
+ | BOMB | LOW | Image decode bomb | ✅ Fixed |
103
+
104
+ ## Positive Security Observations
105
+
106
+ 1. **Defense-in-depth on file reads**: Three layers — `..` check, `isPathAllowed()` with `realpath()` canonicalization, and file extension filtering.
107
+ 2. **Fence neutralisation**: `fenceUntrusted()` properly breaks closing tags for all three fence types.
108
+ 3. **Consent per-provider**: `hasConsent()` validates provider field, preventing cross-provider consent leakage.
109
+ 4. **Attribute escaping**: `escapeAttr()` covers `&`, `"`, `<`, `>`, and null bytes.
110
+ 5. **Sanitized user prompts**: `sanitizeXml()` wraps all user/model input with proper angle-bracket escaping.
111
+ 6. **Memory bounds**: `_imageMeta` capped at 500 entries, `_toolCache` bounded by configurable `cacheSize`.
112
+ 7. **File size limits**: `maxImageFileBytes()` defaults to 10 MB.
113
+ 8. **Persistent file path**: Hardcoded to `~/.pi/agent/vision-proxy.json` — no path injection.
114
+ 9. **Input validation**: All numeric config values range-checked. Provider/model strings validated against allowlist regex.
115
+ 10. **No network surface**: Zero listening ports, no webhooks, no HTTP server.
116
+ 11. **Telemetry sanitization**: All log fields stripped of control characters and length-limited.
117
+ 12. **Config key filtering**: Persistent config only loads known keys; prototype pollution blocked.
118
+ 13. **Rate limiting**: Max 10 tool calls per turn prevents cost runaway.
119
+ 14. **Decode bomb protection**: Max 16K × 16K pixel dimensions prevent memory exhaustion.
120
+
121
+ ## Dependency Audit
122
+
123
+ | Package | Version | Risk |
124
+ |---------|---------|------|
125
+ | `imagescript` | ^1.3.1 | Pure JS. No native deps. Decode bomb mitigated by dimension limit. AGPL-3.0. |
126
+ | `imghash` | ^1.1.4 | Pure JS. Lazy-loaded, wrapped in try/catch. |
127
+ | `image-size` | ^2.0.2 | Header-only extraction. Minimal attack surface. |
@@ -0,0 +1,20 @@
1
+ # Publishing `pi-multimodal-proxy`
2
+
3
+ Tag-driven CI publish with npm provenance.
4
+
5
+ ```bash
6
+ npm version patch -m "release: %s"
7
+ git push origin main --follow-tags
8
+ gh run watch
9
+ ```
10
+
11
+ ## Auth (one-time) — Trusted Publisher / OIDC
12
+
13
+ 1. npmjs.com → `pi-multimodal-proxy` → **Settings → Trusted Publisher**
14
+ 2. Add GitHub Actions:
15
+ - **Organization or user:** `pungggi`
16
+ - **Repository:** `pi-multimodal-proxy`
17
+ - **Workflow filename:** `release.yml`
18
+ 3. Leave `NPM_TOKEN` unset for pure OIDC.
19
+
20
+ Fallback: Classic **Automation** token as `NPM_TOKEN` repo secret.
@@ -0,0 +1,218 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ buildCompactionDigest,
5
+ collectVisibleFenceIds,
6
+ CUSTOM_TYPE_DESCRIPTION,
7
+ CUSTOM_TYPE_VIDEO_DESCRIPTION,
8
+ DIGEST_IMAGE_CHARS,
9
+ DIGEST_LEAN_IMAGE_CHARS,
10
+ DIGEST_MAX_IMAGES,
11
+ findDescriptions,
12
+ findVideoDescriptions,
13
+ truncateForDigest,
14
+ type VideoDescriptionEntry,
15
+ } from "../internal.ts";
16
+
17
+ // SessionEntry minimal shape — typed loose because peer dep types are not loaded in test
18
+ type Entry = any;
19
+
20
+ const customEntry = (customType: string, data: unknown): Entry => ({
21
+ type: "custom",
22
+ customType,
23
+ data,
24
+ });
25
+
26
+ const video = (hash: string, description = `video ${hash}`): VideoDescriptionEntry => ({
27
+ hash,
28
+ filename: `${hash}.mp4`,
29
+ mimeType: "video/mp4",
30
+ description,
31
+ });
32
+
33
+ describe("findVideoDescriptions", () => {
34
+ it("collects hash → entry from video description entries", () => {
35
+ const entries: Entry[] = [
36
+ customEntry(CUSTOM_TYPE_VIDEO_DESCRIPTION, video("v1")),
37
+ customEntry(CUSTOM_TYPE_DESCRIPTION, { hash: "img", description: "not a video" }),
38
+ customEntry(CUSTOM_TYPE_VIDEO_DESCRIPTION, { hash: "", description: "skip" }),
39
+ customEntry(CUSTOM_TYPE_VIDEO_DESCRIPTION, video("v2")),
40
+ ];
41
+ const map = findVideoDescriptions(entries);
42
+ assert.equal(map.size, 2);
43
+ assert.equal(map.get("v1")?.filename, "v1.mp4");
44
+ assert.equal(map.get("v2")?.mimeType, "video/mp4");
45
+ });
46
+
47
+ it("keeps the most recent entry per hash", () => {
48
+ const entries: Entry[] = [
49
+ customEntry(CUSTOM_TYPE_VIDEO_DESCRIPTION, video("v1", "old")),
50
+ customEntry(CUSTOM_TYPE_VIDEO_DESCRIPTION, video("v1", "new")),
51
+ ];
52
+ assert.equal(findVideoDescriptions(entries).get("v1")?.description, "new");
53
+ });
54
+
55
+ it("moves a re-described hash to the end of the iteration order", () => {
56
+ const entries: Entry[] = [
57
+ customEntry(CUSTOM_TYPE_VIDEO_DESCRIPTION, video("v1", "first")),
58
+ customEntry(CUSTOM_TYPE_VIDEO_DESCRIPTION, video("v2", "second")),
59
+ customEntry(CUSTOM_TYPE_VIDEO_DESCRIPTION, video("v1", "updated")),
60
+ ];
61
+ assert.deepEqual([...findVideoDescriptions(entries).keys()], ["v2", "v1"]);
62
+ });
63
+
64
+ it("backfills filename and mimeType on malformed entries instead of crashing later", () => {
65
+ const entries: Entry[] = [
66
+ customEntry(CUSTOM_TYPE_VIDEO_DESCRIPTION, { hash: "v1", description: "d" }),
67
+ customEntry(CUSTOM_TYPE_VIDEO_DESCRIPTION, { hash: "v2", description: "d", filename: 42, mimeType: null }),
68
+ ];
69
+ const map = findVideoDescriptions(entries);
70
+ assert.equal(map.get("v1")?.filename, "unknown");
71
+ assert.equal(map.get("v1")?.mimeType, "application/octet-stream");
72
+ assert.equal(map.get("v2")?.filename, "unknown");
73
+ assert.equal(map.get("v2")?.mimeType, "application/octet-stream");
74
+ // The digest builder accepts the backfilled entries without throwing.
75
+ const digest = buildCompactionDigest([], [...map.values()]);
76
+ assert.ok(digest.includes('file="unknown"'));
77
+ });
78
+ });
79
+
80
+ describe("findDescriptions ordering", () => {
81
+ it("moves a re-described hash to the end of the iteration order", () => {
82
+ const entries: Entry[] = [
83
+ customEntry(CUSTOM_TYPE_DESCRIPTION, { hash: "a", description: "first" }),
84
+ customEntry(CUSTOM_TYPE_DESCRIPTION, { hash: "b", description: "second" }),
85
+ customEntry(CUSTOM_TYPE_DESCRIPTION, { hash: "a", description: "updated" }),
86
+ ];
87
+ const map = findDescriptions(entries);
88
+ assert.deepEqual([...map.keys()], ["b", "a"]);
89
+ assert.equal(map.get("a"), "updated");
90
+ });
91
+ });
92
+
93
+ describe("truncateForDigest", () => {
94
+ it("returns short text unchanged", () => {
95
+ assert.equal(truncateForDigest("short text", 100), "short text");
96
+ });
97
+
98
+ it("trims surrounding whitespace", () => {
99
+ assert.equal(truncateForDigest(" padded ", 100), "padded");
100
+ });
101
+
102
+ it("cuts long text at a word boundary and marks the truncation", () => {
103
+ // slice(0,20) is "alpha beta gamma del"; the boundary cut must drop "del".
104
+ const out = truncateForDigest("alpha beta gamma delta epsilon", 20);
105
+ assert.equal(out, "alpha beta gamma … [truncated]");
106
+ });
107
+
108
+ it("hard-cuts when there is no usable word boundary", () => {
109
+ const out = truncateForDigest("a".repeat(50), 20);
110
+ assert.equal(out, `${"a".repeat(20)} … [truncated]`);
111
+ });
112
+
113
+ it("never splits a surrogate pair on a hard cut", () => {
114
+ // "💥" is 2 UTF-16 code units; a cut at max=21 would land mid-pair.
115
+ const out = truncateForDigest(`${"x".repeat(20)}💥${"y".repeat(30)}`, 21);
116
+ assert.ok(!/[\uD800-\uDBFF]$/.test(out.replace(" … [truncated]", "")));
117
+ assert.ok(!out.includes("�"));
118
+ assert.equal(out, `${"x".repeat(20)} … [truncated]`);
119
+ });
120
+ });
121
+
122
+ describe("collectVisibleFenceIds", () => {
123
+ const h1 = "a".repeat(32);
124
+ const h2 = "b".repeat(32);
125
+ const h3 = "c".repeat(32);
126
+
127
+ it("collects ids from description, analysis, joint, and video fences", () => {
128
+ const text = [
129
+ `<vision_proxy_description image="${h1}" width="10"\n>body\n</vision_proxy_description>`,
130
+ `<vision_proxy_analysis image="${h2}#crop:1,2,3,4"\n>body\n</vision_proxy_analysis>`,
131
+ `dimensions='[{"image":"${h3}","width":5}]'`,
132
+ `<vision_proxy_video_description file="v.mp4" hash="${h1}" mime="video/mp4"\n>t\n</vision_proxy_video_description>`,
133
+ ].join("\n");
134
+ const ids = collectVisibleFenceIds(text);
135
+ assert.deepEqual([...ids].sort(), [h1, h2, h3]);
136
+ });
137
+
138
+ it("ignores bare hashes and user-typed recall references", () => {
139
+ const ids = collectVisibleFenceIds(`zoom into image="${h1}" and also ${h2} please`);
140
+ assert.equal(ids.size, 0);
141
+ });
142
+
143
+ it("accumulates into a provided set", () => {
144
+ const out = new Set<string>();
145
+ collectVisibleFenceIds(`<vision_proxy_description image="${h1}"\n>x\n</vision_proxy_description>`, out);
146
+ collectVisibleFenceIds(`<vision_proxy_description image="${h2}"\n>x\n</vision_proxy_description>`, out);
147
+ assert.equal(out.size, 2);
148
+ });
149
+ });
150
+
151
+ describe("buildCompactionDigest", () => {
152
+ it("returns empty string when there is nothing to restore", () => {
153
+ assert.equal(buildCompactionDigest([], []), "");
154
+ });
155
+
156
+ it("restores image fences with id, meta, and untrusted warning", () => {
157
+ const digest = buildCompactionDigest(
158
+ [{ hash: "hash-a", description: "a red square", meta: { width: 10, height: 20, filename: "sq.png" } }],
159
+ [],
160
+ );
161
+ assert.ok(digest.includes("post-compaction recall"));
162
+ assert.ok(digest.includes('image="hash-a"'));
163
+ assert.ok(digest.includes('width="10"'));
164
+ assert.ok(digest.includes('filename="sq.png"'));
165
+ assert.ok(digest.includes("a red square"));
166
+ assert.ok(digest.includes("UNTRUSTED"));
167
+ });
168
+
169
+ it("includes video fences", () => {
170
+ const digest = buildCompactionDigest([], [video("v9", "a talking head")]);
171
+ assert.ok(digest.includes('hash="v9"'));
172
+ assert.ok(digest.includes('file="v9.mp4"'));
173
+ assert.ok(digest.includes("a talking head"));
174
+ assert.ok(digest.includes("1 video/audio file"));
175
+ });
176
+
177
+ it("mentions analyze_image recall only when the tool is enabled", () => {
178
+ const imgs = [{ hash: "h", description: "d" }];
179
+ assert.ok(buildCompactionDigest(imgs, [], { toolEnabled: true }).includes("analyze_image"));
180
+ assert.ok(!buildCompactionDigest(imgs, [], { toolEnabled: false }).includes("analyze_image"));
181
+ });
182
+
183
+ it("keeps only the most recent images when over the cap", () => {
184
+ const imgs = Array.from({ length: DIGEST_MAX_IMAGES + 3 }, (_, i) => ({
185
+ hash: `h${i}`,
186
+ description: `d${i}`,
187
+ }));
188
+ const digest = buildCompactionDigest(imgs, []);
189
+ assert.ok(!digest.includes('image="h0"'), "oldest images dropped");
190
+ assert.ok(!digest.includes('image="h2"'));
191
+ assert.ok(digest.includes('image="h3"'));
192
+ assert.ok(digest.includes(`image="h${DIGEST_MAX_IMAGES + 2}"`));
193
+ });
194
+
195
+ it("applies tighter budgets in lean mode", () => {
196
+ const long = "word ".repeat(400);
197
+ const [full] = buildCompactionDigest([{ hash: "h", description: long }], [])
198
+ .split("\n\n")
199
+ .slice(1);
200
+ const [lean] = buildCompactionDigest([{ hash: "h", description: long }], [], { lean: true })
201
+ .split("\n\n")
202
+ .slice(1);
203
+ assert.ok(full!.length > lean!.length);
204
+ assert.ok(lean!.length < DIGEST_LEAN_IMAGE_CHARS + 200);
205
+ assert.ok(full!.length < DIGEST_IMAGE_CHARS + 200);
206
+ });
207
+
208
+ it("counts both media kinds in the header", () => {
209
+ const digest = buildCompactionDigest(
210
+ [
211
+ { hash: "a", description: "d1" },
212
+ { hash: "b", description: "d2" },
213
+ ],
214
+ [video("v1"), video("v2")],
215
+ );
216
+ assert.ok(digest.includes("2 images and 2 video/audio files"));
217
+ });
218
+ });