@oh-story/dsh 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -216,8 +216,6 @@ function createDramaSkillProvider(skillRoot = defaultDramaSkillRoot()) {
216
216
  }
217
217
 
218
218
  // src/native-hooks.ts
219
- import { access, readdir as readdir2 } from "node:fs/promises";
220
- import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep } from "node:path";
221
219
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
222
220
  var MUTATION_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_editor"]);
223
221
  function mutationPath(name2, args) {
@@ -229,52 +227,84 @@ function mutationPath(name2, args) {
229
227
  }
230
228
  return typeof record.file_path === "string" && record.file_path.trim() !== "" ? record.file_path : void 0;
231
229
  }
230
+ function normalizedRelativePath(path) {
231
+ const segments = [];
232
+ for (const part of path.split("/")) {
233
+ if (part === "" || part === ".") continue;
234
+ if (part === "..") {
235
+ if (segments.length === 0) return void 0;
236
+ segments.pop();
237
+ } else segments.push(part);
238
+ }
239
+ return segments.join("/");
240
+ }
232
241
  function detectStoryMutation(name2, args, cwd) {
233
- const path = mutationPath(name2, args);
234
- if (cwd === void 0 || path === void 0) return void 0;
235
- const root = resolve2(cwd);
236
- const absolute = isAbsolute2(path) ? resolve2(path) : resolve2(root, path);
237
- const offset = relative2(root, absolute);
238
- if (offset === ".." || offset.startsWith(`..${sep}`) || isAbsolute2(offset)) return void 0;
239
- const normalized = offset.replaceAll("\\", "/");
242
+ const rawPath = mutationPath(name2, args);
243
+ if (cwd === void 0 || rawPath === void 0) return void 0;
244
+ const root = cwd.replaceAll("\\", "/").replace(/\/$/u, "");
245
+ const candidate = rawPath.replaceAll("\\", "/");
246
+ const absolute = candidate.startsWith("/") || /^[a-z]:\//iu.test(candidate) || /^[a-z][a-z\d+.-]*:\/\//iu.test(candidate);
247
+ const insideRoot = /^[a-z]:\//iu.test(root) ? candidate.toLowerCase().startsWith(`${root.toLowerCase()}/`) : candidate.startsWith(`${root}/`);
248
+ if (absolute && !insideRoot) return void 0;
249
+ const normalized = normalizedRelativePath(absolute ? candidate.slice(root.length + 1) : candidate);
250
+ if (normalized === void 0) return void 0;
240
251
  if (!normalized.startsWith("\u6B63\u6587/")) return void 0;
241
252
  const chapterText = /第0*(\d+)章/u.exec(normalized)?.[1];
242
253
  return { root, path: normalized, ...chapterText === void 0 ? {} : { chapter: Number(chapterText) } };
243
254
  }
244
- function storyMutation(exec) {
245
- return detectStoryMutation(exec.name, exec.arguments, exec.agent?.session.header.cwd);
255
+ async function storyMutation(exec, fs) {
256
+ const cwd = exec.agent?.session.header.cwd;
257
+ const path = mutationPath(exec.name, exec.arguments);
258
+ if (cwd === void 0 || path === void 0) return void 0;
259
+ try {
260
+ const [rootTarget, mutationTarget] = await Promise.all([
261
+ fs.resolve(cwd, { signal: exec.signal }),
262
+ fs.resolve(path, { cwd, signal: exec.signal })
263
+ ]);
264
+ if (!fs.contains(rootTarget, mutationTarget)) return void 0;
265
+ } catch {
266
+ return void 0;
267
+ }
268
+ return detectStoryMutation(exec.name, exec.arguments, cwd);
269
+ }
270
+ async function target(fs, root, path, signal) {
271
+ return fs.resolve(path, { cwd: root, ...signal === void 0 ? {} : { signal } });
246
272
  }
247
- async function exists(path) {
248
- return access(path).then(() => true, () => false);
273
+ async function exists(fs, root, path, signal) {
274
+ return target(fs, root, path, signal).then((value) => fs.stat(value, signal)).then((info) => info !== void 0, () => false);
249
275
  }
250
- async function hasChapterOutline(root, chapter) {
251
- const entries = await readdir2(resolve2(root, "\u5927\u7EB2"), { withFileTypes: true }).catch(() => []);
252
- return entries.some((entry) => entry.isFile() && Number(/^细纲_第0*(\d+)章.*\.md$/u.exec(entry.name)?.[1]) === chapter);
276
+ async function hasChapterOutline(fs, root, chapter, signal) {
277
+ const entries = await target(fs, root, "\u5927\u7EB2", signal).then((directory) => fs.listDir(directory, signal)).catch(() => []);
278
+ return entries.some((entry) => entry.type === "file" && Number(/^细纲_第0*(\d+)章.*\.md$/u.exec(entry.name)?.[1]) === chapter);
253
279
  }
254
- async function validateStoryMutation(mutation) {
255
- const hasLongFormLayout = await exists(resolve2(mutation.root, "\u5927\u7EB2")) || await exists(resolve2(mutation.root, "\u8FFD\u8E2A"));
280
+ async function validateStoryMutation(fs, mutation, signal) {
281
+ const hasLongFormLayout = await exists(fs, mutation.root, "\u5927\u7EB2", signal) || await exists(fs, mutation.root, "\u8FFD\u8E2A", signal);
256
282
  if (!hasLongFormLayout) return void 0;
257
- if (!await exists(resolve2(mutation.root, "\u8FFD\u8E2A/_tracking-state.json"))) {
283
+ if (!await exists(fs, mutation.root, "\u8FFD\u8E2A/_tracking-state.json", signal)) {
258
284
  return void 0;
259
285
  }
260
- if (mutation.chapter !== void 0 && !await hasChapterOutline(mutation.root, mutation.chapter)) {
286
+ if (mutation.chapter !== void 0 && !await hasChapterOutline(fs, mutation.root, mutation.chapter, signal)) {
261
287
  return `Oh Story \u963B\u6B62\u5199\u5165\u7B2C ${String(mutation.chapter)} \u7AE0\uFF1A\u672A\u627E\u5230\u5BF9\u5E94\u7684 \u5927\u7EB2/\u7EC6\u7EB2_\u7B2CXXX\u7AE0*.md\u3002\u8BF7\u5148\u5B8C\u6210\u7EC6\u7EB2\u3002`;
262
288
  }
263
289
  return void 0;
264
290
  }
265
291
  async function decideStoryMutation(exec, next) {
266
- const mutation = storyMutation(exec);
292
+ const fs = exec.agent?.ctx.get("fs");
293
+ if (fs === void 0) return next();
294
+ const mutation = await storyMutation(exec, fs);
267
295
  if (mutation === void 0) return next();
268
- const reason = await validateStoryMutation(mutation);
296
+ const reason = await validateStoryMutation(fs, mutation, exec.signal);
269
297
  if (reason !== void 0) return { kind: "deny", reason };
270
298
  return next();
271
299
  }
272
300
  function registerOhStoryHooks(context) {
273
301
  context.on("tools/pre-execute", decideStoryMutation);
274
302
  context.on("tools/post-execute", async (exec, result, next) => {
275
- const mutation = storyMutation(exec);
276
303
  const downstream = await next();
277
- if (mutation === void 0 || result.isError || downstream.kind !== "accept") return downstream;
304
+ if (result.isError || downstream.kind !== "accept") return downstream;
305
+ const fs = exec.agent?.ctx.get("fs");
306
+ const mutation = fs === void 0 ? void 0 : await storyMutation(exec, fs);
307
+ if (mutation === void 0) return downstream;
278
308
  const reminder = createUserMessage({
279
309
  source: { kind: "plugin", plugin: "oh-story" },
280
310
  content: [{
@@ -294,7 +324,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
294
324
 
295
325
  // src/role-provider.ts
296
326
  import { readFile as readFile2 } from "node:fs/promises";
297
- import { basename as basename2, dirname as dirname2, join as join2, resolve as resolve3 } from "node:path";
327
+ import { basename as basename2, dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
298
328
  import { fileURLToPath as fileURLToPath2 } from "node:url";
299
329
  var OH_STORY_ROLE_NAMES = [
300
330
  "chapter-extractor",
@@ -311,10 +341,10 @@ function roleBody(source) {
311
341
  }
312
342
  function defaultBundledRoleRoot() {
313
343
  const current = dirname2(fileURLToPath2(import.meta.url));
314
- return basename2(current) === "src" ? resolve3(current, "../../knowledge/oh-story/roles") : resolve3(current, "oh-story/roles");
344
+ return basename2(current) === "src" ? resolve2(current, "../../knowledge/oh-story/roles") : resolve2(current, "oh-story/roles");
315
345
  }
316
346
  async function loadBundledRole(name2, roleRoot = defaultBundledRoleRoot(), execution = "tool-free") {
317
- const body = roleBody(await readFile2(join2(resolve3(roleRoot), `${name2}.md`), "utf8"));
347
+ const body = roleBody(await readFile2(join2(resolve2(roleRoot), `${name2}.md`), "utf8"));
318
348
  if (body.length === 0) throw new Error(`Bundled role "${name2}" is empty.`);
319
349
  const integration = execution === "tool-free" ? [
320
350
  "You are running inside an Oh Story review-required DSH collaboration. The caller supplies every permitted input in the prompt.",
@@ -337,12 +367,12 @@ async function loadBundledRole(name2, roleRoot = defaultBundledRoleRoot(), execu
337
367
  var OH_STORY_ROLE_TOOL_NAME = "oh_story_role";
338
368
  var roleTools = {
339
369
  "chapter-extractor": ["read", "glob", "grep"],
340
- "character-designer": ["read", "glob", "grep"],
370
+ "character-designer": ["read", "glob", "grep", "write", "edit"],
341
371
  "consistency-checker": ["read", "glob", "grep"],
342
- "narrative-writer": ["read", "glob", "grep", "bash"],
343
- "story-architect": ["read", "glob", "grep"],
372
+ "narrative-writer": ["read", "glob", "grep", "write", "edit", "bash"],
373
+ "story-architect": ["read", "glob", "grep", "write", "edit"],
344
374
  "story-explorer": ["read", "glob", "grep"],
345
- "story-researcher": ["read", "glob", "grep", "bash", "web_search", "web_fetch"]
375
+ "story-researcher": ["read", "glob", "grep", "bash", "write", "web_search", "web_fetch"]
346
376
  };
347
377
  function roleToolFilter(role) {
348
378
  return { allow: roleTools[role] };
@@ -434,68 +464,10 @@ async function registerOhStoryRoleTool(context) {
434
464
  }
435
465
 
436
466
  // src/workspace-route.ts
437
- import { createHash, randomUUID } from "node:crypto";
438
- import { mkdir, readFile as readFile3, readdir as readdir3, rename, rm, stat, writeFile } from "node:fs/promises";
439
- import { dirname as dirname3, extname, join as join3, relative as relative4, resolve as resolve5 } from "node:path";
467
+ import { extname } from "node:path";
468
+ import { FsError } from "@deepseek-ai/dsh-fs";
440
469
  import { SessionId } from "@deepseek-ai/dsh-session";
441
470
 
442
- // src/workspace-security.ts
443
- import { lstat, realpath } from "node:fs/promises";
444
- import { isAbsolute as isAbsolute3, relative as relative3, resolve as resolve4, sep as sep2 } from "node:path";
445
- var PathSecurityError = class extends Error {
446
- constructor(message) {
447
- super(message);
448
- this.name = "PathSecurityError";
449
- }
450
- };
451
- function isInside(root, candidate) {
452
- const offset = relative3(root, candidate);
453
- return offset === "" || !offset.startsWith(`..${sep2}`) && offset !== ".." && !isAbsolute3(offset);
454
- }
455
- async function canonicalWorkspaceRoot(root) {
456
- const canonical = await realpath(root);
457
- const info = await lstat(canonical);
458
- if (!info.isDirectory()) throw new PathSecurityError("Workspace root must be a directory.");
459
- return canonical;
460
- }
461
- async function resolveWorkspacePath(root, relativePath, options = {}) {
462
- if (relativePath.includes("\0") || isAbsolute3(relativePath) || /^[A-Za-z]:[\\/]/u.test(relativePath)) {
463
- throw new PathSecurityError("Only workspace-relative paths are allowed.");
464
- }
465
- const segments = relativePath.replaceAll("\\", "/").split("/");
466
- if (segments.some((segment) => segment === ".." || segment === "")) {
467
- throw new PathSecurityError("Path traversal and empty segments are not allowed.");
468
- }
469
- const canonicalRoot = await canonicalWorkspaceRoot(root);
470
- const candidate = resolve4(canonicalRoot, ...segments);
471
- if (!isInside(canonicalRoot, candidate)) throw new PathSecurityError("Path escapes the workspace.");
472
- let cursor = canonicalRoot;
473
- for (let index = 0; index < segments.length; index += 1) {
474
- cursor = resolve4(cursor, segments[index] ?? "");
475
- try {
476
- const info = await lstat(cursor);
477
- if (info.isSymbolicLink()) {
478
- const target = await realpath(cursor);
479
- if (!isInside(canonicalRoot, target)) throw new PathSecurityError("Symbolic link escapes the workspace.");
480
- cursor = target;
481
- }
482
- if (index < segments.length - 1 && !info.isDirectory()) throw new PathSecurityError("A path parent is not a directory.");
483
- if (index === segments.length - 1 && options.expect !== void 0) {
484
- if (options.expect === "file" && !info.isFile()) throw new PathSecurityError("Expected a regular file.");
485
- if (options.expect === "directory" && !info.isDirectory()) throw new PathSecurityError("Expected a directory.");
486
- }
487
- } catch (error) {
488
- if (error instanceof PathSecurityError) throw error;
489
- const code = error instanceof Error && "code" in error ? String(error.code) : "";
490
- if (code === "ENOENT" && options.allowMissing === true && index === segments.length - 1) return cursor;
491
- throw error;
492
- }
493
- }
494
- const finalPath = await realpath(cursor);
495
- if (!isInside(canonicalRoot, finalPath)) throw new PathSecurityError("Resolved path escapes the workspace.");
496
- return finalPath;
497
- }
498
-
499
471
  // src/workspace-request-trust.ts
500
472
  function header(headers, name2) {
501
473
  if (headers instanceof Headers) return headers.get(name2) ?? void 0;
@@ -552,6 +524,7 @@ var DRAMA_DIRECTORIES = ["\u8F93\u5165", "\u9879\u76EE\u5F00\u53D1", "\u8BBE\u5B
552
524
  var CREATIVE_DIRECTORIES = [...STORY_DIRECTORIES, ...DRAMA_DIRECTORIES];
553
525
  var ROOT_FILES = /* @__PURE__ */ new Set(["short-drama.json"]);
554
526
  var EDITABLE_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".txt", ".json", ".jsonl"]);
527
+ var FILE_LIMIT = 1e3;
555
528
  var WorkspaceHttpError = class extends Error {
556
529
  constructor(status, message) {
557
530
  super(message);
@@ -570,9 +543,6 @@ function send(response, status, value) {
570
543
  });
571
544
  response.end(body);
572
545
  }
573
- function workspaceContentVersion(content) {
574
- return createHash("sha256").update(content).digest("hex");
575
- }
576
546
  async function jsonBody(request, maxBytes) {
577
547
  const chunks = [];
578
548
  let size = 0;
@@ -594,7 +564,7 @@ function assertCreativePath(path) {
594
564
  if (!EDITABLE_EXTENSIONS.has(extname(path).toLocaleLowerCase())) {
595
565
  throw new WorkspaceHttpError(415, "\u5DE5\u4F5C\u53F0\u53EA\u7F16\u8F91 Markdown\u3001\u6587\u672C\u3001JSON \u548C JSONL \u6587\u4EF6\u3002");
596
566
  }
597
- if (path.split("/").some((segment) => segment === "" || segment === "..")) {
567
+ if (path.startsWith("/") || path.includes("\\") || path.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
598
568
  throw new WorkspaceHttpError(403, "\u6587\u4EF6\u8DEF\u5F84\u4E0D\u5728\u521B\u4F5C\u5DE5\u4F5C\u53F0\u4E2D\u3002");
599
569
  }
600
570
  const root = path.split("/", 1)[0];
@@ -602,112 +572,160 @@ function assertCreativePath(path) {
602
572
  throw new WorkspaceHttpError(403, "\u6587\u4EF6\u8DEF\u5F84\u4E0D\u5728\u521B\u4F5C\u5DE5\u4F5C\u53F0\u4E2D\u3002");
603
573
  }
604
574
  }
605
- async function listFiles(root) {
575
+ async function workspaceRealm(context, url) {
576
+ const rawId = url.searchParams.get("sessionId");
577
+ if (rawId === null || rawId === "") throw new WorkspaceHttpError(400, "\u7F3A\u5C11 DSH sessionId\u3002");
578
+ const lookup = context.typert.lookups.get("agent");
579
+ if (lookup === void 0) throw new WorkspaceHttpError(503, "DSH Agent lookup \u5F53\u524D\u4E0D\u53EF\u7528\u3002");
580
+ let agent;
581
+ try {
582
+ agent = await lookup.resolve(SessionId(rawId));
583
+ } catch {
584
+ throw new WorkspaceHttpError(404, "DSH \u4F1A\u8BDD\u4E0D\u53EF\u7528\u3002");
585
+ }
586
+ if (agent === void 0) throw new WorkspaceHttpError(404, "DSH \u4F1A\u8BDD\u4E0D\u53EF\u7528\u3002");
587
+ if (agent.session.header.parentSession !== void 0 || agent.session.header.origin === "subagent") {
588
+ throw new WorkspaceHttpError(403, "\u5B50 Agent \u4F1A\u8BDD\u4E0D\u5F00\u653E\u521B\u4F5C\u7F16\u8F91\u5668\u3002");
589
+ }
590
+ const cwd = agent.session.header.cwd;
591
+ if (cwd === void 0) throw new WorkspaceHttpError(409, "\u5F53\u524D DSH \u4F1A\u8BDD\u6CA1\u6709\u5DE5\u4F5C\u76EE\u5F55\u3002");
592
+ const fs = agent.ctx.get("fs");
593
+ const sandboxPolicy = agent.ctx.get("sandboxPolicy");
594
+ if (fs === void 0 || sandboxPolicy === void 0) throw new WorkspaceHttpError(503, "DSH \u6587\u4EF6\u7CFB\u7EDF\u5F53\u524D\u4E0D\u53EF\u7528\u3002");
595
+ return { agent, fs, sandboxPolicy, cwd, root: await fs.resolve(cwd) };
596
+ }
597
+ async function creativeTarget(realm, path) {
598
+ assertCreativePath(path);
599
+ const target2 = await realm.fs.resolve(path, { cwd: realm.cwd });
600
+ if (!realm.fs.contains(realm.root, target2)) throw new WorkspaceHttpError(403, "\u6587\u4EF6\u8DEF\u5F84\u79BB\u5F00\u4E86 DSH \u5DE5\u4F5C\u76EE\u5F55\u3002");
601
+ return target2;
602
+ }
603
+ function requireRegularFile(info) {
604
+ if (info === void 0) throw new WorkspaceHttpError(404, "\u6587\u4EF6\u4E0D\u5B58\u5728\u3002");
605
+ if (info.type !== "file") throw new WorkspaceHttpError(415, "\u76EE\u6807\u4E0D\u662F\u53EF\u7F16\u8F91\u7684\u666E\u901A\u6587\u4EF6\u3002");
606
+ return info;
607
+ }
608
+ async function readVersionedFile(fs, target2, maxBytes) {
609
+ for (let attempt = 0; attempt < 3; attempt += 1) {
610
+ const before = requireRegularFile(await fs.stat(target2));
611
+ if (before.size !== void 0 && before.size > maxBytes) throw new WorkspaceHttpError(413, "\u6587\u4EF6\u8D85\u8FC7\u5DE5\u4F5C\u53F0\u5927\u5C0F\u9650\u5236\u3002");
612
+ const bytes = await fs.readBytes(target2, void 0, maxBytes);
613
+ const after = requireRegularFile(await fs.stat(target2));
614
+ if (before.version !== after.version) continue;
615
+ let content;
616
+ try {
617
+ content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
618
+ } catch {
619
+ throw new WorkspaceHttpError(415, "\u6587\u4EF6\u4E0D\u662F\u6709\u6548\u7684 UTF-8 \u6587\u672C\u3002");
620
+ }
621
+ return { content, bytes: bytes.byteLength, version: after.version };
622
+ }
623
+ throw new WorkspaceHttpError(409, "\u6587\u4EF6\u6B63\u5728\u88AB\u4FEE\u6539\uFF0C\u8BF7\u91CD\u8BD5\u3002");
624
+ }
625
+ async function listFiles(realm) {
606
626
  const files = [];
607
- const walk = async (directory) => {
608
- const absolute = await resolveWorkspacePath(root, directory, { expect: "directory" });
609
- for (const entry of await readdir3(absolute, { withFileTypes: true })) {
610
- if (entry.isSymbolicLink() || entry.name.startsWith(".")) continue;
611
- const path = `${directory}/${entry.name}`;
612
- if (entry.isDirectory()) await walk(path);
613
- else if (entry.isFile() && EDITABLE_EXTENSIONS.has(extname(entry.name).toLocaleLowerCase())) {
614
- files.push({ path, bytes: (await stat(join3(absolute, entry.name))).size });
627
+ const walk = async (path, directory) => {
628
+ for (const entry of await realm.fs.listDir(directory)) {
629
+ if (entry.name.startsWith(".") || !realm.fs.contains(realm.root, entry.target)) continue;
630
+ const childPath = `${path}/${entry.name}`;
631
+ if (entry.type === "directory") await walk(childPath, entry.target);
632
+ else if (entry.type === "file" && EDITABLE_EXTENSIONS.has(extname(entry.name).toLocaleLowerCase())) {
633
+ const info = entry.version === void 0 || entry.size === void 0 ? await realm.fs.stat(entry.target) : void 0;
634
+ const version = entry.version ?? info?.version;
635
+ if (version !== void 0) files.push({ path: childPath, bytes: entry.size ?? info?.size ?? 0, version });
615
636
  }
616
- if (files.length >= 1e3) return;
637
+ if (files.length >= FILE_LIMIT) return;
617
638
  }
618
639
  };
619
- for (const directory of CREATIVE_DIRECTORIES) await walk(directory).catch(() => void 0);
640
+ for (const directory of CREATIVE_DIRECTORIES) {
641
+ const target2 = await realm.fs.resolve(directory, { cwd: realm.cwd });
642
+ if (!realm.fs.contains(realm.root, target2)) continue;
643
+ const info = await realm.fs.stat(target2);
644
+ if (info?.type === "directory") await walk(directory, target2);
645
+ if (files.length >= FILE_LIMIT) break;
646
+ }
620
647
  for (const path of ROOT_FILES) {
621
- await resolveWorkspacePath(root, path, { expect: "file" }).then(async (absolute) => {
622
- files.push({ path, bytes: (await stat(absolute)).size });
623
- }).catch(() => void 0);
648
+ const target2 = await creativeTarget(realm, path);
649
+ const info = await realm.fs.stat(target2);
650
+ if (info?.type === "file") files.push({ path, bytes: info.size ?? 0, version: info.version });
624
651
  }
625
652
  return files.sort((left, right) => left.path.localeCompare(right.path, "zh-Hans-CN"));
626
653
  }
627
- function sessionRoot(context, url) {
628
- const id = url.searchParams.get("sessionId");
629
- if (id === null || id === "") throw new WorkspaceHttpError(400, "\u7F3A\u5C11 DSH sessionId\u3002");
630
- const session = context.sessions.get(SessionId(id));
631
- if (session === void 0) throw new WorkspaceHttpError(404, "DSH \u4F1A\u8BDD\u4E0D\u53EF\u7528\u3002");
632
- if (session.header.parentSession !== void 0) throw new WorkspaceHttpError(403, "\u5B50 Agent \u4F1A\u8BDD\u4E0D\u5F00\u653E\u5C0F\u8BF4\u7F16\u8F91\u5668\u3002");
633
- const cwd = session.header.cwd;
634
- if (cwd === void 0) throw new WorkspaceHttpError(409, "\u5F53\u524D DSH \u4F1A\u8BDD\u6CA1\u6709\u5DE5\u4F5C\u76EE\u5F55\u3002");
635
- return canonicalWorkspaceRoot(cwd);
654
+ async function metadata(realm, files, path, maxBytes) {
655
+ if (!files.some((file) => file.path === path)) return { value: null };
656
+ try {
657
+ const target2 = await creativeTarget(realm, path);
658
+ return { value: JSON.parse((await readVersionedFile(realm.fs, target2, maxBytes)).content) };
659
+ } catch (error) {
660
+ return { value: null, error: error instanceof SyntaxError ? `${path} \u4E0D\u662F\u6709\u6548\u7684 JSON\u3002` : `${path} \u6682\u65F6\u65E0\u6CD5\u8BFB\u53D6\u3002` };
661
+ }
662
+ }
663
+ function mapFsError(error) {
664
+ if (!(error instanceof FsError)) return void 0;
665
+ switch (error.code) {
666
+ case "FS_NOT_FOUND":
667
+ return new WorkspaceHttpError(404, "\u6587\u4EF6\u4E0D\u5B58\u5728\u3002");
668
+ case "FS_TOO_LARGE":
669
+ return new WorkspaceHttpError(413, "\u6587\u4EF6\u8D85\u8FC7\u5DE5\u4F5C\u53F0\u5927\u5C0F\u9650\u5236\u3002");
670
+ case "FS_NOT_TEXT":
671
+ case "FS_NOT_REGULAR_FILE":
672
+ return new WorkspaceHttpError(415, "\u76EE\u6807\u4E0D\u662F\u53EF\u7F16\u8F91\u7684\u6587\u672C\u6587\u4EF6\u3002");
673
+ case "FS_PERMISSION_DENIED":
674
+ case "FS_SANDBOX_DENIED":
675
+ return new WorkspaceHttpError(403, "\u5F53\u524D DSH \u6743\u9650\u4E0D\u5141\u8BB8\u4FEE\u6539\u8BE5\u6587\u4EF6\u3002");
676
+ case "FS_STALE_VERSION":
677
+ case "FS_NOT_OBSERVED":
678
+ return new WorkspaceHttpError(412, "\u6587\u4EF6\u5DF2\u5728\u78C1\u76D8\u4E0A\u66F4\u65B0\u3002\u8BF7\u5904\u7406\u51B2\u7A81\u540E\u518D\u4FDD\u5B58\u3002");
679
+ case "FS_ABORTED":
680
+ return new WorkspaceHttpError(409, "\u6587\u4EF6\u64CD\u4F5C\u5DF2\u53D6\u6D88\u3002");
681
+ default:
682
+ return new WorkspaceHttpError(500, "DSH \u6587\u4EF6\u7CFB\u7EDF\u64CD\u4F5C\u5931\u8D25\u3002");
683
+ }
636
684
  }
637
685
  async function handle(context, request, response, options) {
638
686
  try {
639
- if (!isTrustedWorkspaceRequest(request, options.trustedHosts ?? [])) {
640
- throw new WorkspaceHttpError(403, "\u8BF7\u6C42\u6765\u6E90\u4E0D\u53D7\u4FE1\u4EFB\u3002");
641
- }
687
+ if (!isTrustedWorkspaceRequest(request, options.trustedHosts ?? [])) throw new WorkspaceHttpError(403, "\u8BF7\u6C42\u6765\u6E90\u4E0D\u53D7\u4FE1\u4EFB\u3002");
642
688
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
643
689
  if (url.pathname === "/oh-story/workspace" && request.method === "GET") {
644
- const root = await sessionRoot(context, url);
645
- const files = await listFiles(root);
646
- const trackingFile = files.find((file) => file.path === "\u8FFD\u8E2A/_tracking-state.json");
647
- const tracking = trackingFile === void 0 ? null : JSON.parse(await readFile3(await resolveWorkspacePath(root, trackingFile.path, { expect: "file" }), "utf8"));
648
- const dramaFile = files.find((file) => file.path === "short-drama.json");
649
- const shortDrama = dramaFile === void 0 ? null : JSON.parse(await readFile3(await resolveWorkspacePath(root, dramaFile.path, { expect: "file" }), "utf8"));
650
- send(response, 200, { cwd: root, files, tracking, shortDrama, mode: "dsh-session" });
690
+ const realm = await workspaceRealm(context, url);
691
+ const files = await listFiles(realm);
692
+ const tracking = await metadata(realm, files, "\u8FFD\u8E2A/_tracking-state.json", options.maxBytes);
693
+ const shortDrama = await metadata(realm, files, "short-drama.json", options.maxBytes);
694
+ const metadataErrors = [tracking.error, shortDrama.error].filter((value) => value !== void 0);
695
+ send(response, 200, { cwd: realm.cwd, files, tracking: tracking.value, shortDrama: shortDrama.value, metadataErrors, mode: "dsh-session" });
651
696
  return;
652
697
  }
653
698
  if (url.pathname === "/oh-story/file" && request.method === "GET") {
654
- const root = await sessionRoot(context, url);
699
+ const realm = await workspaceRealm(context, url);
655
700
  const path = url.searchParams.get("path");
656
701
  if (path === null) throw new WorkspaceHttpError(400, "\u7F3A\u5C11\u6587\u4EF6\u8DEF\u5F84\u3002");
657
- assertCreativePath(path);
658
- const absolute = await resolveWorkspacePath(root, path, { expect: "file" });
659
- const data = await readFile3(absolute);
660
- if (data.byteLength > options.maxBytes) throw new WorkspaceHttpError(413, "\u6587\u4EF6\u8D85\u8FC7\u5DE5\u4F5C\u53F0\u5927\u5C0F\u9650\u5236\u3002");
661
- send(response, 200, {
662
- path,
663
- content: data.toString("utf8"),
664
- bytes: data.byteLength,
665
- version: workspaceContentVersion(data)
666
- });
702
+ const file = await readVersionedFile(realm.fs, await creativeTarget(realm, path), options.maxBytes);
703
+ send(response, 200, { path, ...file });
667
704
  return;
668
705
  }
669
706
  if (url.pathname === "/oh-story/file" && request.method === "PUT") {
670
- const root = await sessionRoot(context, url);
707
+ const realm = await workspaceRealm(context, url);
671
708
  const path = url.searchParams.get("path");
672
709
  if (path === null) throw new WorkspaceHttpError(400, "\u7F3A\u5C11\u6587\u4EF6\u8DEF\u5F84\u3002");
673
- assertCreativePath(path);
674
710
  const input = await jsonBody(request, options.maxBytes * 6 + 1024);
675
711
  if (typeof input.content !== "string") throw new WorkspaceHttpError(400, "content \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\u3002");
676
- if (typeof input.baseVersion !== "string") throw new WorkspaceHttpError(400, "baseVersion \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\u3002");
712
+ if (typeof input.baseVersion !== "string" || input.baseVersion === "") throw new WorkspaceHttpError(400, "baseVersion \u5FC5\u987B\u662F\u6709\u6548\u7248\u672C\u3002");
677
713
  if (Buffer.byteLength(input.content) > options.maxBytes) throw new WorkspaceHttpError(413, "\u6587\u4EF6\u8D85\u8FC7\u5DE5\u4F5C\u53F0\u5927\u5C0F\u9650\u5236\u3002");
678
- const absolute = await resolveWorkspacePath(root, path, { expect: "file" });
679
- const current = await readFile3(absolute);
680
- if (workspaceContentVersion(current) !== input.baseVersion) {
681
- throw new WorkspaceHttpError(412, "\u6587\u4EF6\u5DF2\u5728\u78C1\u76D8\u4E0A\u66F4\u65B0\u3002\u8BF7\u5237\u65B0\u5E76\u5408\u5E76\u540E\u518D\u4FDD\u5B58\u3002");
682
- }
683
- const parent = dirname3(absolute);
684
- await mkdir(parent, { recursive: true });
685
- const rootReal = await canonicalWorkspaceRoot(root);
686
- const parentReal = await canonicalWorkspaceRoot(parent);
687
- const offset = relative4(rootReal, parentReal);
688
- if (offset === ".." || offset.startsWith("../")) throw new WorkspaceHttpError(403, "\u6587\u4EF6\u8DEF\u5F84\u79BB\u5F00\u4E86 DSH \u5DE5\u4F5C\u76EE\u5F55\u3002");
689
- const temporary = resolve5(parent, `.${randomUUID()}.oh-story.tmp`);
690
- try {
691
- await writeFile(temporary, input.content, { encoding: "utf8", flag: "wx" });
692
- if (workspaceContentVersion(await readFile3(absolute)) !== input.baseVersion) {
693
- throw new WorkspaceHttpError(412, "\u6587\u4EF6\u5DF2\u5728\u78C1\u76D8\u4E0A\u66F4\u65B0\u3002\u8BF7\u5237\u65B0\u5E76\u5408\u5E76\u540E\u518D\u4FDD\u5B58\u3002");
694
- }
695
- await rename(temporary, absolute);
696
- } finally {
697
- await rm(temporary, { force: true });
698
- }
699
- send(response, 200, {
700
- path,
701
- content: input.content,
702
- bytes: Buffer.byteLength(input.content),
703
- version: workspaceContentVersion(input.content)
704
- });
714
+ const outcome = await realm.fs.writeText(
715
+ await creativeTarget(realm, path),
716
+ input.content,
717
+ { kind: "replaceIfVersion", version: input.baseVersion },
718
+ void 0,
719
+ realm.sandboxPolicy.resolve({ session: realm.agent.session })
720
+ );
721
+ send(response, 200, { path, content: outcome.after, bytes: Buffer.byteLength(outcome.after), version: outcome.version });
705
722
  return;
706
723
  }
707
724
  send(response, 404, { error: "Oh Story route not found." });
708
725
  } catch (error) {
709
- if (error instanceof WorkspaceHttpError) send(response, error.status, { error: error.message });
710
- else send(response, 500, { error: error instanceof Error ? error.message : String(error) });
726
+ const mapped = error instanceof WorkspaceHttpError ? error : mapFsError(error);
727
+ if (mapped === void 0) context.logger("oh-story").error("workspace route failed", error);
728
+ send(response, mapped?.status ?? 500, { error: mapped?.message ?? "Oh Story workspace operation failed." });
711
729
  }
712
730
  }
713
731
  function registerWorkspaceRoute(context, options) {
@@ -720,7 +738,7 @@ function registerWorkspaceRoute(context, options) {
720
738
 
721
739
  // src/index.ts
722
740
  var name = "oh-story";
723
- var inject = ["sessions", "skills", "subagents", "tools", "webServer"];
741
+ var inject = ["skills", "subagents", "tools", "typert", "webServer"];
724
742
  var Config = z.object({
725
743
  editorMaxBytes: z.natural().min(65536).max(8388608).default(2097152),
726
744
  trustedHosts: z.array(String).default([])