@crosery/dsh-viewer 0.0.0-stage → 0.3.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.
package/lib/index.js ADDED
@@ -0,0 +1,975 @@
1
+ // src/index.ts
2
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
3
+
4
+ // src/contract.ts
5
+ var DISPLAY_TOOL = "display_file";
6
+ var READ_IMAGE_TOOL = "read_image";
7
+ var READ_TOOL = "read";
8
+ var VIEWER_SETTINGS_NAMESPACE = "crosery-viewer";
9
+ var ASSET_ROUTE = "/crosery/dsh-viewer/asset";
10
+ var MEDIA_TABLE = {
11
+ // Rasters and vectors an <img> renders. The first four are also the exact
12
+ // set the durable attachment service admits, which is what lets them reach
13
+ // model context; the rest are display-only.
14
+ ".png": { kind: "image", mediaType: "image/png" },
15
+ ".jpg": { kind: "image", mediaType: "image/jpeg" },
16
+ ".jpeg": { kind: "image", mediaType: "image/jpeg" },
17
+ ".webp": { kind: "image", mediaType: "image/webp" },
18
+ ".gif": { kind: "image", mediaType: "image/gif" },
19
+ ".svg": { kind: "image", mediaType: "image/svg+xml" },
20
+ ".avif": { kind: "image", mediaType: "image/avif" },
21
+ ".bmp": { kind: "image", mediaType: "image/bmp" },
22
+ ".ico": { kind: "image", mediaType: "image/x-icon" },
23
+ ".apng": { kind: "image", mediaType: "image/apng" },
24
+ ".mp4": { kind: "video", mediaType: "video/mp4" },
25
+ ".m4v": { kind: "video", mediaType: "video/x-m4v" },
26
+ ".webm": { kind: "video", mediaType: "video/webm" },
27
+ ".ogv": { kind: "video", mediaType: "video/ogg" },
28
+ ".mov": { kind: "video", mediaType: "video/quicktime" },
29
+ ".mp3": { kind: "audio", mediaType: "audio/mpeg" },
30
+ ".m4a": { kind: "audio", mediaType: "audio/mp4" },
31
+ ".aac": { kind: "audio", mediaType: "audio/aac" },
32
+ ".wav": { kind: "audio", mediaType: "audio/wav" },
33
+ ".flac": { kind: "audio", mediaType: "audio/flac" },
34
+ ".ogg": { kind: "audio", mediaType: "audio/ogg" },
35
+ ".oga": { kind: "audio", mediaType: "audio/ogg" },
36
+ ".opus": { kind: "audio", mediaType: "audio/ogg" },
37
+ ".pdf": { kind: "pdf", mediaType: "application/pdf" },
38
+ // Office and OpenDocument. No browser renders any of these, so the Host
39
+ // converts them before the card ever sees them — `mediaType` here is the
40
+ // SOURCE type, and the served artifact's type replaces it in the outcome.
41
+ ".docx": { kind: "document", mediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
42
+ ".doc": { kind: "document", mediaType: "application/msword" },
43
+ ".rtf": { kind: "document", mediaType: "application/rtf" },
44
+ ".odt": { kind: "document", mediaType: "application/vnd.oasis.opendocument.text" },
45
+ ".xlsx": { kind: "document", mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
46
+ ".xls": { kind: "document", mediaType: "application/vnd.ms-excel" },
47
+ ".ods": { kind: "document", mediaType: "application/vnd.oasis.opendocument.spreadsheet" },
48
+ ".pptx": { kind: "document", mediaType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" },
49
+ ".ppt": { kind: "document", mediaType: "application/vnd.ms-powerpoint" },
50
+ ".odp": { kind: "document", mediaType: "application/vnd.oasis.opendocument.presentation" },
51
+ ".html": { kind: "html", mediaType: "text/html" },
52
+ ".htm": { kind: "html", mediaType: "text/html" }
53
+ };
54
+ var UNKNOWN_MEDIA = { kind: "file", mediaType: "application/octet-stream" };
55
+ var MODEL_IMAGE_EXTENSIONS = {
56
+ ".png": "image/png",
57
+ ".jpg": "image/jpeg",
58
+ ".jpeg": "image/jpeg",
59
+ ".webp": "image/webp",
60
+ ".gif": "image/gif"
61
+ };
62
+ var OPAQUE_KINDS = ["image", "video", "audio", "pdf", "document"];
63
+ function extensionOf(filePath) {
64
+ const base = filePath.slice(Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")) + 1);
65
+ const dot = base.lastIndexOf(".");
66
+ if (dot <= 0) return void 0;
67
+ return base.slice(dot).toLowerCase();
68
+ }
69
+ function classifyPath(filePath) {
70
+ const extension = extensionOf(filePath);
71
+ if (extension === void 0) return UNKNOWN_MEDIA;
72
+ return Object.hasOwn(MEDIA_TABLE, extension) ? MEDIA_TABLE[extension] : UNKNOWN_MEDIA;
73
+ }
74
+ function modelImageMediaTypeForPath(filePath) {
75
+ const extension = extensionOf(filePath);
76
+ if (extension === void 0) return void 0;
77
+ return Object.hasOwn(MODEL_IMAGE_EXTENSIONS, extension) ? MODEL_IMAGE_EXTENSIONS[extension] : void 0;
78
+ }
79
+ function isOpaqueMediaPath(filePath) {
80
+ return OPAQUE_KINDS.includes(classifyPath(filePath).kind);
81
+ }
82
+ function formatBytes(bytes) {
83
+ if (!Number.isFinite(bytes) || bytes <= 0) return "";
84
+ const units = ["B", "KB", "MB", "GB"];
85
+ let value = bytes;
86
+ let unit = 0;
87
+ while (value >= 1024 && unit < units.length - 1) {
88
+ value /= 1024;
89
+ unit += 1;
90
+ }
91
+ return `${unit === 0 ? value : value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
92
+ }
93
+ var TOOL_FIELD = "tool";
94
+ var REDIRECT_READ_FIELD = "redirectRead";
95
+ var FEED_MODEL_FIELD = "feedModel";
96
+ var SUPERSEDE_READ_IMAGE_FIELD = "supersedeReadImage";
97
+
98
+ // src/settings.ts
99
+ import z from "@deepseek-ai/schemastery";
100
+ var ViewerSettingsSchema = z.object({
101
+ [TOOL_FIELD]: z.boolean().default(true).description("Offer the display_file tool, which previews images, video, audio, PDF, Office documents and HTML inline in the conversation."),
102
+ [REDIRECT_READ_FIELD]: z.boolean().default(true).description("When the model calls read on binary media, answer with a pointer to display_file instead of decoding the bytes as text."),
103
+ [FEED_MODEL_FIELD]: z.boolean().default(true).description("On a model route that accepts images, also put a displayed PNG, JPEG, WebP or GIF into the model context."),
104
+ [SUPERSEDE_READ_IMAGE_FIELD]: z.boolean().default(true).description("Hide the built-in read_image tool from agents, so each image enters the model context once, through display_file.")
105
+ });
106
+
107
+ // src/asset-route.ts
108
+ import { createReadStream } from "node:fs";
109
+ import { stat } from "node:fs/promises";
110
+ import { pipeline } from "node:stream/promises";
111
+ import { basename as basename2 } from "node:path";
112
+
113
+ // src/asset-token.ts
114
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
115
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
116
+ import { dirname } from "node:path";
117
+ var PATH_PARAM = "p";
118
+ var SIGNATURE_PARAM = "s";
119
+ var SIGNATURE_HEX_LENGTH = 32;
120
+ var SECRET_BYTES = 32;
121
+ async function loadAssetSecret(keyPath) {
122
+ const existing = await readIfPresent(keyPath);
123
+ if (existing !== void 0 && existing.length >= SECRET_BYTES) return existing;
124
+ const created = randomBytes(SECRET_BYTES);
125
+ await mkdir(dirname(keyPath), { recursive: true });
126
+ if (existing === void 0) {
127
+ try {
128
+ await writeFile(keyPath, created, { mode: 384, flag: "wx" });
129
+ return created;
130
+ } catch (error) {
131
+ if (error.code !== "EEXIST") throw error;
132
+ const raced = await readIfPresent(keyPath);
133
+ if (raced !== void 0 && raced.length >= SECRET_BYTES) return raced;
134
+ }
135
+ }
136
+ await writeFile(keyPath, created, { mode: 384 });
137
+ return created;
138
+ }
139
+ async function readIfPresent(path) {
140
+ try {
141
+ return await readFile(path);
142
+ } catch (error) {
143
+ if (error.code === "ENOENT") return void 0;
144
+ throw error;
145
+ }
146
+ }
147
+ function toBase64Url(value) {
148
+ return Buffer.from(value, "utf8").toString("base64url");
149
+ }
150
+ function fromBase64Url(encoded) {
151
+ const decoded = Buffer.from(encoded, "base64url").toString("utf8");
152
+ return toBase64Url(decoded) === encoded ? decoded : void 0;
153
+ }
154
+ function sign(secret, path) {
155
+ return createHmac("sha256", secret).update(path, "utf8").digest("hex").slice(0, SIGNATURE_HEX_LENGTH);
156
+ }
157
+ function assetUrlFor(secret, processPath) {
158
+ const params = new URLSearchParams({
159
+ [PATH_PARAM]: toBase64Url(processPath),
160
+ [SIGNATURE_PARAM]: sign(secret, processPath)
161
+ });
162
+ return `${ASSET_ROUTE}?${params.toString()}`;
163
+ }
164
+ function verifyAssetRequest(secret, search) {
165
+ const params = new URLSearchParams(search);
166
+ const encoded = params.get(PATH_PARAM);
167
+ const signature = params.get(SIGNATURE_PARAM);
168
+ if (encoded === null || signature === null) return void 0;
169
+ if (signature.length !== SIGNATURE_HEX_LENGTH) return void 0;
170
+ const path = fromBase64Url(encoded);
171
+ if (path === void 0 || path.length === 0) return void 0;
172
+ const expected = Buffer.from(sign(secret, path), "utf8");
173
+ const supplied = Buffer.from(signature, "utf8");
174
+ if (expected.length !== supplied.length) return void 0;
175
+ return timingSafeEqual(expected, supplied) ? path : void 0;
176
+ }
177
+
178
+ // src/cache.ts
179
+ import { randomUUID } from "node:crypto";
180
+ import { lstat, readdir, rename, rm, utimes } from "node:fs/promises";
181
+ import { basename, dirname as dirname2, join, resolve } from "node:path";
182
+ var HOUR_MS = 60 * 60 * 1e3;
183
+ var CACHE_LIMITS = {
184
+ maxAgeMs: 30 * 24 * HOUR_MS,
185
+ maxEntries: 256,
186
+ maxBytes: 512 * 1024 * 1024
187
+ };
188
+ var PARTIAL_GRACE_MS = 24 * HOUR_MS;
189
+ var TOUCH_INTERVAL_MS = HOUR_MS;
190
+ var ARTIFACT_NAME = /^[0-9a-f]{32}\.pdf$/;
191
+ var PARTIAL_NAME = /^[0-9a-f]{32}\.pdf\.\d+\.[0-9a-f-]{36}\.partial$/;
192
+ function isArtifactName(name2) {
193
+ return ARTIFACT_NAME.test(name2);
194
+ }
195
+ async function placeArtifact(artifact, write) {
196
+ const partial = `${artifact}.${process.pid}.${randomUUID()}.partial`;
197
+ try {
198
+ await write(partial);
199
+ await rename(partial, artifact);
200
+ } finally {
201
+ await rm(partial, { force: true });
202
+ }
203
+ }
204
+ async function useArtifact(path) {
205
+ const now = /* @__PURE__ */ new Date();
206
+ try {
207
+ await utimes(path, now, now);
208
+ return true;
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+ async function useServedArtifact(cacheDir, path, mtimeMs, now = Date.now()) {
214
+ if (dirname2(path) !== resolve(cacheDir) || !isArtifactName(basename(path))) return;
215
+ if (now - mtimeMs < TOUCH_INTERVAL_MS) return;
216
+ await useArtifact(path);
217
+ }
218
+ async function removeUnlessUsed(path, mtimeMs) {
219
+ try {
220
+ const info = await lstat(path);
221
+ if (!info.isFile() || info.mtimeMs !== mtimeMs) return false;
222
+ await rm(path, { force: true });
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+ async function pruneCache(cacheDir, limits = CACHE_LIMITS, now = Date.now()) {
229
+ let names;
230
+ try {
231
+ names = await readdir(cacheDir);
232
+ } catch (error) {
233
+ if (error.code === "ENOENT") return [];
234
+ throw error;
235
+ }
236
+ const removed = [];
237
+ const artifacts = [];
238
+ for (const name2 of names) {
239
+ const artifact = ARTIFACT_NAME.test(name2);
240
+ if (!artifact && !PARTIAL_NAME.test(name2)) continue;
241
+ const path = join(cacheDir, name2);
242
+ let info;
243
+ try {
244
+ info = await lstat(path);
245
+ } catch {
246
+ continue;
247
+ }
248
+ if (!info.isFile()) continue;
249
+ if (artifact) {
250
+ artifacts.push({ path, mtimeMs: info.mtimeMs, size: info.size });
251
+ } else if (now - info.mtimeMs > PARTIAL_GRACE_MS && await removeUnlessUsed(path, info.mtimeMs)) {
252
+ removed.push(path);
253
+ }
254
+ }
255
+ artifacts.sort((a, b) => b.mtimeMs - a.mtimeMs);
256
+ let kept = 0;
257
+ let bytes = 0;
258
+ let full = false;
259
+ for (const entry of artifacts) {
260
+ full ||= kept > 0 && (now - entry.mtimeMs > limits.maxAgeMs || kept >= limits.maxEntries || bytes + entry.size > limits.maxBytes);
261
+ if (!full) {
262
+ kept += 1;
263
+ bytes += entry.size;
264
+ } else if (await removeUnlessUsed(entry.path, entry.mtimeMs)) {
265
+ removed.push(entry.path);
266
+ }
267
+ }
268
+ return removed;
269
+ }
270
+ var pruning = /* @__PURE__ */ new Map();
271
+ function schedulePrune(cacheDir, limits = CACHE_LIMITS) {
272
+ const current = pruning.get(cacheDir);
273
+ if (current !== void 0) {
274
+ current.again = true;
275
+ return current.done;
276
+ }
277
+ const state = { again: false, done: Promise.resolve() };
278
+ state.done = (async () => {
279
+ try {
280
+ do {
281
+ state.again = false;
282
+ await pruneCache(cacheDir, limits);
283
+ } while (state.again);
284
+ } catch (error) {
285
+ console.warn(`[dsh-viewer] could not prune the conversion cache at ${cacheDir}`, error);
286
+ } finally {
287
+ pruning.delete(cacheDir);
288
+ }
289
+ })();
290
+ pruning.set(cacheDir, state);
291
+ return state.done;
292
+ }
293
+
294
+ // src/asset-route.ts
295
+ function parseRange(header, size) {
296
+ if (header === void 0) return void 0;
297
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
298
+ if (match === null) return void 0;
299
+ const [, rawStart, rawEnd] = match;
300
+ if (rawStart === "" && rawEnd === "") return void 0;
301
+ if (size === 0) return "invalid";
302
+ if (rawStart === "") {
303
+ const suffix = Number(rawEnd);
304
+ if (!Number.isFinite(suffix) || suffix <= 0) return "invalid";
305
+ return { start: Math.max(0, size - suffix), end: size - 1 };
306
+ }
307
+ const start = Number(rawStart);
308
+ if (!Number.isFinite(start) || start >= size) return "invalid";
309
+ const end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
310
+ if (!Number.isFinite(end) || end < start) return "invalid";
311
+ return { start, end };
312
+ }
313
+ function guardHeaders(kind) {
314
+ if (kind === "html") return { "content-security-policy": "sandbox allow-scripts allow-forms" };
315
+ if (kind === "image") return { "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:" };
316
+ return {};
317
+ }
318
+ function assetHandler(secret, options = {}) {
319
+ return async (req, res) => {
320
+ if (req.method !== "GET" && req.method !== "HEAD") {
321
+ res.writeHead(405, { allow: "GET, HEAD" }).end();
322
+ return;
323
+ }
324
+ const key = secret();
325
+ if (key === void 0) {
326
+ res.writeHead(503, { "content-type": "text/plain; charset=utf-8" }).end("viewer key not ready");
327
+ return;
328
+ }
329
+ const search = (req.url ?? "").slice((req.url ?? "").indexOf("?") + 1);
330
+ const path = verifyAssetRequest(key, search);
331
+ if (path === void 0) {
332
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
333
+ return;
334
+ }
335
+ let size;
336
+ try {
337
+ const info = await stat(path);
338
+ if (!info.isFile()) {
339
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
340
+ return;
341
+ }
342
+ size = info.size;
343
+ if (options.cacheDir !== void 0) void useServedArtifact(options.cacheDir, path, info.mtimeMs);
344
+ } catch {
345
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
346
+ return;
347
+ }
348
+ const spec = classifyPath(path);
349
+ const range = parseRange(req.headers.range, size);
350
+ if (range === "invalid") {
351
+ res.writeHead(416, { "content-range": `bytes */${size}` }).end();
352
+ return;
353
+ }
354
+ const headers = {
355
+ "content-type": spec.mediaType,
356
+ "accept-ranges": "bytes",
357
+ // The reference is signed over a path, not over content: the file behind
358
+ // it may be rewritten at any time, so a cached copy could be stale.
359
+ "cache-control": "no-store",
360
+ // Without this a browser may sniff an octet-stream into something
361
+ // executable; with it, the declared type is the only type.
362
+ "x-content-type-options": "nosniff",
363
+ "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(basename2(path))}`,
364
+ ...guardHeaders(spec.kind)
365
+ };
366
+ if (range === void 0) {
367
+ res.writeHead(200, { ...headers, "content-length": String(size) });
368
+ } else {
369
+ res.writeHead(206, {
370
+ ...headers,
371
+ "content-length": String(range.end - range.start + 1),
372
+ "content-range": `bytes ${range.start}-${range.end}/${size}`
373
+ });
374
+ }
375
+ if (req.method === "HEAD" || size === 0) {
376
+ res.end();
377
+ return;
378
+ }
379
+ const stream = createReadStream(path, range === void 0 ? {} : { start: range.start, end: range.end });
380
+ try {
381
+ await pipeline(stream, res);
382
+ } catch {
383
+ if (!res.writableEnded) res.destroy();
384
+ }
385
+ };
386
+ }
387
+
388
+ // src/display-file.ts
389
+ import { basename as basename4, extname as extname2 } from "node:path";
390
+ import { AttachmentError, AttachmentId } from "@deepseek-ai/dsh-attachment";
391
+ import { defineTool } from "@deepseek-ai/dsh-tools";
392
+
393
+ // src/convert.ts
394
+ import { execFile } from "node:child_process";
395
+ import { createHash } from "node:crypto";
396
+ import { constants } from "node:fs";
397
+ import { copyFile, mkdir as mkdir2, mkdtemp, readdir as readdir2, rm as rm2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
398
+ import { tmpdir } from "node:os";
399
+ import { basename as basename3, extname, join as join2 } from "node:path";
400
+ import { promisify } from "node:util";
401
+ var run = promisify(execFile);
402
+ var MAC_APP_BINARY = "/Applications/LibreOffice.app/Contents/MacOS/soffice";
403
+ var CANDIDATES = ["soffice", "libreoffice", MAC_APP_BINARY];
404
+ var CONVERT_TIMEOUT_MS = 12e4;
405
+ var CONVERTED_MEDIA_TYPE = "application/pdf";
406
+ var probe;
407
+ async function resolveConverter() {
408
+ probe ??= (async () => {
409
+ for (const binary of CANDIDATES) {
410
+ try {
411
+ const { stdout } = await run(binary, ["--version"], { timeout: 3e4 });
412
+ const version = stdout.trim().split("\n")[0] ?? "unknown";
413
+ return { binary, version };
414
+ } catch {
415
+ }
416
+ }
417
+ return void 0;
418
+ })();
419
+ return await probe;
420
+ }
421
+ function artifactName(converter, sourcePath, mtimeMs, size) {
422
+ const key = createHash("sha256").update(converter.version).update("\0").update(sourcePath).update("\0").update(String(Math.trunc(mtimeMs))).update("\0").update(String(size)).digest("hex").slice(0, 32);
423
+ return `${key}.pdf`;
424
+ }
425
+ var queue = Promise.resolve();
426
+ function enqueue(job) {
427
+ const result = queue.then(job, job);
428
+ queue = result.then(() => void 0, () => void 0);
429
+ return result;
430
+ }
431
+ async function convertDocument(sourcePath, cacheDir, signal) {
432
+ const converter = await resolveConverter();
433
+ if (converter === void 0) {
434
+ throw new Error(
435
+ `cannot preview "${basename3(sourcePath)}": converting ${extname(sourcePath)} needs LibreOffice, which is not installed. Install it (macOS: brew install --cask libreoffice) and the preview works with no other change.`
436
+ );
437
+ }
438
+ const info = await stat2(sourcePath);
439
+ const artifact = join2(cacheDir, artifactName(converter, sourcePath, info.mtimeMs, info.size));
440
+ if (await useArtifact(artifact)) return artifact;
441
+ return await enqueue(async () => {
442
+ if (await useArtifact(artifact)) return artifact;
443
+ await mkdir2(cacheDir, { recursive: true });
444
+ const work = await mkdtemp(join2(tmpdir(), "dsh-viewer-convert-"));
445
+ try {
446
+ await run(converter.binary, [
447
+ // A private profile per invocation. Without it, a LibreOffice already
448
+ // open on this desktop makes the headless call exit immediately with no
449
+ // output at all — the single most common way this silently produces
450
+ // nothing.
451
+ `-env:UserInstallation=file://${join2(work, "profile")}`,
452
+ "--headless",
453
+ "--norestore",
454
+ "--convert-to",
455
+ "pdf",
456
+ "--outdir",
457
+ work,
458
+ sourcePath
459
+ ], { timeout: CONVERT_TIMEOUT_MS, ...signal === void 0 ? {} : { signal } });
460
+ const produced = (await readdir2(work)).find((entry) => entry.toLowerCase().endsWith(".pdf"));
461
+ if (produced === void 0) {
462
+ throw new Error(`cannot preview "${basename3(sourcePath)}": LibreOffice produced no PDF for it`);
463
+ }
464
+ await placeArtifact(artifact, (partial) => copyFile(join2(work, produced), partial, constants.COPYFILE_EXCL));
465
+ void schedulePrune(cacheDir);
466
+ return artifact;
467
+ } finally {
468
+ await rm2(work, { recursive: true, force: true });
469
+ }
470
+ });
471
+ }
472
+ var OFFICE_EXTENSIONS = ["doc", "docx", "xls", "xlsx", "ppt", "pptx"];
473
+ function officeToPdfOf(service) {
474
+ if (typeof service !== "object" || service === null) return void 0;
475
+ return typeof service.convert === "function" ? service : void 0;
476
+ }
477
+ function officeExtensionOf(sourcePath) {
478
+ const extension = extname(sourcePath).slice(1).toLowerCase();
479
+ return OFFICE_EXTENSIONS.includes(extension) ? extension : void 0;
480
+ }
481
+ function canonicalJson(value) {
482
+ return JSON.stringify(value, (_key, item) => {
483
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return item;
484
+ return Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
485
+ }) ?? "undefined";
486
+ }
487
+ function officeConverterIdentity(converter) {
488
+ let config;
489
+ try {
490
+ config = canonicalJson(converter.config);
491
+ } catch {
492
+ config = "unserializable";
493
+ }
494
+ return `officeToPdf\0${createHash("sha256").update(config).digest("hex")}`;
495
+ }
496
+ function officeArtifactName(identity, source) {
497
+ const key = createHash("sha256").update("office-to-pdf").update("\0").update(identity).update("\0").update(source.path).update("\0").update(source.version).update("\0").update(String(source.bytes ?? "")).digest("hex").slice(0, 32);
498
+ return `${key}.pdf`;
499
+ }
500
+ async function convertWithOfficeToPdf(converter, source, cacheDir, signal) {
501
+ const extension = officeExtensionOf(source.path);
502
+ if (extension === void 0) throw new Error(`the bundled converter does not accept ${extname(source.path) || "this file"}`);
503
+ const artifact = join2(cacheDir, officeArtifactName(officeConverterIdentity(converter), source));
504
+ if (await useArtifact(artifact)) return artifact;
505
+ const result = await converter.convert({
506
+ extension,
507
+ priority: "foreground",
508
+ source: {
509
+ // Unambiguous per plugin and path; the provider only uses it to share one
510
+ // in-flight conversion between concurrent callers.
511
+ key: JSON.stringify(["@crosery/dsh-viewer", source.path]),
512
+ version: source.version,
513
+ ...source.bytes === void 0 ? {} : { bytes: source.bytes },
514
+ read: async (readSignal, maxBytes) => ({ bytes: await source.read(readSignal, maxBytes), version: source.version })
515
+ }
516
+ }, signal);
517
+ await mkdir2(cacheDir, { recursive: true });
518
+ await placeArtifact(artifact, (partial) => writeFile2(partial, result.pdf, { flag: "wx" }));
519
+ void schedulePrune(cacheDir);
520
+ return artifact;
521
+ }
522
+
523
+ // src/read-target.ts
524
+ import { FsError } from "@deepseek-ai/dsh-fs";
525
+ import { canonicalPath } from "@deepseek-ai/dsh-sandbox";
526
+ var PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
527
+ function sessionCwd(exec, requestedPath) {
528
+ const cwd = exec.agent?.session.header.cwd;
529
+ if (cwd === void 0 || !PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath)) return cwd;
530
+ return canonicalPath(cwd);
531
+ }
532
+ async function resolveDisplayTarget(ctx, exec, requestedPath) {
533
+ const cwd = sessionCwd(exec, requestedPath);
534
+ const target = await ctx.fs.resolve(requestedPath, {
535
+ ...cwd === void 0 ? {} : { cwd },
536
+ signal: exec.signal
537
+ });
538
+ const info = await ctx.fs.stat(target, exec.signal);
539
+ if (info === void 0) {
540
+ ctx.emit("fs/observed", target, { kind: "absent" }, exec);
541
+ throw new FsError(`cannot display "${target.displayPath}": not found`, "FS_NOT_FOUND");
542
+ }
543
+ if (info.type !== "file") {
544
+ throw new FsError(`cannot display "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
545
+ }
546
+ return { target, info };
547
+ }
548
+
549
+ // src/display-file.ts
550
+ async function routeAcceptsImages(ctx, exec) {
551
+ const routed = exec.agent?.session.requestHeader()?.config;
552
+ const provider = routed?.provider ?? exec.agent?.options.provider;
553
+ const model = routed?.model ?? exec.agent?.options.model;
554
+ const llm = ctx.get("llm");
555
+ if (provider === void 0 || model === void 0 || llm === void 0) return false;
556
+ try {
557
+ const active = await llm.resolveModelInfo(provider, model, exec.signal);
558
+ return active.inputModalities?.includes("image") === true;
559
+ } catch {
560
+ return false;
561
+ }
562
+ }
563
+ function modelImageOf(ref) {
564
+ return {
565
+ attachmentId: ref.attachmentId,
566
+ mediaType: ref.mediaType,
567
+ bytes: ref.bytes,
568
+ width: ref.width,
569
+ height: ref.height,
570
+ ...ref.name === void 0 ? {} : { name: ref.name }
571
+ };
572
+ }
573
+ function attachmentRefOf(image) {
574
+ return {
575
+ attachmentId: AttachmentId(image.attachmentId),
576
+ mediaType: image.mediaType,
577
+ bytes: image.bytes,
578
+ width: image.width,
579
+ height: image.height,
580
+ ...image.name === void 0 ? {} : { name: image.name }
581
+ };
582
+ }
583
+ function formatDisplayOutput(value) {
584
+ const size = formatBytes(value.bytes);
585
+ const dimensions = value.image === void 0 ? "" : `, ${value.image.width}x${value.image.height} px`;
586
+ const facts = [value.mediaType, size].filter((part) => part.length > 0).join(", ");
587
+ const note = value.inContext ? "The image is attached below and is also displayed in the web UI." : "It is displayed in the web UI for the user. You cannot see its content \u2014 do not describe or summarize it unless the user tells you what it shows.";
588
+ const machine = [
589
+ `<media>${value.mediaType}</media>`,
590
+ `<bytes>${value.bytes}</bytes>`,
591
+ ...value.assetUrl === void 0 ? [] : [`<asset>${value.assetUrl}</asset>`]
592
+ ].join("\n");
593
+ return `<path>${value.path}</path>
594
+ <type>${value.kind}</type>
595
+ ${machine}
596
+ <content>
597
+ ${facts}${dimensions}
598
+ ${note}
599
+ </content>`;
600
+ }
601
+ function displayContent(value) {
602
+ const blocks = [{ type: "text", text: formatDisplayOutput(value) }];
603
+ if (value.inContext && value.image !== void 0) {
604
+ blocks.push({ type: "image", attachment: attachmentRefOf(value.image) });
605
+ }
606
+ return blocks;
607
+ }
608
+ async function commitImage(ctx, exec, target, mediaType) {
609
+ const attachments = ctx.get("attachments");
610
+ if (attachments === void 0) throw new Error("no attachment service is mounted");
611
+ const byteCap = Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes);
612
+ const data = await ctx.fs.readBytes(target, exec.signal, byteCap);
613
+ try {
614
+ return await attachments.saveImage({ data, mediaType, name: basename4(target.displayPath) });
615
+ } catch (error) {
616
+ if (!(error instanceof AttachmentError) || error.code !== "IMAGE_TYPE_MISMATCH") throw error;
617
+ const extension = extname2(target.displayPath).toLowerCase();
618
+ throw new Error(
619
+ `cannot display "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format, or convert it`,
620
+ { cause: error }
621
+ );
622
+ }
623
+ }
624
+ async function convertForDisplay(ctx, exec, target, info, processPath, cacheDir) {
625
+ const office = officeToPdfOf(ctx.get("officeToPdf"));
626
+ let bundledFailure;
627
+ if (office !== void 0 && officeExtensionOf(processPath) !== void 0) {
628
+ try {
629
+ return await convertWithOfficeToPdf(office, {
630
+ path: processPath,
631
+ version: String(info.version),
632
+ ...info.size === void 0 ? {} : { bytes: info.size },
633
+ read: (signal, maxBytes) => ctx.fs.readBytes(target, signal, maxBytes)
634
+ }, cacheDir, exec.signal);
635
+ } catch (error) {
636
+ if (exec.signal?.aborted === true) throw error;
637
+ bundledFailure = error;
638
+ console.warn(`[dsh-viewer] the harness converter could not convert ${basename4(processPath)}; trying LibreOffice`, error);
639
+ }
640
+ }
641
+ try {
642
+ return await convertDocument(processPath, cacheDir, exec.signal);
643
+ } catch (error) {
644
+ if (bundledFailure === void 0) throw error;
645
+ const reason = bundledFailure instanceof Error ? bundledFailure.message : String(bundledFailure);
646
+ throw new Error(`cannot preview "${basename4(processPath)}": the harness converter failed (${reason})`, { cause: bundledFailure });
647
+ }
648
+ }
649
+ function applyDisplayTool(ctx, options) {
650
+ return ctx.tools.register(defineTool({
651
+ name: DISPLAY_TOOL,
652
+ description: "Display a file to the user in the web UI: images (PNG/JPEG/WebP/GIF/SVG/AVIF/BMP), video (MP4/WebM/MOV/OGV), audio (MP3/WAV/FLAC/OGG/M4A/Opus), PDF, and HTML all render inline with a real player. Use this whenever the user should SEE or HEAR a file \u2014 the read tool decodes UTF-8 text and cannot show any of them. On a model route that accepts image input a PNG/JPEG/WebP/GIF also enters your own context; everything else is shown to the user only.",
653
+ parameters: {
654
+ file_path: { type: "string", required: true, description: "Path to the file, resolved by the filesystem backend." }
655
+ },
656
+ output: {
657
+ schema: {
658
+ type: "object",
659
+ additionalProperties: false,
660
+ properties: {
661
+ path: { type: "string", required: true },
662
+ kind: { type: "string", enum: ["image", "video", "audio", "pdf", "document", "html", "file"], required: true },
663
+ mediaType: { type: "string", required: true },
664
+ bytes: { type: "integer", required: true },
665
+ inContext: { type: "boolean", required: true },
666
+ assetUrl: { type: "string" },
667
+ unavailable: { type: "string" },
668
+ image: {
669
+ type: "object",
670
+ additionalProperties: false,
671
+ properties: {
672
+ attachmentId: { type: "string", required: true },
673
+ mediaType: { type: "string", enum: ["image/png", "image/jpeg", "image/webp", "image/gif"], required: true },
674
+ bytes: { type: "integer", required: true },
675
+ width: { type: "integer", required: true },
676
+ height: { type: "integer", required: true },
677
+ name: { type: "string" }
678
+ }
679
+ }
680
+ }
681
+ },
682
+ render: (_args, value) => displayContent(value),
683
+ // The canonical value never reaches the wire, and most media carry no
684
+ // content block at all — so without this projection a reopened session
685
+ // would have nothing left to rebuild the card from.
686
+ presentationMeta: (_args, value) => ({ ...value, ...value.image === void 0 ? {} : { image: { ...value.image } } })
687
+ },
688
+ // Content-addressed attachment writes are idempotent and the asset route is
689
+ // a pure read, so concurrent displays of one file cannot conflict.
690
+ isConcurrencySafe: () => true,
691
+ async execute(args, exec) {
692
+ if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
693
+ let conversionError;
694
+ const spec = classifyPath(args.file_path);
695
+ const { target, info } = await resolveDisplayTarget(ctx, exec, args.file_path);
696
+ let assetUrl;
697
+ let servedType = spec.mediaType;
698
+ const secret = options.secret();
699
+ if (secret !== void 0) {
700
+ try {
701
+ const processPath = ctx.fs.processPath(target);
702
+ if (spec.kind === "document") {
703
+ assetUrl = assetUrlFor(secret, await convertForDisplay(ctx, exec, target, info, processPath, options.cacheDir));
704
+ servedType = CONVERTED_MEDIA_TYPE;
705
+ } else {
706
+ assetUrl = assetUrlFor(secret, processPath);
707
+ }
708
+ } catch (error) {
709
+ if (spec.kind !== "document") throw error;
710
+ conversionError = error instanceof Error ? error.message : String(error);
711
+ assetUrl = void 0;
712
+ }
713
+ }
714
+ const imageMediaType = modelImageMediaTypeForPath(target.displayPath);
715
+ const attachments = ctx.get("attachments");
716
+ const admissible = imageMediaType !== void 0 && attachments !== void 0 && attachments.imageLimits.mediaTypes.includes(imageMediaType);
717
+ const inContext = admissible && options.feedModel() && await routeAcceptsImages(ctx, exec);
718
+ const image = admissible && (inContext || assetUrl === void 0) ? modelImageOf(await commitImage(ctx, exec, target, imageMediaType)) : void 0;
719
+ ctx.emit("fs/observed", target, { kind: "present", version: info.version }, exec);
720
+ const value = {
721
+ path: target.displayPath,
722
+ kind: spec.kind,
723
+ mediaType: servedType,
724
+ bytes: info.size ?? 0,
725
+ inContext: inContext && image !== void 0,
726
+ ...assetUrl === void 0 ? {} : { assetUrl },
727
+ ...image === void 0 ? {} : { image },
728
+ ...conversionError === void 0 ? {} : { unavailable: conversionError }
729
+ };
730
+ return value;
731
+ },
732
+ // Pure display: a generic card in the read family with a follow-along
733
+ // location, matching how the shipped read tools present.
734
+ presentCall(args) {
735
+ return {
736
+ card: "generic",
737
+ title: `Display ${args.file_path}`,
738
+ kind: "read",
739
+ locations: [{ path: args.file_path }]
740
+ };
741
+ }
742
+ }));
743
+ }
744
+
745
+ // src/read-redirect.ts
746
+ function mediaReadValue(filePath) {
747
+ const kind = classifyPath(filePath).kind;
748
+ return {
749
+ path: filePath,
750
+ offset: 1,
751
+ lines: [{
752
+ number: 1,
753
+ text: `[${kind}] This file is binary media, not UTF-8 text \u2014 there is nothing here to read. Call ${DISPLAY_TOOL} with file_path "${filePath}" to show it to the user.`
754
+ }],
755
+ totalLines: 1
756
+ };
757
+ }
758
+ function readPathOf(args) {
759
+ if (typeof args !== "object" || args === null || Array.isArray(args)) return void 0;
760
+ const { file_path: filePath } = args;
761
+ return typeof filePath === "string" && filePath.trim().length > 0 ? filePath : void 0;
762
+ }
763
+ function isMisdirectedRead(name2, args) {
764
+ if (name2 !== READ_TOOL) return false;
765
+ const filePath = readPathOf(args);
766
+ return filePath !== void 0 && isOpaqueMediaPath(filePath);
767
+ }
768
+ var PRESENT_TOOL = "present";
769
+ var LEGACY_READ_ORDER = 100;
770
+ function displaySectionOrder(registry) {
771
+ const lookup = registry?.getSectionOrder;
772
+ if (typeof lookup === "function") {
773
+ try {
774
+ const read = lookup.call(registry, "TOOL_READ");
775
+ if (typeof read === "number" && Number.isFinite(read)) return read + 1;
776
+ } catch {
777
+ }
778
+ }
779
+ return LEGACY_READ_ORDER + 1;
780
+ }
781
+ function displaySectionText(visible, redirecting) {
782
+ if (!visible(DISPLAY_TOOL)) return "";
783
+ const read = redirecting ? `The ${READ_TOOL} tool only decodes UTF-8 text and cannot show any of them; calling it on an image, video, audio, PDF or Office file returns a pointer back to ${DISPLAY_TOOL} and nothing else.` : `The ${READ_TOOL} tool only decodes UTF-8 text and cannot show any of them.`;
784
+ const base = `Use the ${DISPLAY_TOOL} tool when the user asks to see, view, open, play, watch, or listen to a file: it previews an image, video, audio file, PDF, Office document (Word/Excel/PowerPoint), or HTML page inline in this conversation with a real player or viewer. ${read}`;
785
+ if (!visible(PRESENT_TOOL)) return base;
786
+ return `${base} ${DISPLAY_TOOL} is for inline preview and playback only, not for delivering files: hand over finished deliverables with the ${PRESENT_TOOL} tool, and show an image as part of your answer with a markdown image, as your other instructions describe. Choose exactly one of these for any given file \u2014 never display, embed, and present the same file.`;
787
+ }
788
+ function applyReadRedirect(ctx, enabled) {
789
+ ctx.systemPrompt.section({
790
+ name: "tool:display-file",
791
+ order: displaySectionOrder(ctx.systemPrompt),
792
+ // Evaluated per assembly, against that assembly's scope: the same registry
793
+ // lookup a presenter uses, so a restricted-away tool reads as absent.
794
+ text: ({ scope }) => displaySectionText((tool) => ctx.tools.get(tool, scope) !== void 0, enabled())
795
+ });
796
+ ctx.on("tools/execute", async (exec, next) => {
797
+ if (!enabled() || !isMisdirectedRead(exec.name, exec.arguments)) return await next();
798
+ const filePath = readPathOf(exec.arguments);
799
+ if (filePath === void 0) return await next();
800
+ return { isError: false, value: mediaReadValue(filePath), content: [] };
801
+ });
802
+ }
803
+
804
+ // src/supersede-read-image.ts
805
+ function hideFrom(agent) {
806
+ try {
807
+ agent.ctx.tools.restrict({ deny: [READ_IMAGE_TOOL] });
808
+ return true;
809
+ } catch {
810
+ return false;
811
+ }
812
+ }
813
+ function applySupersedeReadImage(ctx, enabled) {
814
+ const pending = /* @__PURE__ */ new WeakSet();
815
+ const tracked = /* @__PURE__ */ new Set();
816
+ const apply2 = (agent) => {
817
+ if (!enabled()) return;
818
+ if (hideFrom(agent)) {
819
+ pending.delete(agent);
820
+ return;
821
+ }
822
+ if (pending.has(agent)) return;
823
+ pending.add(agent);
824
+ tracked.add(new WeakRef(agent));
825
+ };
826
+ ctx.on("agent/created", ({ agent }) => {
827
+ try {
828
+ apply2(agent);
829
+ } catch {
830
+ }
831
+ return void 0;
832
+ });
833
+ ctx.on("tools/change", () => {
834
+ if (!enabled()) return;
835
+ for (const ref of tracked) {
836
+ const agent = ref.deref();
837
+ if (agent === void 0) {
838
+ tracked.delete(ref);
839
+ continue;
840
+ }
841
+ if (!pending.has(agent)) {
842
+ tracked.delete(ref);
843
+ continue;
844
+ }
845
+ try {
846
+ if (hideFrom(agent)) {
847
+ pending.delete(agent);
848
+ tracked.delete(ref);
849
+ }
850
+ } catch {
851
+ }
852
+ }
853
+ });
854
+ }
855
+
856
+ // src/index.ts
857
+ var VIEWER_NAMESPACE = VIEWER_SETTINGS_NAMESPACE;
858
+ var name = "@crosery/dsh-viewer";
859
+ var SECRET_FILE = ".dsh-viewer-asset-key";
860
+ var CACHE_DIR = ".dsh-viewer-cache";
861
+ var inject = ["tools", "fs", "systemPrompt"];
862
+ var Config = ViewerSettingsSchema;
863
+ var FIBER_DISPOSED = 4;
864
+ var FIBER_UNLOADING = 5;
865
+ function isUnloading(ctx) {
866
+ const state = ctx.fiber?.state;
867
+ return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
868
+ }
869
+ function mountSettingsSection(ctx, config, hooks) {
870
+ ctx.inject(["settings"], (scoped) => {
871
+ const settings = scoped.settings;
872
+ if (typeof settings.installSection === "function") {
873
+ settings.installSection(ctx, VIEWER_NAMESPACE, ViewerSettingsSchema, config, hooks);
874
+ return;
875
+ }
876
+ if (typeof settings.register !== "function") return;
877
+ const scope = settings.register(VIEWER_NAMESPACE, ViewerSettingsSchema, { base: config });
878
+ hooks.setSource(() => scope.get());
879
+ scoped.effect(() => () => {
880
+ if (isUnloading(ctx)) return;
881
+ hooks.setSource(() => config);
882
+ hooks.onChange();
883
+ }, "@crosery/dsh-viewer: settings detach");
884
+ hooks.onChange();
885
+ scope.watch(() => {
886
+ if (isUnloading(ctx)) return;
887
+ hooks.onChange();
888
+ });
889
+ });
890
+ }
891
+ function apply(ctx, config) {
892
+ let source = () => config;
893
+ let secret;
894
+ const secretPath = dshHomePath(SECRET_FILE);
895
+ void loadAssetSecret(secretPath).then(
896
+ (loaded) => {
897
+ secret = loaded;
898
+ },
899
+ (error) => {
900
+ console.warn(`[dsh-viewer] could not open the asset key at ${secretPath}; media cards will stay unavailable`, error);
901
+ }
902
+ );
903
+ let serving = false;
904
+ const cacheDir = dshHomePath(CACHE_DIR);
905
+ ctx.inject(["webServer"], (scoped) => {
906
+ scoped.effect(() => {
907
+ serving = true;
908
+ const dispose = scoped.webServer.register({
909
+ kind: "exact",
910
+ path: ASSET_ROUTE,
911
+ handler: assetHandler(() => secret, { cacheDir })
912
+ });
913
+ return () => {
914
+ serving = false;
915
+ dispose();
916
+ };
917
+ }, "@crosery/dsh-viewer: asset route");
918
+ });
919
+ let disposeTool;
920
+ const reconcile = () => {
921
+ const wanted = source().tool;
922
+ if (wanted && disposeTool === void 0) {
923
+ disposeTool = applyDisplayTool(ctx, {
924
+ feedModel: () => source().feedModel,
925
+ secret: () => serving ? secret : void 0,
926
+ cacheDir
927
+ });
928
+ } else if (!wanted && disposeTool !== void 0) {
929
+ disposeTool();
930
+ disposeTool = void 0;
931
+ }
932
+ };
933
+ ctx.effect(() => () => {
934
+ disposeTool?.();
935
+ disposeTool = void 0;
936
+ }, "@crosery/dsh-viewer: display tool");
937
+ mountSettingsSection(ctx, config, {
938
+ setSource: (current) => {
939
+ source = current;
940
+ },
941
+ onChange: reconcile
942
+ });
943
+ reconcile();
944
+ applyReadRedirect(ctx, () => source().tool && source().redirectRead);
945
+ applySupersedeReadImage(ctx, () => source().tool && source().supersedeReadImage);
946
+ void schedulePrune(cacheDir);
947
+ }
948
+ export {
949
+ ASSET_ROUTE,
950
+ Config,
951
+ DISPLAY_TOOL,
952
+ VIEWER_NAMESPACE,
953
+ VIEWER_SETTINGS_NAMESPACE,
954
+ ViewerSettingsSchema,
955
+ apply,
956
+ applySupersedeReadImage,
957
+ artifactName,
958
+ assetUrlFor,
959
+ classifyPath,
960
+ convertDocument,
961
+ extensionOf,
962
+ formatBytes,
963
+ guardHeaders,
964
+ inject,
965
+ isMisdirectedRead,
966
+ isOpaqueMediaPath,
967
+ mediaReadValue,
968
+ modelImageMediaTypeForPath,
969
+ mountSettingsSection,
970
+ name,
971
+ parseRange,
972
+ readPathOf,
973
+ resolveConverter,
974
+ verifyAssetRequest
975
+ };