@khorsheed/dsh-file-preview 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The read core and the read-surface factory. The `filePreview` Remote face
3
+ * resolves its `agent` parameter through the host's unified lookup — a lookup
4
+ * that can wake an offline session — so a trusted server-side caller that
5
+ * already holds an authorized session source (its events, its cwd anchor, an
6
+ * fs face) must not reach the data through that wire. This module is the seam:
7
+ * the Remote methods and {@link createFilePreviewReadSurface} run the SAME
8
+ * core functions, so the surface's answers are byte-identical to the Remote
9
+ * face's for the same session by construction.
10
+ *
11
+ * Authorization lives entirely with the caller: the surface reads whatever the
12
+ * injected event reader, anchor, and fs face reach, and performs no admission
13
+ * check of its own. The surface never constructs, resolves, or wakes a session
14
+ * runtime object — it derives everything from the injected source plus pure
15
+ * folds, keeps at most watermark-keyed caches scoped to the returned instance
16
+ * ({@link FilePreviewReadSurface.dispose} drops them), and starts no
17
+ * background work.
18
+ *
19
+ * @module @khorsheed/dsh-file-preview
20
+ */
21
+ import type { FileSystem } from '@deepseek-ai/dsh-fs';
22
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
23
+ import { type CapturedWrite } from './bash-writes.ts';
24
+ import { type TurnFilesByTurn } from './fold.ts';
25
+ import type { FilePreviewList, FilePreviewRead, FilePreviewTurnMap } from './types.ts';
26
+ /** MIME type per image extension for a byte read's Content-Type answer. */
27
+ export declare const IMAGE_MIME: Readonly<Record<string, string>>;
28
+ /** Detect a binary file from its display path extension. */
29
+ export declare function isBinaryPath(path: string): boolean;
30
+ /** Detect an image from its display path extension. */
31
+ export declare function isImagePath(path: string): boolean;
32
+ /** Detect an HTML document from its display path extension. */
33
+ export declare function isHtmlPath(path: string): boolean;
34
+ /** Best-effort scripted-HTML detection: any `<script>` tag, inline event
35
+ * handler (`on*="…"`), or `javascript:` URL marks the document as scripted.
36
+ * Only a hint for default-mode selection and warning — never a trust
37
+ * decision; the sandbox and the Tier1 CSP are the real boundary. The scan
38
+ * runs on the read content as delivered (a truncated read may miss markers
39
+ * in the tail; acceptable for a hint). */
40
+ export declare function isScriptedHtml(content: string): boolean;
41
+ /** Stable error text for a failed read, without leaking backend internals. */
42
+ export declare function readErrorMessage(error: unknown): string;
43
+ /**
44
+ * The fs members the read path consumes — the same shape the plugin reads
45
+ * from `ctx.fs`. Any face honoring the same contract (cwd-anchored resolution
46
+ * with the backend's own containment and symlink handling) plugs in
47
+ * unchanged, which is exactly how the read surface inherits the Remote path's
48
+ * path-containment behavior: both go through the same `resolve` with the same
49
+ * anchor.
50
+ */
51
+ export type FilePreviewReadFs = Pick<FileSystem, 'resolve' | 'stat' | 'readByteRange' | 'readBytes'>;
52
+ /** The fold cache watermark: seq of the last event (`-1` for an empty log). */
53
+ export declare function eventsWatermark(events: readonly SessionEvent[]): number;
54
+ /** Shared inputs of the list/turn cores: an fs face, the path anchor, and the caps. */
55
+ export interface FilePreviewFoldCore {
56
+ /** The fs face existence probes resolve and stat through. */
57
+ readonly fs: FilePreviewReadFs;
58
+ /** The path anchor relative display paths resolve against (the session cwd). */
59
+ readonly cwd: string | undefined;
60
+ /** Cap on returned entries (`list` only). */
61
+ readonly maxFiles: number;
62
+ }
63
+ /** Shared inputs of the read core: the fold core plus the per-kind read caps. */
64
+ export interface FilePreviewReadCore {
65
+ /** The fs face the read resolves, stats, and reads bytes through. */
66
+ readonly fs: FilePreviewReadFs;
67
+ /** The path anchor relative display paths resolve against (the session cwd). */
68
+ readonly cwd: string | undefined;
69
+ /** Byte cap for a single read; larger files answer `too-large` without reading. */
70
+ readonly maxReadBytes: number;
71
+ /** The wider byte cap HTML documents read through (the render channel). */
72
+ readonly htmlMaxReadBytes: number;
73
+ /**
74
+ * Image-address issuer: given a recorded display path, the browser-loadable
75
+ * address for its bytes, or undefined to degrade that one read to `binary`.
76
+ * Absent entirely, every image read degrades to `binary`.
77
+ */
78
+ readonly imageUrl: ((path: string) => string | undefined) | undefined;
79
+ }
80
+ /**
81
+ * Resolve the recorded display paths against the anchor and keep only those
82
+ * that currently exist as a regular file. The products surfaces fold the
83
+ * session log (which is history, not disk state): a path the log wrote may
84
+ * have been cleaned up since, and such a path is not a product to list.
85
+ * Existence is checked fresh on every call (never cached), so a deletion that
86
+ * lands after a fold is visible on the next fetch. Unresolvable/unstat-able
87
+ * paths are treated as absent.
88
+ * @param fs - the fs face the probes run through.
89
+ * @param cwd - the anchor relative paths resolve against, or undefined.
90
+ * @param paths - the display paths to probe.
91
+ * @returns the subset that resolves to an existing regular file.
92
+ */
93
+ export declare function retainExistingFiles(fs: FilePreviewReadFs, cwd: string | undefined, paths: readonly string[]): Promise<ReadonlySet<string>>;
94
+ /**
95
+ * The `list` core: the log fold plus any bash-written files the collector
96
+ * verified, merged so a captured path the fold already knows keeps its
97
+ * log-derived entry, then filtered to paths that still exist on disk. This is
98
+ * the exact body the Remote `list` runs.
99
+ * @param core - the fs face, the path anchor, and the entry cap.
100
+ * @param events - the session's events in ascending seq order.
101
+ * @param captured - the collector's verified bash writes, when capture is on.
102
+ * @returns existing first-seen files capped by `maxFiles`, with the log watermark.
103
+ */
104
+ export declare function listFilePreview(core: FilePreviewFoldCore, events: readonly SessionEvent[], captured: ReadonlyMap<string, CapturedWrite> | undefined): Promise<FilePreviewList>;
105
+ /**
106
+ * The `turnFiles` core: merge the per-turn fold with the collector's captured
107
+ * writes by their own turn, then filter every group to paths that still exist
108
+ * on disk (fresh per call — the fold cache is by log watermark, existence
109
+ * moves independently). A path touched in two turns appears in BOTH turn
110
+ * groups. This is the exact body the Remote `turnFiles` runs; the caller owns
111
+ * the by-turn fold (and any cache of it) so the watermark discipline sits
112
+ * next to the caller's own cache.
113
+ * @param core - the fs face and the path anchor (`maxFiles` goes unread here).
114
+ * @param asOfSeq - the watermark of the scanned log ({@link eventsWatermark}).
115
+ * @param byTurn - the per-turn fold over the same events.
116
+ * @param captured - the collector's verified bash writes, when capture is on.
117
+ * @returns existing per-turn file groups plus the scanned watermark.
118
+ */
119
+ export declare function turnFilesFilePreview(core: FilePreviewFoldCore, asOfSeq: number, byTurn: TurnFilesByTurn, captured: ReadonlyMap<string, CapturedWrite> | undefined): Promise<FilePreviewTurnMap>;
120
+ /**
121
+ * The `read` core: resolve the recorded display path against the anchor and
122
+ * serve current text content, capped per kind. The text read is a bounded
123
+ * byte window (`readByteRange` of cap + 1 bytes), so an oversized file costs
124
+ * the cap, never its full size; a known oversized stat still short-circuits
125
+ * to `too-large` before any bytes move. This is the exact body the Remote
126
+ * `read` runs.
127
+ * @param core - the fs face, the path anchor, the caps, and the image issuer.
128
+ * @param path - the display path recorded by the write/edit tool call.
129
+ * @param signal - cooperative cancellation from the calling request.
130
+ * @returns the classified read; `text` carries content, `image` carries the
131
+ * issued URL, other kinds carry the size or a human-readable reason.
132
+ */
133
+ export declare function readFilePreview(core: FilePreviewReadCore, path: string, signal?: AbortSignal): Promise<FilePreviewRead>;
134
+ /** Shared inputs of the image byte read: an fs face, the path anchor, and the byte cap. */
135
+ export interface FilePreviewImageBytesCore {
136
+ /** The fs face the read resolves, stats, and reads bytes through. */
137
+ readonly fs: FilePreviewReadFs;
138
+ /** The path anchor relative display paths resolve against (the session cwd). */
139
+ readonly cwd: string | undefined;
140
+ /** Byte cap for the read; larger images answer `too-large` without reading. */
141
+ readonly maxReadBytes: number;
142
+ }
143
+ /** Outcome of an authorized image byte read (the read surface's byte channel). */
144
+ export type FilePreviewImageBytes = {
145
+ /** The bytes were read; serve them with the given MIME type. */
146
+ readonly ok: true;
147
+ /** The display path the bytes belong to. */
148
+ readonly path: string;
149
+ /** MIME type for the path's image extension. */
150
+ readonly mime: string;
151
+ /** The image bytes, capped by the surface's `maxReadBytes`. */
152
+ readonly bytes: Uint8Array;
153
+ /** Byte size when the backend reported one. */
154
+ readonly size?: number;
155
+ } | {
156
+ /** The bytes could not be served. */
157
+ readonly ok: false;
158
+ /** The display path the read attempted. */
159
+ readonly path: string;
160
+ /** Why: `not-image` = the path carries no image extension; `missing` = does
161
+ * not resolve to an existing file; `too-large` = over the byte cap;
162
+ * `error` = the backend rejected the resolve/stat/read. */
163
+ readonly reason: 'not-image' | 'missing' | 'too-large' | 'error';
164
+ /** Byte size when the backend reported one. */
165
+ readonly size?: number;
166
+ /** Human-readable failure detail for `error`. */
167
+ readonly message?: string;
168
+ };
169
+ /**
170
+ * The authorized image byte read: the same resolution, anchor, containment,
171
+ * and caps as {@link readFilePreview}, returning the bytes themselves instead
172
+ * of an address — the consuming side serves them through its own
173
+ * authenticated channel. The failure taxonomy mirrors `read` (`missing`,
174
+ * `too-large`, `error`) plus `not-image` for a path without an image
175
+ * extension.
176
+ * @param core - the fs face, the path anchor, and the byte cap.
177
+ * @param path - the display path recorded by the session.
178
+ * @param signal - cooperative cancellation from the calling request.
179
+ * @returns the bytes and MIME type, or the classified failure.
180
+ */
181
+ export declare function readFilePreviewImageBytes(core: FilePreviewImageBytesCore, path: string, signal?: AbortSignal): Promise<FilePreviewImageBytes>;
182
+ /**
183
+ * The authorized session source a read surface consumes. Every value is
184
+ * caller-trusted: the CALLER derives what this source may reach (which
185
+ * session's events, which anchor, which fs face) from its own authorization
186
+ * facts — the surface performs no admission check of its own.
187
+ */
188
+ export interface FilePreviewReadSource {
189
+ /**
190
+ * The session's events in ascending seq order, re-read on every call. Wrap
191
+ * whatever lookup the caller holds — a fixed snapshot (`() => events`) or a
192
+ * live reader (`() => readSessionEvents(sessionId)`). The log is append-only,
193
+ * so the surface's derived caches invalidate on the last event's seq, the
194
+ * Remote face's own watermark discipline.
195
+ */
196
+ readonly events: () => readonly SessionEvent[];
197
+ /**
198
+ * The path anchor relative display paths resolve against — the session
199
+ * header's cwd. Path containment is the fs face's own, applied through the
200
+ * same `resolve` call the Remote path makes, so traversal and symlink
201
+ * behavior are identical to it.
202
+ */
203
+ readonly cwd?: string | undefined;
204
+ /** The fs face the surface resolves, stats, and reads through. */
205
+ readonly fs: FilePreviewReadFs;
206
+ /**
207
+ * Image-address issuer: given a recorded display path, return the
208
+ * browser-loadable address the caller authorizes for its bytes (the client
209
+ * renders `read.url` unchanged), or undefined to degrade that one read.
210
+ * With no issuer injected, every image read degrades to `kind: 'binary'`
211
+ * exactly as the headless Remote face does — the surface never mints the
212
+ * legacy image route.
213
+ */
214
+ readonly imageUrl?: ((path: string) => string | undefined) | undefined;
215
+ /** Byte cap for a single read (default 512 KiB, as the service). */
216
+ readonly maxReadBytes?: number;
217
+ /** The wider byte cap HTML documents read through (default 4 MiB, as the service). */
218
+ readonly htmlMaxReadBytes?: number;
219
+ /** Cap on the entries `list` returns (default 500, as the service). */
220
+ readonly maxFiles?: number;
221
+ /** Whether bash-written files (heredocs, redirects, `tee`, `sed -i`) are
222
+ * collected from the same session events and merged into `list`/`turnFiles`
223
+ * (default true, as the service). */
224
+ readonly captureBashWrites?: boolean;
225
+ }
226
+ /**
227
+ * The read-only consumption surface over one authorized session source: the
228
+ * same result shapes as the Remote `list`/`turnFiles`/`read`, plus an
229
+ * authorized image byte read for callers that serve bytes through their own
230
+ * channel. Pure and stateless apart from watermark-keyed caches derived from
231
+ * the injected reader; no timers, no background work.
232
+ */
233
+ export interface FilePreviewReadSurface {
234
+ /** The session's written/edited files that still exist on disk, with the log watermark. */
235
+ list(): Promise<FilePreviewList>;
236
+ /** Every turn's file mutations that still exist on disk, plus the scanned watermark. */
237
+ turnFiles(): Promise<FilePreviewTurnMap>;
238
+ /**
239
+ * Read the current content of one recorded file, classified exactly as the
240
+ * Remote `read` classifies it.
241
+ * @param path - the display path recorded by the session.
242
+ * @param signal - cooperative cancellation.
243
+ */
244
+ read(path: string, signal?: AbortSignal): Promise<FilePreviewRead>;
245
+ /**
246
+ * Read one image file's bytes with the same resolution, containment, and
247
+ * caps as `read` — for a caller that serves the bytes through its own
248
+ * authenticated channel.
249
+ * @param path - the display path recorded by the session.
250
+ * @param signal - cooperative cancellation.
251
+ */
252
+ readImageBytes(path: string, signal?: AbortSignal): Promise<FilePreviewImageBytes>;
253
+ /** Drop the derived caches (the by-turn fold and the bash-write captures).
254
+ * The surface stays usable: the next call re-derives from the reader. */
255
+ dispose(): void;
256
+ }
257
+ /**
258
+ * Build a read-only file-preview surface over an externally-authorized
259
+ * session source. The surface runs the same core functions the Remote methods
260
+ * run, so for the same session its answers are byte-identical to the Remote
261
+ * face's — the bash-write merge included, rebuilt from the same events
262
+ * through the same collector pipeline. The factory itself holds no state: the
263
+ * caches live in the returned instance and die with {@link FilePreviewReadSurface.dispose}.
264
+ * @param source - the caller-authorized events, anchor, fs face, and issuer.
265
+ * @returns the read surface.
266
+ */
267
+ export declare function createFilePreviewReadSurface(source: FilePreviewReadSource): FilePreviewReadSurface;
268
+ export default createFilePreviewReadSurface;
269
+ //# sourceMappingURL=read-surface.d.ts.map
@@ -0,0 +1,413 @@
1
+ /**
2
+ * The read core and the read-surface factory. The `filePreview` Remote face
3
+ * resolves its `agent` parameter through the host's unified lookup — a lookup
4
+ * that can wake an offline session — so a trusted server-side caller that
5
+ * already holds an authorized session source (its events, its cwd anchor, an
6
+ * fs face) must not reach the data through that wire. This module is the seam:
7
+ * the Remote methods and {@link createFilePreviewReadSurface} run the SAME
8
+ * core functions, so the surface's answers are byte-identical to the Remote
9
+ * face's for the same session by construction.
10
+ *
11
+ * Authorization lives entirely with the caller: the surface reads whatever the
12
+ * injected event reader, anchor, and fs face reach, and performs no admission
13
+ * check of its own. The surface never constructs, resolves, or wakes a session
14
+ * runtime object — it derives everything from the injected source plus pure
15
+ * folds, keeps at most watermark-keyed caches scoped to the returned instance
16
+ * ({@link FilePreviewReadSurface.dispose} drops them), and starts no
17
+ * background work.
18
+ *
19
+ * @module @khorsheed/dsh-file-preview
20
+ */
21
+ import { BashWriteCollector } from './bash-writes.js';
22
+ import { foldFilePreview, foldFilePreviewByTurn } from './fold.js';
23
+ /** Extensions treated as binary without reading (their text decode is meaningless). */
24
+ const BINARY_EXTENSIONS = new Set([
25
+ '.pdf', '.zip', '.gz', '.tar', '.7z', '.zstd', '.br',
26
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
27
+ '.wasm', '.node', '.dylib', '.so', '.dll', '.exe', '.kext',
28
+ '.class', '.jar', '.pyc', '.sqlite', '.db',
29
+ '.DS_Store', '.a', '.o',
30
+ ]);
31
+ /** Image extensions served to the browser through an image address. */
32
+ const IMAGE_EXTENSIONS = new Set([
33
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.bmp', '.ico', '.svg',
34
+ ]);
35
+ /** MIME type per image extension for a byte read's Content-Type answer. */
36
+ export const IMAGE_MIME = {
37
+ '.png': 'image/png',
38
+ '.jpg': 'image/jpeg',
39
+ '.jpeg': 'image/jpeg',
40
+ '.gif': 'image/gif',
41
+ '.webp': 'image/webp',
42
+ '.avif': 'image/avif',
43
+ '.bmp': 'image/bmp',
44
+ '.ico': 'image/x-icon',
45
+ '.svg': 'image/svg+xml',
46
+ };
47
+ /** HTML extensions served through the sandboxed render channel. */
48
+ const HTML_EXTENSIONS = new Set(['.html', '.htm']);
49
+ /** NUL bytes in decoded text mark a file as binary despite a successful decode. */
50
+ function hasNulByte(content) {
51
+ return content.slice(0, 4096).includes('\u0000');
52
+ }
53
+ /** Shared non-fatal UTF-8 decoder for the bounded byte-window read: invalid
54
+ * sequences become U+FFFD (a cut multi-byte character at the window edge
55
+ * included) instead of failing the read. */
56
+ const utf8Decoder = new TextDecoder();
57
+ /** Detect a binary file from its display path extension. */
58
+ export function isBinaryPath(path) {
59
+ const dot = path.lastIndexOf('.');
60
+ return dot >= 0 && BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase());
61
+ }
62
+ /** Detect an image from its display path extension. */
63
+ export function isImagePath(path) {
64
+ const dot = path.lastIndexOf('.');
65
+ return dot >= 0 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
66
+ }
67
+ /** Detect an HTML document from its display path extension. */
68
+ export function isHtmlPath(path) {
69
+ const dot = path.lastIndexOf('.');
70
+ return dot >= 0 && HTML_EXTENSIONS.has(path.slice(dot).toLowerCase());
71
+ }
72
+ /** Best-effort scripted-HTML detection: any `<script>` tag, inline event
73
+ * handler (`on*="…"`), or `javascript:` URL marks the document as scripted.
74
+ * Only a hint for default-mode selection and warning — never a trust
75
+ * decision; the sandbox and the Tier1 CSP are the real boundary. The scan
76
+ * runs on the read content as delivered (a truncated read may miss markers
77
+ * in the tail; acceptable for a hint). */
78
+ export function isScriptedHtml(content) {
79
+ return /<\s*script[\s>]/i.test(content)
80
+ || /\son[a-z]+\s*=/i.test(content)
81
+ || /javascript:/i.test(content);
82
+ }
83
+ /** Stable error text for a failed read, without leaking backend internals. */
84
+ export function readErrorMessage(error) {
85
+ return error instanceof Error ? error.message : String(error);
86
+ }
87
+ /** The fold cache watermark: seq of the last event (`-1` for an empty log). */
88
+ export function eventsWatermark(events) {
89
+ return events.length === 0 ? -1 : events[events.length - 1].seq;
90
+ }
91
+ /** The resolve options for one anchored probe, built field-by-field so an
92
+ * absent anchor or signal never materializes as an explicit `undefined`
93
+ * (exactOptionalPropertyTypes). */
94
+ function resolveOptions(cwd, signal) {
95
+ const options = {};
96
+ if (cwd !== undefined)
97
+ options.cwd = cwd;
98
+ if (signal !== undefined)
99
+ options.signal = signal;
100
+ return options;
101
+ }
102
+ /**
103
+ * Resolve the recorded display paths against the anchor and keep only those
104
+ * that currently exist as a regular file. The products surfaces fold the
105
+ * session log (which is history, not disk state): a path the log wrote may
106
+ * have been cleaned up since, and such a path is not a product to list.
107
+ * Existence is checked fresh on every call (never cached), so a deletion that
108
+ * lands after a fold is visible on the next fetch. Unresolvable/unstat-able
109
+ * paths are treated as absent.
110
+ * @param fs - the fs face the probes run through.
111
+ * @param cwd - the anchor relative paths resolve against, or undefined.
112
+ * @param paths - the display paths to probe.
113
+ * @returns the subset that resolves to an existing regular file.
114
+ */
115
+ export async function retainExistingFiles(fs, cwd, paths) {
116
+ if (paths.length === 0)
117
+ return new Set();
118
+ const existing = new Set();
119
+ for (const path of paths) {
120
+ if (existing.has(path))
121
+ continue;
122
+ try {
123
+ const target = await fs.resolve(path, resolveOptions(cwd, undefined));
124
+ const info = await fs.stat(target);
125
+ if (info?.type === 'file')
126
+ existing.add(path);
127
+ }
128
+ catch {
129
+ // Unresolvable or stat failed: the path is not present to list.
130
+ }
131
+ }
132
+ return existing;
133
+ }
134
+ /**
135
+ * The `list` core: the log fold plus any bash-written files the collector
136
+ * verified, merged so a captured path the fold already knows keeps its
137
+ * log-derived entry, then filtered to paths that still exist on disk. This is
138
+ * the exact body the Remote `list` runs.
139
+ * @param core - the fs face, the path anchor, and the entry cap.
140
+ * @param events - the session's events in ascending seq order.
141
+ * @param captured - the collector's verified bash writes, when capture is on.
142
+ * @returns existing first-seen files capped by `maxFiles`, with the log watermark.
143
+ */
144
+ export async function listFilePreview(core, events, captured) {
145
+ const folded = foldFilePreview(events, core.maxFiles);
146
+ let entries = folded.entries;
147
+ if (captured !== undefined && captured.size > 0) {
148
+ const seen = new Set(entries.map(entry => entry.path));
149
+ const extra = [];
150
+ for (const [path, write] of captured) {
151
+ if (seen.has(path) || entries.length + extra.length >= core.maxFiles)
152
+ continue;
153
+ extra.push({ path, op: 'write', seq: write.seq, turn: write.turn, step: write.step, diffs: [] });
154
+ }
155
+ entries = [...entries, ...extra];
156
+ }
157
+ const existing = await retainExistingFiles(core.fs, core.cwd, entries.map(entry => entry.path));
158
+ return {
159
+ entries: entries.filter(entry => existing.has(entry.path)),
160
+ asOfSeq: folded.asOfSeq,
161
+ truncated: folded.truncated || entries.length >= core.maxFiles,
162
+ };
163
+ }
164
+ /**
165
+ * The `turnFiles` core: merge the per-turn fold with the collector's captured
166
+ * writes by their own turn, then filter every group to paths that still exist
167
+ * on disk (fresh per call — the fold cache is by log watermark, existence
168
+ * moves independently). A path touched in two turns appears in BOTH turn
169
+ * groups. This is the exact body the Remote `turnFiles` runs; the caller owns
170
+ * the by-turn fold (and any cache of it) so the watermark discipline sits
171
+ * next to the caller's own cache.
172
+ * @param core - the fs face and the path anchor (`maxFiles` goes unread here).
173
+ * @param asOfSeq - the watermark of the scanned log ({@link eventsWatermark}).
174
+ * @param byTurn - the per-turn fold over the same events.
175
+ * @param captured - the collector's verified bash writes, when capture is on.
176
+ * @returns existing per-turn file groups plus the scanned watermark.
177
+ */
178
+ export async function turnFilesFilePreview(core, asOfSeq, byTurn, captured) {
179
+ const turns = [];
180
+ // One pass over the turn keys in ascending order (turn numbers are small).
181
+ const turnNumbers = new Set(byTurn.keys());
182
+ if (captured !== undefined) {
183
+ for (const write of captured.values())
184
+ turnNumbers.add(write.turn);
185
+ }
186
+ for (const turn of [...turnNumbers].sort((a, b) => a - b)) {
187
+ const files = [];
188
+ const map = byTurn.get(turn);
189
+ if (map !== undefined)
190
+ files.push(...map.values());
191
+ if (captured !== undefined) {
192
+ for (const [path, write] of captured) {
193
+ if (write.turn !== turn || files.some(file => file.path === path))
194
+ continue;
195
+ files.push({ path, seq: write.seq, step: write.step });
196
+ }
197
+ }
198
+ if (files.length === 0)
199
+ continue;
200
+ turns.push({ turn, files: files.sort((a, b) => a.seq - b.seq) });
201
+ }
202
+ const existing = await retainExistingFiles(core.fs, core.cwd, turns.flatMap(group => group.files.map(file => file.path)));
203
+ return {
204
+ turns: turns
205
+ .map(group => ({ turn: group.turn, files: group.files.filter(file => existing.has(file.path)) }))
206
+ .filter(group => group.files.length > 0),
207
+ asOfSeq,
208
+ };
209
+ }
210
+ /**
211
+ * The `read` core: resolve the recorded display path against the anchor and
212
+ * serve current text content, capped per kind. The text read is a bounded
213
+ * byte window (`readByteRange` of cap + 1 bytes), so an oversized file costs
214
+ * the cap, never its full size; a known oversized stat still short-circuits
215
+ * to `too-large` before any bytes move. This is the exact body the Remote
216
+ * `read` runs.
217
+ * @param core - the fs face, the path anchor, the caps, and the image issuer.
218
+ * @param path - the display path recorded by the write/edit tool call.
219
+ * @param signal - cooperative cancellation from the calling request.
220
+ * @returns the classified read; `text` carries content, `image` carries the
221
+ * issued URL, other kinds carry the size or a human-readable reason.
222
+ */
223
+ export async function readFilePreview(core, path, signal) {
224
+ if (typeof path !== 'string' || path.length === 0) {
225
+ return { path, kind: 'error', message: 'file preview requires a non-empty path' };
226
+ }
227
+ let target;
228
+ try {
229
+ target = await core.fs.resolve(path, resolveOptions(core.cwd, signal));
230
+ }
231
+ catch (error) {
232
+ return { path, kind: 'error', message: readErrorMessage(error) };
233
+ }
234
+ let info;
235
+ try {
236
+ info = await core.fs.stat(target, signal);
237
+ }
238
+ catch (error) {
239
+ return { path, kind: 'error', message: readErrorMessage(error) };
240
+ }
241
+ if (info === undefined || info.type !== 'file') {
242
+ return { path, kind: 'missing' };
243
+ }
244
+ // HTML gets its own, wider render-channel cap; every other text read keeps
245
+ // `maxReadBytes`. Images keep the base cap (their bytes ride the byte
246
+ // channel, not the render channel).
247
+ const html = isHtmlPath(path);
248
+ const cap = html ? core.htmlMaxReadBytes : core.maxReadBytes;
249
+ if (info.size !== undefined && info.size > cap) {
250
+ return { path, kind: 'too-large', size: info.size };
251
+ }
252
+ if (isImagePath(path)) {
253
+ const url = core.imageUrl?.(path);
254
+ if (url !== undefined) {
255
+ return {
256
+ path,
257
+ kind: 'image',
258
+ url,
259
+ ...(info.size === undefined ? {} : { size: info.size }),
260
+ };
261
+ }
262
+ // No address to serve bytes through: an image is binary, never decoded as text.
263
+ return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
264
+ }
265
+ if (isBinaryPath(path)) {
266
+ return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
267
+ }
268
+ try {
269
+ // Bounded by the window, not the file: at most cap + 1 bytes are
270
+ // transferred (the extra byte decides `truncated`), never the whole file.
271
+ const bytes = await core.fs.readByteRange(target, { offset: 0, length: cap + 1 }, signal);
272
+ const truncated = bytes.length > cap;
273
+ const content = utf8Decoder.decode(truncated ? bytes.subarray(0, cap) : bytes);
274
+ if (hasNulByte(content)) {
275
+ return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
276
+ }
277
+ return {
278
+ path,
279
+ kind: 'text',
280
+ content,
281
+ truncated,
282
+ ...(html && isScriptedHtml(content) ? { htmlScripted: true } : {}),
283
+ ...(info.size === undefined ? {} : { size: info.size }),
284
+ };
285
+ }
286
+ catch (error) {
287
+ return { path, kind: 'error', message: readErrorMessage(error) };
288
+ }
289
+ }
290
+ /**
291
+ * The authorized image byte read: the same resolution, anchor, containment,
292
+ * and caps as {@link readFilePreview}, returning the bytes themselves instead
293
+ * of an address — the consuming side serves them through its own
294
+ * authenticated channel. The failure taxonomy mirrors `read` (`missing`,
295
+ * `too-large`, `error`) plus `not-image` for a path without an image
296
+ * extension.
297
+ * @param core - the fs face, the path anchor, and the byte cap.
298
+ * @param path - the display path recorded by the session.
299
+ * @param signal - cooperative cancellation from the calling request.
300
+ * @returns the bytes and MIME type, or the classified failure.
301
+ */
302
+ export async function readFilePreviewImageBytes(core, path, signal) {
303
+ if (typeof path !== 'string' || path.length === 0) {
304
+ return { path, ok: false, reason: 'error', message: 'file preview requires a non-empty path' };
305
+ }
306
+ if (!isImagePath(path)) {
307
+ return { path, ok: false, reason: 'not-image' };
308
+ }
309
+ let target;
310
+ try {
311
+ target = await core.fs.resolve(path, resolveOptions(core.cwd, signal));
312
+ }
313
+ catch (error) {
314
+ return { path, ok: false, reason: 'error', message: readErrorMessage(error) };
315
+ }
316
+ let info;
317
+ try {
318
+ info = await core.fs.stat(target, signal);
319
+ }
320
+ catch (error) {
321
+ return { path, ok: false, reason: 'error', message: readErrorMessage(error) };
322
+ }
323
+ if (info === undefined || info.type !== 'file') {
324
+ return { path, ok: false, reason: 'missing' };
325
+ }
326
+ if (info.size !== undefined && info.size > core.maxReadBytes) {
327
+ return { path, ok: false, reason: 'too-large', size: info.size };
328
+ }
329
+ try {
330
+ const bytes = await core.fs.readBytes(target, signal, core.maxReadBytes);
331
+ const dot = path.lastIndexOf('.');
332
+ /* v8 ignore next -- every IMAGE_EXTENSIONS entry has a MIME mapping; the fallback guards a future extension */
333
+ const mime = IMAGE_MIME[path.slice(dot).toLowerCase()] ?? 'application/octet-stream';
334
+ return {
335
+ path,
336
+ ok: true,
337
+ mime,
338
+ bytes,
339
+ ...(info.size === undefined ? {} : { size: info.size }),
340
+ };
341
+ }
342
+ catch (error) {
343
+ return { path, ok: false, reason: 'error', message: readErrorMessage(error) };
344
+ }
345
+ }
346
+ /**
347
+ * Build a read-only file-preview surface over an externally-authorized
348
+ * session source. The surface runs the same core functions the Remote methods
349
+ * run, so for the same session its answers are byte-identical to the Remote
350
+ * face's — the bash-write merge included, rebuilt from the same events
351
+ * through the same collector pipeline. The factory itself holds no state: the
352
+ * caches live in the returned instance and die with {@link FilePreviewReadSurface.dispose}.
353
+ * @param source - the caller-authorized events, anchor, fs face, and issuer.
354
+ * @returns the read surface.
355
+ */
356
+ export function createFilePreviewReadSurface(source) {
357
+ const resolved = {
358
+ maxReadBytes: source.maxReadBytes ?? 512 * 1024,
359
+ htmlMaxReadBytes: source.htmlMaxReadBytes ?? 4 * 1024 * 1024,
360
+ maxFiles: source.maxFiles ?? 500,
361
+ };
362
+ const cwd = source.cwd;
363
+ const collector = source.captureBashWrites === false
364
+ ? undefined
365
+ : new BashWriteCollector({ fs: source.fs, maxFiles: resolved.maxFiles });
366
+ /** Derived caches keyed by the log watermark; `dispose` drops both. */
367
+ let turnCache;
368
+ let captureCache;
369
+ /** The verified bash-write captures for the current watermark (replayed on change). */
370
+ const capturedAt = async (events, asOfSeq) => {
371
+ if (collector === undefined)
372
+ return undefined;
373
+ if (captureCache !== undefined && captureCache.asOfSeq === asOfSeq)
374
+ return captureCache.captured;
375
+ const captured = await collector.replaySnapshot(cwd, events);
376
+ captureCache = { asOfSeq, captured };
377
+ return captured;
378
+ };
379
+ return {
380
+ list: async () => {
381
+ const events = source.events();
382
+ const captured = await capturedAt(events, eventsWatermark(events));
383
+ return listFilePreview({ fs: source.fs, cwd, maxFiles: resolved.maxFiles }, events, captured);
384
+ },
385
+ turnFiles: async () => {
386
+ const events = source.events();
387
+ const asOfSeq = eventsWatermark(events);
388
+ let byTurn;
389
+ if (turnCache !== undefined && turnCache.asOfSeq === asOfSeq) {
390
+ byTurn = turnCache.byTurn;
391
+ }
392
+ else {
393
+ byTurn = foldFilePreviewByTurn(events);
394
+ turnCache = { asOfSeq, byTurn };
395
+ }
396
+ const captured = await capturedAt(events, asOfSeq);
397
+ return turnFilesFilePreview({ fs: source.fs, cwd, maxFiles: resolved.maxFiles }, asOfSeq, byTurn, captured);
398
+ },
399
+ read: (path, signal) => readFilePreview({
400
+ fs: source.fs,
401
+ cwd,
402
+ maxReadBytes: resolved.maxReadBytes,
403
+ htmlMaxReadBytes: resolved.htmlMaxReadBytes,
404
+ imageUrl: source.imageUrl,
405
+ }, path, signal),
406
+ readImageBytes: (path, signal) => readFilePreviewImageBytes({ fs: source.fs, cwd, maxReadBytes: resolved.maxReadBytes }, path, signal),
407
+ dispose: () => {
408
+ turnCache = undefined;
409
+ captureCache = undefined;
410
+ },
411
+ };
412
+ }
413
+ export default createFilePreviewReadSurface;