narraeon 0.7.7 → 0.7.10

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.
Files changed (24) hide show
  1. package/dist/node/runtime/authoring/AuthoringRequestSnapshot.js +14 -1
  2. package/dist/node/runtime/history/HistoryRendering.js +25 -6
  3. package/dist/node/runtime/history/HistoryTools.js +31 -0
  4. package/dist/node/runtime/play/PlayCallChain.js +4 -9
  5. package/dist/node/runtime/play/PlayDocumentTools.js +35 -37
  6. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +33 -17
  7. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +5 -9
  8. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +426 -103
  9. package/dist/node/runtime/setting/SettingImprovementSession.js +40 -3
  10. package/dist/node/runtime/world/FileNativeWorldStore.js +13 -3
  11. package/dist/node/runtime/world/WorldDocumentMaintenance.js +9 -2
  12. package/dist/node/runtime/world/WorldLedgerAuthority.js +18 -2
  13. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +61 -17
  14. package/dist/node/runtime/world-revision/LegacyWorldRevisionEpochUpgrade.js +46 -0
  15. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +83 -30
  16. package/dist/node/runtime/world-revision/WorldRevisionSession.js +64 -4
  17. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +32 -11
  18. package/dist/node/shared/default-setting-improvement-prompt.js +23 -8
  19. package/dist/node/shared/ordered-author-prompts.js +9 -7
  20. package/dist/web/assets/index-B2kbaOGe.js +1408 -0
  21. package/dist/web/assets/{index-BjxBYYQi.css → index-DH2BVsYd.css} +1 -1
  22. package/dist/web/index.html +2 -2
  23. package/package.json +1 -1
  24. package/dist/web/assets/index-Da0tdDij.js +0 -1408
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { isPackageFollowupControlPath } from "../content/PackageFollowups.js";
2
3
  import { renderWorldMaintenanceReport } from "../prompt/WorldMaintenanceReport.js";
3
4
  import { listWorldDocuments as listQueryableWorldDocuments } from "../prompt/WorldMaintenanceReport.js";
@@ -98,7 +99,7 @@ export function settingImprovementRuntimeContract(locale, gameplay) {
98
99
  - 当前请求随附八个读写工具。不调用工具的完整响应会作为普通助手消息显示,并结束本次用户调用。含有工具调用的响应是内部工具步骤;收到全部工具结果后继续处理,最终用一个不调用工具的完整响应答复用户。
99
100
  - 工具写入直接修改内容包当前树。每个完整 Provider 工具响应中的调用按顺序执行,成功改动在该响应结算时原子发布,发布后立即成为内容包权威,无需另一步确认。
100
101
  - 每个写工具调用独立结算,并按本响应中的调用顺序读取之前已接受的结果。某个调用失败只在对应工具结果中返回精确原因,不回滚同一响应内其他成功调用,也不阻止后续调用。Runtime 在写调用结算后自动运行内容树检查和真实 Prompt Preview,并把检查结果附在最后一个成功写调用的结果中。
101
- - 修改既有文件前必须完整读取它。setting_list/setting_search 的 cursor,以及 setting_read 的读取授权,只属于产生它们的当前树 revision;任何其他对话或手动编辑改变当前树后,必须重新读取再写。setting_read 总是完整返回精确文件,不截断、不分页。工具只暴露逻辑路径,不暴露宿主路径。
102
+ - 修改既有文件前必须完整读取它。setting_search 的 cursor,以及 setting_read 的读取授权,只属于产生它们的当前树 revision;任何其他对话或手动编辑改变当前树后,必须重新读取再写。setting_read 总是完整返回精确文件,不截断、不分页。工具只暴露逻辑路径,不暴露宿主路径。
102
103
  - world/ 下只写 .yaml 或 .md 世界文档;专用文件只允许 opening.md、control/frame.yaml、control/player-views.yaml${gameplayFileZh(tabletop)},以及 control/followups.yaml 和其中引用的 prompts/*.md、renderers/*.html、scripts/*.js、regex/*.yaml、assets/* 文本资源。人物、地点、规则与当前情境放在 world/,本世界特有的主持要求放在 control/。
103
104
  - opening.md 是玩家看见的第一页,不得替玩家决定行动、台词或内心。会继续约束首次行动的事实也必须写入自然承载它的世界文档。
104
105
  - 新建 world 文档使用 setting_create:ref 由你提供,并同时提供路径、标题、摘要、别名与不含技术头的正文。ref 必须是唯一的 2~32 位小写 ASCII 短句柄;Runtime 自行完成技术存储。既有文档选择值会在读取时自动投影为对应的 @ref;跨文档引用和 control 中的文档选择只使用 @ref。
@@ -134,7 +135,7 @@ export function settingImprovementRuntimeContract(locale, gameplay) {
134
135
  - The current request includes eight read/write tools. A complete response with no tool calls is shown as an ordinary assistant message and settles this user invocation. A response containing tool calls is an internal tool step; continue after all results and eventually answer with a complete tool-free response.
135
136
  - Tool writes directly modify the content package's current tree. Calls in one complete Provider tool response execute in order; its successful changes publish atomically when that response settles, immediately become authoritative, and require no second confirmation.
136
137
  - Every write tool call settles independently and sees earlier accepted calls in response order. A failure returns its precise cause only in that call's result, does not roll back successful siblings, and does not stop later calls. Runtime automatically runs content-tree checks and a real Prompt Preview after writes and appends that review to the last successful write result.
137
- - Read an existing file completely before changing it. Cursors from setting_list and setting_search, and read authorization from setting_read, belong only to the current-tree revision that produced them. setting_read always returns the complete exact file without truncation or pagination. Re-read after another conversation or a manual edit changes the current tree. Tools expose logical paths, never host paths.
138
+ - Read an existing file completely before changing it. Cursors from setting_search, and read authorization from setting_read, belong only to the current-tree revision that produced them. setting_read always returns the complete exact file without truncation or pagination. Re-read after another conversation or a manual edit changes the current tree. Tools expose logical paths, never host paths.
138
139
  - World documents are .yaml or .md files under world/. Special writes are limited to opening.md, control/frame.yaml, control/player-views.yaml${gameplayFileEn(tabletop)}, plus control/followups.yaml and its referenced prompts/*.md, renderers/*.html, scripts/*.js, regex/*.yaml and assets/* text resources. Put characters, places, rules, and the current situation under world/, and world-specific hosting guidance under control/.
139
140
  - opening.md is the first page shown to the player. Never decide the player's action, dialogue, or inner thoughts. Facts that constrain the first action must also live in the world document that naturally owns them.
140
141
  - Create a world document with setting_create. You provide its unique 2-to-32-character lowercase ASCII ref, path, title, summary, aliases, and body without a technical header; Runtime completes the technical storage. Existing document selectors are automatically projected to their @refs when read. Cross-document references and control selectors use only @refs.
@@ -191,8 +192,6 @@ export function authoringToolDefinitions(surface, locale, gameplay) {
191
192
  type: "string",
192
193
  pattern: `^${surface.root}(?:/[^\\\\]+)?$`,
193
194
  },
194
- limit,
195
- cursor,
196
195
  }, []),
197
196
  },
198
197
  {
@@ -283,42 +282,42 @@ export function authoringToolDefinitions(surface, locale, gameplay) {
283
282
  ];
284
283
  }
285
284
  const toolDescriptionsEn = (tabletop) => ({
286
- list: "List the world root or a world/ subdirectory from the content package's current tree. The root also lists opening and control files. A cursor is valid only for the same query and current-tree revision.",
285
+ list: "List the world root or a world/ subdirectory from the content package's current tree. The root also lists opening and control files. Each call returns the whole directory.",
287
286
  search: "Search literal source text in world documents in the content package's current tree. within may be world, a world/ directory or path, or @ref.",
288
287
  read: "Read a complete world document by world/ path or @ref, or read a complete opening/control file by exact path. Existing document selectors are projected to @refs. The result is never truncated or paginated.",
289
288
  create: "Create one world/ .yaml or .md document. Supply a unique lowercase ASCII ref, title, summary, aliases, and body without a technical header; Runtime completes the storage envelope. The created document is already fully read.",
290
- write_file: `Replace a completely read world/ .yaml or .md document body while preserving its identity and metadata, or create/replace opening.md, control/frame.yaml, control/player-views.yaml, ${tabletop ? "control/blocks/*.md, or control/gameplay.json (only a tabletop resolution scheme may change)" : "or control/blocks/*.md"}, plus control/followups.yaml and its referenced prompts/*.md, renderers/*.html, scripts/*.js, regex/*.yaml and assets/* text resources. To repair a damaged world document, also provide ref, title, summary, and aliases; Runtime preserves any recoverable storage identity. Create new world documents with setting_create.`,
291
- patch: 'Update a completely read YAML world document without rewriting unrelated content. locator is a non-empty path whose segments are map keys or zero-based array indexes. Use add for a missing map key or array index, replace for an existing node, append for the end of an existing array, and remove for an existing map key or array item. When title, summary, or aliases are stale, update all three together with op "set_metadata". Rewrite Markdown bodies with setting_write_file.',
289
+ write_file: `Replace a completely read world/ .yaml or .md document body while preserving its identity and metadata, or create/replace opening.md, control/frame.yaml, control/player-views.yaml, ${tabletop ? "control/blocks/*.md, or control/gameplay.json (only a tabletop resolution scheme may change)" : "or control/blocks/*.md"}, plus control/followups.yaml and its referenced prompts/*.md, renderers/*.html, scripts/*.js, regex/*.yaml and assets/* text resources. path may also be an @ref. On a valid document, title, summary, and aliases may be given in the same write to update metadata (any subset; omitted fields are kept), but ref never changes. To repair a damaged world document, provide ref, title, summary, and aliases together; Runtime preserves any recoverable storage identity. Create new world documents with setting_create.`,
290
+ patch: 'Update a completely read YAML world document without rewriting unrelated content. locator is a non-empty path whose segments are map keys or zero-based array indexes. Use add for a missing map key or array index, replace for an existing node, append for the end of an existing array, and remove for an existing map key or array item. To update title, summary, or aliases, use op "set_metadata" with only the fields that change; omitted fields are kept. Rewrite Markdown bodies with setting_write_file.',
292
291
  move: "Move a completely read world document to a new world/ .yaml or .md logical path while preserving its identity and contents.",
293
292
  delete: "Permanently delete a completely read world document from the content-package current tree. Runtime rejects deletion while currentSituation, a frame slot, a player-view selector, or another document's $ref still points to it, and reports every blocking location. Delete or redirect those references first.",
294
293
  });
295
294
  const toolDescriptionsZhCN = (tabletop) => ({
296
- list: "列出内容包当前树的 world 根目录或 world/ 子目录;根目录同时列出 opening 和 control 专用文件。cursor 只对同一查询和当前树 revision 有效。",
295
+ list: "列出内容包当前树的 world 根目录或 world/ 子目录;根目录同时列出 opening 和 control 专用文件,每次整份返回。",
297
296
  search: "在内容包当前树的 world 文档中按原文字面搜索;within 可为 world、world/ 目录或路径或 @ref。",
298
297
  read: "按 world/ 路径或 @ref 完整读取世界文档,或按精确路径完整读取 opening/control;既有文档选择值自动投影为 @ref。结果不会截断或分页。",
299
298
  create: "创建一份 world/ 下的 .yaml 或 .md 文档;提供唯一的小写 ASCII ref、标题、摘要、别名和不含技术头的正文,Runtime 自行完成存储封装。创建成功的文档视为已经完整读取。",
300
- write_file: `整份替换已完整读取的 world/ .yaml 或 .md 正文并保留身份和元信息,或创建/替换 opening.md、control/frame.yaml、control/player-views.yaml、control/blocks/*.md${tabletop ? "、control/gameplay.json(只能修改跑团判定方案,玩法模式不可改)" : ""},以及 control/followups.yaml 和其中引用的 prompts/*.md、renderers/*.html、scripts/*.js、regex/*.yaml、assets/* 文本资源。修复损坏世界文档时同时提供 ref、title、summary、aliases,Runtime 会保留仍可恢复的存储身份。新建世界文档使用 setting_create。`,
301
- patch: '细粒度更新已完整读取的 YAML 世界文档,不重写无关内容。locator 是非空路径,每段可以是 map key 或从 0 开始的数组下标;add 新建 map key 或数组位置,replace 更新既有节点,append 追加既有数组,remove 删除既有 map key 或数组项。title、summary 或 aliases 过时时用 op "set_metadata" 整组更新三项。Markdown 正文用 setting_write_file 整份重写。',
299
+ write_file: `整份替换已完整读取的 world/ .yaml 或 .md 正文并保留身份和元信息,或创建/替换 opening.md、control/frame.yaml、control/player-views.yaml、control/blocks/*.md${tabletop ? "、control/gameplay.json(只能修改跑团判定方案,玩法模式不可改)" : ""},以及 control/followups.yaml 和其中引用的 prompts/*.md、renderers/*.html、scripts/*.js、regex/*.yaml、assets/* 文本资源。path 也可以是 @ref。有效文档可在同一次写入中提供 title、summary、aliases 的任意子集更新元信息,未提供的保持不变,但 ref 不能改。修复损坏世界文档时同时提供 ref、title、summary、aliases,Runtime 会保留仍可恢复的存储身份。新建世界文档使用 setting_create。`,
300
+ patch: '细粒度更新已完整读取的 YAML 世界文档,不重写无关内容。locator 是非空路径,每段可以是 map key 或从 0 开始的数组下标;add 新建 map key 或数组位置,replace 更新既有节点,append 追加既有数组,remove 删除既有 map key 或数组项。需要更新 title、summary 或 aliases 时用 op "set_metadata",只提供要改变的字段,未提供的保持不变。Markdown 正文用 setting_write_file 整份重写。',
302
301
  move: "把已完整读取的世界文档移动到新的 world/ .yaml 或 .md 逻辑路径,并保留身份与内容。",
303
302
  delete: "从内容包当前树永久删除一份已完整读取的 world 文档。若 currentSituation、frame slot、player-view selector 或其他文档的 $ref 仍指向它,Runtime 会拒绝并返回全部精确阻挡位置;请先删除或改向这些引用。",
304
303
  });
305
304
  const worldRevisionDescriptionsEn = (tabletop) => ({
306
- list: "List the state root or a state/ subdirectory from the world revision worktree. The root also lists control files. A cursor is valid only for the same query and worktree revision.",
305
+ list: "List the state root or a state/ subdirectory from the world revision worktree. The root also lists control files. Each call returns the whole directory.",
307
306
  search: "Search literal source text in world documents in the world revision worktree. within may be state, a state/ directory or path, or @ref.",
308
307
  read: "Read a complete world document by state/ path or @ref, or read a complete control file by exact path. Existing document selectors are projected to @refs. The result is never truncated or paginated.",
309
308
  create: "Create one state/ .yaml or .md document. Supply a unique lowercase ASCII ref, title, summary, aliases, and body without a technical header; Runtime completes the storage envelope. The created document is already fully read.",
310
- write_file: `Replace a completely read state/ .yaml or .md document body while preserving its identity and metadata, or create/replace control/frame.yaml, control/player-views.yaml, ${tabletop ? "control/blocks/*.md, or control/gameplay.json (only a tabletop resolution scheme may change)" : "or control/blocks/*.md"}, plus control/followups.yaml and its referenced prompts/*.md, renderers/*.html, scripts/*.js, regex/*.yaml and assets/* text resources. To repair a damaged world document, also provide ref, title, summary, and aliases; Runtime preserves any recoverable storage identity. Create new world documents with world_revision_create.`,
311
- patch: 'Update a completely read YAML world document without rewriting unrelated content. locator is a non-empty path whose segments are map keys or zero-based array indexes. Use add for a missing map key or array index, replace for an existing node, append for the end of an existing array, and remove for an existing map key or array item. When title, summary, or aliases are stale, update all three together with op "set_metadata". Rewrite Markdown bodies with world_revision_write_file.',
309
+ write_file: `Replace a completely read state/ .yaml or .md document body while preserving its identity and metadata, or create/replace control/frame.yaml, control/player-views.yaml, ${tabletop ? "control/blocks/*.md, or control/gameplay.json (only a tabletop resolution scheme may change)" : "or control/blocks/*.md"}, plus control/followups.yaml and its referenced prompts/*.md, renderers/*.html, scripts/*.js, regex/*.yaml and assets/* text resources. path may also be an @ref. On a valid document, title, summary, and aliases may be given in the same write to update metadata (any subset; omitted fields are kept), but ref never changes. To repair a damaged world document, provide ref, title, summary, and aliases together; Runtime preserves any recoverable storage identity. Create new world documents with world_revision_create.`,
310
+ patch: 'Update a completely read YAML world document without rewriting unrelated content. locator is a non-empty path whose segments are map keys or zero-based array indexes. Use add for a missing map key or array index, replace for an existing node, append for the end of an existing array, and remove for an existing map key or array item. To update title, summary, or aliases, use op "set_metadata" with only the fields that change; omitted fields are kept. Rewrite Markdown bodies with world_revision_write_file.',
312
311
  move: "Move a completely read world document to a new state/ .yaml or .md logical path while preserving its identity and contents.",
313
312
  delete: "Permanently delete a completely read world document from the world revision worktree. Runtime rejects deletion while currentSituation, a frame slot, a player-view selector, or another document's $ref still points to it, and reports every blocking location. Delete or redirect those references first.",
314
313
  });
315
314
  const worldRevisionDescriptionsZhCN = (tabletop) => ({
316
- list: "列出世界修订工作树的 state 根目录或 state/ 子目录;根目录同时列出 control 文件。cursor 只对同一查询和工作树 revision 有效。",
315
+ list: "列出世界修订工作树的 state 根目录或 state/ 子目录;根目录同时列出 control 文件,每次整份返回。",
317
316
  search: "在世界修订工作树的 state 文档中按原文字面搜索;within 可为 state、state/ 目录或路径或 @ref。",
318
317
  read: "按 state/ 路径或 @ref 完整读取世界文档,或按精确路径完整读取 control 文件;既有文档选择值自动投影为 @ref。结果不会截断或分页。",
319
318
  create: "创建一份 state/ 下的 .yaml 或 .md 文档;提供唯一的小写 ASCII ref、标题、摘要、别名和不含技术头的正文,Runtime 自行完成存储封装。创建成功的文档视为已经完整读取。",
320
- write_file: `整份替换已完整读取的 state/ .yaml 或 .md 正文并保留身份和元信息,或创建/替换 control/frame.yaml、control/player-views.yaml、control/blocks/*.md${tabletop ? "、control/gameplay.json(只能修改跑团判定方案,玩法模式不可改)" : ""},以及 control/followups.yaml 和其中引用的 prompts/*.md、renderers/*.html、scripts/*.js、regex/*.yaml、assets/* 文本资源。修复损坏世界文档时同时提供 ref、title、summary、aliases,Runtime 会保留仍可恢复的存储身份。新建世界文档使用 world_revision_create。`,
321
- patch: '细粒度更新已完整读取的 YAML 世界文档,不重写无关内容。locator 是非空路径,每段可以是 map key 或从 0 开始的数组下标;add 新建 map key 或数组位置,replace 更新既有节点,append 追加既有数组,remove 删除既有 map key 或数组项。title、summary 或 aliases 过时时用 op "set_metadata" 整组更新三项。Markdown 正文用 world_revision_write_file 整份重写。',
319
+ write_file: `整份替换已完整读取的 state/ .yaml 或 .md 正文并保留身份和元信息,或创建/替换 control/frame.yaml、control/player-views.yaml、control/blocks/*.md${tabletop ? "、control/gameplay.json(只能修改跑团判定方案,玩法模式不可改)" : ""},以及 control/followups.yaml 和其中引用的 prompts/*.md、renderers/*.html、scripts/*.js、regex/*.yaml、assets/* 文本资源。path 也可以是 @ref。有效文档可在同一次写入中提供 title、summary、aliases 的任意子集更新元信息,未提供的保持不变,但 ref 不能改。修复损坏世界文档时同时提供 ref、title、summary、aliases,Runtime 会保留仍可恢复的存储身份。新建世界文档使用 world_revision_create。`,
320
+ patch: '细粒度更新已完整读取的 YAML 世界文档,不重写无关内容。locator 是非空路径,每段可以是 map key 或从 0 开始的数组下标;add 新建 map key 或数组位置,replace 更新既有节点,append 追加既有数组,remove 删除既有 map key 或数组项。需要更新 title、summary 或 aliases 时用 op "set_metadata",只提供要改变的字段,未提供的保持不变。Markdown 正文用 world_revision_write_file 整份重写。',
322
321
  move: "把已完整读取的世界文档移动到新的 state/ .yaml 或 .md 逻辑路径,并保留身份与内容。",
323
322
  delete: "从世界修订工作树永久删除一份已完整读取的 world 文档。若 currentSituation、frame slot、player-view selector 或其他文档的 $ref 仍指向它,Runtime 会拒绝并返回全部精确阻挡位置;请先删除或改向这些引用。",
324
323
  });
@@ -397,6 +396,7 @@ export class SettingAuthoringTransaction {
397
396
  #surface;
398
397
  #preview;
399
398
  #validateFiles;
399
+ #additionalToolNames;
400
400
  #snapshot;
401
401
  #reads;
402
402
  #review;
@@ -406,6 +406,7 @@ export class SettingAuthoringTransaction {
406
406
  this.#surface = input.surface ?? contentPackageAuthoringSurface;
407
407
  this.#preview = input.preview;
408
408
  this.#validateFiles = input.validateFiles ?? (() => undefined);
409
+ this.#additionalToolNames = input.additionalToolNames ?? [];
409
410
  this.#snapshot = WorldDocumentStore.open({
410
411
  layout: this.#surface.layout,
411
412
  files: cloneFiles(input.baseFiles),
@@ -436,7 +437,7 @@ export class SettingAuthoringTransaction {
436
437
  };
437
438
  }
438
439
  execute(calls) {
439
- const normalized = calls.map((call) => normalizeCall(call, this.#surface, this.#locale));
440
+ const normalized = calls.map((call) => normalizeCall(call, this.#surface, this.#locale, this.#additionalToolNames));
440
441
  const results = [];
441
442
  const responseBase = this.files();
442
443
  let lastSuccessfulWrite = -1;
@@ -518,9 +519,7 @@ export class SettingAuthoringTransaction {
518
519
  ? requiredSettingRef(call.arguments.ref, this.#locale)
519
520
  : null;
520
521
  const revised = snapshot.revise({
521
- commands: [
522
- worldRevisionCommand(snapshot, call, this.#surface, this.#locale),
523
- ],
522
+ commands: worldRevisionCommands(snapshot, call, this.#surface, this.#locale),
524
523
  });
525
524
  if (!revised.ok)
526
525
  throw new SettingAuthoringError(renderRevisionFailure(revised.diagnostics, this.#surface, this.#locale));
@@ -667,16 +666,51 @@ function buildSettingAuthoringPlayCoverage(snapshot, preview, diff, surface) {
667
666
  }
668
667
  return {
669
668
  totals,
670
- changed: diff.map(({ path, after }) => {
671
- const access = changedPathPlayAccess(path, after, accessByPath, enabledWorldInstructionPaths);
669
+ changed: diff.map(({ path, before, after }) => {
670
+ const movedTo = after === null
671
+ ? movedDocumentPath(before, path, snapshot, surface)
672
+ : null;
673
+ const access = movedTo !== null
674
+ ? "moved"
675
+ : changedPathPlayAccess(path, after, accessByPath, enabledWorldInstructionPaths);
672
676
  return {
673
677
  path,
674
678
  access,
675
679
  detail: playAccessDetail(access, surface),
680
+ ...(movedTo === null ? {} : { movedTo }),
676
681
  };
677
682
  }),
678
683
  };
679
684
  }
685
+ /**
686
+ * A deleted-looking world document whose identity survives at another path
687
+ * was moved, not removed: the old path is reported with its new location.
688
+ */
689
+ function movedDocumentPath(before, path, snapshot, surface) {
690
+ if (before === null)
691
+ return null;
692
+ const previous = WorldDocumentStore.open({
693
+ layout: surface.layout,
694
+ files: [{ path, contents: before }],
695
+ });
696
+ if (previous.status !== "usable")
697
+ return null;
698
+ const identity = previous.query({
699
+ kind: "read_document",
700
+ document: { logicalPath: path },
701
+ });
702
+ if (identity.kind !== "read_document" || !identity.ok)
703
+ return null;
704
+ const current = snapshot.query({
705
+ kind: "read_document",
706
+ document: { documentId: identity.scope.document.documentId },
707
+ });
708
+ if (current.kind !== "read_document" || !current.ok)
709
+ return null;
710
+ return current.scope.document.logicalPath === path
711
+ ? null
712
+ : current.scope.document.logicalPath;
713
+ }
680
714
  function changedPathPlayAccess(path, after, accessByPath, enabledWorldInstructionPaths) {
681
715
  if (after === null)
682
716
  return "removed";
@@ -715,6 +749,7 @@ function playAccessDetail(access, surface) {
715
749
  play_control: "compiled as immutable play-author control",
716
750
  unused_control: "not enabled by control/frame.yaml and unavailable to ordinary play AI",
717
751
  player_view: "projected through exact player-view selectors",
752
+ moved: "its document identity continues at the new path",
718
753
  removed: `removed from ${surface.phrases.en.bareTree}`,
719
754
  };
720
755
  return details[access];
@@ -731,6 +766,7 @@ function renderPlayCoverage(coverage, surface, locale) {
731
766
  play_control: "游玩控制",
732
767
  unused_control: "未被 control/frame.yaml 启用",
733
768
  player_view: "玩家视图精确投影",
769
+ moved: "文档身份已延续到新路径",
734
770
  removed: `已从${surface.phrases["zh-CN"].tree}删除`,
735
771
  }
736
772
  : {
@@ -743,11 +779,16 @@ function renderPlayCoverage(coverage, surface, locale) {
743
779
  play_control: "play-author control",
744
780
  unused_control: "not enabled by control/frame.yaml",
745
781
  player_view: "exact player-view projection",
782
+ moved: "identity continues at the new path",
746
783
  removed: `removed from ${surface.phrases.en.bareTree}`,
747
784
  };
748
785
  const maximumReportedChanges = 64;
749
786
  const reportedChanges = coverage.changed.slice(0, maximumReportedChanges);
750
- const changed = reportedChanges.map(({ path, access }) => `- ${path} — ${labels[access]}`);
787
+ const changed = reportedChanges.map(({ path, access, movedTo }) => {
788
+ if (access === "moved" && movedTo !== undefined)
789
+ return localized(locale, `- ${path} — moved to ${movedTo}`, `- ${path} — 已移动到 ${movedTo}`);
790
+ return `- ${path} — ${labels[access]}`;
791
+ });
751
792
  const onDemand = coverage.changed.filter(({ access }) => access === "on_demand");
752
793
  const unusedControl = coverage.changed.filter(({ access }) => access === "unused_control");
753
794
  return [
@@ -783,7 +824,7 @@ class SettingAuthoringError extends Error {
783
824
  this.name = "SettingAuthoringError";
784
825
  }
785
826
  }
786
- function normalizeCall(call, surface, locale) {
827
+ function normalizeCall(call, surface, locale, additionalToolNames) {
787
828
  const cloned = isRecord(call.arguments)
788
829
  ? structuredClone(call.arguments)
789
830
  : {};
@@ -797,7 +838,7 @@ function normalizeCall(call, surface, locale) {
797
838
  operation: null,
798
839
  arguments: cloned,
799
840
  coerced: [],
800
- rejection: unknownToolMessage(call.name, provided, locale),
841
+ rejection: unknownToolMessage(call.name, [...provided, ...additionalToolNames], locale),
801
842
  };
802
843
  const name = resolved.name;
803
844
  const schema = definitions.find((definition) => definition.name === name)?.inputSchema;
@@ -842,7 +883,51 @@ function isWorldWriteCall(call, surface) {
842
883
  return true;
843
884
  return (call.operation === "write_file" &&
844
885
  typeof call.arguments.path === "string" &&
845
- call.arguments.path.startsWith(`${surface.root}/`));
886
+ (call.arguments.path.startsWith(`${surface.root}/`) ||
887
+ call.arguments.path.startsWith("@")));
888
+ }
889
+ /**
890
+ * A world write may need two revision commands: the body write, then a
891
+ * metadata patch for any title, summary or aliases given on a valid document.
892
+ * Both target the same logical path, so they apply in one revision.
893
+ */
894
+ function worldRevisionCommands(snapshot, call, surface, locale) {
895
+ if (call.operation !== "write_file")
896
+ return [worldRevisionCommand(snapshot, call, surface, locale)];
897
+ const logicalPath = writeFileLogicalPath(snapshot, requiredString(call.arguments.path, "path"), surface, locale);
898
+ const request = worldWriteRequest(snapshot, logicalPath, call.arguments, surface, locale);
899
+ const write = {
900
+ kind: "write",
901
+ logicalPath,
902
+ contents: request.contents,
903
+ };
904
+ if (request.metadata === null)
905
+ return [write];
906
+ return [
907
+ write,
908
+ {
909
+ kind: "patch",
910
+ document: { logicalPath },
911
+ edits: [{ op: "set_metadata", ...request.metadata }],
912
+ },
913
+ ];
914
+ }
915
+ /** Resolves `@ref` write targets to their logical path; paths are checked as before. */
916
+ function writeFileLogicalPath(snapshot, path, surface, locale) {
917
+ if (!path.startsWith("@"))
918
+ return safeWorldDocumentPath(path, surface, locale);
919
+ const selector = documentSelector(path, surface);
920
+ if (selector === null)
921
+ throw new SettingAuthoringError(localized(locale, `${authoringToolName(surface, "write_file")}.path must be an @ref or ${surface.root}/ document path`, `${authoringToolName(surface, "write_file")}.path 必须是 @ref 或 ${surface.root}/ 文档路径`));
922
+ const resolved = snapshot.query({
923
+ kind: "read_document",
924
+ document: selector,
925
+ });
926
+ if (resolved.kind === "error")
927
+ throw new SettingAuthoringError(localized(locale, `Unknown document ${path}`, `未知文档 ${path}`));
928
+ if (resolved.kind !== "read_document")
929
+ throw new Error("Unexpected world-document write lookup result");
930
+ return resolved.document.logicalPath;
846
931
  }
847
932
  function worldRevisionCommand(snapshot, call, surface, locale) {
848
933
  if (call.operation === "create") {
@@ -874,16 +959,6 @@ function worldRevisionCommand(snapshot, call, surface, locale) {
874
959
  body,
875
960
  };
876
961
  }
877
- if (call.operation === "write_file") {
878
- const logicalPath = safeWorldDocumentPath(requiredString(call.arguments.path, "path"), surface, locale);
879
- const contents = worldWriteContents(snapshot, logicalPath, call.arguments, surface, locale);
880
- assertNoLiteralUnicodeEscapes(contents, locale);
881
- return {
882
- kind: "write",
883
- logicalPath,
884
- contents,
885
- };
886
- }
887
962
  if (call.operation === "move") {
888
963
  const toLogicalPath = safePath(requiredString(call.arguments.to, "to"), surface);
889
964
  if (!documentPathPattern(surface).test(toLogicalPath))
@@ -895,20 +970,15 @@ function worldRevisionCommand(snapshot, call, surface, locale) {
895
970
  };
896
971
  }
897
972
  if (call.operation === "patch") {
898
- const op = requiredString(call.arguments.op, "op");
973
+ const op = requiredPatchOp(call.arguments, surface);
899
974
  if (op === "set_metadata") {
900
- const title = requiredString(call.arguments.title, "title");
901
- const summary = requiredString(call.arguments.summary, "summary");
902
- const aliases = requiredAliases(call.arguments.aliases);
903
- assertNoLiteralUnicodeEscapes({ title, summary, aliases }, locale);
975
+ const metadata = metadataFields(call.arguments, surface, locale);
904
976
  return {
905
977
  kind: "patch",
906
978
  document: revisionTarget(requiredString(call.arguments.document, "document"), surface),
907
- edits: [{ op: "set_metadata", title, summary, aliases }],
979
+ edits: [{ op: "set_metadata", ...metadata }],
908
980
  };
909
981
  }
910
- if (op !== "add" && op !== "replace" && op !== "append" && op !== "remove")
911
- throw new SettingAuthoringError(`${authoringToolName(surface, "patch")}.op must be add, replace, append, remove, or set_metadata`);
912
982
  const locator = requiredYamlLocatorPath(call.arguments.locator, "locator");
913
983
  if (op === "remove") {
914
984
  if (!hasOnly(call.arguments, ["document", "op", "locator"]))
@@ -951,29 +1021,59 @@ function safeWorldDocumentPath(path, surface, locale) {
951
1021
  throw new SettingAuthoringError(localized(locale, `World files must be .yaml or .md documents below ${surface.root}/`, `world 文件必须是 ${surface.root}/ 下的 .yaml 或 .md 文档`));
952
1022
  return logicalPath;
953
1023
  }
954
- function worldWriteContents(snapshot, logicalPath, args, surface, locale) {
1024
+ /**
1025
+ * Title, summary and aliases are public index metadata that AI may update on a
1026
+ * valid document. Only the ones given are read, and at least one must be given.
1027
+ */
1028
+ function metadataFields(args, surface, locale) {
1029
+ const fields = {};
1030
+ if (args.title !== undefined)
1031
+ fields.title = requiredString(args.title, "title");
1032
+ if (args.summary !== undefined)
1033
+ fields.summary = requiredString(args.summary, "summary");
1034
+ if (args.aliases !== undefined)
1035
+ fields.aliases = requiredAliases(args.aliases);
1036
+ if (Object.keys(fields).length === 0)
1037
+ throw new SettingAuthoringError(localized(locale, `${authoringToolName(surface, "patch")} set_metadata needs at least one of title, summary, or aliases`, `${authoringToolName(surface, "patch")} set_metadata 至少需要 title、summary 或 aliases 之一`));
1038
+ assertNoLiteralUnicodeEscapes(fields, locale);
1039
+ return fields;
1040
+ }
1041
+ function worldWriteRequest(snapshot, logicalPath, args, surface, locale) {
955
1042
  if (!hasOnly(args, ["path", "contents", "ref", "title", "summary", "aliases"]))
956
1043
  throw new SettingAuthoringError(`${authoringToolName(surface, "write_file")} accepts only its declared arguments`);
957
1044
  const body = requiredString(args.contents, "contents");
958
- const metadataKeys = ["ref", "title", "summary", "aliases"];
959
- const provided = metadataKeys.filter((key) => args[key] !== undefined);
960
1045
  const existingFile = snapshot.files.some(({ path }) => path === logicalPath);
961
1046
  const existingDocument = listQueryableWorldDocuments(snapshot).some(({ logicalPath: existingPath }) => existingPath === logicalPath);
1047
+ const metadataGiven = ["title", "summary", "aliases"].some((key) => args[key] !== undefined);
1048
+ if (existingDocument) {
1049
+ if (args.ref !== undefined)
1050
+ throw new SettingAuthoringError(localized(locale, `ref is the stable identity of a valid document and cannot change; omit ref, and use title, summary, or aliases to update its metadata`, `有效文档的 ref 是稳定身份,写入不能改变;请省略 ref,需要更新时只用 title、summary 或 aliases`));
1051
+ return {
1052
+ contents: body,
1053
+ metadata: metadataGiven ? metadataFields(args, surface, locale) : null,
1054
+ };
1055
+ }
1056
+ if (!existingFile) {
1057
+ if (!metadataGiven && containsTechnicalDocumentHeader(logicalPath, body))
1058
+ return { contents: body, metadata: null };
1059
+ throw new SettingAuthoringError(localized(locale, `Create a new world document with ${authoringToolName(surface, "create")}`, `新建世界文档请使用 ${authoringToolName(surface, "create")}`));
1060
+ }
1061
+ return {
1062
+ contents: worldRepairContents(snapshot, logicalPath, args, body, surface, locale),
1063
+ metadata: null,
1064
+ };
1065
+ }
1066
+ /** Damaged documents keep their body only with a complete ref, title, summary and aliases set. */
1067
+ function worldRepairContents(snapshot, logicalPath, args, body, surface, locale) {
1068
+ const metadataKeys = ["ref", "title", "summary", "aliases"];
1069
+ const provided = metadataKeys.filter((key) => args[key] !== undefined);
962
1070
  if (provided.length === 0) {
963
- if (!existingFile && !containsTechnicalDocumentHeader(logicalPath, body))
964
- throw new SettingAuthoringError(localized(locale, `Create a new world document with ${authoringToolName(surface, "create")}`, `新建世界文档请使用 ${authoringToolName(surface, "create")}`));
965
- if (existingFile &&
966
- !existingDocument &&
967
- !containsTechnicalDocumentHeader(logicalPath, body))
1071
+ if (!containsTechnicalDocumentHeader(logicalPath, body))
968
1072
  throw new SettingAuthoringError(localized(locale, "Repair a damaged document by providing ref, title, summary, and aliases with its body", "修复损坏文档时,请在正文之外同时提供 ref、title、summary 和 aliases"));
969
1073
  return body;
970
1074
  }
971
1075
  if (provided.length !== metadataKeys.length)
972
1076
  throw new SettingAuthoringError(localized(locale, "Repair metadata must provide ref, title, summary, and aliases together", "修复元信息时必须同时提供 ref、title、summary 和 aliases"));
973
- if (!existingFile)
974
- throw new SettingAuthoringError(localized(locale, `Create a new world document with ${authoringToolName(surface, "create")}`, `新建世界文档请使用 ${authoringToolName(surface, "create")}`));
975
- if (existingDocument)
976
- throw new SettingAuthoringError(localized(locale, `Repair metadata is only for a damaged document; update valid metadata with ${authoringToolName(surface, "patch")} set_metadata`, `修复元信息只用于损坏文档;有效文档请用 ${authoringToolName(surface, "patch")} set_metadata 更新元信息`));
977
1077
  const ref = requiredSettingRef(args.ref, locale);
978
1078
  const title = requiredString(args.title, "title");
979
1079
  const summary = requiredString(args.summary, "summary");
@@ -1061,7 +1161,7 @@ function deleteWorldDocument(snapshot, call, reads, surface, locale) {
1061
1161
  markdown: localized(locale, `# Current-tree deletion accepted\n\nDeleted @${target.shortRef} · ${target.logicalPath}. No currentSituation binding, frame slot, player-view selector, or cross-document $ref points to it.`, `# 当前树删除已接受\n\n已删除 @${target.shortRef} · ${target.logicalPath}。没有 currentSituation、frame slot、player-view selector 或跨文档 $ref 指向它。`),
1062
1162
  };
1063
1163
  }
1064
- function settingDocumentDeletionBlockers(snapshot, target) {
1164
+ export function settingDocumentDeletionBlockers(snapshot, target) {
1065
1165
  const blockers = [];
1066
1166
  for (const document of listQueryableWorldDocuments(snapshot)) {
1067
1167
  if (document.documentId === target.documentId || document.codec !== "yaml")
@@ -1134,13 +1234,13 @@ function deletionLocator(locator) {
1134
1234
  : `markdown:${JSON.stringify(locator.markdown)}`;
1135
1235
  }
1136
1236
  function writeOpaque(snapshot, call, reads, surface, locale) {
1137
- if (!hasOnly(call.arguments, ["path", "contents"]))
1138
- throw new SettingAuthoringError("Special-file writes accept only path and contents");
1139
1237
  const path = safePath(requiredString(call.arguments.path, "path"), surface);
1140
1238
  if (!writableOpaquePath(path, surface))
1141
1239
  throw new SettingAuthoringError(surface.opening
1142
1240
  ? localized(locale, "Special-file writes accept only opening.md, control/frame.yaml, control/player-views.yaml, control/blocks/*.md, or control/gameplay.json", "专用文件只允许写入 opening.md、control/frame.yaml、control/player-views.yaml、control/blocks/*.md 或 control/gameplay.json")
1143
1241
  : localized(locale, "Special-file writes accept only control/frame.yaml, control/player-views.yaml, control/blocks/*.md, or control/gameplay.json", "专用文件只允许写入 control/frame.yaml、control/player-views.yaml、control/blocks/*.md 或 control/gameplay.json"));
1242
+ if (!hasOnly(call.arguments, ["path", "contents"]))
1243
+ throw new SettingAuthoringError("Special-file writes accept only path and contents");
1144
1244
  const authoredContents = requiredString(call.arguments.contents, "contents");
1145
1245
  assertNoLiteralUnicodeEscapes(authoredContents, locale);
1146
1246
  const contents = projectOpaqueDocumentIds(snapshot, path, authoredContents);
@@ -1166,7 +1266,7 @@ function writeOpaque(snapshot, call, reads, surface, locale) {
1166
1266
  return {
1167
1267
  snapshot: revised,
1168
1268
  markdown: [
1169
- localized(locale, `# Current-tree write accepted\n\n${existing === undefined ? "Created" : "Updated"} ${path} in ${surface.phrases.en.tree}. This file is already fully read; do not read it again unless a later decision needs Runtime's exact serialization.`, `# 当前树写入已接受\n\n已在${surface.phrases["zh-CN"].tree}中${existing === undefined ? "创建" : "更新"} ${path}。该文件已视为完整读取;除非后续判断依赖 Runtime 的精确序列化结果,否则不要再次读取。`),
1269
+ localized(locale, `# Current-tree write accepted\n\n${existing === undefined ? "Created" : "Updated"} ${path} in ${surface.phrases.en.tree}.`, `# 当前树写入已接受\n\n已在${surface.phrases["zh-CN"].tree}中${existing === undefined ? "创建" : "更新"} ${path}。`),
1170
1270
  ...(gameplay === null
1171
1271
  ? []
1172
1272
  : [
@@ -1218,8 +1318,6 @@ function renderWorldWriteSuccess(changes, locale) {
1218
1318
  return `- ${localized(locale, "Updated", "更新")} @${change.shortRef} · ${change.after.logicalPath}`;
1219
1319
  return `- ${localized(locale, "Moved", "移动")} @${change.shortRef} · ${change.before.logicalPath} → ${change.after.logicalPath}`;
1220
1320
  })),
1221
- "",
1222
- localized(locale, "This call was accepted independently. Every created or changed document above is already fully read; do not read it again unless a later decision needs Runtime's exact serialization.", "本调用已独立接受。上方新建或修改的文档均视为已经完整读取;除非后续判断依赖 Runtime 的精确序列化结果,否则不要再次读取。"),
1223
1321
  ].join("\n");
1224
1322
  }
1225
1323
  function renderRevisionFailure(diagnostics, surface, locale) {
@@ -1230,41 +1328,43 @@ function renderRevisionFailure(diagnostics, surface, locale) {
1230
1328
  function listDocuments(snapshot, args, surface, locale) {
1231
1329
  const tool = authoringToolName(surface, "list");
1232
1330
  const root = surface.root;
1331
+ // Frozen sessions may still send obsolete limit/cursor; directories are
1332
+ // always listed in full, so those keys are accepted and ignored.
1233
1333
  if (!hasOnly(args, ["directory", "limit", "cursor"]))
1234
- return failure(`${tool} accepts only directory, limit, and cursor`, locale);
1334
+ return failure(`${tool} accepts only directory`, locale);
1235
1335
  const directory = args.directory ?? root;
1236
- const limit = args.limit ?? 100;
1237
1336
  if (typeof directory !== "string" ||
1238
- (directory !== root && !directory.startsWith(`${root}/`)) ||
1239
- typeof limit !== "number" ||
1240
- !Number.isInteger(limit) ||
1241
- limit < 1 ||
1242
- limit > 100 ||
1243
- !validCursor(args.cursor))
1244
- return failure(`Invalid ${tool} directory, limit, or cursor`, locale);
1337
+ (directory !== root && !directory.startsWith(`${root}/`)))
1338
+ return failure(`Invalid ${tool} directory`, locale);
1245
1339
  const relative = directory === root ? "" : directory.slice(root.length + 1);
1246
- const result = snapshot.query({
1247
- kind: "catalog",
1248
- directory: relative,
1249
- limit,
1250
- ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
1251
- });
1252
- if (result.kind === "error")
1253
- return renderQueryFailure(result, surface, locale);
1254
- if (result.kind !== "catalog")
1255
- throw new Error("Unexpected catalog result");
1256
- const entries = result.entries.map((entry) => {
1257
- if (entry.kind === "directory")
1258
- return `- [dir] ${entry.logicalPath}/`;
1259
- if (entry.document === undefined)
1260
- return `- [damaged] ${entry.logicalPath} · ${entry.diagnostics.map(({ code }) => code).join(", ")}`;
1261
- return `- @${entry.document.shortRef} · ${entry.document.title} · ${entry.logicalPath}`;
1262
- });
1263
- if (relative === "" && result.page.start === 0)
1340
+ const entries = [];
1341
+ let cursor = null;
1342
+ do {
1343
+ const result = snapshot.query({
1344
+ kind: "catalog",
1345
+ directory: relative,
1346
+ limit: 100,
1347
+ ...(cursor === null ? {} : { cursor }),
1348
+ });
1349
+ if (result.kind === "error")
1350
+ return renderQueryFailure(result, surface, locale);
1351
+ if (result.kind !== "catalog")
1352
+ throw new Error("Unexpected catalog result");
1353
+ entries.push(...result.entries.map(catalogEntryLine));
1354
+ cursor = result.page.nextCursor;
1355
+ } while (cursor !== null);
1356
+ if (relative === "")
1264
1357
  entries.unshift(...snapshot.files
1265
1358
  .filter(({ path }) => !path.startsWith(`${root}/`))
1266
1359
  .map(({ path, encoding }) => encoding === undefined ? `- [special] ${path}` : `- [binary] ${path}`));
1267
- return success(`# ${localized(locale, surface.phrases.en.directoryHeading, surface.phrases["zh-CN"].directoryHeading)}\n\n${entries.join("\n") || localized(locale, "(empty)", "(空)")}\n\n${pageFooter(result.page, locale)}`);
1360
+ return success(`# ${localized(locale, surface.phrases.en.directoryHeading, surface.phrases["zh-CN"].directoryHeading)}\n\n${entries.join("\n") || localized(locale, "(empty)", "(空)")}\n\nComplete: yes`);
1361
+ }
1362
+ function catalogEntryLine(entry) {
1363
+ if (entry.kind === "directory")
1364
+ return `- [dir] ${entry.logicalPath}/`;
1365
+ if (entry.document === undefined)
1366
+ return `- [damaged] ${entry.logicalPath} · ${entry.diagnostics.map(({ code }) => code).join(", ")}`;
1367
+ return `- @${entry.document.shortRef} · ${entry.document.title} · ${entry.logicalPath}`;
1268
1368
  }
1269
1369
  function searchDocuments(snapshot, args, surface, locale) {
1270
1370
  const tool = authoringToolName(surface, "search");
@@ -1285,20 +1385,90 @@ function searchDocuments(snapshot, args, surface, locale) {
1285
1385
  const within = searchScope(args.within, surface);
1286
1386
  if (within === invalidScope)
1287
1387
  return failure(`Invalid ${tool} within scope`, locale);
1288
- const result = snapshot.query({
1289
- kind: "literal_search",
1388
+ const matches = [];
1389
+ let storeCursor = null;
1390
+ let snapshotId;
1391
+ do {
1392
+ const result = snapshot.query({
1393
+ kind: "literal_search",
1394
+ query,
1395
+ caseSensitive: args.caseSensitive === true,
1396
+ ...(within === undefined ? {} : { within }),
1397
+ limit: 100,
1398
+ ...(storeCursor === null ? {} : { cursor: storeCursor }),
1399
+ });
1400
+ if (result.kind === "error")
1401
+ return renderQueryFailure(result, surface, locale);
1402
+ if (result.kind !== "literal_search")
1403
+ throw new Error("Unexpected literal search result");
1404
+ matches.push(...result.matches);
1405
+ snapshotId = result.snapshotId;
1406
+ storeCursor = result.page.nextCursor;
1407
+ } while (storeCursor !== null);
1408
+ // Pages count whole document lines so one line never spans two pages.
1409
+ const groups = groupMatchesByLine(matches);
1410
+ const scope = createHash("sha256")
1411
+ .update(JSON.stringify({
1290
1412
  query,
1291
1413
  caseSensitive: args.caseSensitive === true,
1292
- ...(within === undefined ? {} : { within }),
1414
+ within: within ?? null,
1293
1415
  limit,
1294
- ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
1295
- });
1296
- if (result.kind === "error")
1297
- return renderQueryFailure(result, surface, locale);
1298
- if (result.kind !== "literal_search")
1299
- throw new Error("Unexpected literal search result");
1300
- const lines = result.matches.map(({ document, referenceProjection, range }) => `- @${document.shortRef} · ${document.logicalPath} · ${range.start.line}:${range.start.column}\n ${JSON.stringify(referenceProjection.excerpt)}`);
1301
- return success(`# ${localized(locale, "Literal setting search", "设定字面搜索")}\n\n${lines.join("\n") || localized(locale, "Zero literal matches do not prove the fact is absent.", "0 个字面命中不证明设定中不存在该事实。")}\n\n${pageFooter(result.page, locale)}`);
1416
+ snapshotId,
1417
+ }))
1418
+ .digest("base64url")
1419
+ .slice(0, 22);
1420
+ const offset = args.cursor === undefined ? 0 : decodeLineCursor(args.cursor, scope);
1421
+ if (offset === null || offset > groups.length)
1422
+ return failure(`Invalid ${tool} cursor`, locale);
1423
+ const page = groups.slice(offset, offset + limit);
1424
+ const end = offset + page.length;
1425
+ const complete = end >= groups.length;
1426
+ const lines = page.map(({ shortRef, line, columns, excerpt }) => `- @${shortRef} · ${line}:${columns.join(",")}\n ${JSON.stringify(excerpt)}`);
1427
+ return success(`# ${localized(locale, "Literal setting search", "设定字面搜索")}\n\n${lines.join("\n") || localized(locale, "Zero literal matches do not prove the fact is absent.", "0 个字面命中不证明设定中不存在该事实。")}\n\n${pageFooter({
1428
+ start: offset,
1429
+ end,
1430
+ total: groups.length,
1431
+ complete,
1432
+ nextCursor: complete ? null : encodeLineCursor(scope, end),
1433
+ }, locale)}`);
1434
+ }
1435
+ function encodeLineCursor(scope, offset) {
1436
+ return Buffer.from(JSON.stringify({ scope, offset })).toString("base64url");
1437
+ }
1438
+ /** Returns the grouped-line offset, or null when the cursor is not for this scope. */
1439
+ function decodeLineCursor(cursor, scope) {
1440
+ if (typeof cursor !== "string")
1441
+ return null;
1442
+ try {
1443
+ const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
1444
+ if (!isRecord(decoded) || decoded.scope !== scope)
1445
+ return null;
1446
+ return typeof decoded.offset === "number" &&
1447
+ Number.isInteger(decoded.offset) &&
1448
+ decoded.offset >= 0
1449
+ ? decoded.offset
1450
+ : null;
1451
+ }
1452
+ catch {
1453
+ return null;
1454
+ }
1455
+ }
1456
+ /** Hits on one line share a single entry; document order is preserved. */
1457
+ function groupMatchesByLine(matches) {
1458
+ const groups = [];
1459
+ for (const { document: { shortRef }, range: { start }, referenceProjection: { excerpt }, } of matches) {
1460
+ const group = groups.at(-1);
1461
+ if (group?.shortRef === shortRef && group.line === start.line)
1462
+ group.columns.push(start.column);
1463
+ else
1464
+ groups.push({
1465
+ shortRef,
1466
+ line: start.line,
1467
+ columns: [start.column],
1468
+ excerpt,
1469
+ });
1470
+ }
1471
+ return groups;
1302
1472
  }
1303
1473
  const invalidScope = Symbol("invalid-scope");
1304
1474
  function searchScope(value, surface) {
@@ -1332,7 +1502,7 @@ function readDocument(snapshot, args, reads, surface, locale) {
1332
1502
  return failure("Special files require one complete text read", locale);
1333
1503
  reads.opaquePaths.add(path);
1334
1504
  const projected = projectOpaqueDocumentIds(snapshot, path, opaque.contents);
1335
- return success(`# ${localized(locale, "Special-file source", "专用文件原文")} ${path}\n\n${projected}\n\n---\nComplete: yes`);
1505
+ return success(`# ${localized(locale, "Special-file source", "专用文件原文")} ${path}\n\n${projected}`);
1336
1506
  }
1337
1507
  const selector = documentSelector(path, surface);
1338
1508
  if (selector === null)
@@ -1352,7 +1522,7 @@ function readDocument(snapshot, args, reads, surface, locale) {
1352
1522
  if (result.kind !== "read_document")
1353
1523
  throw new Error("Unexpected read result");
1354
1524
  reads.worldDocumentIds.add(result.document.documentId);
1355
- return success(`# ${localized(locale, "Exact read", "精确读取")} @${result.document.shortRef}\n\ntitle: ${result.document.title}\nsummary: ${result.document.summary}\ncodec: ${result.document.codec}\nlogicalPath: ${result.document.logicalPath}\n[body]\n${result.body.trimEnd()}\n[/body]\n\n---\nComplete: yes`);
1525
+ return success(`# ${localized(locale, "Exact read", "精确读取")} @${result.document.shortRef}\n\ntitle: ${result.document.title}\nsummary: ${result.document.summary}\ncodec: ${result.document.codec}\nlogicalPath: ${result.document.logicalPath}\n[body]\n${result.body.trimEnd()}\n[/body]`);
1356
1526
  }
1357
1527
  function repairableDamagedWorldRead(snapshot, path, surface, result) {
1358
1528
  if (!path.startsWith(`${surface.root}/`) ||
@@ -1512,6 +1682,22 @@ function safePath(path, surface) {
1512
1682
  throw new SettingAuthoringError(`Unsafe ${surface.phrases.en.slug} path: ${path}`);
1513
1683
  return path;
1514
1684
  }
1685
+ const patchOps = [
1686
+ "add",
1687
+ "replace",
1688
+ "append",
1689
+ "remove",
1690
+ "set_metadata",
1691
+ ];
1692
+ function isPatchOp(value) {
1693
+ return patchOps.includes(value);
1694
+ }
1695
+ function requiredPatchOp(args, surface) {
1696
+ const op = args.op;
1697
+ if (isPatchOp(op))
1698
+ return op;
1699
+ throw new SettingAuthoringError(`${authoringToolName(surface, "patch")} document ${JSON.stringify(args.document)} received op ${JSON.stringify(op)}; valid ops: ${patchOps.join(", ")}`);
1700
+ }
1515
1701
  function requiredString(value, name) {
1516
1702
  if (typeof value !== "string" || value.length === 0)
1517
1703
  throw new SettingAuthoringError(`${name} must be a non-empty string`);
@@ -1589,3 +1775,140 @@ function sortFiles(files) {
1589
1775
  function localized(locale, english, chinese) {
1590
1776
  return locale === "zh-CN" ? chinese : english;
1591
1777
  }
1778
+ /**
1779
+ * Builds the complete-read preload for one bootstrap. Every document body goes
1780
+ * through the same read path the model's read tool uses, so the text matches it
1781
+ * byte for byte and the read authority is exactly what that path would grant.
1782
+ */
1783
+ export function buildAuthoringPreload(input) {
1784
+ const { snapshot, surface, locale, sourcePrefix } = input;
1785
+ const reads = {
1786
+ snapshotId: snapshot.id,
1787
+ worldDocumentIds: new Set(),
1788
+ damagedWorldPaths: new Set(),
1789
+ opaquePaths: new Set(),
1790
+ };
1791
+ const blocks = [
1792
+ {
1793
+ source: `${sourcePrefix}/directory`,
1794
+ markdown: preloadDirectory(snapshot, surface, locale),
1795
+ },
1796
+ ];
1797
+ const controlPaths = snapshot.files
1798
+ .map(({ path }) => path)
1799
+ .filter(isPreloadControlPath)
1800
+ .sort((left, right) => controlRank(left) - controlRank(right) || left.localeCompare(right));
1801
+ for (const path of controlPaths) {
1802
+ const result = readDocument(snapshot, { path }, reads, surface, locale);
1803
+ if (result.ok)
1804
+ blocks.push({
1805
+ source: `${sourcePrefix}/${path}`,
1806
+ markdown: result.markdown,
1807
+ });
1808
+ }
1809
+ const seen = new Set();
1810
+ for (const { shortRef, slot } of input.injected) {
1811
+ if (seen.has(shortRef))
1812
+ continue;
1813
+ seen.add(shortRef);
1814
+ const document = snapshot.query({
1815
+ kind: "read_document",
1816
+ document: { shortRef },
1817
+ referenceProjection: "short_ref",
1818
+ });
1819
+ if (document.kind !== "read_document")
1820
+ continue;
1821
+ const result = readDocument(snapshot, { path: `@${shortRef}` }, reads, surface, locale);
1822
+ if (!result.ok)
1823
+ continue;
1824
+ blocks.push({
1825
+ source: `${sourcePrefix}/${document.document.logicalPath}`,
1826
+ markdown: `${localized(locale, `Injected by slot ${slot}`, `由槽位 ${slot} 注入`)}\n\n${result.markdown}`,
1827
+ });
1828
+ }
1829
+ if (input.injectedUnavailable !== undefined)
1830
+ blocks.push({
1831
+ source: `${sourcePrefix}/injected-unavailable`,
1832
+ markdown: localized(locale, `# Injected documents unavailable\n\nThe play preview could not be built (${input.injectedUnavailable}), so documents injected by the world frame are not preloaded. Read them before writing.`, `# 注入文档不可用\n\n游玩预览无法生成(${input.injectedUnavailable}),因此未预载世界框架注入的文档。写入前请先读取它们。`),
1833
+ });
1834
+ blocks.push({
1835
+ source: `${sourcePrefix}/runtime`,
1836
+ markdown: localized(locale, `# Runtime preload note\n\nThe documents above are a snapshot of the worktree at revision ${input.revision}, taken when this request was sent.`, `# Runtime 预载说明\n\n以上内容是本次请求发出时工作树 revision ${input.revision} 的快照。`),
1837
+ });
1838
+ return {
1839
+ blocks,
1840
+ record: {
1841
+ revision: input.revision,
1842
+ worldDocumentIds: [...reads.worldDocumentIds].sort(),
1843
+ opaquePaths: [...reads.opaquePaths].sort(),
1844
+ },
1845
+ };
1846
+ }
1847
+ /** Merges a preload's read authority into the authorization of the same revision. */
1848
+ export function authoringPreloadAuthorization(current, preload) {
1849
+ const same = current?.revision === preload.revision;
1850
+ return {
1851
+ revision: preload.revision,
1852
+ readWorldDocumentIds: sortedUnion(same ? current.readWorldDocumentIds : [], preload.worldDocumentIds),
1853
+ readableDamagedWorldPaths: same
1854
+ ? [...current.readableDamagedWorldPaths]
1855
+ : [],
1856
+ readOpaquePaths: sortedUnion(same ? current.readOpaquePaths : [], preload.opaquePaths),
1857
+ };
1858
+ }
1859
+ function sortedUnion(left, right) {
1860
+ return [...new Set([...left, ...right])].sort();
1861
+ }
1862
+ const preloadControlFiles = [
1863
+ "control/frame.yaml",
1864
+ "control/player-views.yaml",
1865
+ "control/gameplay.json",
1866
+ "control/followups.yaml",
1867
+ ];
1868
+ function isPreloadControlPath(path) {
1869
+ return (preloadControlFiles.includes(path) ||
1870
+ /^control\/blocks\/[^/]+\.md$/u.test(path));
1871
+ }
1872
+ function controlRank(path) {
1873
+ const index = preloadControlFiles.indexOf(path);
1874
+ return index === -1 ? preloadControlFiles.length : index;
1875
+ }
1876
+ function preloadDirectory(snapshot, surface, locale) {
1877
+ const entries = snapshot.files
1878
+ .filter(({ path }) => !path.startsWith(`${surface.root}/`))
1879
+ .map(({ path, encoding }) => encoding === undefined ? `- [special] ${path}` : `- [binary] ${path}`);
1880
+ entries.push(...preloadCatalogLines(snapshot, surface.root, ""));
1881
+ return `# ${localized(locale, surface.phrases.en.directoryHeading, surface.phrases["zh-CN"].directoryHeading)}\n\n${entries.join("\n") || localized(locale, "(empty)", "(空)")}\n\nComplete: yes`;
1882
+ }
1883
+ /**
1884
+ * Depth-first walk of one directory: documents are listed in place, and each
1885
+ * subdirectory is expanded. A subdirectory yields a `[dir]` line only when it
1886
+ * holds nothing, since every non-empty listing produces at least one line.
1887
+ */
1888
+ function preloadCatalogLines(snapshot, root, directory) {
1889
+ const lines = [];
1890
+ let cursor = null;
1891
+ do {
1892
+ const result = snapshot.query({
1893
+ kind: "catalog",
1894
+ directory,
1895
+ limit: 100,
1896
+ ...(cursor === null ? {} : { cursor }),
1897
+ });
1898
+ if (result.kind === "error")
1899
+ throw new Error("The authoring directory could not be listed");
1900
+ if (result.kind !== "catalog")
1901
+ throw new Error("Unexpected catalog result");
1902
+ for (const entry of result.entries) {
1903
+ if (entry.kind === "document") {
1904
+ lines.push(catalogEntryLine(entry));
1905
+ continue;
1906
+ }
1907
+ const child = entry.logicalPath.slice(`${root}/`.length);
1908
+ const childLines = preloadCatalogLines(snapshot, root, child);
1909
+ lines.push(...(childLines.length === 0 ? [catalogEntryLine(entry)] : childLines));
1910
+ }
1911
+ cursor = result.page.nextCursor;
1912
+ } while (cursor !== null);
1913
+ return lines;
1914
+ }