@ngockhoale/ukit 1.6.4 → 1.6.5

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.
@@ -0,0 +1,155 @@
1
+ #!/bin/bash
2
+ # UserPromptSubmit hook: detect image input (pasted / local path / URL) and arm the
3
+ # TASK-008 write gate by writing pending-<sha>.json markers via extract-image.mjs.
4
+ #
5
+ # FAILS OPEN, unconditionally: this hook must never block the user's prompt. Any
6
+ # failure (malformed stdin, missing extractor, node crash) degrades to "no hint,
7
+ # no markers" and exit 0. This is the deliberate opposite of the TASK-008 gate,
8
+ # which fails closed on Edit/Write.
9
+ #
10
+ # Detection only — never decodes an image, never writes an image file. Marker
11
+ # writing is delegated entirely to `extract-image.mjs --mark-pending`; this hook
12
+ # never computes a sha itself (PLAN §3.1).
13
+ #
14
+ # Runs on EVERY prompt, 8s budget: one shell-out to extract-image.mjs
15
+ # --detect --mark-pending --json, no decoding.
16
+
17
+ INPUT=$(cat)
18
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
19
+
20
+ INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { execFileSync } = require('child_process');
24
+ const { pathToFileURL } = require('url');
25
+
26
+ (async () => {
27
+ const payload = (() => {
28
+ try {
29
+ const parsed = JSON.parse(process.env.INPUT || '');
30
+ return parsed && typeof parsed === 'object' ? parsed : {};
31
+ } catch {
32
+ return {};
33
+ }
34
+ })();
35
+ const projectRoot = process.env.PROJECT_ROOT;
36
+
37
+ function extractPromptText(p) {
38
+ const candidates = [p?.prompt, p?.user_prompt, p?.text];
39
+ for (const candidate of candidates) {
40
+ if (typeof candidate === 'string' && candidate.trim()) return candidate;
41
+ }
42
+ return '';
43
+ }
44
+
45
+ const promptText = extractPromptText(payload);
46
+
47
+ // URL images are checked first and stripped out so the local-path regex never
48
+ // re-matches the tail of a URL (case c is a hint-only lane — no download here).
49
+ const URL_IMAGE_RE = /https?:\/\/\S+\.(?:png|jpe?g|gif|webp)\b/gi;
50
+ const LOCAL_PATH_RE = /\S+\.(?:png|jpe?g|gif|webp|bmp)\b/gi;
51
+
52
+ const urlMatches = promptText.match(URL_IMAGE_RE) || [];
53
+ const textWithoutUrls = promptText.replace(URL_IMAGE_RE, ' ');
54
+ const localMatches = textWithoutUrls.match(LOCAL_PATH_RE) || [];
55
+
56
+ // Shell out to the single owner of image detection + marker writing. Runs on every
57
+ // prompt (cheap: --detect --mark-pending, never decodes). All three input forms are
58
+ // armed here, not just pasted images: a path or URL that only produced hint text would
59
+ // leave Edit/Write ungated, which is exactly the "analyse blind" case this cycle exists
60
+ // to prevent. --ref hashes a local file's bytes / a URL string inside the extractor.
61
+ let markedPasted = 0;
62
+ let markedPath = 0;
63
+ let markedUrl = 0;
64
+ const extractorPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'extract-image.mjs');
65
+ if (fs.existsSync(extractorPath)) {
66
+ const args = [extractorPath, '--detect', '--mark-pending', '--json'];
67
+ if (typeof payload?.transcript_path === 'string' && payload.transcript_path.trim()) {
68
+ args.push('--session', payload.transcript_path.trim());
69
+ }
70
+ if (typeof payload?.session_id === 'string' && payload.session_id.trim()) {
71
+ args.push('--session-id', payload.session_id.trim());
72
+ }
73
+ for (const ref of [...localMatches, ...urlMatches]) {
74
+ args.push('--ref', ref);
75
+ }
76
+ try {
77
+ const out = execFileSync('node', args, {
78
+ cwd: projectRoot,
79
+ encoding: 'utf8',
80
+ timeout: 6000,
81
+ });
82
+ const parsed = JSON.parse(out);
83
+ const images = Array.isArray(parsed?.images) ? parsed.images : [];
84
+ // Ref markers carry a `source`; pasted ones do not. Counting this way stays correct
85
+ // when a named path does not exist on disk and the extractor drops it.
86
+ markedPasted = images.filter((img) => !img?.source).length;
87
+ markedPath = images.filter((img) => img?.source === 'path').length;
88
+ markedUrl = images.filter((img) => img?.source === 'url').length;
89
+ } catch (err) {
90
+ process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})\n`);
91
+ }
92
+ }
93
+
94
+ // `cases` reflects what the PROMPT mentioned; `armed` reflects what actually got a
95
+ // pending marker. They differ when a named path does not resolve from the project root
96
+ // — still worth a hint, but the gate is not armed and must not be claimed to be.
97
+ const cases = [];
98
+ if (markedPasted > 0) cases.push('pasted image');
99
+ if (localMatches.length > 0) cases.push('local file path');
100
+ if (urlMatches.length > 0) cases.push('image URL');
101
+ const armed = markedPasted + markedPath + markedUrl;
102
+
103
+ if (cases.length === 0) {
104
+ process.exit(0);
105
+ return;
106
+ }
107
+
108
+ // Optional: name the concrete vision model when the UNIC gateway is active
109
+ // (TASK-003). Never fatal — a missing/broken gateway module just omits the note.
110
+ let unicNote = '';
111
+ try {
112
+ const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
113
+ if (fs.existsSync(gatewayPath)) {
114
+ const mod = await import(pathToFileURL(gatewayPath).href);
115
+ if (typeof mod.detectUnicGateway === 'function') {
116
+ const result = mod.detectUnicGateway({ rootDir: projectRoot });
117
+ if (result?.unicMode) {
118
+ unicNote = ` (UNIC gateway active — ${result.visionModel} routes through it.)`;
119
+ }
120
+ }
121
+ }
122
+ } catch {
123
+ unicNote = '';
124
+ }
125
+
126
+ const lines = [
127
+ `UKIT VISION ROUTE — image input detected (${cases.join(', ')}).`,
128
+ armed > 0
129
+ ? 'unic-code / unic-smart cannot read images on this gateway, and Edit/Write is now GATED\nuntil a vision analysis exists. Do this before anything else:'
130
+ : 'unic-code / unic-smart cannot read images on this gateway. Do this before anything else:',
131
+ ' 1. node .claude/ukit/index/extract-image.mjs --json',
132
+ ' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]',
133
+ ' Pass the ABSOLUTE paths from images[].path as text — a subagent does NOT',
134
+ ' inherit image blocks; it can only Read files.',
135
+ ' 3. Continue the real task using the returned description.',
136
+ ];
137
+ if (markedUrl > 0) {
138
+ lines.push(' Image URL detected: ukit-vision-analyst downloads it with Bash into');
139
+ lines.push(' .ukit/storage/cache/vision/, then Reads the downloaded file.');
140
+ }
141
+ if (localMatches.length > markedPath) {
142
+ lines.push(' A named image path did not resolve from the project root — resolve it before');
143
+ lines.push(' analysing, and do not guess at its contents.');
144
+ }
145
+ if (unicNote) lines.push(unicNote);
146
+
147
+ process.stdout.write(`${lines.join('\n')}\n`);
148
+ process.exit(0);
149
+ })().catch((err) => {
150
+ process.stderr.write(`vision-router: unexpected error (${err?.message ?? err})\n`);
151
+ process.exit(0);
152
+ });
153
+ NODE
154
+
155
+ exit 0
@@ -82,6 +82,11 @@
82
82
  "type": "command",
83
83
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
84
84
  "timeout": 8
85
+ },
86
+ {
87
+ "type": "command",
88
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-gate.sh\"",
89
+ "timeout": 8
85
90
  }
86
91
  ]
87
92
  },
@@ -145,6 +150,11 @@
145
150
  "type": "command",
146
151
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
147
152
  "timeout": 8
153
+ },
154
+ {
155
+ "type": "command",
156
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\"",
157
+ "timeout": 8
148
158
  }
149
159
  ]
150
160
  }
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * extract-image.mjs — materialise transcript images so a subagent can Read them.
4
+ *
5
+ * A spawned subagent receives TEXT ONLY and does not inherit the parent's image
6
+ * blocks, so pasted images are invisible unless this script first decodes them
7
+ * to disk. This is the single home of the sha256 key: `pending-<sha>` markers
8
+ * (written by --mark-pending) and `<sha>.<ext>` image files (written by a normal
9
+ * extract) MUST agree exactly, or the TASK-008 gate deadlocks forever. Both
10
+ * paths call the same hashPayload() function on the exact base64 string as it
11
+ * appears in the transcript — no trimming, no normalising, no decoding first.
12
+ *
13
+ * Usage:
14
+ * node extract-image.mjs [options]
15
+ * --session <path> explicit transcript .jsonl (default: auto-discover)
16
+ * --detect count only; decode nothing, write nothing
17
+ * --mark-pending write pending-<sha>.json markers; NO decode, NO image write
18
+ * --json machine-readable stdout
19
+ * --limit <n> max images, default 3 (most recent first)
20
+ * --out <dir> default <projectRoot>/.ukit/storage/cache/vision
21
+ * --session-id <id> marker subdirectory (default: from transcript filename, else "default")
22
+ * --ref <path|url> repeatable; arm a marker for an image named in the prompt text
23
+ * rather than pasted. Pasted images hash their base64 payload;
24
+ * a local file hashes its bytes and a URL hashes the URL string.
25
+ * Different domains, but the sha only has to be stable and unique.
26
+ *
27
+ * Builtins only (fs, path, crypto, os). No dependencies, no network calls.
28
+ * Never throws: always exits 0, including "no images found" and missing session file.
29
+ */
30
+
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+ import crypto from 'node:crypto';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const MEDIA_EXT = {
38
+ 'image/png': 'png',
39
+ 'image/jpeg': 'jpg',
40
+ 'image/gif': 'gif',
41
+ 'image/webp': 'webp',
42
+ };
43
+
44
+ const DEFAULT_LIMIT = 3;
45
+ const MAX_DECODED_BYTES = 10 * 1024 * 1024; // 10 MB
46
+ const RETENTION_MAX_IMAGE_FILES = 20;
47
+ const MARKER_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
48
+
49
+ /**
50
+ * Single shared hash function for both the image filename sha and the
51
+ * pending-<sha> marker sha. Hashes the base64 payload string EXACTLY as it
52
+ * appears in the transcript (no trim/normalise/decode) so the two paths can
53
+ * never diverge.
54
+ */
55
+ export function hashPayload(base64Data) {
56
+ return crypto.createHash('sha256').update(base64Data, 'utf8').digest('hex');
57
+ }
58
+
59
+ const REF_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)(?:[?#].*)?$/i;
60
+ const EXT_MEDIA = {
61
+ png: 'image/png',
62
+ jpg: 'image/jpeg',
63
+ jpeg: 'image/jpeg',
64
+ gif: 'image/gif',
65
+ webp: 'image/webp',
66
+ bmp: 'image/bmp',
67
+ };
68
+
69
+ /**
70
+ * Turns --ref values (local paths and URLs named in the prompt) into markers.
71
+ *
72
+ * Pasted images hash their base64 payload; these have no payload to hash, so a local
73
+ * file hashes its bytes and a URL hashes the URL string. The domains differ, but the
74
+ * sha only has to be stable and unique — the gate matches pending-<sha> to
75
+ * analyzed-<sha> by filename and never recomputes it. Still routed through
76
+ * crypto.createHash here so this file remains the single owner of the hash.
77
+ *
78
+ * Silently drops anything that is not an image ref or cannot be read: this feeds a
79
+ * hook that must never wedge a prompt.
80
+ */
81
+ export function resolveRefs(values) {
82
+ const out = [];
83
+ const seen = new Set();
84
+ for (const value of values ?? []) {
85
+ const match = REF_EXT_RE.exec(value);
86
+ if (!match) continue;
87
+ const mediaType = EXT_MEDIA[match[1].toLowerCase()] ?? 'application/octet-stream';
88
+ const isUrl = /^https?:\/\//i.test(value);
89
+ let sha;
90
+ if (isUrl) {
91
+ sha = crypto.createHash('sha256').update(value, 'utf8').digest('hex');
92
+ } else {
93
+ let bytes;
94
+ try {
95
+ bytes = fs.readFileSync(path.resolve(value));
96
+ } catch {
97
+ continue; // path named in the prompt but not present on disk: nothing to analyse
98
+ }
99
+ if (bytes.length > MAX_DECODED_BYTES) continue;
100
+ sha = crypto.createHash('sha256').update(bytes).digest('hex');
101
+ }
102
+ if (seen.has(sha)) continue;
103
+ seen.add(sha);
104
+ out.push({ sha, mediaType, source: isUrl ? 'url' : 'path', value });
105
+ }
106
+ return out;
107
+ }
108
+
109
+ function parseArgs(argv) {
110
+ const opts = {
111
+ session: null,
112
+ detect: false,
113
+ markPending: false,
114
+ json: false,
115
+ limit: DEFAULT_LIMIT,
116
+ out: null,
117
+ sessionId: null,
118
+ refs: [],
119
+ };
120
+ for (let i = 0; i < argv.length; i += 1) {
121
+ const arg = argv[i];
122
+ if (arg === '--session') {
123
+ opts.session = argv[i + 1];
124
+ i += 1;
125
+ } else if (arg === '--detect') {
126
+ opts.detect = true;
127
+ } else if (arg === '--mark-pending') {
128
+ opts.markPending = true;
129
+ } else if (arg === '--json') {
130
+ opts.json = true;
131
+ } else if (arg === '--limit') {
132
+ const n = Number(argv[i + 1]);
133
+ if (Number.isFinite(n) && n > 0) opts.limit = Math.floor(n);
134
+ i += 1;
135
+ } else if (arg === '--out') {
136
+ opts.out = argv[i + 1];
137
+ i += 1;
138
+ } else if (arg === '--session-id') {
139
+ opts.sessionId = argv[i + 1];
140
+ i += 1;
141
+ } else if (arg === '--ref') {
142
+ const value = argv[i + 1];
143
+ if (typeof value === 'string' && value.trim()) opts.refs.push(value.trim());
144
+ i += 1;
145
+ }
146
+ }
147
+ return opts;
148
+ }
149
+
150
+ function slugifyCwd(cwd) {
151
+ return cwd.replace(/[^a-zA-Z0-9]/g, '-');
152
+ }
153
+
154
+ function discoverSessionFile(cwd) {
155
+ const slug = slugifyCwd(cwd);
156
+ const dir = path.join(os.homedir(), '.claude', 'projects', slug);
157
+ let entries;
158
+ try {
159
+ entries = fs.readdirSync(dir, { withFileTypes: true });
160
+ } catch {
161
+ return null;
162
+ }
163
+
164
+ let newestPath = null;
165
+ let newestMtimeMs = -Infinity;
166
+ for (const entry of entries) {
167
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
168
+ const full = path.join(dir, entry.name);
169
+ let stat;
170
+ try {
171
+ stat = fs.statSync(full);
172
+ } catch {
173
+ continue;
174
+ }
175
+ if (stat.mtimeMs > newestMtimeMs) {
176
+ newestMtimeMs = stat.mtimeMs;
177
+ newestPath = full;
178
+ }
179
+ }
180
+ return newestPath;
181
+ }
182
+
183
+ function readLines(filePath) {
184
+ let raw;
185
+ try {
186
+ raw = fs.readFileSync(filePath, 'utf8');
187
+ } catch {
188
+ return [];
189
+ }
190
+ return raw.split('\n').filter((line) => line.trim().length > 0);
191
+ }
192
+
193
+ /**
194
+ * Parses each JSONL line independently. A malformed line is skipped via its
195
+ * own try/catch and never throws out to the caller.
196
+ */
197
+ function extractImageBlocks(lines) {
198
+ const blocks = [];
199
+ for (let i = 0; i < lines.length; i += 1) {
200
+ try {
201
+ const obj = JSON.parse(lines[i]);
202
+ const content = obj?.message?.content;
203
+ if (!Array.isArray(content)) continue;
204
+ for (const block of content) {
205
+ if (block?.type !== 'image') continue;
206
+ const source = block.source;
207
+ if (!source || source.type !== 'base64') continue;
208
+ const mediaType = source.media_type;
209
+ const data = source.data;
210
+ if (typeof mediaType !== 'string' || typeof data !== 'string' || !data) continue;
211
+ blocks.push({ mediaType, data, lineIndex: i });
212
+ }
213
+ } catch {
214
+ // malformed line: skip and continue
215
+ }
216
+ }
217
+ return blocks;
218
+ }
219
+
220
+ /**
221
+ * Filters to the allowlisted media types, dedupes by sha (content-addressed —
222
+ * same payload always produces the same key), and orders most-recent-first.
223
+ */
224
+ function selectImages(blocks, limit) {
225
+ const allowed = blocks.filter((b) => MEDIA_EXT[b.mediaType]);
226
+ const bySha = new Map();
227
+ for (const block of allowed) {
228
+ const sha = hashPayload(block.data);
229
+ bySha.set(sha, { sha, mediaType: block.mediaType, data: block.data, lineIndex: block.lineIndex });
230
+ }
231
+ const deduped = Array.from(bySha.values());
232
+ deduped.sort((a, b) => b.lineIndex - a.lineIndex);
233
+ return deduped.slice(0, limit);
234
+ }
235
+
236
+ function pruneImageFiles(outDir) {
237
+ let entries;
238
+ try {
239
+ entries = fs.readdirSync(outDir, { withFileTypes: true });
240
+ } catch {
241
+ return;
242
+ }
243
+ const imageFiles = entries
244
+ .filter((e) => e.isFile() && /^[0-9a-f]{64}\.(png|jpg|gif|webp)$/.test(e.name))
245
+ .map((e) => {
246
+ const full = path.join(outDir, e.name);
247
+ let mtimeMs = 0;
248
+ try {
249
+ mtimeMs = fs.statSync(full).mtimeMs;
250
+ } catch {
251
+ // ignore
252
+ }
253
+ return { full, mtimeMs };
254
+ })
255
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
256
+
257
+ for (const f of imageFiles.slice(RETENTION_MAX_IMAGE_FILES)) {
258
+ try {
259
+ fs.unlinkSync(f.full);
260
+ } catch {
261
+ // ignore
262
+ }
263
+ }
264
+ }
265
+
266
+ function pruneMarkerDirs(outDir) {
267
+ let entries;
268
+ try {
269
+ entries = fs.readdirSync(outDir, { withFileTypes: true });
270
+ } catch {
271
+ return;
272
+ }
273
+ const now = Date.now();
274
+ for (const entry of entries) {
275
+ if (!entry.isDirectory()) continue;
276
+ const full = path.join(outDir, entry.name);
277
+ let mtimeMs;
278
+ try {
279
+ mtimeMs = fs.statSync(full).mtimeMs;
280
+ } catch {
281
+ continue;
282
+ }
283
+ if (now - mtimeMs > MARKER_MAX_AGE_MS) {
284
+ try {
285
+ fs.rmSync(full, { recursive: true, force: true });
286
+ } catch {
287
+ // ignore
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ function printResult(opts, result) {
294
+ if (opts.json) {
295
+ console.log(JSON.stringify(result));
296
+ return;
297
+ }
298
+ console.log(`imageCount: ${result.imageCount}`);
299
+ for (const img of result.images) {
300
+ if (img.path) console.log(img.path);
301
+ }
302
+ }
303
+
304
+ function main() {
305
+ const opts = parseArgs(process.argv.slice(2));
306
+ const cwd = process.cwd();
307
+
308
+ const sessionPath = opts.session ? path.resolve(opts.session) : discoverSessionFile(cwd);
309
+ const outDir = opts.out
310
+ ? path.resolve(opts.out)
311
+ : path.join(cwd, '.ukit', 'storage', 'cache', 'vision');
312
+
313
+ const sessionId = opts.sessionId
314
+ || (sessionPath ? path.basename(sessionPath, path.extname(sessionPath)) : 'default');
315
+
316
+ const lines = sessionPath ? readLines(sessionPath) : [];
317
+ const blocks = extractImageBlocks(lines);
318
+ const selected = selectImages(blocks, opts.limit);
319
+ const refs = resolveRefs(opts.refs);
320
+
321
+ // --detect is report-only, so it must not short-circuit --mark-pending when both are
322
+ // passed together. vision-router.sh combines them; short-circuiting here would arm
323
+ // nothing and leave the TASK-008 write gate permanently open.
324
+ if (opts.detect && !opts.markPending) {
325
+ printResult(opts, { imageCount: selected.length + refs.length, sessionId, images: [] });
326
+ return;
327
+ }
328
+
329
+ if (opts.markPending) {
330
+ const markerDir = path.join(outDir, sessionId);
331
+ if (selected.length > 0 || refs.length > 0) {
332
+ try {
333
+ fs.mkdirSync(markerDir, { recursive: true });
334
+ } catch (err) {
335
+ process.stderr.write(`extract-image: failed to create marker dir ${markerDir}: ${err.message}\n`);
336
+ printResult(opts, { imageCount: 0, sessionId, images: [] });
337
+ return;
338
+ }
339
+ }
340
+
341
+ const written = [];
342
+ for (const img of selected) {
343
+ const markerPath = path.join(markerDir, `pending-${img.sha}.json`);
344
+ const marker = { sha: img.sha, mediaType: img.mediaType, ts: Date.now(), sessionId };
345
+ try {
346
+ fs.writeFileSync(markerPath, JSON.stringify(marker));
347
+ written.push({ sha: img.sha, path: markerPath, mediaType: img.mediaType, bytes: 0 });
348
+ } catch (err) {
349
+ process.stderr.write(`extract-image: failed to write marker ${markerPath}: ${err.message}\n`);
350
+ }
351
+ }
352
+ for (const ref of refs) {
353
+ const markerPath = path.join(markerDir, `pending-${ref.sha}.json`);
354
+ const marker = {
355
+ sha: ref.sha,
356
+ mediaType: ref.mediaType,
357
+ ts: Date.now(),
358
+ sessionId,
359
+ source: ref.source,
360
+ ref: ref.value,
361
+ };
362
+ try {
363
+ fs.writeFileSync(markerPath, JSON.stringify(marker));
364
+ written.push({ sha: ref.sha, path: markerPath, mediaType: ref.mediaType, bytes: 0, source: ref.source, ref: ref.value });
365
+ } catch (err) {
366
+ process.stderr.write(`extract-image: failed to write marker ${markerPath}: ${err.message}\n`);
367
+ }
368
+ }
369
+ pruneMarkerDirs(outDir);
370
+ printResult(opts, { imageCount: written.length, sessionId, images: written });
371
+ return;
372
+ }
373
+
374
+ // default: decode + write image files
375
+ if (selected.length > 0) {
376
+ try {
377
+ fs.mkdirSync(outDir, { recursive: true });
378
+ } catch (err) {
379
+ process.stderr.write(`extract-image: failed to create out dir ${outDir}: ${err.message}\n`);
380
+ printResult(opts, { imageCount: 0, sessionId, images: [] });
381
+ return;
382
+ }
383
+ }
384
+
385
+ const images = [];
386
+ for (const img of selected) {
387
+ let decoded;
388
+ try {
389
+ decoded = Buffer.from(img.data, 'base64');
390
+ } catch (err) {
391
+ process.stderr.write(`extract-image: failed to decode image (sha=${img.sha}): ${err.message}\n`);
392
+ continue;
393
+ }
394
+ if (decoded.length > MAX_DECODED_BYTES) {
395
+ process.stderr.write(
396
+ `extract-image: skipping oversized image (sha=${img.sha}, bytes=${decoded.length} > ${MAX_DECODED_BYTES})\n`
397
+ );
398
+ continue;
399
+ }
400
+ const ext = MEDIA_EXT[img.mediaType];
401
+ const filePath = path.join(outDir, `${img.sha}.${ext}`);
402
+ try {
403
+ fs.writeFileSync(filePath, decoded);
404
+ } catch (err) {
405
+ process.stderr.write(`extract-image: failed to write ${filePath}: ${err.message}\n`);
406
+ continue;
407
+ }
408
+ images.push({ sha: img.sha, path: filePath, mediaType: img.mediaType, bytes: decoded.length });
409
+ }
410
+ pruneImageFiles(outDir);
411
+ printResult(opts, { imageCount: images.length, sessionId, images });
412
+ }
413
+
414
+ // Only run as a CLI. Importing this module (tests import resolveRefs/hashPayload) must
415
+ // not decode images, write markers, or prune the real cache as a side effect.
416
+ const invokedDirectly = (() => {
417
+ const entry = process.argv[1];
418
+ if (!entry) return false;
419
+ try {
420
+ return fs.realpathSync(entry) === fs.realpathSync(fileURLToPath(import.meta.url));
421
+ } catch {
422
+ return false;
423
+ }
424
+ })();
425
+
426
+ if (invokedDirectly) {
427
+ try {
428
+ main();
429
+ process.exitCode = 0;
430
+ } catch (err) {
431
+ process.stderr.write(`extract-image: unexpected error: ${err?.message ?? err}\n`);
432
+ process.exitCode = 0;
433
+ }
434
+ }