dsh-plugin-library 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/dist/host.js ADDED
@@ -0,0 +1,1626 @@
1
+ // ../../shared/host/route.mjs
2
+ function json(body, status) {
3
+ return Response.json(body, { status, headers: { "cache-control": "no-store" } });
4
+ }
5
+ function ok(value) {
6
+ return json({ ok: true, value: value ?? null }, 200);
7
+ }
8
+ function fail(code, message) {
9
+ return json({ ok: false, error: { code, message } }, 200);
10
+ }
11
+ var FeatureError2 = class extends Error {
12
+ /**
13
+ * @param {string} code - stable machine-readable code, for example `connector/not-found`.
14
+ * @param {string} message - human-readable reason shown to the user.
15
+ */
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.name = "FeatureError";
19
+ this.code = code;
20
+ }
21
+ };
22
+ function registerFeatureRoute(ctx, { feature, handlers, methods = ["POST"] }) {
23
+ const path = `/api/dsh-skills/${feature}`;
24
+ return ctx.effect(
25
+ () => ctx.connection.fetch.register({
26
+ path,
27
+ methods: [...methods],
28
+ requestBody: "buffered",
29
+ fetch: async (request) => {
30
+ try {
31
+ if (request.method !== "POST") return fail("method-not-allowed", "\u6B64\u8DEF\u7531\u53EA\u63A5\u53D7 POST");
32
+ const body = await request.json().catch(() => null);
33
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
34
+ return fail("malformed-request", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
35
+ }
36
+ const { endpoint, payload } = body;
37
+ if (typeof endpoint !== "string") return fail("malformed-request", "endpoint \u5FC5\u987B\u662F\u5B57\u7B26\u4E32");
38
+ const handler = Object.hasOwn(handlers, endpoint) ? handlers[endpoint] : void 0;
39
+ if (typeof handler !== "function") return fail("unknown-endpoint", `\u672A\u77E5\u7AEF\u70B9 ${JSON.stringify(endpoint)}`);
40
+ const result = await handler(payload ?? {}, request);
41
+ return result instanceof Response ? result : ok(result);
42
+ } catch (error) {
43
+ const code = typeof error?.code === "string" && error.code.length > 0 ? error.code : "handler-failed";
44
+ return fail(code, String(error?.message ?? error));
45
+ }
46
+ }
47
+ }),
48
+ `dsh-skills: ${feature} route`
49
+ );
50
+ }
51
+
52
+ // ../../shared/dsh-package.mjs
53
+ import { createRequire } from "node:module";
54
+ import { isAbsolute, join } from "node:path";
55
+ import { pathToFileURL } from "node:url";
56
+ function dshResolutionAnchors({ argv = process.argv, cwd = process.cwd() } = {}) {
57
+ const anchors = [];
58
+ if (typeof argv[1] === "string" && isAbsolute(argv[1])) anchors.push(argv[1]);
59
+ if (typeof argv[2] === "string" && isAbsolute(argv[2])) anchors.push(join(argv[2], "package.json"));
60
+ anchors.push(join(cwd, "package.json"));
61
+ return [...new Set(anchors)];
62
+ }
63
+ var stubs = /* @__PURE__ */ new Map();
64
+ function resolveDshPackage(specifier, options = {}) {
65
+ if (stubs.has(specifier)) return `stub:${specifier}`;
66
+ const tried = [];
67
+ for (const anchor of dshResolutionAnchors(options)) {
68
+ try {
69
+ return createRequire(anchor).resolve(specifier);
70
+ } catch (error) {
71
+ tried.push(`${anchor} (${error?.code ?? error?.name ?? "error"})`);
72
+ }
73
+ }
74
+ throw new Error(
75
+ `dsh-skills: cannot resolve ${JSON.stringify(specifier)} from the dsh installation or the profile.
76
+ Tried: ${tried.join(", ")}
77
+ A Host half must not import an official package by bare specifier: Node resolves that from
78
+ this plugin's realpath, which is outside the installation. Use importDshPackage() instead.`
79
+ );
80
+ }
81
+ async function importDshPackage(specifier, options) {
82
+ const stub = stubs.get(specifier);
83
+ if (stub !== void 0) return stub;
84
+ return import(pathToFileURL(resolveDshPackage(specifier, options)).href);
85
+ }
86
+
87
+ // ../../shared/host/storage.mjs
88
+ var cached;
89
+ async function storageKit() {
90
+ if (cached !== void 0) return cached;
91
+ const [domain, zod] = await Promise.all([
92
+ importDshPackage("@deepseek-ai/dsh-storage-domain"),
93
+ importDshPackage("zod")
94
+ ]);
95
+ const z = zod.z ?? zod.default ?? zod;
96
+ if (typeof domain?.defineDomain !== "function" || typeof domain?.domainTable !== "function") {
97
+ throw new Error("dsh-skills: @deepseek-ai/dsh-storage-domain no longer exports defineDomain/domainTable");
98
+ }
99
+ if (typeof z?.object !== "function") {
100
+ throw new Error("dsh-skills: the installation's zod does not expose z.object");
101
+ }
102
+ cached = { defineDomain: domain.defineDomain, domainTable: domain.domainTable, descriptorOf: domain.descriptorOf, z };
103
+ return cached;
104
+ }
105
+
106
+ // src/attribution.js
107
+ import { resolve } from "node:path";
108
+ function absolutePath(path, cwd) {
109
+ if (cwd === void 0 || cwd.length === 0) return path;
110
+ return resolve(cwd, path);
111
+ }
112
+ function registerDeliverableImport(ctx, manager) {
113
+ const warn = (message) => ctx.logger?.warn?.(`dsh-plugin-library: ${message}`);
114
+ const inflight = /* @__PURE__ */ new Set();
115
+ const dispose = ctx.on("session/event", (session, event) => {
116
+ if (event?.type !== "deliverables/presented") return;
117
+ const sessionId = String(session?.id ?? "");
118
+ if (sessionId.length === 0) return;
119
+ const cwd = session?.header?.cwd;
120
+ const files = Array.isArray(event?.data?.files) ? event.data.files : [];
121
+ const seen = /* @__PURE__ */ new Set();
122
+ for (const file of files) {
123
+ const path = absolutePath(file?.path ?? "", cwd);
124
+ if (path.length === 0) continue;
125
+ if (seen.has(path)) continue;
126
+ seen.add(path);
127
+ const run = (async () => {
128
+ const entry = await manager.importTaskFile({
129
+ path,
130
+ sourceTaskId: sessionId
131
+ });
132
+ ctx.emit("library/deliverable-imported", { sessionId, path, entry });
133
+ })().catch((cause) => {
134
+ warn(`\u4EA4\u4ED8\u5165\u5E93\u5931\u8D25\uFF1A${path}\uFF08${cause instanceof Error ? cause.message : String(cause)}\uFF09`);
135
+ });
136
+ inflight.add(run);
137
+ run.finally(() => {
138
+ inflight.delete(run);
139
+ });
140
+ }
141
+ });
142
+ return {
143
+ dispose,
144
+ flush: async () => {
145
+ while (inflight.size > 0) await Promise.allSettled([...inflight]);
146
+ }
147
+ };
148
+ }
149
+
150
+ // src/domains.js
151
+ var LOCAL_PRINCIPAL = "local";
152
+ function buildLibraryDomains({ z, defineDomain, domainTable }) {
153
+ const loose = z.record(z.string(), z.unknown());
154
+ const stateSchema = z.object({
155
+ schemaVersion: z.literal(1),
156
+ /** Tree nodes: folders and asset nodes. `id` is the node id (equal to `assetId` for assets). */
157
+ nodes: loose,
158
+ /** Asset records keyed by asset id. */
159
+ assets: loose,
160
+ /** Immutable revision records keyed `${assetId}:${sha256}`. */
161
+ revisions: loose,
162
+ /**
163
+ * Editable drafts keyed by draft id, added after the first release.
164
+ *
165
+ * `.default({})`, never a plain required key: the domain facility validates
166
+ * **every stored record on `open()`**, before any consumer sees it, and an
167
+ * unparsable record is a hard `DomainError`. A plain key would therefore make
168
+ * every library record written before drafts existed fail to parse — taking
169
+ * the route, the tools and the whole panel down on the next launch. This is
170
+ * exactly the failure docs/PORTING-NOTES.md §15 records for experts.
171
+ */
172
+ drafts: loose.default({})
173
+ });
174
+ return {
175
+ stateSchema,
176
+ libraryDomain: defineDomain({
177
+ name: "dsh_skills_library",
178
+ version: 1,
179
+ layout: "per-record",
180
+ tables: { states: domainTable(stateSchema) }
181
+ })
182
+ };
183
+ }
184
+
185
+ // src/manager.js
186
+ import { createHash, randomUUID } from "node:crypto";
187
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
188
+ import { homedir } from "node:os";
189
+ import { basename, dirname, extname as extname2, join as join2, resolve as resolve2, sep } from "node:path";
190
+
191
+ // src/convert.js
192
+ import { extname } from "node:path";
193
+ var KINDS_BY_EXTENSION = {
194
+ ".md": "markdown",
195
+ ".markdown": "markdown",
196
+ ".txt": "text",
197
+ ".html": "html",
198
+ ".htm": "html",
199
+ ".json": "json",
200
+ ".csv": "csv",
201
+ ".log": "log"
202
+ };
203
+ var MEDIA_TYPES_BY_KIND = {
204
+ markdown: "text/markdown",
205
+ text: "text/plain",
206
+ html: "text/html",
207
+ json: "application/json",
208
+ csv: "text/csv",
209
+ log: "text/plain",
210
+ binary: "application/octet-stream"
211
+ };
212
+ var ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
213
+ function kindForName(name2) {
214
+ return KINDS_BY_EXTENSION[extname(String(name2)).toLowerCase()] ?? "binary";
215
+ }
216
+ function mediaTypeForKind(kind) {
217
+ return MEDIA_TYPES_BY_KIND[kind] ?? MEDIA_TYPES_BY_KIND.binary;
218
+ }
219
+ function decodeEntities(value) {
220
+ return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code) => {
221
+ if (code[0] !== "#") return ENTITIES[code.toLowerCase()] ?? whole;
222
+ const point = code[1]?.toLowerCase() === "x" ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
223
+ return Number.isFinite(point) ? String.fromCodePoint(point) : whole;
224
+ });
225
+ }
226
+ function stripHtml(source) {
227
+ const withoutComments = source.replace(/<!--[\s\S]*?-->/g, " ");
228
+ const withoutHidden = withoutComments.replace(
229
+ /<(script|style|noscript|template|svg|canvas)(?:\s[^>]*)?>[\s\S]*?<\/\1\s*>/gi,
230
+ " "
231
+ );
232
+ const broken = withoutHidden.replace(/<(br|hr)\s*\/?>/gi, "\n").replace(/<\/(p|div|section|article|main|header|footer|li|tr|h[1-6]|ul|ol|table|blockquote|pre)\s*>/gi, "\n");
233
+ const text = decodeEntities(broken.replace(/<[^>]*>/g, " "));
234
+ return `${text.split("\n").map((line) => line.replace(/[\t ]+/g, " ").trim()).filter(Boolean).join("\n\n")}
235
+ `;
236
+ }
237
+ function deriveText(kind, bytes) {
238
+ if (kind === "binary") {
239
+ return {
240
+ text: "",
241
+ status: "unavailable",
242
+ warnings: ["\u8BE5\u683C\u5F0F\u6CA1\u6709\u6587\u672C\u8F6C\u6362\uFF0C\u4EC5\u4FDD\u5B58\u539F\u4EF6\uFF1B\u4E0D\u53C2\u4E0E\u6B63\u6587\u641C\u7D22\u3002"]
243
+ };
244
+ }
245
+ let source;
246
+ try {
247
+ source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
248
+ } catch {
249
+ return {
250
+ text: "",
251
+ status: "failed",
252
+ warnings: ["\u6587\u4EF6\u4E0D\u662F\u6709\u6548\u7684 UTF-8 \u6587\u672C\uFF0C\u65E0\u6CD5\u751F\u6210\u68C0\u7D22\u6587\u672C\uFF1B\u539F\u4EF6\u5DF2\u5B8C\u6574\u4FDD\u5B58\u3002"]
253
+ };
254
+ }
255
+ const normalized = source.replace(/\r\n?/g, "\n");
256
+ if (kind === "html") {
257
+ return {
258
+ text: stripHtml(normalized),
259
+ status: "ready",
260
+ warnings: ["\u811A\u672C\u3001\u6837\u5F0F\u3001SVG \u4E0E\u753B\u5E03\u5185\u5BB9\u4E0D\u8FDB\u5165\u68C0\u7D22\u6587\u672C\uFF1B\u539F\u59CB HTML \u4ECD\u6309\u5B57\u8282\u4FDD\u5B58\u3002"]
261
+ };
262
+ }
263
+ return { text: normalized, status: "ready", warnings: [] };
264
+ }
265
+
266
+ // src/native-open.js
267
+ var cached2;
268
+ async function loadNativeCommand() {
269
+ if (cached2 !== void 0) return cached2;
270
+ try {
271
+ const mod = await importDshPackage("@deepseek-ai/dsh-native-command");
272
+ if (typeof mod?.openNativeAssociatedPath === "function") {
273
+ cached2 = mod;
274
+ return cached2;
275
+ }
276
+ } catch {
277
+ }
278
+ cached2 = {
279
+ openNativeAssociatedPath: async () => {
280
+ throw new FeatureError2("library/native-unavailable", "\u5F53\u524D\u73AF\u5883\u65E0\u6CD5\u8C03\u7528\u7CFB\u7EDF\u7A0B\u5E8F\u6253\u5F00\u6587\u4EF6\uFF08\u539F\u751F\u547D\u4EE4\u5C42\u4E0D\u53EF\u7528\uFF09\u3002");
281
+ },
282
+ nativeFileApplications: async () => [],
283
+ openNativeFileApplication: async () => {
284
+ throw new FeatureError2("library/native-unavailable", "\u5F53\u524D\u73AF\u5883\u65E0\u6CD5\u8C03\u7528\u7CFB\u7EDF\u7A0B\u5E8F\u6253\u5F00\u6587\u4EF6\uFF08\u539F\u751F\u547D\u4EE4\u5C42\u4E0D\u53EF\u7528\uFF09\u3002");
285
+ }
286
+ };
287
+ return cached2;
288
+ }
289
+ async function openInSystemApp(absolutePath2, appId, ctx) {
290
+ if (typeof absolutePath2 !== "string" || !absolutePath2.startsWith("/")) {
291
+ throw new FeatureError2("library/invalid-request", "\u9700\u8981\u4E00\u4E2A\u672C\u673A\u7EDD\u5BF9\u8DEF\u5F84\u3002");
292
+ }
293
+ const signal = new AbortController().signal;
294
+ const native = await loadNativeCommand();
295
+ try {
296
+ if (typeof appId === "string" && appId.length > 0) {
297
+ if (typeof native.openNativeFileApplication !== "function") {
298
+ throw new FeatureError2("library/native-unavailable", "\u5F53\u524D\u539F\u751F\u547D\u4EE4\u5C42\u4E0D\u652F\u6301\u6307\u5B9A\u7A0B\u5E8F\u6253\u5F00\u3002");
299
+ }
300
+ await native.openNativeFileApplication(absolutePath2, appId, signal);
301
+ return { opened: true, appId };
302
+ }
303
+ await native.openNativeAssociatedPath(absolutePath2, signal);
304
+ return { opened: true, appId: void 0 };
305
+ } catch (error) {
306
+ if (error instanceof FeatureError2) throw error;
307
+ ctx?.logger?.warn?.(`dsh-plugin-library: \u7CFB\u7EDF\u6253\u5F00\u5931\u8D25 ${absolutePath2}\uFF08${appId ?? "\u9ED8\u8BA4"}\uFF09\uFF1A${String(error?.message ?? error)}`);
308
+ throw new FeatureError2("library/open-failed", `\u6CA1\u6709\u6253\u5F00\u6210\u529F\uFF1A${String(error?.message ?? error)}`);
309
+ }
310
+ }
311
+ async function fileApplications(absolutePath2) {
312
+ if (typeof absolutePath2 !== "string" || !absolutePath2.startsWith("/")) return [];
313
+ const signal = new AbortController().signal;
314
+ const native = await loadNativeCommand();
315
+ if (typeof native.nativeFileApplications !== "function") return [];
316
+ const rows = await native.nativeFileApplications(absolutePath2, signal);
317
+ return Array.isArray(rows) ? rows : [];
318
+ }
319
+
320
+ // src/manager.js
321
+ var MAX_BYTES = 50 * 1024 * 1024;
322
+ var MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024;
323
+ var MAX_HITS = 50;
324
+ var NAME_ATTEMPTS = 5;
325
+ var DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
326
+ var EDITABLE_KINDS = /* @__PURE__ */ new Set(["markdown", "text"]);
327
+ var FS_CODES = {
328
+ FS_NOT_FOUND: "library/not-found",
329
+ FS_NOT_REGULAR_FILE: "library/not-a-file",
330
+ FS_TOO_LARGE: "library/file-size",
331
+ FS_SANDBOX_DENIED: "library/read-denied",
332
+ FS_ABORTED: "library/aborted",
333
+ FS_IO_ERROR: "library/read-failed"
334
+ };
335
+ function sha256Hex(bytes) {
336
+ return createHash("sha256").update(bytes).digest("hex");
337
+ }
338
+ function requireString(value, field) {
339
+ if (typeof value !== "string" || value.length === 0) {
340
+ throw new FeatureError2("library/invalid-request", `\u7F3A\u5C11\u5B57\u6BB5 ${field}\u3002`);
341
+ }
342
+ return value;
343
+ }
344
+ function optionalId(value) {
345
+ return typeof value === "string" && value.length > 0 ? value : void 0;
346
+ }
347
+ function cleanName(value) {
348
+ const name2 = typeof value === "string" ? value.trim() : "";
349
+ if (name2.length === 0 || name2.length > 256 || name2 === "." || name2 === ".." || /[/\\\u0000-\u001f]/.test(name2)) {
350
+ throw new FeatureError2("library/invalid-name", "\u540D\u79F0\u9700\u8981 1\u2013256 \u4E2A\u5B57\u7B26\uFF0C\u4E14\u4E0D\u80FD\u5305\u542B\u8DEF\u5F84\u5206\u9694\u7B26\u3002");
351
+ }
352
+ return name2;
353
+ }
354
+ function cleanCreateName(value) {
355
+ const name2 = cleanName(value);
356
+ if (name2.includes("..")) {
357
+ throw new FeatureError2("library/invalid-name", '\u540D\u79F0\u4E0D\u80FD\u5305\u542B\u8DEF\u5F84\u5206\u9694\u7B26\u3001\u63A7\u5236\u5B57\u7B26\u6216 ".."\u3002');
358
+ }
359
+ return name2;
360
+ }
361
+ function requireContent(value) {
362
+ if (typeof value !== "string") {
363
+ throw new FeatureError2("library/invalid-request", "\u7F3A\u5C11\u5B57\u6BB5 content\u3002");
364
+ }
365
+ return value;
366
+ }
367
+ function emptyState() {
368
+ return { schemaVersion: 1, nodes: {}, assets: {}, revisions: {}, drafts: {} };
369
+ }
370
+ function draftsOf(state) {
371
+ const drafts = state?.drafts;
372
+ return drafts !== null && typeof drafts === "object" ? drafts : {};
373
+ }
374
+ function sweepDrafts(state) {
375
+ const drafts = draftsOf(state);
376
+ const keys = Object.keys(drafts);
377
+ if (keys.length === 0) return state;
378
+ const cutoff = Date.now() - DRAFT_TTL_MS;
379
+ const kept = {};
380
+ for (const key of keys) {
381
+ const stamp = Date.parse(drafts[key]?.updatedAt ?? "");
382
+ if (Number.isFinite(stamp) && stamp >= cutoff) kept[key] = drafts[key];
383
+ }
384
+ if (Object.keys(kept).length === keys.length) return state;
385
+ return { ...state, drafts: kept };
386
+ }
387
+ var LibraryManager = class {
388
+ /**
389
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Host context.
390
+ * @param {{ libraryDomain: object }} domains - built spec.
391
+ * @param {{ root?: string, maxBytes?: number, maxTotalBytes?: number }} [options] - overrides, used by tests.
392
+ */
393
+ constructor(ctx, domains, options = {}) {
394
+ this.ctx = ctx;
395
+ this.domains = domains;
396
+ const home = options.home ?? process.env.DSH_HOME ?? join2(homedir(), ".dsh");
397
+ this.root = resolve2(options.root ?? join2(home, "dsh-skills-library"));
398
+ this.maxBytes = options.maxBytes ?? MAX_BYTES;
399
+ this.maxTotalBytes = options.maxTotalBytes ?? MAX_TOTAL_BYTES;
400
+ this.table = void 0;
401
+ this.serial = Promise.resolve();
402
+ }
403
+ /** Open the domain. Creates nothing on disk. */
404
+ async init() {
405
+ const domain = await this.ctx.storageDomain.open(this.domains.libraryDomain);
406
+ this.ctx.effect(() => () => domain.close(), "dsh-plugin-library: domain");
407
+ this.table = domain.table("states");
408
+ }
409
+ // ---- read surface --------------------------------------------------------
410
+ /** Root, limits and live usage; the panel's header data. */
411
+ overview() {
412
+ const state = this.load();
413
+ const revisions = Object.values(state.revisions);
414
+ return {
415
+ root: this.root,
416
+ limits: { maxBytes: this.maxBytes, maxTotalBytes: this.maxTotalBytes },
417
+ usage: {
418
+ bytes: revisions.reduce((total, row) => total + (row.originalByteLength ?? 0), 0),
419
+ revisions: revisions.length,
420
+ assets: Object.values(state.assets).filter((row) => row.removedAt === void 0).length,
421
+ folders: Object.values(state.nodes).filter((row) => row.kind === "folder" && row.removedAt === void 0).length
422
+ }
423
+ };
424
+ }
425
+ /** Every live node, folders first within each parent, for the client to nest. */
426
+ tree() {
427
+ const state = this.load();
428
+ return Object.values(state.nodes).filter((node) => node.removedAt === void 0).map((node) => this.entry(state, node)).sort((a, b) => {
429
+ if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
430
+ return a.name.localeCompare(b.name, "zh-CN");
431
+ });
432
+ }
433
+ /** One asset with its full immutable revision history, newest first. */
434
+ assetDetail(assetId) {
435
+ const state = this.load();
436
+ const node = this.requireLiveNode(state, assetId);
437
+ this.requireAsset(state, assetId);
438
+ const view = this.entry(state, node);
439
+ const revisions = Object.values(state.revisions).filter((row) => row.assetId === assetId).sort((a, b) => b.number - a.number);
440
+ return { node: view, asset: view.asset, revisions, folderPath: this.folderPath(state, node.parentId) };
441
+ }
442
+ /**
443
+ * Search derived text.
444
+ *
445
+ * Non-`ready` revisions are listed by name (so an unconvertible file is visible)
446
+ * but are never text-matched, and a disabled or removed asset never appears.
447
+ * @param {string} query - keyword; empty returns everything active.
448
+ * @param {string} [kind] - optional asset kind filter.
449
+ * @returns {Promise<object[]>} up to {@link MAX_HITS} hits, best first.
450
+ */
451
+ async search(query, kind) {
452
+ const needle = String(query ?? "").trim().toLocaleLowerCase();
453
+ const state = this.load();
454
+ const hits = [];
455
+ for (const asset of Object.values(state.assets)) {
456
+ if (asset.status !== "active" || asset.removedAt !== void 0) continue;
457
+ const node = state.nodes[asset.id];
458
+ if (node === void 0 || node.removedAt !== void 0) continue;
459
+ if (kind !== void 0 && asset.kind !== kind) continue;
460
+ const revision = state.revisions[`${asset.id}:${asset.currentRevisionId}`];
461
+ if (revision === void 0) continue;
462
+ const titleMatch = needle.length > 0 && node.name.toLocaleLowerCase().includes(needle);
463
+ let excerpt = "";
464
+ let score = 0;
465
+ if (revision.conversionStatus === "ready" && needle.length > 0) {
466
+ const text = await this.readContent(revision);
467
+ const offset = text.toLocaleLowerCase().indexOf(needle);
468
+ if (!titleMatch && offset < 0) continue;
469
+ const start = Math.max(0, offset < 0 ? 0 : offset - 80);
470
+ excerpt = text.slice(start, start + 240).replace(/\s+/g, " ").trim();
471
+ score = titleMatch ? 2 : 1;
472
+ } else if (revision.conversionStatus !== "ready" && needle.length > 0) {
473
+ if (!titleMatch) continue;
474
+ score = 1;
475
+ }
476
+ hits.push({
477
+ assetId: asset.id,
478
+ nodeId: node.id,
479
+ revisionId: revision.id,
480
+ revisionNumber: revision.number,
481
+ name: node.name,
482
+ kind: asset.kind,
483
+ folderPath: this.folderPath(state, node.parentId),
484
+ conversionStatus: revision.conversionStatus,
485
+ byteLength: asset.byteLength,
486
+ updatedAt: asset.updatedAt,
487
+ excerpt,
488
+ score
489
+ });
490
+ }
491
+ return hits.sort((a, b) => b.score - a.score || b.updatedAt.localeCompare(a.updatedAt) || a.name.localeCompare(b.name, "zh-CN")).slice(0, MAX_HITS);
492
+ }
493
+ /**
494
+ * Read the derived searchable text of one revision.
495
+ * @param {string} assetId - the asset.
496
+ * @param {string} [revisionId] - a pinned revision; defaults to the current one.
497
+ * @returns {Promise<string>} the derived text.
498
+ */
499
+ async readText(assetId, revisionId) {
500
+ const { revision } = this.resolveReadable(assetId, revisionId);
501
+ return this.readContent(revision);
502
+ }
503
+ /**
504
+ * Read the original bytes of one revision.
505
+ *
506
+ * This is the one read that does not need a derived text view: an asset with
507
+ * `unavailable` or `failed` conversion can still hand back its exact bytes. A
508
+ * *disabled* asset is still refused, because disable is meant to stop history
509
+ * references from resolving at all.
510
+ * @param {string} assetId - the asset.
511
+ * @param {string} [revisionId] - a pinned revision; defaults to the current one.
512
+ * @returns {Promise<Uint8Array>} the exact stored bytes.
513
+ */
514
+ async readOriginal(assetId, revisionId) {
515
+ const { revision } = this.resolveStored(assetId, revisionId);
516
+ return new Uint8Array(await readFile(this.safePath(revision.originalRelativePath)));
517
+ }
518
+ /**
519
+ * The on-disk facts about one revision's original: where it lives and whether
520
+ * the bytes there still hash to the stored revision id.
521
+ *
522
+ * The client uses this to offer 「用系统程序打开」 a file the OS can edit, and
523
+ * then to notice — by the digest changing — that the user has saved a new
524
+ * version outside, so 「导入新版本」 can be offered against real evidence
525
+ * rather than a guess.
526
+ * @param {string} assetId - the asset.
527
+ * @param {string} [revisionId] - a pinned revision; defaults to the current one.
528
+ * @returns {Promise<{ absolutePath: string, exists: boolean, matchesRevision: boolean }>} the disk facts.
529
+ */
530
+ async originalPath(assetId, revisionId) {
531
+ const { revision } = this.resolveStored(assetId, revisionId);
532
+ const absolutePath2 = this.safePath(revision.originalRelativePath);
533
+ let bytes;
534
+ try {
535
+ bytes = await readFile(absolutePath2);
536
+ } catch {
537
+ return { absolutePath: absolutePath2, exists: false, matchesRevision: false };
538
+ }
539
+ return { absolutePath: absolutePath2, exists: true, matchesRevision: sha256Hex(bytes) === revision.id };
540
+ }
541
+ /**
542
+ * Hand one revision's original to the OS-associated application (WPS,
543
+ * PowerPoint, Preview …). The save afterwards writes the disk file directly;
544
+ * the library learns about the edit only when 「导入新版本」 is invoked.
545
+ * @param {string} assetId - the asset.
546
+ * @param {string} [revisionId] - a pinned revision; defaults to the current one.
547
+ * @returns {Promise<{ opened: true, absolutePath: string }>} the receipt with the opened path.
548
+ */
549
+ async openInSystemApp(assetId, revisionId, open, appId = void 0) {
550
+ const { revision } = this.resolveStored(assetId, revisionId);
551
+ const absolutePath2 = this.safePath(revision.originalRelativePath);
552
+ await open(absolutePath2, appId);
553
+ return { opened: true, absolutePath: absolutePath2, ...appId !== void 0 ? { appId } : {} };
554
+ }
555
+ /**
556
+ * Re-read the on-disk original and file it as a new revision when its bytes
557
+ * differ from the stored ones. Content addressing decides: identical bytes
558
+ * reuse the existing revision (`reused: true`), changed bytes append a new
559
+ * one, and the previous revisions stay readable.
560
+ * @param {string} assetId - the asset whose original was edited outside.
561
+ * @returns {Promise<object>} the entry from `storeBytes`, with `reused` set.
562
+ */
563
+ async importExternalChanges(assetId) {
564
+ return this.enqueue(async () => {
565
+ const state = this.load();
566
+ const node = this.requireLiveNode(state, assetId);
567
+ if (node.kind !== "asset") {
568
+ throw new FeatureError2("library/not-an-asset", "\u8BE5\u8282\u70B9\u4E0D\u662F\u8D44\u6599\u3002");
569
+ }
570
+ const asset = state.assets[assetId];
571
+ const revision = state.revisions[`${assetId}:${asset.currentRevisionId}`];
572
+ const absolutePath2 = this.safePath(revision.originalRelativePath);
573
+ let bytes;
574
+ try {
575
+ bytes = await readFile(absolutePath2);
576
+ } catch {
577
+ throw new FeatureError2("library/not-found", `\u539F\u4EF6\u4E0D\u5728\u78C1\u76D8\u4E0A\uFF08${absolutePath2}\uFF09\uFF0C\u65E0\u6CD5\u5BFC\u5165\u5916\u90E8\u4FEE\u6539\u3002`);
578
+ }
579
+ this.assertFolder(state, node.parentId);
580
+ const name2 = node.name;
581
+ return this.storeBytes({ name: name2, parentId: node.parentId, bytes, source: asset.source ?? "import" });
582
+ });
583
+ }
584
+ // ---- write surface -------------------------------------------------------
585
+ /** Create one folder. */
586
+ createFolder(input) {
587
+ return this.enqueue(async () => {
588
+ const name2 = cleanName(input?.name);
589
+ const parentId = optionalId(input?.parentId);
590
+ const state = this.load();
591
+ this.assertFolder(state, parentId);
592
+ this.assertUniqueName(state, parentId, name2);
593
+ const now = (/* @__PURE__ */ new Date()).toISOString();
594
+ const node = {
595
+ id: randomUUID(),
596
+ kind: "folder",
597
+ name: name2,
598
+ ...parentId !== void 0 ? { parentId } : {},
599
+ createdAt: now,
600
+ updatedAt: now
601
+ };
602
+ await this.put({ ...state, nodes: { ...state.nodes, [node.id]: node } });
603
+ return node;
604
+ });
605
+ }
606
+ /**
607
+ * Import a file from an absolute path, reading it through `ctx.fs`.
608
+ *
609
+ * The asset is identified by `(parentId, name)`: importing a changed file under
610
+ * the same name appends a revision to that asset rather than creating a second
611
+ * node, which is what makes "re-import" mean "new revision".
612
+ * @param {{ path: string, parentId?: string, name?: string }} input - the import request.
613
+ * @returns {Promise<object>} the resulting tree entry.
614
+ */
615
+ async importFromPath(input) {
616
+ const sourcePath = requireString(input?.path, "path");
617
+ const fs = this.fs();
618
+ let target;
619
+ try {
620
+ target = await fs.resolve(sourcePath);
621
+ } catch (error) {
622
+ throw this.fsFailure(error);
623
+ }
624
+ const info = await Promise.resolve(fs.stat(target)).catch((error) => {
625
+ throw this.fsFailure(error);
626
+ });
627
+ if (info === void 0) throw new FeatureError2("library/not-found", "\u627E\u4E0D\u5230\u8981\u5BFC\u5165\u7684\u6587\u4EF6\u3002");
628
+ if (info.type !== "file") throw new FeatureError2("library/not-a-file", "\u53EA\u80FD\u5BFC\u5165\u666E\u901A\u6587\u4EF6\uFF0C\u4E0D\u80FD\u5BFC\u5165\u76EE\u5F55\u3002");
629
+ const name2 = cleanName(input?.name ?? basename(target.displayPath ?? sourcePath));
630
+ if (typeof info.size === "number" && info.size > this.maxBytes) {
631
+ throw new FeatureError2("library/file-size", `\u6587\u4EF6\u8D85\u8FC7\u5355\u4E2A\u6587\u4EF6\u4E0A\u9650 ${this.maxBytes} \u5B57\u8282\u3002`);
632
+ }
633
+ let bytes;
634
+ try {
635
+ bytes = await fs.readBytes(target, void 0, this.maxBytes);
636
+ } catch (error) {
637
+ throw this.fsFailure(error);
638
+ }
639
+ return this.importBytes({ name: name2, parentId: optionalId(input?.parentId), bytes, source: "import" });
640
+ }
641
+ /**
642
+ * Store one buffer as an immutable revision of `(parentId, name)`.
643
+ *
644
+ * Identical bytes reuse the existing revision — same id, same number, no
645
+ * second directory — which is the content-addressing contract. A disabled asset
646
+ * accepts a re-import and becomes the current revision again, but stays disabled.
647
+ * @param {{ name: string, parentId?: string, bytes: Uint8Array, source?: string }} input - the payload.
648
+ * @returns {Promise<object>} the resulting tree entry, with `reused` set.
649
+ */
650
+ importBytes(input) {
651
+ return this.enqueue(() => this.storeBytes(input));
652
+ }
653
+ /**
654
+ * Store one delivered file as `source: 'task'` — a 本地产物.
655
+ *
656
+ * The ingest path behind automatic deliverable attribution. It is **not** a
657
+ * second implementation: the bytes go through the same {@link storeBytes} the
658
+ * `import` endpoint uses, so hashing, the `objects/<assetId>/<revisionId>/`
659
+ * layout, `revision.json`, the derived `content.md` and every read behave
660
+ * identically.
661
+ *
662
+ * Two things make it a distinct entry point rather than a call to
663
+ * {@link importFromPath}:
664
+ *
665
+ * 1. `source` is `'task'` and the producing session is recorded as
666
+ * `sourceTaskId`, which is what the panel's 本地产物 filter matches on.
667
+ * `import` deliberately keeps `source: 'import'`.
668
+ * 2. A name collision does **not** mean "same asset, new revision". Two
669
+ * different deliverables called `report.md` are two different files;
670
+ * folding them into one asset would make one overwrite the other's body.
671
+ * So the name is advanced to `report (2).md`, `report (3).md` … up to
672
+ * {@link NAME_ATTEMPTS}, and a refusal is the honest outcome after that.
673
+ *
674
+ * Re-delivering *the same bytes* for the *same task* is idempotent: the bytes
675
+ * hash to the same revision id, so the existing `source: 'task'` asset is
676
+ * reused and `reused` comes back `true` instead of minting `report (2).md`.
677
+ *
678
+ * @param {{ path: string, name?: string, parentId?: string, sourceTaskId?: string }} input - the request.
679
+ * @returns {Promise<object>} the resulting tree entry, with `reused` set.
680
+ */
681
+ async importTaskFile(input) {
682
+ const sourcePath = requireString(input?.path, "path");
683
+ const fs = this.fs();
684
+ let target;
685
+ try {
686
+ target = await fs.resolve(sourcePath);
687
+ } catch (error) {
688
+ throw this.fsFailure(error);
689
+ }
690
+ const info = await Promise.resolve(fs.stat(target)).catch((error) => {
691
+ throw this.fsFailure(error);
692
+ });
693
+ if (info === void 0) throw new FeatureError2("library/not-found", "\u627E\u4E0D\u5230\u8981\u5BFC\u5165\u7684\u6587\u4EF6\u3002");
694
+ if (info.type !== "file") throw new FeatureError2("library/not-a-file", "\u53EA\u80FD\u5BFC\u5165\u666E\u901A\u6587\u4EF6\uFF0C\u4E0D\u80FD\u5BFC\u5165\u76EE\u5F55\u3002");
695
+ if (typeof info.size === "number" && info.size > this.maxBytes) {
696
+ throw new FeatureError2("library/file-size", `\u6587\u4EF6\u8D85\u8FC7\u5355\u4E2A\u6587\u4EF6\u4E0A\u9650 ${this.maxBytes} \u5B57\u8282\u3002`);
697
+ }
698
+ let bytes;
699
+ try {
700
+ bytes = await fs.readBytes(target, void 0, this.maxBytes);
701
+ } catch (error) {
702
+ throw this.fsFailure(error);
703
+ }
704
+ const baseName = cleanName(input?.name ?? basename(target.displayPath ?? sourcePath));
705
+ const parentId = optionalId(input?.parentId);
706
+ const sourceTaskId = optionalId(input?.sourceTaskId);
707
+ return this.enqueue(async () => {
708
+ const state = this.load();
709
+ this.assertFolder(state, parentId);
710
+ const revisionId = sha256Hex(bytes);
711
+ if (sourceTaskId !== void 0) {
712
+ const reused = Object.values(state.assets).find((asset) => asset.removedAt === void 0 && asset.source === "task" && asset.sourceTaskId === sourceTaskId && asset.currentRevisionId === revisionId);
713
+ if (reused !== void 0 && state.nodes[reused.id]?.removedAt === void 0) {
714
+ return { ...this.entry(state, state.nodes[reused.id]), reused: true };
715
+ }
716
+ }
717
+ const name2 = this.freeName(state, parentId, baseName);
718
+ return this.storeBytes({ name: name2, parentId, bytes, source: "task", sourceTaskId });
719
+ });
720
+ }
721
+ /**
722
+ * A sibling-free name for one import, or a refusal after {@link NAME_ATTEMPTS}.
723
+ *
724
+ * Runs inside a serialized task, so the name it returns is still free when
725
+ * {@link storeBytes} writes it.
726
+ * @param {object} state - the current state record.
727
+ * @param {string|undefined} parentId - the destination folder.
728
+ * @param {string} baseName - the name to start from.
729
+ * @returns {string} `baseName`, or `name (2)` … up to the attempt ceiling.
730
+ */
731
+ freeName(state, parentId, baseName) {
732
+ const taken = new Set(Object.values(state.nodes).filter((node) => node.removedAt === void 0 && node.parentId === parentId).map((node) => node.name.toLocaleLowerCase()));
733
+ if (!taken.has(baseName.toLocaleLowerCase())) return baseName;
734
+ const extension = extname2(baseName);
735
+ const stem = extension.length > 0 ? baseName.slice(0, -extension.length) : baseName;
736
+ for (let attempt = 2; attempt <= NAME_ATTEMPTS; attempt++) {
737
+ const candidate = `${stem} (${attempt})${extension}`;
738
+ if (!taken.has(candidate.toLocaleLowerCase())) return candidate;
739
+ }
740
+ throw new FeatureError2("library/name-conflict", `\u540C\u540D\u8D44\u6599\u5DF2\u6709 ${NAME_ATTEMPTS} \u4EFD\uFF0C\u65E0\u6CD5\u518D\u81EA\u52A8\u5165\u5E93\u3002`);
741
+ }
742
+ /**
743
+ * This is the panel's "new document" path and the only write that originates
744
+ * in the browser, but it is **not** a second ingest implementation: the
745
+ * content becomes the asset's first revision through the same
746
+ * {@link storeBytes} the `import` endpoint uses, so hashing, the
747
+ * `objects/<assetId>/<revisionId>/` layout, the derived `content.md`, the
748
+ * conversion status and every read (`tree` / `search` / `read-text` /
749
+ * `read-original`) behave identically.
750
+ *
751
+ * What deliberately differs from `import`: identity. An import addressed by
752
+ * `(parentId, name)` appends a revision to an existing asset, while a create
753
+ * refuses an existing name with the same `library/name-conflict` rule
754
+ * `create-folder` uses — "new document" must never silently replace the body
755
+ * of a document that already exists.
756
+ *
757
+ * The asset is recorded with `source: 'created'`, the panel's own label for
758
+ * it; the kind still comes from the name's extension exactly as an import's
759
+ * does (`kindForName`), so a `.md` / `.markdown` name is markdown and `.txt`
760
+ * is text.
761
+ *
762
+ * @param {{ name: string, content: string, parentId?: string }} input - the request.
763
+ * @returns {Promise<object>} the resulting tree entry, with `reused: false`.
764
+ */
765
+ createText(input) {
766
+ return this.enqueue(async () => {
767
+ const name2 = cleanCreateName(input?.name);
768
+ const parentId = optionalId(input?.parentId);
769
+ const content = requireContent(input?.content);
770
+ const state = this.load();
771
+ this.assertFolder(state, parentId);
772
+ this.assertUniqueName(state, parentId, name2);
773
+ const bytes = new TextEncoder().encode(content);
774
+ if (bytes.byteLength > this.maxBytes) {
775
+ throw new FeatureError2("library/file-size", `\u5185\u5BB9\u8D85\u8FC7\u5355\u4E2A\u6587\u4EF6\u4E0A\u9650 ${this.maxBytes} \u5B57\u8282\u3002`);
776
+ }
777
+ return this.storeBytes({ name: name2, parentId, bytes, source: "created" });
778
+ });
779
+ }
780
+ /**
781
+ * The shared ingest body behind `import`, `importFromPath` and `createText`.
782
+ *
783
+ * Runs inside {@link enqueue}: callers are already serialized, and it must not
784
+ * be called from inside another queued task (that would deadlock the chain).
785
+ * @param {{ name: string, parentId?: string, bytes: Uint8Array, source?: string }} input - the payload.
786
+ * @returns {Promise<object>} the resulting tree entry, with `reused` set.
787
+ */
788
+ async storeBytes(input) {
789
+ const name2 = cleanName(input?.name);
790
+ const parentId = optionalId(input?.parentId);
791
+ const bytes = input?.bytes;
792
+ if (!(bytes instanceof Uint8Array)) throw new FeatureError2("library/invalid-request", "\u7F3A\u5C11\u6587\u4EF6\u5185\u5BB9\u3002");
793
+ const state = this.load();
794
+ this.assertFolder(state, parentId);
795
+ const existing = Object.values(state.nodes).find((node2) => node2.kind === "asset" && node2.removedAt === void 0 && node2.parentId === parentId && node2.name.toLocaleLowerCase() === name2.toLocaleLowerCase());
796
+ const assetId = existing?.id ?? randomUUID();
797
+ const revisionId = sha256Hex(bytes);
798
+ const key = `${assetId}:${revisionId}`;
799
+ const now = (/* @__PURE__ */ new Date()).toISOString();
800
+ const found = state.revisions[key];
801
+ if (found !== void 0) {
802
+ const asset2 = state.assets[assetId];
803
+ const nextAsset = { ...asset2, currentRevisionId: revisionId, updatedAt: now };
804
+ const nextNode2 = { ...existing, updatedAt: now };
805
+ await this.put({
806
+ ...state,
807
+ assets: { ...state.assets, [assetId]: nextAsset },
808
+ nodes: { ...state.nodes, [assetId]: nextNode2 }
809
+ });
810
+ return { ...this.entry({ ...state, assets: { ...state.assets, [assetId]: nextAsset } }, nextNode2), reused: true };
811
+ }
812
+ const used = Object.values(state.revisions).reduce((total, row) => total + (row.originalByteLength ?? 0), 0);
813
+ if (used + bytes.byteLength > this.maxTotalBytes) {
814
+ throw new FeatureError2("library/quota-exceeded", `\u8D44\u6599\u5E93\u539F\u4EF6\u603B\u91CF\u5C06\u8D85\u8FC7 ${this.maxTotalBytes} \u5B57\u8282\u4E0A\u9650\u3002`);
815
+ }
816
+ const kind = kindForName(name2);
817
+ const derived = deriveText(kind, bytes);
818
+ const extension = extname2(name2).toLowerCase() || ".bin";
819
+ const relativeBase = join2("objects", assetId, revisionId);
820
+ const originalRelativePath = join2(relativeBase, `original${extension}`);
821
+ const contentRelativePath = join2(relativeBase, "content.md");
822
+ const metadataRelativePath = join2(relativeBase, "revision.json");
823
+ const number = Object.values(state.revisions).filter((row) => row.assetId === assetId).length + 1;
824
+ const revision = {
825
+ id: revisionId,
826
+ assetId,
827
+ number,
828
+ kind,
829
+ originalSha256: revisionId,
830
+ contentSha256: sha256Hex(new TextEncoder().encode(derived.text)),
831
+ originalByteLength: bytes.byteLength,
832
+ originalRelativePath,
833
+ contentRelativePath,
834
+ metadataRelativePath,
835
+ conversionStatus: derived.status,
836
+ conversionWarnings: [...derived.warnings],
837
+ createdAt: now
838
+ };
839
+ await this.writeObject({
840
+ directory: this.safePath(relativeBase),
841
+ entries: [
842
+ { relativePath: originalRelativePath, data: bytes },
843
+ { relativePath: contentRelativePath, data: derived.text },
844
+ {
845
+ relativePath: metadataRelativePath,
846
+ data: `${JSON.stringify({
847
+ schemaVersion: 1,
848
+ assetId,
849
+ revisionId,
850
+ kind,
851
+ originalSha256: revisionId,
852
+ originalByteLength: bytes.byteLength,
853
+ conversionStatus: derived.status,
854
+ conversionWarnings: derived.warnings,
855
+ createdAt: now
856
+ }, null, 2)}
857
+ `
858
+ }
859
+ ]
860
+ });
861
+ const node = existing ?? {
862
+ id: assetId,
863
+ kind: "asset",
864
+ name: name2,
865
+ ...parentId !== void 0 ? { parentId } : {},
866
+ createdAt: now,
867
+ updatedAt: now
868
+ };
869
+ const asset = {
870
+ id: assetId,
871
+ kind,
872
+ mediaType: mediaTypeForKind(kind),
873
+ byteLength: bytes.byteLength,
874
+ status: existing !== void 0 ? state.assets[assetId]?.status ?? "active" : "active",
875
+ currentRevisionId: revisionId,
876
+ source: typeof input?.source === "string" ? input.source : "import",
877
+ ...typeof input?.sourceTaskId === "string" && input.sourceTaskId.length > 0 ? { sourceTaskId: input.sourceTaskId } : {},
878
+ createdAt: existing !== void 0 ? state.assets[assetId]?.createdAt ?? now : now,
879
+ updatedAt: now
880
+ };
881
+ const nextNode = { ...node, updatedAt: now };
882
+ const nextState = {
883
+ ...state,
884
+ nodes: { ...state.nodes, [assetId]: nextNode },
885
+ assets: { ...state.assets, [assetId]: asset },
886
+ revisions: { ...state.revisions, [key]: revision }
887
+ };
888
+ await this.put(nextState);
889
+ return { ...this.entry(nextState, nextNode), reused: false };
890
+ }
891
+ /** Rename one folder or asset node. */
892
+ rename(nodeId, name2) {
893
+ return this.enqueue(async () => {
894
+ const state = this.load();
895
+ const node = this.requireLiveNode(state, nodeId);
896
+ const next = cleanName(name2);
897
+ this.assertUniqueName(state, node.parentId, next, node.id);
898
+ const updated = { ...node, name: next, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
899
+ await this.put({ ...state, nodes: { ...state.nodes, [nodeId]: updated } });
900
+ return this.entry({ ...state, nodes: { ...state.nodes, [nodeId]: updated } }, updated);
901
+ });
902
+ }
903
+ /** Move one folder or asset node to another folder (or the root). */
904
+ move(nodeId, parentIdInput) {
905
+ return this.enqueue(async () => {
906
+ const parentId = optionalId(parentIdInput);
907
+ const state = this.load();
908
+ const node = this.requireLiveNode(state, nodeId);
909
+ this.assertFolder(state, parentId);
910
+ if (parentId === nodeId || parentId !== void 0 && this.descendants(state, nodeId).has(parentId)) {
911
+ throw new FeatureError2("library/cycle", "\u6587\u4EF6\u5939\u4E0D\u80FD\u79FB\u52A8\u5230\u5B83\u81EA\u5DF1\u7684\u5B50\u76EE\u5F55\u91CC\u3002");
912
+ }
913
+ this.assertUniqueName(state, parentId, node.name, node.id);
914
+ const moved = { ...node, parentId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
915
+ if (moved.parentId === void 0) delete moved.parentId;
916
+ await this.put({ ...state, nodes: { ...state.nodes, [nodeId]: moved } });
917
+ return this.entry({ ...state, nodes: { ...state.nodes, [nodeId]: moved } }, moved);
918
+ });
919
+ }
920
+ /**
921
+ * Soft-remove a node and its subtree.
922
+ *
923
+ * Bytes and revision records are kept: "remove" hides the entry from the tree
924
+ * and from every read, it never deletes the immutable originals. This is what
925
+ * makes the operation safe to offer without a second confirmation step.
926
+ */
927
+ remove(nodeId) {
928
+ return this.enqueue(async () => {
929
+ const state = this.load();
930
+ this.requireLiveNode(state, nodeId);
931
+ const ids = /* @__PURE__ */ new Set([nodeId, ...this.descendants(state, nodeId)]);
932
+ const now = (/* @__PURE__ */ new Date()).toISOString();
933
+ const nodes = { ...state.nodes };
934
+ const assets = { ...state.assets };
935
+ for (const id of ids) {
936
+ nodes[id] = { ...nodes[id], removedAt: now, updatedAt: now };
937
+ if (assets[id] !== void 0) assets[id] = { ...assets[id], status: "removed", removedAt: now, updatedAt: now };
938
+ }
939
+ await this.put({ ...state, nodes, assets });
940
+ return { removed: true, nodes: ids.size };
941
+ });
942
+ }
943
+ /** Enable or disable one asset. A disabled asset stops resolving immediately. */
944
+ setEnabled(assetId, enabled) {
945
+ return this.enqueue(async () => {
946
+ if (typeof enabled !== "boolean") throw new FeatureError2("library/invalid-request", "enabled \u5FC5\u987B\u662F\u5E03\u5C14\u503C\u3002");
947
+ const state = this.load();
948
+ const node = this.requireLiveNode(state, assetId);
949
+ if (node.kind !== "asset") throw new FeatureError2("library/not-an-asset", "\u53EA\u6709\u8D44\u6599\u53EF\u4EE5\u542F\u505C\u3002");
950
+ const asset = this.requireAsset(state, assetId);
951
+ const now = (/* @__PURE__ */ new Date()).toISOString();
952
+ const nextAsset = { ...asset, status: enabled ? "active" : "disabled", updatedAt: now };
953
+ const nextNode = { ...node, updatedAt: now };
954
+ const nextState = {
955
+ ...state,
956
+ assets: { ...state.assets, [assetId]: nextAsset },
957
+ nodes: { ...state.nodes, [assetId]: nextNode }
958
+ };
959
+ await this.put(nextState);
960
+ return this.entry(nextState, nextNode);
961
+ });
962
+ }
963
+ // ---- draft → publish -----------------------------------------------------
964
+ /**
965
+ * Open an editable draft over one revision's derived text.
966
+ *
967
+ * The port of upstream's `createDraft`. A draft is **working scratch, not a
968
+ * revision**: it lives in the state record, never in `objects/`, so the
969
+ * immutable store is untouched until 发布新版本. Cancelling a draft is
970
+ * therefore a client-side `setDraft(undefined)` — there is nothing to undo,
971
+ * and an abandoned draft is swept by {@link DRAFT_TTL_MS}.
972
+ *
973
+ * A draft is identified by `(assetId, baseRevisionId)` and re-opening the same
974
+ * base returns the *same* draft with its edits intact, which is what makes
975
+ * 取消 → 编辑 behave. Opening against a different revision starts a fresh
976
+ * draft, because the text being edited is different text.
977
+ *
978
+ * @param {string} assetId - the asset to edit.
979
+ * @param {string} [revisionId] - the revision to base the draft on; defaults to the current one.
980
+ * @returns {Promise<{id: string, assetId: string, baseRevisionId: string, number: number, content: string, revision: string, createdAt: string, updatedAt: string}>} the draft.
981
+ */
982
+ createDraft(assetId, revisionId) {
983
+ return this.enqueue(async () => {
984
+ const state = this.load();
985
+ const { node, asset, revision } = this.resolveEditable(state, assetId, revisionId);
986
+ const drafts = draftsOf(state);
987
+ const existing = Object.values(drafts).find((row) => row.assetId === asset.id && row.baseRevisionId === revision.id);
988
+ if (existing !== void 0) return { ...existing };
989
+ const now = (/* @__PURE__ */ new Date()).toISOString();
990
+ const draft = {
991
+ id: randomUUID(),
992
+ assetId: asset.id,
993
+ baseRevisionId: revision.id,
994
+ // The revision the panel was showing, so a publish can prove the view
995
+ // the user edited against is still the current one.
996
+ number: revision.number,
997
+ content: await this.readContent(revision),
998
+ revision: randomUUID(),
999
+ createdAt: now,
1000
+ updatedAt: now
1001
+ };
1002
+ void node;
1003
+ await this.put({ ...state, drafts: { ...drafts, [draft.id]: draft } });
1004
+ return { ...draft };
1005
+ });
1006
+ }
1007
+ /**
1008
+ * Replace a draft's content under an optimistic revision check.
1009
+ *
1010
+ * `expectedRevision` is the draft token the client last read. A mismatch means
1011
+ * another editor (or another tab) saved in between, and the write is refused
1012
+ * rather than applied, so neither editor's text is silently overwritten.
1013
+ * @param {string} draftId - the draft.
1014
+ * @param {string} content - the complete replacement text.
1015
+ * @param {string} expectedRevision - the draft token the client holds.
1016
+ * @returns {Promise<object>} the updated draft.
1017
+ */
1018
+ updateDraft(draftId, content, expectedRevision) {
1019
+ return this.enqueue(async () => {
1020
+ const id = requireString(draftId, "draftId");
1021
+ const expected = requireString(expectedRevision, "expectedRevision");
1022
+ const text = requireContent(content);
1023
+ const state = this.load();
1024
+ const draft = this.requireDraft(state, id);
1025
+ if (draft.revision !== expected) throw this.staleDraft();
1026
+ const bytes = new TextEncoder().encode(text);
1027
+ if (bytes.byteLength > this.maxBytes) {
1028
+ throw new FeatureError2("library/file-size", `\u5185\u5BB9\u8D85\u8FC7\u5355\u4E2A\u6587\u4EF6\u4E0A\u9650 ${this.maxBytes} \u5B57\u8282\u3002`);
1029
+ }
1030
+ const next = { ...draft, content: text, revision: randomUUID(), updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1031
+ await this.put({ ...state, drafts: { ...draftsOf(state), [id]: next } });
1032
+ return { ...next };
1033
+ });
1034
+ }
1035
+ /**
1036
+ * Publish a draft as a **new immutable revision** of its asset.
1037
+ *
1038
+ * The commit step, and deliberately not a second ingest implementation: the
1039
+ * draft text becomes UTF-8 bytes and goes through {@link storeBytes} — the same
1040
+ * body `import` and `create-text` use — so hashing, the
1041
+ * `objects/<assetId>/<revisionId>/` layout, `revision.json`, the derived
1042
+ * `content.md` and every existing read behave identically. Nothing here
1043
+ * rewrites `objects/`: publishing *appends*, and the base revision stays
1044
+ * readable byte for byte.
1045
+ *
1046
+ * Two guards run before the write:
1047
+ *
1048
+ * 1. `expectedRevision` must match the draft token, or the *text* being
1049
+ * committed is not the text the user saw — refused, not applied.
1050
+ * 2. the asset's current revision must still be the draft's base. If someone
1051
+ * re-imported or published in between, the draft was written against a
1052
+ * body that is no longer current, and committing it would drop that change.
1053
+ *
1054
+ * A publish that stores bytes identical to the base revision reuses that
1055
+ * revision (content addressing) and the asset simply points at it again, so
1056
+ * 发布新版本 with no edits is harmless.
1057
+ *
1058
+ * @param {string} draftId - the draft.
1059
+ * @param {string} expectedRevision - the draft token the client holds.
1060
+ * @returns {Promise<object>} the resulting tree entry, `reused` included.
1061
+ */
1062
+ publishDraft(draftId, expectedRevision) {
1063
+ return this.enqueue(async () => {
1064
+ const id = requireString(draftId, "draftId");
1065
+ const expected = requireString(expectedRevision, "expectedRevision");
1066
+ const state = this.load();
1067
+ const draft = this.requireDraft(state, id);
1068
+ if (draft.revision !== expected) throw this.staleDraft();
1069
+ const node = this.requireLiveNode(state, draft.assetId);
1070
+ if (node.kind !== "asset") throw new FeatureError2("library/not-an-asset", "\u8349\u7A3F\u5BF9\u5E94\u7684\u8282\u70B9\u4E0D\u662F\u8D44\u6599\u3002");
1071
+ const asset = this.requireAsset(state, draft.assetId);
1072
+ if (asset.status === "disabled") {
1073
+ throw new FeatureError2("library/disabled", "\u8D44\u6599\u5DF2\u505C\u7528\uFF0C\u65E0\u6CD5\u53D1\u5E03\u8349\u7A3F\uFF1B\u91CD\u65B0\u542F\u7528\u540E\u518D\u8BD5\u3002");
1074
+ }
1075
+ if (!EDITABLE_KINDS.has(asset.kind)) {
1076
+ throw new FeatureError2("library/not-editable", "\u53EA\u6709 Markdown \u548C\u7EAF\u6587\u672C\u8D44\u6599\u53EF\u4EE5\u7F16\u8F91\u5E76\u53D1\u5E03\u65B0\u7248\u672C\u3002");
1077
+ }
1078
+ if (asset.currentRevisionId !== draft.baseRevisionId) {
1079
+ throw new FeatureError2("library/draft-conflict", "\u8FD9\u4EFD\u8D44\u6599\u5DF2\u7ECF\u6709\u4E86\u66F4\u65B0\u7684\u4FEE\u8BA2\uFF0C\u8349\u7A3F\u57FA\u4E8E\u7684\u5185\u5BB9\u4E0D\u662F\u5F53\u524D\u7248\u672C\uFF1B\u8BF7\u91CD\u65B0\u6253\u5F00\u8349\u7A3F\u540E\u518D\u53D1\u5E03\u3002");
1080
+ }
1081
+ const bytes = new TextEncoder().encode(draft.content);
1082
+ if (bytes.byteLength > this.maxBytes) {
1083
+ throw new FeatureError2("library/file-size", `\u5185\u5BB9\u8D85\u8FC7\u5355\u4E2A\u6587\u4EF6\u4E0A\u9650 ${this.maxBytes} \u5B57\u8282\u3002`);
1084
+ }
1085
+ const entry = await this.storeBytes({
1086
+ name: node.name,
1087
+ parentId: node.parentId,
1088
+ bytes,
1089
+ source: asset.source
1090
+ });
1091
+ const drafts = { ...draftsOf(this.load()) };
1092
+ delete drafts[id];
1093
+ await this.put({ ...this.load(), drafts });
1094
+ return entry;
1095
+ });
1096
+ }
1097
+ /** The route dispatch table. */
1098
+ handlers() {
1099
+ return {
1100
+ overview: () => this.overview(),
1101
+ tree: () => this.tree(),
1102
+ asset: (payload) => this.assetDetail(requireString(payload.assetId, "assetId")),
1103
+ "create-folder": (payload) => this.createFolder({ name: payload.name, parentId: payload.parentId }),
1104
+ "create-text": (payload) => this.createText({ name: payload.name, content: payload.content, parentId: payload.parentId }),
1105
+ "import-task": (payload) => this.importTaskFile({
1106
+ path: requireString(payload.path, "path"),
1107
+ name: payload.name,
1108
+ parentId: payload.parentId,
1109
+ sourceTaskId: payload.sourceTaskId
1110
+ }),
1111
+ rename: (payload) => this.rename(requireString(payload.nodeId, "nodeId"), requireString(payload.name, "name")),
1112
+ move: (payload) => this.move(requireString(payload.nodeId, "nodeId"), payload.parentId),
1113
+ remove: (payload) => this.remove(requireString(payload.nodeId, "nodeId")),
1114
+ "set-enabled": (payload) => this.setEnabled(requireString(payload.assetId, "assetId"), payload.enabled),
1115
+ "create-draft": (payload) => this.createDraft(
1116
+ requireString(payload.assetId, "assetId"),
1117
+ optionalId(payload.revisionId)
1118
+ ),
1119
+ "update-draft": (payload) => this.updateDraft(
1120
+ requireString(payload.draftId, "draftId"),
1121
+ requireContent(payload.content),
1122
+ requireString(payload.expectedRevision, "expectedRevision")
1123
+ ),
1124
+ "publish-draft": (payload) => this.publishDraft(
1125
+ requireString(payload.draftId, "draftId"),
1126
+ requireString(payload.expectedRevision, "expectedRevision")
1127
+ ),
1128
+ import: (payload) => this.importFromPath(payload),
1129
+ "original-path": (payload) => this.originalPath(requireString(payload.assetId, "assetId"), optionalId(payload.revisionId)),
1130
+ "open-in-system": async (payload) => this.openInSystemApp(
1131
+ requireString(payload.assetId, "assetId"),
1132
+ optionalId(payload.revisionId),
1133
+ (absolutePath2, appId) => openInSystemApp(absolutePath2, appId, this.ctx),
1134
+ typeof payload.appId === "string" && payload.appId.length > 0 ? payload.appId : void 0
1135
+ ),
1136
+ "file-applications": async (payload) => {
1137
+ const { absolutePath: absolutePath2 } = await this.originalPath(requireString(payload.assetId, "assetId"), optionalId(payload.revisionId));
1138
+ return { applications: await fileApplications(absolutePath2) };
1139
+ },
1140
+ "import-external": (payload) => this.importExternalChanges(requireString(payload.assetId, "assetId")),
1141
+ search: (payload) => this.search(
1142
+ typeof payload.query === "string" ? payload.query : "",
1143
+ typeof payload.kind === "string" && payload.kind.length > 0 ? payload.kind : void 0
1144
+ ),
1145
+ "read-text": (payload) => this.readText(requireString(payload.assetId, "assetId"), optionalId(payload.revisionId)),
1146
+ "read-original": async (payload) => {
1147
+ const assetId = requireString(payload.assetId, "assetId");
1148
+ const revisionId = optionalId(payload.revisionId);
1149
+ const node = this.requireLiveNode(this.load(), assetId);
1150
+ const bytes = await this.readOriginal(assetId, revisionId);
1151
+ const asset = this.requireAsset(this.load(), assetId);
1152
+ return {
1153
+ name: node.name,
1154
+ mediaType: asset.mediaType,
1155
+ byteLength: bytes.byteLength,
1156
+ base64: Buffer.from(bytes).toString("base64")
1157
+ };
1158
+ }
1159
+ };
1160
+ }
1161
+ // ---- internals -----------------------------------------------------------
1162
+ /** The current state record, or the empty state. */
1163
+ load() {
1164
+ if (this.table === void 0) throw new FeatureError2("library/not-ready", "\u8D44\u6599\u5E93\u5B58\u50A8\u5C1A\u672A\u5C31\u7EEA\u3002");
1165
+ return this.table.get(LOCAL_PRINCIPAL) ?? emptyState();
1166
+ }
1167
+ /** Persist one state record, dropping drafts that have gone stale. */
1168
+ async put(next) {
1169
+ if (this.table === void 0) throw new FeatureError2("library/not-ready", "\u8D44\u6599\u5E93\u5B58\u50A8\u5C1A\u672A\u5C31\u7EEA\u3002");
1170
+ await this.table.put(LOCAL_PRINCIPAL, sweepDrafts(next));
1171
+ }
1172
+ /** Serialize one mutation. */
1173
+ enqueue(run) {
1174
+ const queued = this.serial.then(run, run);
1175
+ this.serial = queued.then(() => void 0, () => void 0);
1176
+ return queued;
1177
+ }
1178
+ /** `ctx.fs` or a clear refusal; the real composition always provides it. */
1179
+ fs() {
1180
+ const fs = this.ctx.get?.("fs") ?? this.ctx.fs;
1181
+ if (fs === void 0 || typeof fs.resolve !== "function") {
1182
+ throw new FeatureError2("library/fs-unavailable", "\u6587\u4EF6\u7CFB\u7EDF\u670D\u52A1\u4E0D\u53EF\u7528\uFF0C\u65E0\u6CD5\u8BFB\u53D6\u5BFC\u5165\u6765\u6E90\u3002");
1183
+ }
1184
+ return fs;
1185
+ }
1186
+ /** Map an `fs` refusal onto the library's own vocabulary. */
1187
+ fsFailure(error) {
1188
+ if (error instanceof FeatureError2) return error;
1189
+ const code = typeof error?.code === "string" ? error.code : void 0;
1190
+ const mapped = code !== void 0 ? FS_CODES[code] : void 0;
1191
+ if (mapped !== void 0) return new FeatureError2(mapped, String(error?.message ?? "\u8BFB\u53D6\u5931\u8D25\u3002"));
1192
+ if (error?.name === "AbortError") return new FeatureError2("library/aborted", "\u5BFC\u5165\u5DF2\u53D6\u6D88\u3002");
1193
+ return new FeatureError2("library/read-failed", `\u8BFB\u53D6\u6765\u6E90\u6587\u4EF6\u5931\u8D25\uFF1A${String(error?.message ?? error)}`);
1194
+ }
1195
+ /** The panel/tool view of one node, with its asset and current revision attached. */
1196
+ entry(state, node) {
1197
+ const asset = node.kind === "asset" ? state.assets[node.id] : void 0;
1198
+ const revision = asset !== void 0 ? state.revisions[`${asset.id}:${asset.currentRevisionId}`] : void 0;
1199
+ const draft = asset !== void 0 ? Object.values(draftsOf(state)).find((row) => row.assetId === asset.id) : void 0;
1200
+ return {
1201
+ id: node.id,
1202
+ kind: node.kind,
1203
+ name: node.name,
1204
+ ...node.parentId !== void 0 ? { parentId: node.parentId } : {},
1205
+ ...node.removedAt !== void 0 ? { removedAt: node.removedAt } : {},
1206
+ createdAt: node.createdAt,
1207
+ updatedAt: node.updatedAt,
1208
+ ...asset !== void 0 ? {
1209
+ asset: {
1210
+ id: asset.id,
1211
+ kind: asset.kind,
1212
+ mediaType: asset.mediaType,
1213
+ byteLength: asset.byteLength,
1214
+ status: asset.status,
1215
+ currentRevisionId: asset.currentRevisionId,
1216
+ source: asset.source,
1217
+ revisionCount: Object.values(state.revisions).filter((row) => row.assetId === asset.id).length,
1218
+ createdAt: asset.createdAt,
1219
+ updatedAt: asset.updatedAt
1220
+ },
1221
+ /**
1222
+ * Whether this asset may be edited at all. The rule lives here, next to
1223
+ * the refusals that enforce it, and the panel renders 编辑 from this
1224
+ * rather than re-deriving it from `kind` and `status` — one source of
1225
+ * truth for a condition two halves have to agree on.
1226
+ */
1227
+ editable: asset.status === "active" && EDITABLE_KINDS.has(asset.kind) && revision?.conversionStatus === "ready",
1228
+ ...draft !== void 0 ? { draft: { id: draft.id, baseRevisionId: draft.baseRevisionId, updatedAt: draft.updatedAt } } : {}
1229
+ } : {},
1230
+ ...revision !== void 0 ? { revision } : {}
1231
+ };
1232
+ }
1233
+ /** Resolve `(assetId, revisionId)` to a revision an edit may start from. */
1234
+ resolveEditable(state, assetId, revisionId) {
1235
+ const node = this.requireLiveNode(state, assetId);
1236
+ if (node.kind !== "asset") throw new FeatureError2("library/not-an-asset", "\u53EA\u6709\u8D44\u6599\u53EF\u4EE5\u7F16\u8F91\u3002");
1237
+ const asset = this.requireReadableAsset(state, assetId);
1238
+ if (!EDITABLE_KINDS.has(asset.kind)) {
1239
+ throw new FeatureError2("library/not-editable", "\u53EA\u6709 Markdown \u548C\u7EAF\u6587\u672C\u8D44\u6599\u53EF\u4EE5\u7F16\u8F91\uFF1B\u5176\u4ED6\u683C\u5F0F\u8BF7\u4E0B\u8F7D\u540E\u5728\u672C\u673A\u4FEE\u6539\u518D\u91CD\u65B0\u5BFC\u5165\u3002");
1240
+ }
1241
+ const revision = state.revisions[`${assetId}:${revisionId ?? asset.currentRevisionId}`];
1242
+ if (revision === void 0) throw new FeatureError2("library/not-found", "\u627E\u4E0D\u5230\u8BE5\u4FEE\u8BA2\u3002");
1243
+ if (revision.conversionStatus !== "ready") {
1244
+ throw new FeatureError2("library/conversion-unavailable", "\u8BE5\u4FEE\u8BA2\u6CA1\u6709\u53EF\u7F16\u8F91\u7684\u6D3E\u751F\u6B63\u6587\u3002");
1245
+ }
1246
+ return { node, asset, revision };
1247
+ }
1248
+ /** One live draft, or a refusal that names the reason. */
1249
+ requireDraft(state, draftId) {
1250
+ const draft = draftsOf(state)[draftId];
1251
+ if (draft === void 0) throw new FeatureError2("library/draft-not-found", "\u8349\u7A3F\u4E0D\u5B58\u5728\u6216\u5DF2\u8FC7\u671F\u3002");
1252
+ return draft;
1253
+ }
1254
+ /**
1255
+ * The refusal a stale draft token earns.
1256
+ *
1257
+ * One code and one sentence for every caller, so 取消/发布/再次编辑 all say the
1258
+ * same thing and the panel can surface it verbatim.
1259
+ */
1260
+ staleDraft() {
1261
+ return new FeatureError2("library/draft-conflict", "\u8349\u7A3F\u5DF2\u88AB\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5\u3002");
1262
+ }
1263
+ /** Resolve `(assetId, revisionId)` to a readable revision, refusing anything inactive. */
1264
+ resolveReadable(assetId, revisionId) {
1265
+ const resolved = this.resolveStored(assetId, revisionId);
1266
+ const { revision } = resolved;
1267
+ if (revision.conversionStatus !== "ready") {
1268
+ throw new FeatureError2(
1269
+ "library/conversion-unavailable",
1270
+ revision.conversionStatus === "failed" ? "\u8BE5\u4FEE\u8BA2\u7684\u6587\u672C\u8F6C\u6362\u5931\u8D25\uFF0C\u65E0\u6CD5\u8BFB\u53D6\u6B63\u6587\uFF1B\u539F\u4EF6\u4ECD\u53EF\u4E0B\u8F7D\u3002" : "\u8BE5\u683C\u5F0F\u6CA1\u6709\u6587\u672C\u8F6C\u6362\uFF0C\u53EA\u80FD\u8BFB\u53D6\u539F\u4EF6\u3002"
1271
+ );
1272
+ }
1273
+ return resolved;
1274
+ }
1275
+ /** Resolve `(assetId, revisionId)` to an enabled revision, without a conversion requirement. */
1276
+ resolveStored(assetId, revisionId) {
1277
+ const state = this.load();
1278
+ const node = this.requireLiveNode(state, assetId);
1279
+ if (node.kind !== "asset") throw new FeatureError2("library/not-an-asset", "\u8BE5\u8282\u70B9\u4E0D\u662F\u8D44\u6599\u3002");
1280
+ const asset = this.requireReadableAsset(state, assetId);
1281
+ const revision = state.revisions[`${assetId}:${revisionId ?? asset.currentRevisionId}`];
1282
+ if (revision === void 0) throw new FeatureError2("library/not-found", "\u627E\u4E0D\u5230\u8BE5\u4FEE\u8BA2\u3002");
1283
+ return { state, node, asset, revision };
1284
+ }
1285
+ /** Read a derived-text file, guarded by `safePath`. */
1286
+ readContent(revision) {
1287
+ return readFile(this.safePath(revision.contentRelativePath), "utf8");
1288
+ }
1289
+ /**
1290
+ * Write one revision directory atomically.
1291
+ *
1292
+ * Staged under `.tmp/<uuid>` and renamed into place, so a crash can never leave
1293
+ * a half-written revision that a later read would treat as authoritative.
1294
+ */
1295
+ async writeObject({ directory, entries }) {
1296
+ const staging = this.safePath(join2(".tmp", randomUUID()));
1297
+ await mkdir(staging, { recursive: true, mode: 448 });
1298
+ try {
1299
+ for (const entry of entries) {
1300
+ const target = this.safePath(entry.relativePath);
1301
+ await writeFile(join2(staging, basename(target)), entry.data, { flag: "wx", mode: 384 });
1302
+ }
1303
+ await mkdir(dirname(directory), { recursive: true, mode: 448 });
1304
+ await rename(staging, directory);
1305
+ } catch (error) {
1306
+ await rm(staging, { recursive: true, force: true });
1307
+ throw error;
1308
+ }
1309
+ }
1310
+ /**
1311
+ * Resolve a library-relative path, refusing anything outside the root.
1312
+ *
1313
+ * Every disk access in this module goes through here. A corrupted or hostile
1314
+ * state record therefore cannot make the library read or write outside its own
1315
+ * directory, which is the whole reason the check exists.
1316
+ * @param {string} relative - a path relative to the library root.
1317
+ * @returns {string} the absolute path.
1318
+ * @throws {FeatureError} `library/path-escape` when the target is outside the root.
1319
+ */
1320
+ safePath(relative) {
1321
+ const target = resolve2(this.root, String(relative ?? ""));
1322
+ if (target !== this.root && !target.startsWith(`${this.root}${sep}`)) {
1323
+ throw new FeatureError2("library/path-escape", "\u5BF9\u8C61\u8DEF\u5F84\u8D85\u51FA\u8D44\u6599\u5E93\u6839\u76EE\u5F55\uFF0C\u5DF2\u62D2\u7EDD\u3002");
1324
+ }
1325
+ return target;
1326
+ }
1327
+ /** A node that is neither removed nor missing. */
1328
+ requireLiveNode(state, id) {
1329
+ const node = state.nodes[id];
1330
+ if (node === void 0 || node.removedAt !== void 0) {
1331
+ throw new FeatureError2("library/not-found", "\u8D44\u6599\u4E0D\u5B58\u5728\u6216\u5DF2\u79FB\u9664\u3002");
1332
+ }
1333
+ return node;
1334
+ }
1335
+ /** An asset record that has not been removed (disabled is allowed; reads check separately). */
1336
+ requireAsset(state, assetId) {
1337
+ const asset = state.assets[assetId];
1338
+ if (asset === void 0) throw new FeatureError2("library/not-found", "\u8D44\u6599\u4E0D\u5B58\u5728\u3002");
1339
+ if (asset.status === "removed") throw new FeatureError2("library/not-found", "\u8D44\u6599\u5DF2\u79FB\u9664\u3002");
1340
+ return asset;
1341
+ }
1342
+ /** An asset record a read is allowed to resolve: present and enabled. */
1343
+ requireReadableAsset(state, assetId) {
1344
+ const asset = this.requireAsset(state, assetId);
1345
+ if (asset.status === "disabled") {
1346
+ throw new FeatureError2("library/disabled", "\u8D44\u6599\u5DF2\u505C\u7528\uFF0C\u8BFB\u53D6\u4E0E\u68C0\u7D22\u88AB\u963B\u65AD\uFF1B\u91CD\u65B0\u542F\u7528\u540E\u53EF\u7EE7\u7EED\u4F7F\u7528\u3002");
1347
+ }
1348
+ return asset;
1349
+ }
1350
+ /** A parent id must be a live folder when present. */
1351
+ assertFolder(state, parentId) {
1352
+ if (parentId === void 0) return;
1353
+ const node = state.nodes[parentId];
1354
+ if (node === void 0 || node.removedAt !== void 0) throw new FeatureError2("library/not-found", "\u76EE\u6807\u6587\u4EF6\u5939\u4E0D\u5B58\u5728\u3002");
1355
+ if (node.kind !== "folder") throw new FeatureError2("library/not-folder", "\u76EE\u6807\u4E0D\u662F\u6587\u4EF6\u5939\u3002");
1356
+ }
1357
+ /** Two live siblings may not share a name (case-insensitively). */
1358
+ assertUniqueName(state, parentId, name2, exceptId) {
1359
+ const conflict = Object.values(state.nodes).some((node) => node.removedAt === void 0 && node.id !== exceptId && node.parentId === parentId && node.name.toLocaleLowerCase() === name2.toLocaleLowerCase());
1360
+ if (conflict) throw new FeatureError2("library/name-conflict", "\u540C\u4E00\u76EE\u5F55\u4E0B\u5DF2\u6709\u540C\u540D\u9879\u76EE\u3002");
1361
+ }
1362
+ /** Every descendant id of one node. */
1363
+ descendants(state, id) {
1364
+ const result = /* @__PURE__ */ new Set();
1365
+ const queue = [id];
1366
+ while (queue.length > 0) {
1367
+ const parent = queue.shift();
1368
+ for (const node of Object.values(state.nodes)) {
1369
+ if (node.parentId === parent && !result.has(node.id)) {
1370
+ result.add(node.id);
1371
+ queue.push(node.id);
1372
+ }
1373
+ }
1374
+ }
1375
+ return result;
1376
+ }
1377
+ /** Human breadcrumb for a folder id. */
1378
+ folderPath(state, parentId) {
1379
+ const parts = [];
1380
+ const seen = /* @__PURE__ */ new Set();
1381
+ let current = parentId;
1382
+ while (current !== void 0 && !seen.has(current)) {
1383
+ seen.add(current);
1384
+ const node = state.nodes[current];
1385
+ if (node === void 0) break;
1386
+ parts.unshift(node.name);
1387
+ current = node.parentId;
1388
+ }
1389
+ return parts.length > 0 ? `\u8D44\u6599\u5E93 / ${parts.join(" / ")}` : "\u8D44\u6599\u5E93";
1390
+ }
1391
+ };
1392
+
1393
+ // src/tools.js
1394
+ function compileValue(node) {
1395
+ if (node === null || typeof node !== "object") throw new Error("dsh-plugin-library: schema node must be an object");
1396
+ if (node.type === "object") {
1397
+ const properties = {};
1398
+ const required = [];
1399
+ for (const [key, value] of Object.entries(node.properties ?? {})) {
1400
+ if (value?.required === true) required.push(key);
1401
+ properties[key] = compileValue(value);
1402
+ }
1403
+ return {
1404
+ type: "object",
1405
+ additionalProperties: node.additionalProperties === true,
1406
+ properties,
1407
+ ...required.length > 0 ? { required } : {},
1408
+ ...node.description !== void 0 ? { description: node.description } : {}
1409
+ };
1410
+ }
1411
+ if (node.type === "array") {
1412
+ return {
1413
+ type: "array",
1414
+ ...node.items !== void 0 ? { items: compileValue(node.items) } : {},
1415
+ ...node.description !== void 0 ? { description: node.description } : {}
1416
+ };
1417
+ }
1418
+ return {
1419
+ type: node.type,
1420
+ ...node.description !== void 0 ? { description: node.description } : {},
1421
+ ...node.enum !== void 0 ? { enum: [...node.enum] } : {}
1422
+ };
1423
+ }
1424
+ function compileParameters(spec) {
1425
+ const properties = {};
1426
+ const required = [];
1427
+ for (const [key, value] of Object.entries(spec ?? {})) {
1428
+ if (value?.required === true) required.push(key);
1429
+ properties[key] = compileValue(value);
1430
+ }
1431
+ return { type: "object", properties, ...required.length > 0 ? { required } : {} };
1432
+ }
1433
+ function fallbackDefineTool(options) {
1434
+ return {
1435
+ name: options.name,
1436
+ description: options.description,
1437
+ parameters: compileParameters(options.parameters),
1438
+ output: { schema: compileValue(options.output.schema), render: options.output.render },
1439
+ execute: options.execute
1440
+ };
1441
+ }
1442
+ var cached3;
1443
+ async function loadDefineTool() {
1444
+ if (cached3 !== void 0) return cached3;
1445
+ try {
1446
+ const mod = await importDshPackage("@deepseek-ai/dsh-tools");
1447
+ if (typeof mod?.defineTool === "function") {
1448
+ cached3 = mod.defineTool;
1449
+ return cached3;
1450
+ }
1451
+ } catch {
1452
+ }
1453
+ cached3 = fallbackDefineTool;
1454
+ return cached3;
1455
+ }
1456
+ function hitForModel(hit) {
1457
+ return {
1458
+ asset_id: hit.assetId,
1459
+ revision_id: hit.revisionId,
1460
+ revision_number: hit.revisionNumber,
1461
+ name: hit.name,
1462
+ kind: hit.kind,
1463
+ folder_path: hit.folderPath,
1464
+ conversion_status: hit.conversionStatus,
1465
+ byte_length: hit.byteLength,
1466
+ updated_at: hit.updatedAt,
1467
+ excerpt: hit.excerpt
1468
+ };
1469
+ }
1470
+ async function registerLibraryTools(ctx, manager) {
1471
+ const defineTool = await loadDefineTool();
1472
+ ctx.effect(() => ctx.tools.register(defineTool({
1473
+ name: "library_search",
1474
+ description: "\u641C\u7D22\u672C\u5730\u8D44\u6599\u5E93\u7684\u6D3E\u751F\u6B63\u6587\uFF0C\u8FD4\u56DE\u4E0D\u53EF\u53D8\u7684 asset_id + revision_id \u5F15\u7528\u3002\u8D44\u6599\u4E0D\u5728\u5DE5\u4F5C\u533A\u6587\u4EF6\u7CFB\u7EDF\u91CC\uFF0C\u4E0D\u9700\u8981\u4E5F\u4E0D\u5E94\u8BE5\u5148\u7528\u6587\u4EF6\u5DE5\u5177\u53BB\u627E\u3002",
1475
+ parameters: {
1476
+ query: { type: "string", required: true, description: "\u6807\u9898\u6216\u6B63\u6587\u5173\u952E\u8BCD\u3002" },
1477
+ kind: {
1478
+ type: "string",
1479
+ description: "\u53EA\u770B\u67D0\u4E00\u7C7B\u8D44\u6599\u3002",
1480
+ enum: ["markdown", "text", "html", "json", "csv", "log", "binary"]
1481
+ }
1482
+ },
1483
+ output: {
1484
+ schema: {
1485
+ type: "object",
1486
+ additionalProperties: false,
1487
+ properties: {
1488
+ hits: {
1489
+ type: "array",
1490
+ required: true,
1491
+ items: {
1492
+ type: "object",
1493
+ additionalProperties: false,
1494
+ properties: {
1495
+ asset_id: { type: "string", required: true },
1496
+ revision_id: { type: "string", required: true },
1497
+ revision_number: { type: "integer", required: true },
1498
+ name: { type: "string", required: true },
1499
+ kind: { type: "string", required: true },
1500
+ folder_path: { type: "string", required: true },
1501
+ conversion_status: { type: "string", required: true },
1502
+ byte_length: { type: "integer", required: true },
1503
+ updated_at: { type: "string", required: true },
1504
+ excerpt: { type: "string", required: true }
1505
+ }
1506
+ }
1507
+ }
1508
+ }
1509
+ },
1510
+ render: (_args, value) => [{
1511
+ type: "text",
1512
+ text: value.hits.length > 0 ? value.hits.map((hit) => `${hit.name} (${hit.folder_path} \xB7 ${hit.kind} \xB7 \u4FEE\u8BA2 ${hit.revision_number} \xB7 ${hit.conversion_status})
1513
+ ${hit.excerpt}`).join("\n\n") : "\u8D44\u6599\u5E93\u91CC\u6CA1\u6709\u5339\u914D\u7684\u8D44\u6599\u3002"
1514
+ }]
1515
+ },
1516
+ async execute(args) {
1517
+ const hits = await manager.search(args.query, args.kind);
1518
+ return { hits: hits.map(hitForModel) };
1519
+ }
1520
+ })), "dsh-plugin-library: tool library_search");
1521
+ ctx.effect(() => ctx.tools.register(defineTool({
1522
+ name: "library_read",
1523
+ description: "\u6309 asset_id \u8BFB\u53D6\u672C\u5730\u8D44\u6599\u5E93\u67D0\u4E2A\u4FEE\u8BA2\u7684\u6D3E\u751F\u6B63\u6587\uFF1B\u53EF\u6307\u5B9A revision_id \u8BFB\u53D6\u5386\u53F2\u4FEE\u8BA2\u3002\u53EA\u8BFB\uFF0C\u4E0D\u4FEE\u6539\u4EFB\u4F55\u5185\u5BB9\u3002",
1524
+ parameters: {
1525
+ asset_id: { type: "string", required: true, description: "library_search \u8FD4\u56DE\u7684 asset_id\u3002" },
1526
+ revision_id: { type: "string", description: "\u5386\u53F2\u4FEE\u8BA2 id\uFF1B\u7701\u7565\u5219\u8BFB\u5F53\u524D\u4FEE\u8BA2\u3002" },
1527
+ offset: { type: "integer", description: "\u4ECE 0 \u5F00\u59CB\u7684\u5B57\u7B26\u504F\u79FB\u3002" },
1528
+ limit: { type: "integer", description: "\u672C\u6B21\u6700\u591A\u8FD4\u56DE\u7684\u5B57\u7B26\u6570\uFF0C1\u201320000\u3002" }
1529
+ },
1530
+ output: {
1531
+ schema: {
1532
+ type: "object",
1533
+ additionalProperties: false,
1534
+ properties: {
1535
+ asset_id: { type: "string", required: true },
1536
+ revision_id: { type: "string", required: true },
1537
+ conversion_status: { type: "string", required: true },
1538
+ content: { type: "string", required: true },
1539
+ offset: { type: "integer", required: true },
1540
+ truncated: { type: "boolean", required: true },
1541
+ next_offset: { type: "integer" }
1542
+ }
1543
+ },
1544
+ render: (_args, value) => [{ type: "text", text: value.content }]
1545
+ },
1546
+ async execute(args, exec) {
1547
+ const offset = args.offset ?? 0;
1548
+ const limit = args.limit ?? 12e3;
1549
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 2e4) {
1550
+ throw new Error("library/invalid-page");
1551
+ }
1552
+ const detail = manager.assetDetail(args.asset_id);
1553
+ const revisionId = args.revision_id ?? detail.asset.currentRevisionId;
1554
+ const revision = detail.revisions.find((row) => row.id === revisionId);
1555
+ const complete = await manager.readText(args.asset_id, revisionId);
1556
+ const content = complete.slice(offset, offset + limit);
1557
+ const next = offset + content.length;
1558
+ const truncated = next < complete.length;
1559
+ return {
1560
+ asset_id: args.asset_id,
1561
+ revision_id: revisionId,
1562
+ conversion_status: revision?.conversionStatus ?? "ready",
1563
+ content,
1564
+ offset,
1565
+ truncated,
1566
+ ...truncated ? { next_offset: next } : {}
1567
+ };
1568
+ }
1569
+ })), "dsh-plugin-library: tool library_read");
1570
+ ctx.effect(() => ctx.tools.register(defineTool({
1571
+ name: "library_import",
1572
+ description: "\u628A\u5185\u5BB9\u6216\u6587\u4EF6\u5BFC\u5165\u672C\u5730\u8D44\u6599\u5E93\uFF0C\u6210\u4E3A\u4E00\u4E2A\u65B0\u7684\u8D44\u6599\uFF08Markdown / TXT \u53EF\u7F16\u8F91\uFF1BCSV \u7B49\u6309\u6269\u5C55\u540D\u5165\u5E93\uFF09\u3002\u4E8C\u9009\u4E00\uFF1A\u7ED9 content\uFF08\u6587\u672C\u5185\u5BB9\uFF0CMarkdown \u6587\u672C\u63A8\u8350\uFF09\u6216\u7ED9 path\uFF08\u672C\u673A\u5DF2\u6709\u6587\u4EF6\u7684\u7EDD\u5BF9\u8DEF\u5F84\uFF09\u3002\u540C\u540D\u8D44\u6599\u5DF2\u5B58\u5728\u4F1A\u88AB\u62D2\u7EDD\uFF08library/name-conflict\uFF09\uFF0C\u4E0D\u4F1A\u9759\u9ED8\u8986\u76D6\u3002\u5BFC\u5165\u540E\u53EF\u7528 library_search / library_read \u627E\u5230\u5B83\u3002",
1573
+ parameters: {
1574
+ name: { type: "string", required: true, description: "\u8D44\u6599\u540D\u79F0\uFF08\u542B\u6269\u5C55\u540D\uFF0C\u5982 \u9700\u6C42.md \u6216 \u6570\u636E.csv\uFF09\u3002.md / .markdown / .txt \u6309\u6269\u5C55\u540D\u5B9A\u7C7B\u578B\u4E14\u53EF\u7F16\u8F91\u3002" },
1575
+ content: { type: "string", description: "\u6587\u672C\u5185\u5BB9\uFF08\u4E0E path \u4E8C\u9009\u4E00\uFF09\u3002Markdown / \u7EAF\u6587\u672C\u7528\u8FD9\u4E2A\uFF1A\u5185\u5BB9\u4F1A\u6210\u4E3A\u7B2C\u4E00\u4E2A\u4FEE\u8BA2\uFF0C\u4E4B\u540E\u53EF\u5728\u9762\u677F\u91CC\u7F16\u8F91\u5E76\u53D1\u5E03\u65B0\u7248\u672C\u3002" },
1576
+ path: { type: "string", description: "\u672C\u673A\u5DF2\u6709\u6587\u4EF6\u7684\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u4E0E content \u4E8C\u9009\u4E00\uFF09\u3002\u7531 Host \u8BFB\u6587\u4EF6\u5B57\u8282\u5165\u5E93\uFF1B\u9002\u5408 CSV / PDF \u7B49\u5DF2\u6709\u6587\u4EF6\u3002" },
1577
+ parent_id: { type: "string", description: "\u53EF\u9009\uFF1A\u76EE\u6807\u6587\u4EF6\u5939\u7684 node id\uFF1B\u7701\u7565\u5219\u653E\u5230\u6839\u76EE\u5F55\u3002" }
1578
+ },
1579
+ output: {
1580
+ schema: { type: "object", additionalProperties: true, description: "\u5165\u5E93\u540E\u7684\u8D44\u6599\u6761\u76EE\uFF08\u542B asset_id \u4E0E reused \u6807\u8BB0\uFF09\u3002" },
1581
+ render: (_args, value) => [{
1582
+ type: "text",
1583
+ text: [
1584
+ `\u5DF2\u5BFC\u5165\u8D44\u6599\u300C${String(value?.name ?? "")}\u300D`,
1585
+ value?.reused === true ? "\uFF08\u4E0E\u5DF2\u6709\u4FEE\u8BA2\u5185\u5BB9\u4E00\u81F4\uFF0C\u76F4\u63A5\u590D\u7528\uFF09" : "",
1586
+ `asset_id=${String(value?.asset?.id ?? value?.id ?? "")}`,
1587
+ value?.source === "task" ? "\uFF08\u672C\u5730\u4EA7\u7269\uFF09" : ""
1588
+ ].filter(Boolean).join(" ")
1589
+ }]
1590
+ },
1591
+ async execute(args) {
1592
+ const hasContent = typeof args.content === "string" && args.content.length > 0;
1593
+ const hasPath = typeof args.path === "string" && args.path.length > 0;
1594
+ if (hasContent && hasPath) {
1595
+ throw new FeatureError("library/invalid-request", "content \u4E0E path \u53EA\u80FD\u4E8C\u9009\u4E00\u3002");
1596
+ }
1597
+ if (!hasContent && !hasPath) {
1598
+ throw new FeatureError("library/invalid-request", "content \u4E0E path \u5FC5\u987B\u4E8C\u9009\u4E00\uFF1A\u7ED9\u6587\u672C\u5185\u5BB9\uFF0C\u6216\u7ED9\u4E00\u4E2A\u672C\u673A\u6587\u4EF6\u7684\u7EDD\u5BF9\u8DEF\u5F84\u3002");
1599
+ }
1600
+ if (hasPath) {
1601
+ return manager.importFromPath({ path: args.path, name: args.name, parentId: args.parent_id });
1602
+ }
1603
+ return manager.createText({ name: args.name, content: args.content, parentId: args.parent_id });
1604
+ }
1605
+ })), "dsh-plugin-library: tool library_import");
1606
+ }
1607
+
1608
+ // index.js
1609
+ var name = "dsh-plugin-library";
1610
+ var inject = ["connection", "storageDomain", "tools", "fs"];
1611
+ async function apply(ctx, options = {}) {
1612
+ const kit = await storageKit();
1613
+ const manager = new LibraryManager(ctx, buildLibraryDomains(kit), options);
1614
+ await manager.init();
1615
+ registerFeatureRoute(ctx, { feature: "library", handlers: manager.handlers() });
1616
+ await registerLibraryTools(ctx, manager);
1617
+ const attribution = registerDeliverableImport(ctx, manager);
1618
+ if (typeof options.attribution === "object" && options.attribution !== null) {
1619
+ options.attribution.flush = attribution.flush;
1620
+ }
1621
+ }
1622
+ export {
1623
+ apply,
1624
+ inject,
1625
+ name
1626
+ };