@khorsheed/dsh-file-preview 0.1.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,1338 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
5
+ import z from "@deepseek-ai/schemastery";
6
+ import { homedir, release } from "node:os";
7
+ import { runNativeCommand } from "@deepseek-ai/dsh-native-command";
8
+ //#region lib/types/bash-writes.js
9
+ /**
10
+ * Bash-write capture: the "B channel + A collection" seam (upstream-seam
11
+ * registry S2). The official bash tool writes files (heredocs, redirects,
12
+ * `tee`, `sed -i`) with no log structure a products view can fold — the
13
+ * file-preview fold only knows `read`/`write`/`edit` tool calls. This module
14
+ * watches each session's event stream, extracts high-precision write targets
15
+ * from bash command strings, verifies each with `fs.stat` after the call
16
+ * settles (prefer a miss over a false positive), and keeps a per-session
17
+ * registry the `filePreview.list` fold merges into its entries. It never
18
+ * touches the session log — the official `Session.append` cannot mark an
19
+ * event `ignorable: true`, and the persistence read path refuses unknown
20
+ * event types that lack the marker (a plugin-appended event would poison the
21
+ * log on reload), which is exactly why this seam is a side registry instead.
22
+ * The registry is rebuilt on `session/created` by replaying the session's own
23
+ * history, so a restarted host regains its captures.
24
+ * @module @khorsheed/dsh-file-preview
25
+ */
26
+ /** Cap on write candidates extracted from one command (bounds stat load). */
27
+ const MAX_CANDIDATES_PER_COMMAND = 8;
28
+ /** A write-target token stripped of its surrounding quotes. */
29
+ function stripQuotes(token) {
30
+ let t = token;
31
+ while (t.length >= 2 && (t.startsWith("'") && t.endsWith("'") || t.startsWith("\"") && t.endsWith("\""))) t = t.slice(1, -1);
32
+ return t;
33
+ }
34
+ /** Whether a character ends a shell token (outside quotes). */
35
+ function isTokenBoundary(ch) {
36
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === ";" || ch === "|" || ch === "&" || ch === "<" || ch === ">";
37
+ }
38
+ /**
39
+ * Read the next shell token starting at `from` (after any whitespace), with
40
+ * quote awareness so `'a b'` stays one token. Stops at a boundary character
41
+ * outside quotes.
42
+ * @param command - the command text.
43
+ * @param from - scan start index.
44
+ * @returns the raw token and the index just past it, or null at end / boundary.
45
+ */
46
+ function nextToken(command, from) {
47
+ const n = command.length;
48
+ let i = from;
49
+ while (i < n && (command[i] === " " || command[i] === " ")) i++;
50
+ if (i >= n || isTokenBoundary(command[i] ?? "")) return null;
51
+ let token = "";
52
+ let quote;
53
+ while (i < n) {
54
+ const ch = command[i] ?? "";
55
+ if (quote !== void 0) {
56
+ if (ch === quote) quote = void 0;
57
+ token += ch;
58
+ i++;
59
+ continue;
60
+ }
61
+ if (ch === "'" || ch === "\"") {
62
+ quote = ch;
63
+ token += ch;
64
+ i++;
65
+ continue;
66
+ }
67
+ if (isTokenBoundary(ch)) break;
68
+ token += ch;
69
+ i++;
70
+ }
71
+ return token.length === 0 ? null : {
72
+ token,
73
+ end: i
74
+ };
75
+ }
76
+ /**
77
+ * Extract write-target tokens from one bash command string. High-precision
78
+ * forms only (the "minimal set"): a single `>` redirect (`cat > path`, any
79
+ * `cmd > path`), `tee path` (append forms skipped), and `sed -i ... path`
80
+ * (the first non-option token after the inline script). `>>`, fd redirects
81
+ * (`2>`), `>&`, `<>`, and any write form inside a heredoc body are ignored;
82
+ * `cp`/`mv`/`python open()` are deliberately deferred. Quoted targets keep
83
+ * their quotes here — expansion strips them.
84
+ * @param command - the bash tool's command string.
85
+ * @returns raw target tokens, capped per command.
86
+ */
87
+ function extractWriteTargets(command) {
88
+ const targets = [];
89
+ const n = command.length;
90
+ let i = 0;
91
+ let quote;
92
+ let heredoc;
93
+ while (i < n) {
94
+ const ch = command[i] ?? "";
95
+ if (heredoc !== void 0) {
96
+ const nl = command.indexOf("\n", i);
97
+ if (command.slice(i, nl === -1 ? n : nl).trim() === heredoc) heredoc = void 0;
98
+ i = nl === -1 ? n : nl + 1;
99
+ continue;
100
+ }
101
+ if (quote !== void 0) {
102
+ if (ch === quote) quote = void 0;
103
+ i++;
104
+ continue;
105
+ }
106
+ if (ch === "'" || ch === "\"") {
107
+ quote = ch;
108
+ i++;
109
+ continue;
110
+ }
111
+ if (ch === "<" && command[i + 1] === "<") {
112
+ let j = i + 2;
113
+ if (command[j] === "-") j++;
114
+ const q = command[j];
115
+ let delim = "";
116
+ if (q === "'" || q === "\"") {
117
+ const end = command.indexOf(q, j + 1);
118
+ delim = end === -1 ? "" : command.slice(j + 1, end);
119
+ j = end === -1 ? n : end + 1;
120
+ } else {
121
+ const start = j;
122
+ while (j < n && !/\s/.test(command[j] ?? "")) j++;
123
+ delim = command.slice(start, j);
124
+ }
125
+ if (delim.length > 0) heredoc = delim;
126
+ i = j;
127
+ continue;
128
+ }
129
+ if (ch === "t" && command.startsWith("tee", i) && (i === 0 || isTokenBoundary(command[i - 1] ?? ""))) {
130
+ const after = command.slice(i + 3);
131
+ if (/^\s*(?:-a\b|--append\b)/.test(after)) {
132
+ i += 3;
133
+ continue;
134
+ }
135
+ const consumed = /^\s*(?:-i\b|--ignore-interrupts\s+)*/.exec(after)?.[0].length ?? 0;
136
+ const tok = nextToken(command, i + 3 + consumed);
137
+ if (tok !== null) {
138
+ targets.push(tok.token);
139
+ i = tok.end;
140
+ continue;
141
+ }
142
+ i += 3;
143
+ continue;
144
+ }
145
+ if (ch === "s" && command.startsWith("sed", i) && (i === 0 || isTokenBoundary(command[i - 1] ?? ""))) {
146
+ const im = /^\s*-i([A-Za-z0-9.]*)/.exec(command.slice(i + 3));
147
+ if (im === null) {
148
+ i++;
149
+ continue;
150
+ }
151
+ let j = i + 3 + im[0].length;
152
+ const opt = nextToken(command, j);
153
+ if (opt !== null && (opt.token === "-e" || opt.token === "--expression")) {
154
+ const script = nextToken(command, opt.end);
155
+ j = script === null ? opt.end : script.end;
156
+ } else if (opt !== null) j = opt.end;
157
+ const file = nextToken(command, j);
158
+ if (file !== null && !file.token.startsWith("-")) {
159
+ targets.push(file.token);
160
+ i = file.end;
161
+ continue;
162
+ }
163
+ i = j;
164
+ continue;
165
+ }
166
+ if (ch === ">") {
167
+ const prev = command[i - 1] ?? "";
168
+ const next = command[i + 1] ?? "";
169
+ if (next === ">" || next === "&" || prev === ">" || prev === "<" || prev === "&" || /[0-9]/.test(prev)) {
170
+ i++;
171
+ continue;
172
+ }
173
+ const tok = nextToken(command, i + 1);
174
+ if (tok !== null) {
175
+ targets.push(tok.token);
176
+ i = tok.end;
177
+ continue;
178
+ }
179
+ i++;
180
+ continue;
181
+ }
182
+ i++;
183
+ }
184
+ return targets.slice(0, MAX_CANDIDATES_PER_COMMAND);
185
+ }
186
+ /**
187
+ * Expand one raw target token into an absolute host path. Strips quotes,
188
+ * expands a leading `~` and `$VAR`/`${VAR}` from the given environment, and
189
+ * resolves a relative target against the session cwd. Rejects anything that
190
+ * cannot be a literal path: glob metacharacters, command substitution, an
191
+ * unset variable (rather than guessing a truncated path), or a relative
192
+ * target with no cwd to anchor it.
193
+ * @param token - a raw target from {@link extractWriteTargets}.
194
+ * @param env - the host environment (the collector runs in the host process,
195
+ * so `$DSH_HOME`-style variables resolve here, not in the fold).
196
+ * @param home - the host home directory for `~`.
197
+ * @param cwd - the owning session's cwd for relative targets, or undefined.
198
+ * @returns the absolute path, or null when the token is not statable literally.
199
+ */
200
+ function expandShellPath(token, env, home, cwd) {
201
+ let t = stripQuotes(token);
202
+ if (t.length === 0) return null;
203
+ if (/[*?[\]{}]/.test(t.replace(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/g, ""))) return null;
204
+ if (t.includes("$(") || t.includes("`")) return null;
205
+ if (t.startsWith("~")) t = home + t.slice(1);
206
+ const varPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
207
+ let match;
208
+ while ((match = varPattern.exec(t)) !== null) {
209
+ const name = match[1] ?? match[2];
210
+ if (name === void 0 || env[name] === void 0) return null;
211
+ }
212
+ t = t.replace(varPattern, (_, braced, bare) => env[braced ?? bare ?? ""] ?? "");
213
+ if (t.length === 0) return null;
214
+ if (t.startsWith("/")) return t;
215
+ if (cwd === void 0 || cwd.length === 0) return null;
216
+ return resolve(cwd, t);
217
+ }
218
+ /** Parse a tool-call arguments JSON payload into a plain object ({} on failure). */
219
+ function parseArguments(argumentsJson) {
220
+ try {
221
+ const value = JSON.parse(argumentsJson);
222
+ return value !== null && typeof value === "object" ? value : {};
223
+ } catch {
224
+ return {};
225
+ }
226
+ }
227
+ /**
228
+ * Per-session registry of bash-written files. Subscribes to the session event
229
+ * firehose: `tool/call` (bash) records the command's write candidates keyed by
230
+ * callId; the matching `tool/result` stat-verifies them (files must exist and
231
+ * be files — prefer a miss over a false positive) and stores the survivors.
232
+ * `session/created` replays the session's own history so a restarted host
233
+ * regains captures without any durable state of its own.
234
+ */
235
+ var BashWriteCollector = class {
236
+ #fs;
237
+ #env;
238
+ #home;
239
+ #maxFiles;
240
+ /** sessionId → callId → pending bash write candidates. */
241
+ #pending = /* @__PURE__ */ new Map();
242
+ /** sessionId → path → verified capture. */
243
+ #captured = /* @__PURE__ */ new Map();
244
+ constructor(options) {
245
+ this.#fs = options.fs;
246
+ this.#env = options.env ?? process.env;
247
+ this.#home = options.home ?? homedir();
248
+ this.#maxFiles = options.maxFiles ?? 500;
249
+ }
250
+ /**
251
+ * Subscribe to the session feed; the returned disposer removes both
252
+ * listeners (fiber disposal also cleans them up — this is belt and braces).
253
+ * @param ctx - the owning composition context.
254
+ * @returns a disposer removing the listeners.
255
+ */
256
+ attach(ctx) {
257
+ const offs = [ctx.on("session/event", (session, event) => {
258
+ this.#onEvent(session, event);
259
+ }), ctx.on("session/created", (session) => {
260
+ this.#replay(session);
261
+ })];
262
+ return () => {
263
+ for (const off of offs) if (typeof off === "function") off();
264
+ };
265
+ }
266
+ /** Verified captures for one session (the fold-merge source), or undefined. */
267
+ captured(sessionId) {
268
+ return this.#captured.get(sessionId);
269
+ }
270
+ /** Rebuild one session's registry from its own history (host restart). */
271
+ #replay(session) {
272
+ this.#pending.set(session.id, /* @__PURE__ */ new Map());
273
+ this.#captured.set(session.id, /* @__PURE__ */ new Map());
274
+ for (const event of session.events) this.#onEvent(session, event);
275
+ }
276
+ #onEvent(session, event) {
277
+ if (event.type === "tool/call") {
278
+ if (event.data.name !== "bash") return;
279
+ const command = parseArguments(event.data.arguments).command;
280
+ if (typeof command !== "string" || command.length === 0) return;
281
+ const candidates = extractWriteTargets(command).map((token) => expandShellPath(token, this.#env, this.#home, session.header.cwd)).filter((path) => path !== null);
282
+ if (candidates.length === 0) return;
283
+ const calls = this.#pending.get(session.id) ?? /* @__PURE__ */ new Map();
284
+ calls.set(String(event.data.callId), {
285
+ turn: event.data.turn,
286
+ step: event.data.step,
287
+ seq: event.seq,
288
+ candidates
289
+ });
290
+ this.#pending.set(session.id, calls);
291
+ return;
292
+ }
293
+ if (event.type === "tool/result") {
294
+ const calls = this.#pending.get(session.id);
295
+ if (calls === void 0) return;
296
+ const callId = String(event.data.message?.source?.callId ?? "");
297
+ const pending = calls.get(callId);
298
+ if (pending === void 0) return;
299
+ calls.delete(callId);
300
+ return this.#verify(session, pending);
301
+ }
302
+ }
303
+ /** stat-verify the pending candidates and store the survivors. */
304
+ async #verify(session, pending) {
305
+ const captured = this.#captured.get(session.id) ?? /* @__PURE__ */ new Map();
306
+ for (const candidate of pending.candidates) {
307
+ if (captured.size >= this.#maxFiles || captured.has(candidate)) continue;
308
+ let ok = false;
309
+ try {
310
+ const target = await this.#fs.resolve(candidate);
311
+ ok = (await this.#fs.stat(target))?.type === "file";
312
+ } catch {
313
+ ok = false;
314
+ }
315
+ if (ok) captured.set(candidate, {
316
+ turn: pending.turn,
317
+ step: pending.step,
318
+ seq: pending.seq
319
+ });
320
+ }
321
+ if (captured.size > 0) this.#captured.set(session.id, captured);
322
+ }
323
+ };
324
+ //#endregion
325
+ //#region lib/types/fold.js
326
+ /** Pure log fold over the files a session read, wrote, or edited. @module @khorsheed/dsh-file-preview/fold */
327
+ /** Tool names whose calls record a touched file path. */
328
+ const FILE_TOOL_NAMES = /* @__PURE__ */ new Set([
329
+ "read",
330
+ "write",
331
+ "edit"
332
+ ]);
333
+ /** The argument key carrying the touched path (shared by all three file tools). */
334
+ const FILE_PATH_ARGUMENT = "file_path";
335
+ /**
336
+ * Extract the touched path and operation from parsed file-tool arguments.
337
+ * @param name - the tool name.
338
+ * @param args - the parsed arguments payload (an object when well-formed).
339
+ * @returns the touch target, or undefined when the call is not a file touch.
340
+ */
341
+ function targetFromArguments(name, args) {
342
+ if (!FILE_TOOL_NAMES.has(name)) return void 0;
343
+ if (args !== null && typeof args === "object") {
344
+ const candidate = args[FILE_PATH_ARGUMENT];
345
+ if (typeof candidate === "string" && candidate.length > 0) return {
346
+ op: name,
347
+ path: candidate
348
+ };
349
+ }
350
+ }
351
+ /** Extract the touched path and operation from one tool call's raw arguments JSON. */
352
+ function pathFromToolCall(name, argumentsJson) {
353
+ try {
354
+ return targetFromArguments(name, JSON.parse(argumentsJson));
355
+ } catch {
356
+ return;
357
+ }
358
+ }
359
+ /** Read the write/edit diff list out of a tool result's tool-private meta. */
360
+ function diffsFromResultMeta(meta) {
361
+ if (meta === null || typeof meta !== "object") return void 0;
362
+ const diffs = meta.diffs;
363
+ if (!Array.isArray(diffs) || diffs.length === 0) return void 0;
364
+ const result = [];
365
+ for (const item of diffs) {
366
+ if (item === null || typeof item !== "object") return void 0;
367
+ const { path, oldText, newText } = item;
368
+ if (typeof path !== "string" || path.length === 0) return void 0;
369
+ if (typeof newText !== "string") return void 0;
370
+ if (oldText !== null && typeof oldText !== "string") return void 0;
371
+ result.push({
372
+ path,
373
+ oldText,
374
+ newText
375
+ });
376
+ }
377
+ return result;
378
+ }
379
+ /**
380
+ * Fold a session's events into the files it read, wrote, or edited, in
381
+ * first-seen order and deduplicated by display path; a later occurrence
382
+ * refreshes the entry's op and location without moving it, and each write/edit
383
+ * tool result appends its change's diff to the entry's `diffs` in event order.
384
+ * Nested Code Mode file dispatches (`tool/code-dispatch` events) count too —
385
+ * they carry no turn/step of their own, so the entry borrows the enclosing
386
+ * root call's location.
387
+ * @param events - the session's events in ascending seq order.
388
+ * @param maxFiles - cap on returned entries; excess files are dropped in
389
+ * first-seen order after the cap is reached.
390
+ * @returns the capped list plus the last scanned seq and the truncation flag.
391
+ */
392
+ function foldFilePreview(events, maxFiles) {
393
+ const entries = /* @__PURE__ */ new Map();
394
+ /** Root tool/call locations, so nested code dispatches borrow their turn/step. */
395
+ const callSites = /* @__PURE__ */ new Map();
396
+ let asOfSeq = -1;
397
+ /** Upsert one touched path, refreshing a repeat in place and keeping its diffs. */
398
+ const record = (target, seq, turn, step) => {
399
+ const next = {
400
+ path: target.path,
401
+ op: target.op,
402
+ seq,
403
+ turn,
404
+ step,
405
+ diffs: []
406
+ };
407
+ const existing = entries.get(target.path);
408
+ if (existing === void 0 && entries.size >= maxFiles) return;
409
+ entries.set(target.path, existing === void 0 ? next : {
410
+ ...existing,
411
+ ...next,
412
+ diffs: existing.diffs
413
+ });
414
+ };
415
+ for (const event of events) {
416
+ asOfSeq = event.seq;
417
+ if (event.type === "tool/call") {
418
+ callSites.set(String(event.data.callId), {
419
+ turn: event.data.turn,
420
+ step: event.data.step
421
+ });
422
+ const target = pathFromToolCall(event.data.name, event.data.arguments);
423
+ if (target === void 0) continue;
424
+ record(target, event.seq, event.data.turn, event.data.step);
425
+ continue;
426
+ }
427
+ if (event.type === "tool/code-dispatch") {
428
+ if (event.data.isError) continue;
429
+ const target = targetFromArguments(event.data.name, event.data.arguments);
430
+ if (target === void 0) continue;
431
+ const site = callSites.get(String(event.data.rootCallId)) ?? {
432
+ turn: 0,
433
+ step: 0
434
+ };
435
+ record(target, event.seq, site.turn, site.step);
436
+ continue;
437
+ }
438
+ if (event.type === "tool/result") {
439
+ const diffs = diffsFromResultMeta(event.data.meta);
440
+ if (diffs === void 0) continue;
441
+ for (const diff of diffs) {
442
+ if (!entries.has(diff.path)) record({
443
+ op: "edit",
444
+ path: diff.path
445
+ }, event.seq, event.data.turn, event.data.step);
446
+ const entry = entries.get(diff.path);
447
+ if (entry === void 0) continue;
448
+ const change = {
449
+ seq: event.seq,
450
+ turn: event.data.turn,
451
+ step: event.data.step,
452
+ oldText: diff.oldText,
453
+ newText: diff.newText
454
+ };
455
+ entries.set(diff.path, {
456
+ ...entry,
457
+ diffs: [...entry.diffs, change]
458
+ });
459
+ }
460
+ }
461
+ }
462
+ return {
463
+ entries: [...entries.values()].map((entry) => {
464
+ const last = entry.diffs[entry.diffs.length - 1];
465
+ return last === void 0 ? entry : {
466
+ ...entry,
467
+ lastDiff: {
468
+ oldText: last.oldText,
469
+ newText: last.newText
470
+ }
471
+ };
472
+ }),
473
+ asOfSeq,
474
+ truncated: entries.size >= maxFiles
475
+ };
476
+ }
477
+ /** Lines in one diff text: a trailing newline terminates the last line rather than adding one. */
478
+ function lineCount(text) {
479
+ if (text === "") return 0;
480
+ const lines = text.split("\n").length;
481
+ return text.endsWith("\n") ? lines - 1 : lines;
482
+ }
483
+ /** Sum two optional counts; unknown wins (any uncounted mutation reports no total). */
484
+ function sumKnown(a, b) {
485
+ return a === void 0 || b === void 0 ? void 0 : a + b;
486
+ }
487
+ /** Extract the full written content from a `write` tool call's arguments JSON. */
488
+ function contentFromWriteCall(argumentsJson) {
489
+ try {
490
+ const args = JSON.parse(argumentsJson);
491
+ if (args !== null && typeof args === "object") {
492
+ const content = args.content;
493
+ if (typeof content === "string") return content;
494
+ }
495
+ } catch {}
496
+ }
497
+ /** Read the tool-result block's call identity and failure flag off a result message. */
498
+ function resultCallFacts(message) {
499
+ const block = message.content[0];
500
+ if (block?.type !== "tool-result" || typeof block.toolCallId !== "string") return void 0;
501
+ return {
502
+ callId: block.toolCallId,
503
+ isError: block.isError === true
504
+ };
505
+ }
506
+ /**
507
+ * Fold a session's events into per-turn file-mutation maps — the turn card's
508
+ * single source of truth. Unlike {@link foldFilePreview}, this does NOT dedupe
509
+ * a path to its last occurrence: a file touched in two turns appears in BOTH
510
+ * turn maps, so each turn's card lists exactly what that turn mutated.
511
+ * write/edit calls register with their own turn; nested Code Mode dispatches
512
+ * borrow the enclosing root call's turn (dispatch events carry none); a
513
+ * `tool/result` whose presentation meta carries diffs registers its paths (the
514
+ * render-intent vocabulary) with the result's turn, and per-turn line deltas
515
+ * are summed from the diffs. A `write` whose result carries no diffs is a
516
+ * create (the write tool records no diffs when the file had no prior content):
517
+ * its full written content — read from the call arguments, which the log keeps
518
+ * — is the added-line count, once per file per turn.
519
+ *
520
+ * Count discipline: a write/edit tool/call only registers the path's location
521
+ * and never touches counts (its counted result follows); the first count
522
+ * contribution for a turn+path is adopted verbatim, later ones accumulate per
523
+ * field with unknown-wins — an uncounted mutation (a Code Mode dispatch, or a
524
+ * diff whose side reports no prior content) poisons that field to undefined
525
+ * rather than reporting a misleading partial.
526
+ * @param events - the session's events in ascending seq order.
527
+ * @returns turn → path → facts, in event order per turn.
528
+ */
529
+ function foldFilePreviewByTurn(events) {
530
+ const byTurn = /* @__PURE__ */ new Map();
531
+ /** Root tool/call locations, so nested code dispatches borrow their turn/step. */
532
+ const callSites = /* @__PURE__ */ new Map();
533
+ /** Write-tool calls keyed by callId (create fallback; see {@link WriteCallFacts}). */
534
+ const writeCalls = /* @__PURE__ */ new Map();
535
+ /** Every path the session log has written/edited or diff-reported so far
536
+ * (create discrimination: a first-touch write is a create). */
537
+ const sessionSeen = /* @__PURE__ */ new Set();
538
+ /** Turn+path keys that have received a line-count contribution. */
539
+ const counted = /* @__PURE__ */ new Set();
540
+ const countKey = (turn, path) => `${turn}:${path}`;
541
+ const turnMap = (turn) => {
542
+ let map = byTurn.get(turn);
543
+ if (map === void 0) {
544
+ map = /* @__PURE__ */ new Map();
545
+ byTurn.set(turn, map);
546
+ }
547
+ return map;
548
+ };
549
+ /** Upsert a path's location without touching its line counts: a write/edit
550
+ * tool/call precedes its counted result, so it must neither wipe counts a
551
+ * sibling mutation contributed nor adopt anything itself. */
552
+ const recordLocation = (path, turn, seq, step) => {
553
+ const map = turnMap(turn);
554
+ const existing = map.get(path);
555
+ map.set(path, existing === void 0 ? {
556
+ path,
557
+ seq,
558
+ step
559
+ } : {
560
+ ...existing,
561
+ seq,
562
+ step
563
+ });
564
+ };
565
+ /** Apply one mutation's line-count contribution. The first contribution for
566
+ * a turn+path is adopted verbatim (a count-less placeholder from a
567
+ * preceding tool/call must not poison it); later contributions accumulate
568
+ * per field with unknown-wins. `unknown` marks an uncountable mutation (a
569
+ * Code Mode dispatch): it poisons both fields. */
570
+ const recordContribution = (path, turn, seq, step, added, removed, unknown) => {
571
+ const map = turnMap(turn);
572
+ const key = countKey(turn, path);
573
+ if (unknown) {
574
+ counted.add(key);
575
+ map.set(path, {
576
+ path,
577
+ seq,
578
+ step
579
+ });
580
+ return;
581
+ }
582
+ const existing = map.get(path);
583
+ if (!counted.has(key)) {
584
+ counted.add(key);
585
+ map.set(path, {
586
+ path,
587
+ seq,
588
+ step,
589
+ ...added === void 0 ? {} : { added },
590
+ ...removed === void 0 ? {} : { removed }
591
+ });
592
+ return;
593
+ }
594
+ const mergedAdded = added === void 0 ? void 0 : sumKnown(existing?.added, added);
595
+ const mergedRemoved = removed === void 0 ? void 0 : sumKnown(existing?.removed, removed);
596
+ map.set(path, {
597
+ path,
598
+ seq,
599
+ step,
600
+ ...mergedAdded === void 0 ? {} : { added: mergedAdded },
601
+ ...mergedRemoved === void 0 ? {} : { removed: mergedRemoved }
602
+ });
603
+ };
604
+ for (const event of events) {
605
+ if (event.type === "tool/call") {
606
+ callSites.set(String(event.data.callId), {
607
+ turn: event.data.turn,
608
+ step: event.data.step
609
+ });
610
+ const target = pathFromToolCall(event.data.name, event.data.arguments);
611
+ if (target === void 0) continue;
612
+ if (target.op === "write") {
613
+ const content = contentFromWriteCall(event.data.arguments);
614
+ if (content !== void 0) {
615
+ const firstTouch = !sessionSeen.has(target.path);
616
+ sessionSeen.add(target.path);
617
+ writeCalls.set(String(event.data.callId), {
618
+ path: target.path,
619
+ content,
620
+ firstTouch
621
+ });
622
+ }
623
+ }
624
+ recordLocation(target.path, event.data.turn, event.seq, event.data.step);
625
+ continue;
626
+ }
627
+ if (event.type === "tool/code-dispatch") {
628
+ if (event.data.isError) continue;
629
+ const target = targetFromArguments(event.data.name, event.data.arguments);
630
+ if (target === void 0) continue;
631
+ const site = callSites.get(String(event.data.rootCallId)) ?? {
632
+ turn: 0,
633
+ step: 0
634
+ };
635
+ sessionSeen.add(target.path);
636
+ recordContribution(target.path, site.turn, event.seq, site.step, void 0, void 0, true);
637
+ continue;
638
+ }
639
+ if (event.type === "tool/result") {
640
+ const diffs = diffsFromResultMeta(event.data.meta);
641
+ if (diffs !== void 0) {
642
+ for (const diff of diffs) {
643
+ sessionSeen.add(diff.path);
644
+ recordContribution(diff.path, event.data.turn, event.seq, event.data.step, lineCount(diff.newText), diff.oldText === null ? void 0 : lineCount(diff.oldText), false);
645
+ }
646
+ continue;
647
+ }
648
+ const call = resultCallFacts(event.data.message);
649
+ const write = call === void 0 ? void 0 : writeCalls.get(call.callId);
650
+ if (write === void 0 || !write.firstTouch || call?.isError === true) continue;
651
+ recordContribution(write.path, event.data.turn, event.seq, event.data.step, lineCount(write.content), 0, false);
652
+ }
653
+ }
654
+ return byTurn;
655
+ }
656
+ //#endregion
657
+ //#region lib/types/reveal.js
658
+ /**
659
+ * Cross-platform native "show in folder, selecting the file" dispatch used by
660
+ * the file-preview Remote's `reveal` method.
661
+ *
662
+ * The official host opener only opens a path with the default application —
663
+ * it has no select-in-folder intent — so this plugin carries its own
664
+ * shell-free dispatch: macOS `open -R` (Finder reveals and selects), Windows
665
+ * `explorer /select,<path>` (Explorer reveals and selects), WSL translates the
666
+ * path to the Windows spelling first, and desktop Linux tries the
667
+ * select-capable file managers (`nautilus` / `dolphin` / `nemo` `--select`) in
668
+ * order, falling back to the caller's parent-folder open when none is
669
+ * installed. Every command runs through the official
670
+ * `@deepseek-ai/dsh-native-command` runner — no shell, abort propagation.
671
+ * @module @khorsheed/dsh-file-preview
672
+ */
673
+ /** Whether one environment marker is set to a non-empty value. */
674
+ function present(value) {
675
+ return value !== void 0 && value !== "";
676
+ }
677
+ /** Distinguish WSL from desktop Linux using its process and kernel markers. */
678
+ function isWsl(internals) {
679
+ const env = internals.env ?? process.env;
680
+ if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true;
681
+ return (internals.osRelease ?? release()).toLowerCase().includes("microsoft");
682
+ }
683
+ /** Desktop file managers with a documented `--select <path>` flag, tried in order. */
684
+ const SELECT_FILE_MANAGERS = [
685
+ "nautilus",
686
+ "dolphin",
687
+ "nemo"
688
+ ];
689
+ /**
690
+ * Reveal one path in the host file manager, opening its folder and selecting
691
+ * the path. Throws when the platform cannot select (no select-capable file
692
+ * manager, unsupported platform) — the caller then opens the parent folder.
693
+ * @param path - absolute path in the host's execution world (caller owns
694
+ * resolution, typically `FileSystem.processPath`).
695
+ * @param signal - caller/connection lifetime; abort terminates the native command.
696
+ * @param internals - platform and environment seam for deterministic tests.
697
+ */
698
+ async function revealNativePath(path, signal, internals = {}) {
699
+ const platform = internals.platform ?? process.platform;
700
+ const run = internals.run ?? runNativeCommand;
701
+ if (platform === "linux" && isWsl(internals)) {
702
+ const translated = await run("wslpath", ["-w", path], signal);
703
+ signal.throwIfAborted();
704
+ const windowsPath = translated.stdout.replace(/[\r\n]+$/, "");
705
+ if (windowsPath === "") throw new Error("wslpath returned no Windows path");
706
+ await run("explorer.exe", [`/select,${windowsPath}`], signal);
707
+ return;
708
+ }
709
+ if (platform === "darwin") {
710
+ await run("open", ["-R", path], signal);
711
+ return;
712
+ }
713
+ if (platform === "win32") {
714
+ await run("explorer.exe", [`/select,${path}`], signal);
715
+ return;
716
+ }
717
+ if (platform === "linux") {
718
+ let lastError;
719
+ for (const manager of SELECT_FILE_MANAGERS) try {
720
+ await run(manager, ["--select", path], signal);
721
+ return;
722
+ } catch (error) {
723
+ lastError = error;
724
+ }
725
+ throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error("no select-capable file manager found");
726
+ }
727
+ throw new Error(`native path reveal is unsupported on ${platform}`);
728
+ }
729
+ //#endregion
730
+ //#region lib/types/index.js
731
+ /** Read-only file-preview Remote service over live sessions and the filesystem.
732
+ * @module @khorsheed/dsh-file-preview
733
+ */
734
+ var __runInitializers = function(thisArg, initializers, value) {
735
+ var useValue = arguments.length > 2;
736
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
737
+ return useValue ? value : void 0;
738
+ };
739
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
740
+ function accept(f) {
741
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
742
+ return f;
743
+ }
744
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
745
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
746
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
747
+ var _, done = false;
748
+ for (var i = decorators.length - 1; i >= 0; i--) {
749
+ var context = {};
750
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
751
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
752
+ context.addInitializer = function(f) {
753
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
754
+ extraInitializers.push(accept(f || null));
755
+ };
756
+ var result = (0, decorators[i])(kind === "accessor" ? {
757
+ get: descriptor.get,
758
+ set: descriptor.set
759
+ } : descriptor[key], context);
760
+ if (kind === "accessor") {
761
+ if (result === void 0) continue;
762
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
763
+ if (_ = accept(result.get)) descriptor.get = _;
764
+ if (_ = accept(result.set)) descriptor.set = _;
765
+ if (_ = accept(result.init)) initializers.unshift(_);
766
+ } else if (_ = accept(result)) {
767
+ if (kind === "field") initializers.unshift(_);
768
+ else descriptor[key] = _;
769
+ }
770
+ }
771
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
772
+ done = true;
773
+ };
774
+ /**
775
+ * Register the 3d-artifact skill — the generation-side contract for
776
+ * sandbox-runnable interactive 3D / digital-twin single-file HTML (self-
777
+ * contained, zero runtime network, GLB-inline zero-fetch models). Pull-based
778
+ * discovery: an agent whose task involves generating such an HTML page finds
779
+ * the contract through the skill catalog — no per-session push notice.
780
+ * Optional: compositions without the skill capability skip the registration.
781
+ * A missing/malformed shipped SKILL.md degrades to a warning — a discovery
782
+ * aid must never take a boot down; the pack-smoke test owns the file's
783
+ * presence in the tarball.
784
+ * @param ctx - plugin context.
785
+ */
786
+ function registerArtifactSkill(ctx) {
787
+ const skills = ctx.get("skills");
788
+ if (skills === void 0) {
789
+ ctx.logger.warn("file-preview: skills capability absent — the 3d-artifact skill is not registered");
790
+ return;
791
+ }
792
+ try {
793
+ const skillFile = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "3d-artifact", "SKILL.md");
794
+ const raw = readFileSync(skillFile, "utf8");
795
+ const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(raw);
796
+ const name = /^name: (.+)$/m.exec(match?.[1] ?? "")?.[1]?.trim();
797
+ const description = /^description: (.+)$/m.exec(match?.[1] ?? "")?.[1]?.trim();
798
+ const content = match?.[2];
799
+ if (match === null || name === void 0 || description === void 0 || content === void 0) {
800
+ ctx.logger.warn("file-preview: shipped SKILL.md is malformed — the 3d-artifact skill is not registered");
801
+ return;
802
+ }
803
+ ctx.effect(() => skills.register({
804
+ name,
805
+ description,
806
+ content
807
+ }));
808
+ } catch (error) {
809
+ ctx.logger.warn(`file-preview: shipped SKILL.md unreadable (${String(error)}) — the 3d-artifact skill is not registered`);
810
+ }
811
+ }
812
+ /** Extensions treated as binary without reading (their text decode is meaningless). */
813
+ const BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
814
+ ".pdf",
815
+ ".zip",
816
+ ".gz",
817
+ ".tar",
818
+ ".7z",
819
+ ".zstd",
820
+ ".br",
821
+ ".woff",
822
+ ".woff2",
823
+ ".ttf",
824
+ ".otf",
825
+ ".eot",
826
+ ".wasm",
827
+ ".node",
828
+ ".dylib",
829
+ ".so",
830
+ ".dll",
831
+ ".exe",
832
+ ".kext",
833
+ ".class",
834
+ ".jar",
835
+ ".pyc",
836
+ ".sqlite",
837
+ ".db",
838
+ ".DS_Store",
839
+ ".a",
840
+ ".o"
841
+ ]);
842
+ /** Image extensions served to the browser through the image route. */
843
+ const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
844
+ ".png",
845
+ ".jpg",
846
+ ".jpeg",
847
+ ".gif",
848
+ ".webp",
849
+ ".avif",
850
+ ".bmp",
851
+ ".ico",
852
+ ".svg"
853
+ ]);
854
+ /** MIME type per image extension for the image route's Content-Type header. */
855
+ const IMAGE_MIME = {
856
+ ".png": "image/png",
857
+ ".jpg": "image/jpeg",
858
+ ".jpeg": "image/jpeg",
859
+ ".gif": "image/gif",
860
+ ".webp": "image/webp",
861
+ ".avif": "image/avif",
862
+ ".bmp": "image/bmp",
863
+ ".ico": "image/x-icon",
864
+ ".svg": "image/svg+xml"
865
+ };
866
+ /** NUL bytes in decoded text mark a file as binary despite a successful decode. */
867
+ function hasNulByte(content) {
868
+ return content.slice(0, 4096).includes("\0");
869
+ }
870
+ /** Detect a binary file from its display path extension. */
871
+ function isBinaryPath(path) {
872
+ const dot = path.lastIndexOf(".");
873
+ return dot >= 0 && BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase());
874
+ }
875
+ /** Detect an image from its display path extension. */
876
+ function isImagePath(path) {
877
+ const dot = path.lastIndexOf(".");
878
+ return dot >= 0 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
879
+ }
880
+ /** HTML extensions served through the sandboxed render channel. */
881
+ const HTML_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
882
+ /** Detect an HTML document from its display path extension. */
883
+ function isHtmlPath(path) {
884
+ const dot = path.lastIndexOf(".");
885
+ return dot >= 0 && HTML_EXTENSIONS.has(path.slice(dot).toLowerCase());
886
+ }
887
+ /** Best-effort scripted-HTML detection: any `<script>` tag, inline event
888
+ * handler (`on*="…"`), or `javascript:` URL marks the document as scripted.
889
+ * Only a hint for default-mode selection and warning — never a trust
890
+ * decision; the sandbox and the Tier1 CSP are the real boundary. The scan
891
+ * runs on the read content as delivered (a truncated read may miss markers
892
+ * in the tail; acceptable for a hint). */
893
+ function isScriptedHtml(content) {
894
+ return /<\s*script[\s>]/i.test(content) || /\son[a-z]+\s*=/i.test(content) || /javascript:/i.test(content);
895
+ }
896
+ /** Stable error text for a failed read, without leaking backend internals. */
897
+ function readErrorMessage(error) {
898
+ return error instanceof Error ? error.message : String(error);
899
+ }
900
+ /**
901
+ * The `filePreview` Remote service: session-scoped read-only file inspection
902
+ * for web surfaces. `list` is a pure fold over the session log (no filesystem
903
+ * access); `read` resolves the path against the session cwd and serves current
904
+ * text content through `ctx.fs` (or a browser URL for images), capped by
905
+ * config. Image bytes ride a dedicated host route so the browser loads them
906
+ * natively without bloating the RPC channel.
907
+ */
908
+ let FilePreviewService = (() => {
909
+ let _classSuper = TypertRemoteService;
910
+ let _instanceExtraInitializers = [];
911
+ let _list_decorators;
912
+ let _turnFiles_decorators;
913
+ let _read_decorators;
914
+ let _reveal_decorators;
915
+ return class FilePreviewService extends _classSuper {
916
+ static {
917
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
918
+ _list_decorators = [Remote("list")];
919
+ _turnFiles_decorators = [Remote("turnFiles")];
920
+ _read_decorators = [Remote("read")];
921
+ _reveal_decorators = [Remote("reveal")];
922
+ __esDecorate(this, null, _list_decorators, {
923
+ kind: "method",
924
+ name: "list",
925
+ static: false,
926
+ private: false,
927
+ access: {
928
+ has: (obj) => "list" in obj,
929
+ get: (obj) => obj.list
930
+ },
931
+ metadata: _metadata
932
+ }, null, _instanceExtraInitializers);
933
+ __esDecorate(this, null, _turnFiles_decorators, {
934
+ kind: "method",
935
+ name: "turnFiles",
936
+ static: false,
937
+ private: false,
938
+ access: {
939
+ has: (obj) => "turnFiles" in obj,
940
+ get: (obj) => obj.turnFiles
941
+ },
942
+ metadata: _metadata
943
+ }, null, _instanceExtraInitializers);
944
+ __esDecorate(this, null, _read_decorators, {
945
+ kind: "method",
946
+ name: "read",
947
+ static: false,
948
+ private: false,
949
+ access: {
950
+ has: (obj) => "read" in obj,
951
+ get: (obj) => obj.read
952
+ },
953
+ metadata: _metadata
954
+ }, null, _instanceExtraInitializers);
955
+ __esDecorate(this, null, _reveal_decorators, {
956
+ kind: "method",
957
+ name: "reveal",
958
+ static: false,
959
+ private: false,
960
+ access: {
961
+ has: (obj) => "reveal" in obj,
962
+ get: (obj) => obj.reveal
963
+ },
964
+ metadata: _metadata
965
+ }, null, _instanceExtraInitializers);
966
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
967
+ enumerable: true,
968
+ configurable: true,
969
+ writable: true,
970
+ value: _metadata
971
+ });
972
+ }
973
+ static inject = ["fs"];
974
+ static Config = z.object({
975
+ maxReadBytes: z.natural().min(1).default(524288),
976
+ htmlMaxReadBytes: z.natural().min(1).default(4194304),
977
+ maxFiles: z.natural().min(1).default(500),
978
+ captureBashWrites: z.boolean().default(true)
979
+ });
980
+ /** URL prefix the image route answers under. */
981
+ static IMAGE_ROUTE = "/file-preview-image";
982
+ resolved = __runInitializers(this, _instanceExtraInitializers);
983
+ /** Whether a web server is present to host the image route; image reads
984
+ * answer `binary` without it (the headless face has no browser to serve). */
985
+ imageRoute;
986
+ /** The agents service when present (image route only; null headless). */
987
+ agents;
988
+ /** Native reveal runner; a constructor seam for deterministic tests. */
989
+ revealNative;
990
+ /** Bash-write collector (S2 seam), or undefined when disabled in config. */
991
+ collector;
992
+ /** Per-session by-turn fold cache, invalidated by the log watermark. */
993
+ turnCache = /* @__PURE__ */ new Map();
994
+ /**
995
+ * @param ctx - owning Cordis Context carrying `fs`; `webServer` and `agents`
996
+ * are optional (the image route needs them, headless faces lack both).
997
+ * @param config - optional deployment tuning; defaults cap reads and lists,
998
+ * and enable the bash-write collector.
999
+ * @param deps - test seam for the native reveal dispatch.
1000
+ */
1001
+ constructor(ctx, config = {}, deps = {}) {
1002
+ super(ctx, "filePreview");
1003
+ this.resolved = {
1004
+ maxReadBytes: config.maxReadBytes ?? 524288,
1005
+ htmlMaxReadBytes: config.htmlMaxReadBytes ?? 4194304,
1006
+ maxFiles: config.maxFiles ?? 500,
1007
+ captureBashWrites: config.captureBashWrites ?? true
1008
+ };
1009
+ this.revealNative = deps.revealNative ?? revealNativePath;
1010
+ if (config.captureBashWrites !== false) {
1011
+ this.collector = new BashWriteCollector({
1012
+ fs: this.fs,
1013
+ maxFiles: this.resolved.maxFiles
1014
+ });
1015
+ ctx.effect(() => this.collector.attach(ctx), "file-preview: bash-write capture");
1016
+ } else this.collector = void 0;
1017
+ const webServer = ctx.get("webServer");
1018
+ this.agents = ctx.get("agents");
1019
+ this.imageRoute = webServer !== void 0 && this.agents !== void 0;
1020
+ if (this.imageRoute && webServer !== void 0) ctx.effect(() => webServer.register({
1021
+ kind: "prefix",
1022
+ path: FilePreviewService.IMAGE_ROUTE,
1023
+ /* v8 ignore next 2 -- the handler body is the request path; the
1024
+ unit tests drive serveImage directly, the real server reaches it
1025
+ through this registration */
1026
+ handler: (req, res) => {
1027
+ this.serveImage(req, res);
1028
+ }
1029
+ }), "file-preview: image route");
1030
+ registerArtifactSkill(ctx);
1031
+ }
1032
+ get fs() {
1033
+ return this.ctx.fs;
1034
+ }
1035
+ /**
1036
+ * Serve one image file's bytes for a browser `<img>` request. The URL is
1037
+ * `IMAGE_ROUTE/<sessionId>/<encodeURIComponent(path)>`; the session must be
1038
+ * live (the same scope `read` serves) and the path resolves against its cwd.
1039
+ * @param req - the incoming image request.
1040
+ * @param res - the response to fill with bytes or an error status.
1041
+ */
1042
+ async serveImage(req, res) {
1043
+ const rest = (req.url === void 0 ? "" : req.url.split("?")[0] ?? "").slice(FilePreviewService.IMAGE_ROUTE.length + 1);
1044
+ const slash = rest.indexOf("/");
1045
+ if (slash <= 0) {
1046
+ res.writeHead(400).end("file preview: expected /<sessionId>/<path>");
1047
+ return;
1048
+ }
1049
+ const sessionId = rest.slice(0, slash);
1050
+ const encoded = rest.slice(slash + 1);
1051
+ let display;
1052
+ try {
1053
+ display = decodeURIComponent(encoded);
1054
+ } catch {
1055
+ res.writeHead(400).end("file preview: malformed path");
1056
+ return;
1057
+ }
1058
+ const agent = this.agents?.get(sessionId);
1059
+ if (agent === void 0) {
1060
+ res.writeHead(404).end("file preview: session not found");
1061
+ return;
1062
+ }
1063
+ if (!isImagePath(display)) {
1064
+ res.writeHead(400).end("file preview: not an image path");
1065
+ return;
1066
+ }
1067
+ const cwd = agent.session.header.cwd;
1068
+ let target;
1069
+ try {
1070
+ target = await this.fs.resolve(display, cwd === void 0 ? {} : { cwd });
1071
+ } catch (error) {
1072
+ res.writeHead(404).end(readErrorMessage(error));
1073
+ return;
1074
+ }
1075
+ let info;
1076
+ try {
1077
+ info = await this.fs.stat(target);
1078
+ } catch (error) {
1079
+ res.writeHead(404).end(readErrorMessage(error));
1080
+ return;
1081
+ }
1082
+ if (info === void 0 || info.type !== "file") {
1083
+ res.writeHead(404).end("file preview: not a file");
1084
+ return;
1085
+ }
1086
+ if (info.size !== void 0 && info.size > this.resolved.maxReadBytes) {
1087
+ res.writeHead(413).end("file preview: image exceeds the read cap");
1088
+ return;
1089
+ }
1090
+ let bytes;
1091
+ try {
1092
+ bytes = await this.fs.readBytes(target, void 0, this.resolved.maxReadBytes);
1093
+ } catch (error) {
1094
+ res.writeHead(404).end(readErrorMessage(error));
1095
+ return;
1096
+ }
1097
+ const dot = display.lastIndexOf(".");
1098
+ /* v8 ignore next -- every IMAGE_EXTENSIONS entry has a MIME mapping; the fallback guards a future extension */
1099
+ const mime = IMAGE_MIME[display.slice(dot).toLowerCase()] ?? "application/octet-stream";
1100
+ res.writeHead(200, {
1101
+ "Content-Type": mime,
1102
+ "Cache-Control": "no-store"
1103
+ });
1104
+ res.end(Buffer.from(bytes));
1105
+ }
1106
+ /**
1107
+ * List the files one session wrote or edited: the log fold plus any
1108
+ * bash-written files the collector verified (the S2 seam), merged so a
1109
+ * captured path the fold already knows keeps its log-derived entry.
1110
+ * @param agent - owning live agent; its session log is the data source.
1111
+ * @returns first-seen files capped by `maxFiles`, with the log watermark.
1112
+ */
1113
+ list(agent) {
1114
+ const folded = foldFilePreview(agent.session.events, this.resolved.maxFiles);
1115
+ const captured = this.collector?.captured(agent.session.id);
1116
+ if (captured === void 0 || captured.size === 0) return folded;
1117
+ const seen = new Set(folded.entries.map((entry) => entry.path));
1118
+ const extra = [];
1119
+ for (const [path, write] of captured) {
1120
+ if (seen.has(path) || folded.entries.length + extra.length >= this.resolved.maxFiles) continue;
1121
+ extra.push({
1122
+ path,
1123
+ op: "write",
1124
+ seq: write.seq,
1125
+ turn: write.turn,
1126
+ step: write.step,
1127
+ diffs: []
1128
+ });
1129
+ }
1130
+ if (extra.length === 0) return folded;
1131
+ return {
1132
+ entries: [...folded.entries, ...extra],
1133
+ asOfSeq: folded.asOfSeq,
1134
+ truncated: folded.truncated || folded.entries.length + extra.length >= this.resolved.maxFiles
1135
+ };
1136
+ }
1137
+ /**
1138
+ * Every turn's file mutations for the turn-tail card — the single source of
1139
+ * truth the card renders (the client's old write/edit-only fold retired).
1140
+ * Built by folding the session log per turn (write/edit calls, Code Mode
1141
+ * dispatches borrowing the root call's turn, and render-intent paths from
1142
+ * result diff meta) and merging the collector's bash-written captures by
1143
+ * their own turn. The fold is cached per session and invalidated by the log
1144
+ * watermark, so repeated card fetches do not refold. A path touched in two
1145
+ * turns appears in BOTH turn groups — per-turn attribution is exact.
1146
+ * @param agent - owning live agent; its session log is the data source.
1147
+ * @returns per-turn file groups plus the scanned watermark.
1148
+ */
1149
+ turnFiles(agent) {
1150
+ const session = agent.session;
1151
+ const events = session.events;
1152
+ const asOfSeq = events.length === 0 ? -1 : events[events.length - 1].seq;
1153
+ const cached = this.turnCache.get(session.id);
1154
+ let byTurn;
1155
+ if (cached === void 0 || cached.asOfSeq !== asOfSeq) {
1156
+ byTurn = foldFilePreviewByTurn(events);
1157
+ this.turnCache.set(session.id, {
1158
+ asOfSeq,
1159
+ byTurn
1160
+ });
1161
+ } else byTurn = cached.byTurn;
1162
+ const captured = this.collector?.captured(session.id);
1163
+ const turns = [];
1164
+ const turnNumbers = new Set(byTurn.keys());
1165
+ if (captured !== void 0) for (const write of captured.values()) turnNumbers.add(write.turn);
1166
+ for (const turn of [...turnNumbers].sort((a, b) => a - b)) {
1167
+ const files = [];
1168
+ const map = byTurn.get(turn);
1169
+ if (map !== void 0) files.push(...map.values());
1170
+ if (captured !== void 0) for (const [path, write] of captured) {
1171
+ if (write.turn !== turn || files.some((file) => file.path === path)) continue;
1172
+ files.push({
1173
+ path,
1174
+ seq: write.seq,
1175
+ step: write.step
1176
+ });
1177
+ }
1178
+ if (files.length === 0) continue;
1179
+ turns.push({
1180
+ turn,
1181
+ files: files.sort((a, b) => a.seq - b.seq)
1182
+ });
1183
+ }
1184
+ return {
1185
+ turns,
1186
+ asOfSeq
1187
+ };
1188
+ }
1189
+ /**
1190
+ * Read the current content of one recorded file, resolved against the
1191
+ * session cwd and capped by `maxReadBytes`.
1192
+ * @param agent - owning live agent; its session cwd anchors relative paths.
1193
+ * @param path - the display path recorded by the write/edit tool call.
1194
+ * @param signal - cooperative cancellation from the calling UI request.
1195
+ * @returns the classified read; `text` carries content, `image` carries a
1196
+ * browser URL, other kinds carry the size or a human-readable reason.
1197
+ */
1198
+ async read(agent, path, signal) {
1199
+ if (typeof path !== "string" || path.length === 0) return {
1200
+ path,
1201
+ kind: "error",
1202
+ message: "file preview requires a non-empty path"
1203
+ };
1204
+ const cwd = agent.session.header.cwd;
1205
+ let target;
1206
+ try {
1207
+ target = await this.fs.resolve(path, cwd === void 0 ? { signal } : {
1208
+ cwd,
1209
+ signal
1210
+ });
1211
+ } catch (error) {
1212
+ return {
1213
+ path,
1214
+ kind: "error",
1215
+ message: readErrorMessage(error)
1216
+ };
1217
+ }
1218
+ let info;
1219
+ try {
1220
+ info = await this.fs.stat(target, signal);
1221
+ } catch (error) {
1222
+ return {
1223
+ path,
1224
+ kind: "error",
1225
+ message: readErrorMessage(error)
1226
+ };
1227
+ }
1228
+ if (info === void 0 || info.type !== "file") return {
1229
+ path,
1230
+ kind: "missing"
1231
+ };
1232
+ const html = isHtmlPath(path);
1233
+ const cap = html ? this.resolved.htmlMaxReadBytes : this.resolved.maxReadBytes;
1234
+ if (info.size !== void 0 && info.size > cap) return {
1235
+ path,
1236
+ kind: "too-large",
1237
+ size: info.size
1238
+ };
1239
+ if (isImagePath(path)) {
1240
+ if (this.imageRoute) {
1241
+ const encoded = encodeURIComponent(path);
1242
+ return {
1243
+ path,
1244
+ kind: "image",
1245
+ url: `${FilePreviewService.IMAGE_ROUTE}/${agent.session.id}/${encoded}`,
1246
+ ...info.size === void 0 ? {} : { size: info.size }
1247
+ };
1248
+ }
1249
+ return {
1250
+ path,
1251
+ kind: "binary",
1252
+ ...info.size === void 0 ? {} : { size: info.size }
1253
+ };
1254
+ }
1255
+ if (isBinaryPath(path)) return {
1256
+ path,
1257
+ kind: "binary",
1258
+ ...info.size === void 0 ? {} : { size: info.size }
1259
+ };
1260
+ try {
1261
+ const content = await this.fs.readText(target, signal);
1262
+ if (hasNulByte(content)) return {
1263
+ path,
1264
+ kind: "binary",
1265
+ ...info.size === void 0 ? {} : { size: info.size }
1266
+ };
1267
+ const truncated = content.length > cap;
1268
+ return {
1269
+ path,
1270
+ kind: "text",
1271
+ content: truncated ? content.slice(0, cap) : content,
1272
+ truncated,
1273
+ ...html && isScriptedHtml(content) ? { htmlScripted: true } : {},
1274
+ ...info.size === void 0 ? {} : { size: info.size }
1275
+ };
1276
+ } catch (error) {
1277
+ return {
1278
+ path,
1279
+ kind: "error",
1280
+ message: readErrorMessage(error)
1281
+ };
1282
+ }
1283
+ }
1284
+ /**
1285
+ * Reveal one recorded file in the host file manager — open its folder and
1286
+ * select the file (macOS Finder, Windows Explorer, or a select-capable
1287
+ * desktop file manager). The display path resolves against the session cwd;
1288
+ * the native reveal runs on the host. A path that does not resolve to an
1289
+ * existing target, or a platform that cannot select, answers `revealed:
1290
+ * false` and the caller opens the parent folder instead (the pre-reveal
1291
+ * "show in folder" behavior), so the gesture always lands somewhere visible.
1292
+ * @param agent - owning live agent; its session cwd anchors relative paths.
1293
+ * @param path - the display path recorded by the read/write/edit tool call.
1294
+ * @param signal - cooperative cancellation from the calling UI request.
1295
+ * @returns whether the file manager selected the file, or why not.
1296
+ */
1297
+ async reveal(agent, path, signal) {
1298
+ if (typeof path !== "string" || path.length === 0) return {
1299
+ revealed: false,
1300
+ reason: "missing"
1301
+ };
1302
+ const cwd = agent.session.header.cwd;
1303
+ let target;
1304
+ try {
1305
+ target = await this.fs.resolve(path, cwd === void 0 ? { signal } : {
1306
+ cwd,
1307
+ signal
1308
+ });
1309
+ } catch (error) {
1310
+ return {
1311
+ revealed: false,
1312
+ reason: "missing"
1313
+ };
1314
+ }
1315
+ let info;
1316
+ try {
1317
+ info = await this.fs.stat(target, signal);
1318
+ } catch {
1319
+ info = void 0;
1320
+ }
1321
+ if (info === void 0) return {
1322
+ revealed: false,
1323
+ reason: "missing"
1324
+ };
1325
+ try {
1326
+ await this.revealNative(this.fs.processPath(target), signal);
1327
+ return { revealed: true };
1328
+ } catch {
1329
+ return {
1330
+ revealed: false,
1331
+ reason: "select-failed"
1332
+ };
1333
+ }
1334
+ }
1335
+ };
1336
+ })();
1337
+ //#endregion
1338
+ export { FilePreviewService, FilePreviewService as default };