@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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.en.md +88 -0
- package/README.i18n.yaml +6 -0
- package/README.md +88 -0
- package/cordis.patch.yml +7 -0
- package/lib/index.js +1338 -0
- package/lib/invariant.js +25 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +549 -0
- package/lib/typert.remote-client.d.ts +35 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +210 -0
- package/lib/types/bash-writes.d.ts +83 -0
- package/lib/types/bash-writes.d.ts.map +1 -0
- package/lib/types/bash-writes.js +367 -0
- package/lib/types/fold.d.ts +77 -0
- package/lib/types/fold.d.ts.map +1 -0
- package/lib/types/fold.js +309 -0
- package/lib/types/index.d.ts +105 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +517 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +23 -0
- package/lib/types/reveal.d.ts +36 -0
- package/lib/types/reveal.d.ts.map +1 -0
- package/lib/types/reveal.js +77 -0
- package/lib/types/types.d.ts +124 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/types.js +2 -0
- package/package.json +81 -0
- package/skills/3d-artifact/SKILL.md +52 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/** Read-only file-preview Remote service over live sessions and the filesystem.
|
|
2
|
+
* @module @khorsheed/dsh-file-preview
|
|
3
|
+
*/
|
|
4
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
5
|
+
var useValue = arguments.length > 2;
|
|
6
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
7
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
8
|
+
}
|
|
9
|
+
return useValue ? value : void 0;
|
|
10
|
+
};
|
|
11
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
12
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
13
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
14
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
15
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
16
|
+
var _, done = false;
|
|
17
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
18
|
+
var context = {};
|
|
19
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
20
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
21
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
22
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
23
|
+
if (kind === "accessor") {
|
|
24
|
+
if (result === void 0) continue;
|
|
25
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
26
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
27
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
28
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
29
|
+
}
|
|
30
|
+
else if (_ = accept(result)) {
|
|
31
|
+
if (kind === "field") initializers.unshift(_);
|
|
32
|
+
else descriptor[key] = _;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
36
|
+
done = true;
|
|
37
|
+
};
|
|
38
|
+
import { readFileSync } from 'node:fs';
|
|
39
|
+
import { dirname, join } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
42
|
+
import z from '@deepseek-ai/schemastery';
|
|
43
|
+
import { BashWriteCollector } from './bash-writes.js';
|
|
44
|
+
import { foldFilePreview, foldFilePreviewByTurn } from './fold.js';
|
|
45
|
+
import { revealNativePath } from './reveal.js';
|
|
46
|
+
/**
|
|
47
|
+
* Register the 3d-artifact skill — the generation-side contract for
|
|
48
|
+
* sandbox-runnable interactive 3D / digital-twin single-file HTML (self-
|
|
49
|
+
* contained, zero runtime network, GLB-inline zero-fetch models). Pull-based
|
|
50
|
+
* discovery: an agent whose task involves generating such an HTML page finds
|
|
51
|
+
* the contract through the skill catalog — no per-session push notice.
|
|
52
|
+
* Optional: compositions without the skill capability skip the registration.
|
|
53
|
+
* A missing/malformed shipped SKILL.md degrades to a warning — a discovery
|
|
54
|
+
* aid must never take a boot down; the pack-smoke test owns the file's
|
|
55
|
+
* presence in the tarball.
|
|
56
|
+
* @param ctx - plugin context.
|
|
57
|
+
*/
|
|
58
|
+
function registerArtifactSkill(ctx) {
|
|
59
|
+
const skills = ctx.get('skills');
|
|
60
|
+
if (skills === undefined) {
|
|
61
|
+
// Not a crash: file preview degrades without the skill (and a minimal
|
|
62
|
+
// composition may legitimately lack the capability). The line exists so a
|
|
63
|
+
// host-API migration that drops or renames the skills service shows up in
|
|
64
|
+
// boot logs instead of failing silently — same diagnostic as ankh-guard's
|
|
65
|
+
// restart-skill registration; grep "skill .* not registered".
|
|
66
|
+
ctx.logger.warn('file-preview: skills capability absent — the 3d-artifact skill is not registered');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const skillFile = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', '3d-artifact', 'SKILL.md');
|
|
71
|
+
const raw = readFileSync(skillFile, 'utf8');
|
|
72
|
+
const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(raw);
|
|
73
|
+
const name = /^name: (.+)$/m.exec(match?.[1] ?? '')?.[1]?.trim();
|
|
74
|
+
const description = /^description: (.+)$/m.exec(match?.[1] ?? '')?.[1]?.trim();
|
|
75
|
+
const content = match?.[2];
|
|
76
|
+
if (match === null || name === undefined || description === undefined || content === undefined) {
|
|
77
|
+
ctx.logger.warn('file-preview: shipped SKILL.md is malformed — the 3d-artifact skill is not registered');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
ctx.effect(() => skills.register({ name, description, content }));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
ctx.logger.warn(`file-preview: shipped SKILL.md unreadable (${String(error)}) — the 3d-artifact skill is not registered`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Extensions treated as binary without reading (their text decode is meaningless). */
|
|
87
|
+
const BINARY_EXTENSIONS = new Set([
|
|
88
|
+
'.pdf', '.zip', '.gz', '.tar', '.7z', '.zstd', '.br',
|
|
89
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
90
|
+
'.wasm', '.node', '.dylib', '.so', '.dll', '.exe', '.kext',
|
|
91
|
+
'.class', '.jar', '.pyc', '.sqlite', '.db',
|
|
92
|
+
'.DS_Store', '.a', '.o',
|
|
93
|
+
]);
|
|
94
|
+
/** Image extensions served to the browser through the image route. */
|
|
95
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
96
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.bmp', '.ico', '.svg',
|
|
97
|
+
]);
|
|
98
|
+
/** MIME type per image extension for the image route's Content-Type header. */
|
|
99
|
+
const IMAGE_MIME = {
|
|
100
|
+
'.png': 'image/png',
|
|
101
|
+
'.jpg': 'image/jpeg',
|
|
102
|
+
'.jpeg': 'image/jpeg',
|
|
103
|
+
'.gif': 'image/gif',
|
|
104
|
+
'.webp': 'image/webp',
|
|
105
|
+
'.avif': 'image/avif',
|
|
106
|
+
'.bmp': 'image/bmp',
|
|
107
|
+
'.ico': 'image/x-icon',
|
|
108
|
+
'.svg': 'image/svg+xml',
|
|
109
|
+
};
|
|
110
|
+
/** NUL bytes in decoded text mark a file as binary despite a successful decode. */
|
|
111
|
+
function hasNulByte(content) {
|
|
112
|
+
return content.slice(0, 4096).includes('\u0000');
|
|
113
|
+
}
|
|
114
|
+
/** Detect a binary file from its display path extension. */
|
|
115
|
+
function isBinaryPath(path) {
|
|
116
|
+
const dot = path.lastIndexOf('.');
|
|
117
|
+
return dot >= 0 && BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
118
|
+
}
|
|
119
|
+
/** Detect an image from its display path extension. */
|
|
120
|
+
function isImagePath(path) {
|
|
121
|
+
const dot = path.lastIndexOf('.');
|
|
122
|
+
return dot >= 0 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
123
|
+
}
|
|
124
|
+
/** HTML extensions served through the sandboxed render channel. */
|
|
125
|
+
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
|
126
|
+
/** Detect an HTML document from its display path extension. */
|
|
127
|
+
function isHtmlPath(path) {
|
|
128
|
+
const dot = path.lastIndexOf('.');
|
|
129
|
+
return dot >= 0 && HTML_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
130
|
+
}
|
|
131
|
+
/** Best-effort scripted-HTML detection: any `<script>` tag, inline event
|
|
132
|
+
* handler (`on*="…"`), or `javascript:` URL marks the document as scripted.
|
|
133
|
+
* Only a hint for default-mode selection and warning — never a trust
|
|
134
|
+
* decision; the sandbox and the Tier1 CSP are the real boundary. The scan
|
|
135
|
+
* runs on the read content as delivered (a truncated read may miss markers
|
|
136
|
+
* in the tail; acceptable for a hint). */
|
|
137
|
+
function isScriptedHtml(content) {
|
|
138
|
+
return /<\s*script[\s>]/i.test(content)
|
|
139
|
+
|| /\son[a-z]+\s*=/i.test(content)
|
|
140
|
+
|| /javascript:/i.test(content);
|
|
141
|
+
}
|
|
142
|
+
/** Stable error text for a failed read, without leaking backend internals. */
|
|
143
|
+
function readErrorMessage(error) {
|
|
144
|
+
return error instanceof Error ? error.message : String(error);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The `filePreview` Remote service: session-scoped read-only file inspection
|
|
148
|
+
* for web surfaces. `list` is a pure fold over the session log (no filesystem
|
|
149
|
+
* access); `read` resolves the path against the session cwd and serves current
|
|
150
|
+
* text content through `ctx.fs` (or a browser URL for images), capped by
|
|
151
|
+
* config. Image bytes ride a dedicated host route so the browser loads them
|
|
152
|
+
* natively without bloating the RPC channel.
|
|
153
|
+
*/
|
|
154
|
+
let FilePreviewService = (() => {
|
|
155
|
+
let _classSuper = TypertRemoteService;
|
|
156
|
+
let _instanceExtraInitializers = [];
|
|
157
|
+
let _list_decorators;
|
|
158
|
+
let _turnFiles_decorators;
|
|
159
|
+
let _read_decorators;
|
|
160
|
+
let _reveal_decorators;
|
|
161
|
+
return class FilePreviewService extends _classSuper {
|
|
162
|
+
static {
|
|
163
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
164
|
+
_list_decorators = [Remote('list')];
|
|
165
|
+
_turnFiles_decorators = [Remote('turnFiles')];
|
|
166
|
+
_read_decorators = [Remote('read')];
|
|
167
|
+
_reveal_decorators = [Remote('reveal')];
|
|
168
|
+
__esDecorate(this, null, _list_decorators, { kind: "method", name: "list", static: false, private: false, access: { has: obj => "list" in obj, get: obj => obj.list }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
169
|
+
__esDecorate(this, null, _turnFiles_decorators, { kind: "method", name: "turnFiles", static: false, private: false, access: { has: obj => "turnFiles" in obj, get: obj => obj.turnFiles }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
170
|
+
__esDecorate(this, null, _read_decorators, { kind: "method", name: "read", static: false, private: false, access: { has: obj => "read" in obj, get: obj => obj.read }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
171
|
+
__esDecorate(this, null, _reveal_decorators, { kind: "method", name: "reveal", static: false, private: false, access: { has: obj => "reveal" in obj, get: obj => obj.reveal }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
172
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
173
|
+
}
|
|
174
|
+
static inject = ['fs'];
|
|
175
|
+
static Config = z.object({
|
|
176
|
+
maxReadBytes: z.natural().min(1).default(512 * 1024),
|
|
177
|
+
htmlMaxReadBytes: z.natural().min(1).default(4 * 1024 * 1024),
|
|
178
|
+
maxFiles: z.natural().min(1).default(500),
|
|
179
|
+
captureBashWrites: z.boolean().default(true),
|
|
180
|
+
});
|
|
181
|
+
/** URL prefix the image route answers under. */
|
|
182
|
+
static IMAGE_ROUTE = '/file-preview-image';
|
|
183
|
+
resolved = __runInitializers(this, _instanceExtraInitializers);
|
|
184
|
+
/** Whether a web server is present to host the image route; image reads
|
|
185
|
+
* answer `binary` without it (the headless face has no browser to serve). */
|
|
186
|
+
imageRoute;
|
|
187
|
+
/** The agents service when present (image route only; null headless). */
|
|
188
|
+
agents;
|
|
189
|
+
/** Native reveal runner; a constructor seam for deterministic tests. */
|
|
190
|
+
revealNative;
|
|
191
|
+
/** Bash-write collector (S2 seam), or undefined when disabled in config. */
|
|
192
|
+
collector;
|
|
193
|
+
/** Per-session by-turn fold cache, invalidated by the log watermark. */
|
|
194
|
+
turnCache = new Map();
|
|
195
|
+
/**
|
|
196
|
+
* @param ctx - owning Cordis Context carrying `fs`; `webServer` and `agents`
|
|
197
|
+
* are optional (the image route needs them, headless faces lack both).
|
|
198
|
+
* @param config - optional deployment tuning; defaults cap reads and lists,
|
|
199
|
+
* and enable the bash-write collector.
|
|
200
|
+
* @param deps - test seam for the native reveal dispatch.
|
|
201
|
+
*/
|
|
202
|
+
constructor(ctx, config = {}, deps = {}) {
|
|
203
|
+
super(ctx, 'filePreview');
|
|
204
|
+
this.resolved = {
|
|
205
|
+
maxReadBytes: config.maxReadBytes ?? 512 * 1024,
|
|
206
|
+
htmlMaxReadBytes: config.htmlMaxReadBytes ?? 4 * 1024 * 1024,
|
|
207
|
+
maxFiles: config.maxFiles ?? 500,
|
|
208
|
+
captureBashWrites: config.captureBashWrites ?? true,
|
|
209
|
+
};
|
|
210
|
+
this.revealNative = deps.revealNative ?? revealNativePath;
|
|
211
|
+
if (config.captureBashWrites !== false) {
|
|
212
|
+
this.collector = new BashWriteCollector({ fs: this.fs, maxFiles: this.resolved.maxFiles });
|
|
213
|
+
ctx.effect(() => this.collector.attach(ctx), 'file-preview: bash-write capture');
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
this.collector = undefined;
|
|
217
|
+
}
|
|
218
|
+
const webServer = ctx.get('webServer');
|
|
219
|
+
this.agents = ctx.get('agents');
|
|
220
|
+
this.imageRoute = webServer !== undefined && this.agents !== undefined;
|
|
221
|
+
if (this.imageRoute && webServer !== undefined) {
|
|
222
|
+
ctx.effect(() => webServer.register({
|
|
223
|
+
kind: 'prefix',
|
|
224
|
+
path: FilePreviewService.IMAGE_ROUTE,
|
|
225
|
+
/* v8 ignore next 2 -- the handler body is the request path; the
|
|
226
|
+
unit tests drive serveImage directly, the real server reaches it
|
|
227
|
+
through this registration */
|
|
228
|
+
handler: (req, res) => { void this.serveImage(req, res); },
|
|
229
|
+
}), 'file-preview: image route');
|
|
230
|
+
}
|
|
231
|
+
registerArtifactSkill(ctx);
|
|
232
|
+
}
|
|
233
|
+
get fs() {
|
|
234
|
+
return this.ctx.fs;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Serve one image file's bytes for a browser `<img>` request. The URL is
|
|
238
|
+
* `IMAGE_ROUTE/<sessionId>/<encodeURIComponent(path)>`; the session must be
|
|
239
|
+
* live (the same scope `read` serves) and the path resolves against its cwd.
|
|
240
|
+
* @param req - the incoming image request.
|
|
241
|
+
* @param res - the response to fill with bytes or an error status.
|
|
242
|
+
*/
|
|
243
|
+
async serveImage(req, res) {
|
|
244
|
+
/* v8 ignore next -- split('?')[0] is always a string when url is defined; the fallback guards a future parse change */
|
|
245
|
+
const pathname = req.url === undefined ? '' : req.url.split('?')[0] ?? '';
|
|
246
|
+
const rest = pathname.slice(FilePreviewService.IMAGE_ROUTE.length + 1);
|
|
247
|
+
const slash = rest.indexOf('/');
|
|
248
|
+
if (slash <= 0) {
|
|
249
|
+
res.writeHead(400).end('file preview: expected /<sessionId>/<path>');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const sessionId = rest.slice(0, slash);
|
|
253
|
+
const encoded = rest.slice(slash + 1);
|
|
254
|
+
let display;
|
|
255
|
+
try {
|
|
256
|
+
display = decodeURIComponent(encoded);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
res.writeHead(400).end('file preview: malformed path');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const agent = this.agents?.get(sessionId);
|
|
263
|
+
if (agent === undefined) {
|
|
264
|
+
res.writeHead(404).end('file preview: session not found');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (!isImagePath(display)) {
|
|
268
|
+
res.writeHead(400).end('file preview: not an image path');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const cwd = agent.session.header.cwd;
|
|
272
|
+
let target;
|
|
273
|
+
try {
|
|
274
|
+
target = await this.fs.resolve(display, cwd === undefined ? {} : { cwd });
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
res.writeHead(404).end(readErrorMessage(error));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
let info;
|
|
281
|
+
try {
|
|
282
|
+
info = await this.fs.stat(target);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
res.writeHead(404).end(readErrorMessage(error));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (info === undefined || info.type !== 'file') {
|
|
289
|
+
res.writeHead(404).end('file preview: not a file');
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (info.size !== undefined && info.size > this.resolved.maxReadBytes) {
|
|
293
|
+
res.writeHead(413).end('file preview: image exceeds the read cap');
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
let bytes;
|
|
297
|
+
try {
|
|
298
|
+
bytes = await this.fs.readBytes(target, undefined, this.resolved.maxReadBytes);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
res.writeHead(404).end(readErrorMessage(error));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const dot = display.lastIndexOf('.');
|
|
305
|
+
/* v8 ignore next -- every IMAGE_EXTENSIONS entry has a MIME mapping; the fallback guards a future extension */
|
|
306
|
+
const mime = IMAGE_MIME[display.slice(dot).toLowerCase()] ?? 'application/octet-stream';
|
|
307
|
+
res.writeHead(200, {
|
|
308
|
+
'Content-Type': mime,
|
|
309
|
+
'Cache-Control': 'no-store',
|
|
310
|
+
});
|
|
311
|
+
res.end(Buffer.from(bytes));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* List the files one session wrote or edited: the log fold plus any
|
|
315
|
+
* bash-written files the collector verified (the S2 seam), merged so a
|
|
316
|
+
* captured path the fold already knows keeps its log-derived entry.
|
|
317
|
+
* @param agent - owning live agent; its session log is the data source.
|
|
318
|
+
* @returns first-seen files capped by `maxFiles`, with the log watermark.
|
|
319
|
+
*/
|
|
320
|
+
list(agent) {
|
|
321
|
+
const folded = foldFilePreview(agent.session.events, this.resolved.maxFiles);
|
|
322
|
+
const captured = this.collector?.captured(agent.session.id);
|
|
323
|
+
if (captured === undefined || captured.size === 0)
|
|
324
|
+
return folded;
|
|
325
|
+
const seen = new Set(folded.entries.map(entry => entry.path));
|
|
326
|
+
const extra = [];
|
|
327
|
+
for (const [path, write] of captured) {
|
|
328
|
+
if (seen.has(path) || folded.entries.length + extra.length >= this.resolved.maxFiles)
|
|
329
|
+
continue;
|
|
330
|
+
extra.push({ path, op: 'write', seq: write.seq, turn: write.turn, step: write.step, diffs: [] });
|
|
331
|
+
}
|
|
332
|
+
if (extra.length === 0)
|
|
333
|
+
return folded;
|
|
334
|
+
return {
|
|
335
|
+
entries: [...folded.entries, ...extra],
|
|
336
|
+
asOfSeq: folded.asOfSeq,
|
|
337
|
+
truncated: folded.truncated || folded.entries.length + extra.length >= this.resolved.maxFiles,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Every turn's file mutations for the turn-tail card — the single source of
|
|
342
|
+
* truth the card renders (the client's old write/edit-only fold retired).
|
|
343
|
+
* Built by folding the session log per turn (write/edit calls, Code Mode
|
|
344
|
+
* dispatches borrowing the root call's turn, and render-intent paths from
|
|
345
|
+
* result diff meta) and merging the collector's bash-written captures by
|
|
346
|
+
* their own turn. The fold is cached per session and invalidated by the log
|
|
347
|
+
* watermark, so repeated card fetches do not refold. A path touched in two
|
|
348
|
+
* turns appears in BOTH turn groups — per-turn attribution is exact.
|
|
349
|
+
* @param agent - owning live agent; its session log is the data source.
|
|
350
|
+
* @returns per-turn file groups plus the scanned watermark.
|
|
351
|
+
*/
|
|
352
|
+
turnFiles(agent) {
|
|
353
|
+
const session = agent.session;
|
|
354
|
+
const events = session.events;
|
|
355
|
+
const asOfSeq = events.length === 0 ? -1 : events[events.length - 1].seq;
|
|
356
|
+
const cached = this.turnCache.get(session.id);
|
|
357
|
+
let byTurn;
|
|
358
|
+
if (cached === undefined || cached.asOfSeq !== asOfSeq) {
|
|
359
|
+
byTurn = foldFilePreviewByTurn(events);
|
|
360
|
+
this.turnCache.set(session.id, { asOfSeq, byTurn });
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
byTurn = cached.byTurn;
|
|
364
|
+
}
|
|
365
|
+
const captured = this.collector?.captured(session.id);
|
|
366
|
+
const turns = [];
|
|
367
|
+
// One pass over the turn keys in ascending order (turn numbers are small).
|
|
368
|
+
const turnNumbers = new Set(byTurn.keys());
|
|
369
|
+
if (captured !== undefined) {
|
|
370
|
+
for (const write of captured.values())
|
|
371
|
+
turnNumbers.add(write.turn);
|
|
372
|
+
}
|
|
373
|
+
for (const turn of [...turnNumbers].sort((a, b) => a - b)) {
|
|
374
|
+
const files = [];
|
|
375
|
+
const map = byTurn.get(turn);
|
|
376
|
+
if (map !== undefined)
|
|
377
|
+
files.push(...map.values());
|
|
378
|
+
if (captured !== undefined) {
|
|
379
|
+
for (const [path, write] of captured) {
|
|
380
|
+
if (write.turn !== turn || files.some(file => file.path === path))
|
|
381
|
+
continue;
|
|
382
|
+
files.push({ path, seq: write.seq, step: write.step });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (files.length === 0)
|
|
386
|
+
continue;
|
|
387
|
+
turns.push({ turn, files: files.sort((a, b) => a.seq - b.seq) });
|
|
388
|
+
}
|
|
389
|
+
return { turns, asOfSeq };
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Read the current content of one recorded file, resolved against the
|
|
393
|
+
* session cwd and capped by `maxReadBytes`.
|
|
394
|
+
* @param agent - owning live agent; its session cwd anchors relative paths.
|
|
395
|
+
* @param path - the display path recorded by the write/edit tool call.
|
|
396
|
+
* @param signal - cooperative cancellation from the calling UI request.
|
|
397
|
+
* @returns the classified read; `text` carries content, `image` carries a
|
|
398
|
+
* browser URL, other kinds carry the size or a human-readable reason.
|
|
399
|
+
*/
|
|
400
|
+
async read(agent, path, signal) {
|
|
401
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
402
|
+
return { path, kind: 'error', message: 'file preview requires a non-empty path' };
|
|
403
|
+
}
|
|
404
|
+
const cwd = agent.session.header.cwd;
|
|
405
|
+
let target;
|
|
406
|
+
try {
|
|
407
|
+
target = await this.fs.resolve(path, cwd === undefined ? { signal } : { cwd, signal });
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
return { path, kind: 'error', message: readErrorMessage(error) };
|
|
411
|
+
}
|
|
412
|
+
let info;
|
|
413
|
+
try {
|
|
414
|
+
info = await this.fs.stat(target, signal);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
return { path, kind: 'error', message: readErrorMessage(error) };
|
|
418
|
+
}
|
|
419
|
+
if (info === undefined || info.type !== 'file') {
|
|
420
|
+
return { path, kind: 'missing' };
|
|
421
|
+
}
|
|
422
|
+
// HTML gets its own, wider render-channel cap; every other text read
|
|
423
|
+
// keeps `maxReadBytes`. Images keep the base cap (their route serves
|
|
424
|
+
// bytes, not the render channel).
|
|
425
|
+
const html = isHtmlPath(path);
|
|
426
|
+
const cap = html ? this.resolved.htmlMaxReadBytes : this.resolved.maxReadBytes;
|
|
427
|
+
if (info.size !== undefined && info.size > cap) {
|
|
428
|
+
return { path, kind: 'too-large', size: info.size };
|
|
429
|
+
}
|
|
430
|
+
if (isImagePath(path)) {
|
|
431
|
+
if (this.imageRoute) {
|
|
432
|
+
const encoded = encodeURIComponent(path);
|
|
433
|
+
return {
|
|
434
|
+
path,
|
|
435
|
+
kind: 'image',
|
|
436
|
+
url: `${FilePreviewService.IMAGE_ROUTE}/${agent.session.id}/${encoded}`,
|
|
437
|
+
...(info.size === undefined ? {} : { size: info.size }),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
// No web face to serve bytes: an image is binary, never decoded as text.
|
|
441
|
+
return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
|
|
442
|
+
}
|
|
443
|
+
if (isBinaryPath(path)) {
|
|
444
|
+
return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const content = await this.fs.readText(target, signal);
|
|
448
|
+
if (hasNulByte(content)) {
|
|
449
|
+
return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
|
|
450
|
+
}
|
|
451
|
+
const truncated = content.length > cap;
|
|
452
|
+
return {
|
|
453
|
+
path,
|
|
454
|
+
kind: 'text',
|
|
455
|
+
content: truncated ? content.slice(0, cap) : content,
|
|
456
|
+
truncated,
|
|
457
|
+
...(html && isScriptedHtml(content) ? { htmlScripted: true } : {}),
|
|
458
|
+
...(info.size === undefined ? {} : { size: info.size }),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
return { path, kind: 'error', message: readErrorMessage(error) };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Reveal one recorded file in the host file manager — open its folder and
|
|
467
|
+
* select the file (macOS Finder, Windows Explorer, or a select-capable
|
|
468
|
+
* desktop file manager). The display path resolves against the session cwd;
|
|
469
|
+
* the native reveal runs on the host. A path that does not resolve to an
|
|
470
|
+
* existing target, or a platform that cannot select, answers `revealed:
|
|
471
|
+
* false` and the caller opens the parent folder instead (the pre-reveal
|
|
472
|
+
* "show in folder" behavior), so the gesture always lands somewhere visible.
|
|
473
|
+
* @param agent - owning live agent; its session cwd anchors relative paths.
|
|
474
|
+
* @param path - the display path recorded by the read/write/edit tool call.
|
|
475
|
+
* @param signal - cooperative cancellation from the calling UI request.
|
|
476
|
+
* @returns whether the file manager selected the file, or why not.
|
|
477
|
+
*/
|
|
478
|
+
async reveal(agent, path, signal) {
|
|
479
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
480
|
+
return { revealed: false, reason: 'missing' };
|
|
481
|
+
}
|
|
482
|
+
const cwd = agent.session.header.cwd;
|
|
483
|
+
let target;
|
|
484
|
+
try {
|
|
485
|
+
target = await this.fs.resolve(path, cwd === undefined ? { signal } : { cwd, signal });
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
// Unresolvable recorded path: the file is gone; the caller opens the parent.
|
|
489
|
+
return { revealed: false, reason: 'missing' };
|
|
490
|
+
}
|
|
491
|
+
let info;
|
|
492
|
+
try {
|
|
493
|
+
info = await this.fs.stat(target, signal);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// A stat failure is treated as missing; the caller still opens the parent.
|
|
497
|
+
info = undefined;
|
|
498
|
+
}
|
|
499
|
+
if (info === undefined) {
|
|
500
|
+
return { revealed: false, reason: 'missing' };
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
// processPath hands the canonical path a host subprocess can open; the
|
|
504
|
+
// recorded display path may be relative or backend-normalized.
|
|
505
|
+
await this.revealNative(this.fs.processPath(target), signal);
|
|
506
|
+
return { revealed: true };
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// No select-capable file manager (or a failed launch): fall back to the
|
|
510
|
+
// parent-folder open rather than failing the whole gesture.
|
|
511
|
+
return { revealed: false, reason: 'select-failed' };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
})();
|
|
516
|
+
export { FilePreviewService };
|
|
517
|
+
export default FilePreviewService;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@khorsheed/dsh-file-preview`.
|
|
3
|
+
* @module @khorsheed/dsh-file-preview/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "file-preview-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invariant.d.ts","sourceRoot":"","sources":["../../src/invariant.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKlD,oCAAoC;AACpC,eAAO,MAAM,IAAI,2BAA2B,CAAA;AAC5C,2EAA2E;AAC3E,eAAO,MAAM,MAAM,UAAiB,CAAA;AAUpC;;;;GAIG;AACH,eAAO,MAAM,KAAK,QAAS,OAAO,KAAG,OAAO,CAAC,MAAM,IAAI,CACU,CAAA"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@khorsheed/dsh-file-preview`.
|
|
3
|
+
* @module @khorsheed/dsh-file-preview/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = '@khorsheed/dsh-file-preview';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'file-preview-invariant';
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* No runtime invariant: the service owns no durable package-local state and
|
|
12
|
+
* writes nothing — `list` is a pure deterministic fold over the session log
|
|
13
|
+
* (unit-tested), and `read` re-reads the filesystem through `ctx.fs`, whose
|
|
14
|
+
* own invariants own that relationship.
|
|
15
|
+
*/
|
|
16
|
+
const install = () => { };
|
|
17
|
+
/**
|
|
18
|
+
* Register this package's invariant companion.
|
|
19
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
20
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
21
|
+
*/
|
|
22
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
23
|
+
/* jscpd:ignore-end */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform native "show in folder, selecting the file" dispatch used by
|
|
3
|
+
* the file-preview Remote's `reveal` method.
|
|
4
|
+
*
|
|
5
|
+
* The official host opener only opens a path with the default application —
|
|
6
|
+
* it has no select-in-folder intent — so this plugin carries its own
|
|
7
|
+
* shell-free dispatch: macOS `open -R` (Finder reveals and selects), Windows
|
|
8
|
+
* `explorer /select,<path>` (Explorer reveals and selects), WSL translates the
|
|
9
|
+
* path to the Windows spelling first, and desktop Linux tries the
|
|
10
|
+
* select-capable file managers (`nautilus` / `dolphin` / `nemo` `--select`) in
|
|
11
|
+
* order, falling back to the caller's parent-folder open when none is
|
|
12
|
+
* installed. Every command runs through the official
|
|
13
|
+
* `@deepseek-ai/dsh-native-command` runner — no shell, abort propagation.
|
|
14
|
+
* @module @khorsheed/dsh-file-preview
|
|
15
|
+
*/
|
|
16
|
+
import { type NativeCommandRunner } from '@deepseek-ai/dsh-native-command';
|
|
17
|
+
/** Injectable platform facts for deterministic adapter tests. */
|
|
18
|
+
export interface RevealInternals {
|
|
19
|
+
platform?: NodeJS.Platform;
|
|
20
|
+
/** Kernel release override used to distinguish WSL from desktop Linux. */
|
|
21
|
+
osRelease?: string;
|
|
22
|
+
/** Environment used for the WSL markers. */
|
|
23
|
+
env?: NodeJS.ProcessEnv;
|
|
24
|
+
run?: NativeCommandRunner;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Reveal one path in the host file manager, opening its folder and selecting
|
|
28
|
+
* the path. Throws when the platform cannot select (no select-capable file
|
|
29
|
+
* manager, unsupported platform) — the caller then opens the parent folder.
|
|
30
|
+
* @param path - absolute path in the host's execution world (caller owns
|
|
31
|
+
* resolution, typically `FileSystem.processPath`).
|
|
32
|
+
* @param signal - caller/connection lifetime; abort terminates the native command.
|
|
33
|
+
* @param internals - platform and environment seam for deterministic tests.
|
|
34
|
+
*/
|
|
35
|
+
export declare function revealNativePath(path: string, signal: AbortSignal, internals?: RevealInternals): Promise<void>;
|
|
36
|
+
//# sourceMappingURL=reveal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reveal.d.ts","sourceRoot":"","sources":["../../src/reveal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,EAAoB,KAAK,mBAAmB,EAAE,MAAM,iCAAiC,CAAA;AAE5F,iEAAiE;AACjE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAA;IAC1B,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,GAAG,CAAC,EAAE,mBAAmB,CAAA;CAC1B;AAiBD;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,WAAW,EACnB,SAAS,GAAE,eAAoB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAoCf"}
|