narraeon 0.7.7 → 0.7.9

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 +326 -62
  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,7 +282,7 @@ 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.",
@@ -293,7 +292,7 @@ const toolDescriptionsEn = (tabletop) => ({
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 自行完成存储封装。创建成功的文档视为已经完整读取。",
@@ -303,7 +302,7 @@ const toolDescriptionsZhCN = (tabletop) => ({
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.",
@@ -313,7 +312,7 @@ const worldRevisionDescriptionsEn = (tabletop) => ({
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 自行完成存储封装。创建成功的文档视为已经完整读取。",
@@ -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;
@@ -667,16 +668,51 @@ function buildSettingAuthoringPlayCoverage(snapshot, preview, diff, surface) {
667
668
  }
668
669
  return {
669
670
  totals,
670
- changed: diff.map(({ path, after }) => {
671
- const access = changedPathPlayAccess(path, after, accessByPath, enabledWorldInstructionPaths);
671
+ changed: diff.map(({ path, before, after }) => {
672
+ const movedTo = after === null
673
+ ? movedDocumentPath(before, path, snapshot, surface)
674
+ : null;
675
+ const access = movedTo !== null
676
+ ? "moved"
677
+ : changedPathPlayAccess(path, after, accessByPath, enabledWorldInstructionPaths);
672
678
  return {
673
679
  path,
674
680
  access,
675
681
  detail: playAccessDetail(access, surface),
682
+ ...(movedTo === null ? {} : { movedTo }),
676
683
  };
677
684
  }),
678
685
  };
679
686
  }
687
+ /**
688
+ * A deleted-looking world document whose identity survives at another path
689
+ * was moved, not removed: the old path is reported with its new location.
690
+ */
691
+ function movedDocumentPath(before, path, snapshot, surface) {
692
+ if (before === null)
693
+ return null;
694
+ const previous = WorldDocumentStore.open({
695
+ layout: surface.layout,
696
+ files: [{ path, contents: before }],
697
+ });
698
+ if (previous.status !== "usable")
699
+ return null;
700
+ const identity = previous.query({
701
+ kind: "read_document",
702
+ document: { logicalPath: path },
703
+ });
704
+ if (identity.kind !== "read_document" || !identity.ok)
705
+ return null;
706
+ const current = snapshot.query({
707
+ kind: "read_document",
708
+ document: { documentId: identity.scope.document.documentId },
709
+ });
710
+ if (current.kind !== "read_document" || !current.ok)
711
+ return null;
712
+ return current.scope.document.logicalPath === path
713
+ ? null
714
+ : current.scope.document.logicalPath;
715
+ }
680
716
  function changedPathPlayAccess(path, after, accessByPath, enabledWorldInstructionPaths) {
681
717
  if (after === null)
682
718
  return "removed";
@@ -715,6 +751,7 @@ function playAccessDetail(access, surface) {
715
751
  play_control: "compiled as immutable play-author control",
716
752
  unused_control: "not enabled by control/frame.yaml and unavailable to ordinary play AI",
717
753
  player_view: "projected through exact player-view selectors",
754
+ moved: "its document identity continues at the new path",
718
755
  removed: `removed from ${surface.phrases.en.bareTree}`,
719
756
  };
720
757
  return details[access];
@@ -731,6 +768,7 @@ function renderPlayCoverage(coverage, surface, locale) {
731
768
  play_control: "游玩控制",
732
769
  unused_control: "未被 control/frame.yaml 启用",
733
770
  player_view: "玩家视图精确投影",
771
+ moved: "文档身份已延续到新路径",
734
772
  removed: `已从${surface.phrases["zh-CN"].tree}删除`,
735
773
  }
736
774
  : {
@@ -743,11 +781,16 @@ function renderPlayCoverage(coverage, surface, locale) {
743
781
  play_control: "play-author control",
744
782
  unused_control: "not enabled by control/frame.yaml",
745
783
  player_view: "exact player-view projection",
784
+ moved: "identity continues at the new path",
746
785
  removed: `removed from ${surface.phrases.en.bareTree}`,
747
786
  };
748
787
  const maximumReportedChanges = 64;
749
788
  const reportedChanges = coverage.changed.slice(0, maximumReportedChanges);
750
- const changed = reportedChanges.map(({ path, access }) => `- ${path} — ${labels[access]}`);
789
+ const changed = reportedChanges.map(({ path, access, movedTo }) => {
790
+ if (access === "moved" && movedTo !== undefined)
791
+ return localized(locale, `- ${path} — moved to ${movedTo}`, `- ${path} — 已移动到 ${movedTo}`);
792
+ return `- ${path} — ${labels[access]}`;
793
+ });
751
794
  const onDemand = coverage.changed.filter(({ access }) => access === "on_demand");
752
795
  const unusedControl = coverage.changed.filter(({ access }) => access === "unused_control");
753
796
  return [
@@ -783,7 +826,7 @@ class SettingAuthoringError extends Error {
783
826
  this.name = "SettingAuthoringError";
784
827
  }
785
828
  }
786
- function normalizeCall(call, surface, locale) {
829
+ function normalizeCall(call, surface, locale, additionalToolNames) {
787
830
  const cloned = isRecord(call.arguments)
788
831
  ? structuredClone(call.arguments)
789
832
  : {};
@@ -797,7 +840,7 @@ function normalizeCall(call, surface, locale) {
797
840
  operation: null,
798
841
  arguments: cloned,
799
842
  coerced: [],
800
- rejection: unknownToolMessage(call.name, provided, locale),
843
+ rejection: unknownToolMessage(call.name, [...provided, ...additionalToolNames], locale),
801
844
  };
802
845
  const name = resolved.name;
803
846
  const schema = definitions.find((definition) => definition.name === name)?.inputSchema;
@@ -895,7 +938,7 @@ function worldRevisionCommand(snapshot, call, surface, locale) {
895
938
  };
896
939
  }
897
940
  if (call.operation === "patch") {
898
- const op = requiredString(call.arguments.op, "op");
941
+ const op = requiredPatchOp(call.arguments, surface);
899
942
  if (op === "set_metadata") {
900
943
  const title = requiredString(call.arguments.title, "title");
901
944
  const summary = requiredString(call.arguments.summary, "summary");
@@ -907,8 +950,6 @@ function worldRevisionCommand(snapshot, call, surface, locale) {
907
950
  edits: [{ op: "set_metadata", title, summary, aliases }],
908
951
  };
909
952
  }
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
953
  const locator = requiredYamlLocatorPath(call.arguments.locator, "locator");
913
954
  if (op === "remove") {
914
955
  if (!hasOnly(call.arguments, ["document", "op", "locator"]))
@@ -1061,7 +1102,7 @@ function deleteWorldDocument(snapshot, call, reads, surface, locale) {
1061
1102
  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
1103
  };
1063
1104
  }
1064
- function settingDocumentDeletionBlockers(snapshot, target) {
1105
+ export function settingDocumentDeletionBlockers(snapshot, target) {
1065
1106
  const blockers = [];
1066
1107
  for (const document of listQueryableWorldDocuments(snapshot)) {
1067
1108
  if (document.documentId === target.documentId || document.codec !== "yaml")
@@ -1166,7 +1207,7 @@ function writeOpaque(snapshot, call, reads, surface, locale) {
1166
1207
  return {
1167
1208
  snapshot: revised,
1168
1209
  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 的精确序列化结果,否则不要再次读取。`),
1210
+ 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
1211
  ...(gameplay === null
1171
1212
  ? []
1172
1213
  : [
@@ -1218,8 +1259,6 @@ function renderWorldWriteSuccess(changes, locale) {
1218
1259
  return `- ${localized(locale, "Updated", "更新")} @${change.shortRef} · ${change.after.logicalPath}`;
1219
1260
  return `- ${localized(locale, "Moved", "移动")} @${change.shortRef} · ${change.before.logicalPath} → ${change.after.logicalPath}`;
1220
1261
  })),
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
1262
  ].join("\n");
1224
1263
  }
1225
1264
  function renderRevisionFailure(diagnostics, surface, locale) {
@@ -1230,41 +1269,43 @@ function renderRevisionFailure(diagnostics, surface, locale) {
1230
1269
  function listDocuments(snapshot, args, surface, locale) {
1231
1270
  const tool = authoringToolName(surface, "list");
1232
1271
  const root = surface.root;
1272
+ // Frozen sessions may still send obsolete limit/cursor; directories are
1273
+ // always listed in full, so those keys are accepted and ignored.
1233
1274
  if (!hasOnly(args, ["directory", "limit", "cursor"]))
1234
- return failure(`${tool} accepts only directory, limit, and cursor`, locale);
1275
+ return failure(`${tool} accepts only directory`, locale);
1235
1276
  const directory = args.directory ?? root;
1236
- const limit = args.limit ?? 100;
1237
1277
  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);
1278
+ (directory !== root && !directory.startsWith(`${root}/`)))
1279
+ return failure(`Invalid ${tool} directory`, locale);
1245
1280
  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)
1281
+ const entries = [];
1282
+ let cursor = null;
1283
+ do {
1284
+ const result = snapshot.query({
1285
+ kind: "catalog",
1286
+ directory: relative,
1287
+ limit: 100,
1288
+ ...(cursor === null ? {} : { cursor }),
1289
+ });
1290
+ if (result.kind === "error")
1291
+ return renderQueryFailure(result, surface, locale);
1292
+ if (result.kind !== "catalog")
1293
+ throw new Error("Unexpected catalog result");
1294
+ entries.push(...result.entries.map(catalogEntryLine));
1295
+ cursor = result.page.nextCursor;
1296
+ } while (cursor !== null);
1297
+ if (relative === "")
1264
1298
  entries.unshift(...snapshot.files
1265
1299
  .filter(({ path }) => !path.startsWith(`${root}/`))
1266
1300
  .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)}`);
1301
+ return success(`# ${localized(locale, surface.phrases.en.directoryHeading, surface.phrases["zh-CN"].directoryHeading)}\n\n${entries.join("\n") || localized(locale, "(empty)", "(空)")}\n\nComplete: yes`);
1302
+ }
1303
+ function catalogEntryLine(entry) {
1304
+ if (entry.kind === "directory")
1305
+ return `- [dir] ${entry.logicalPath}/`;
1306
+ if (entry.document === undefined)
1307
+ return `- [damaged] ${entry.logicalPath} · ${entry.diagnostics.map(({ code }) => code).join(", ")}`;
1308
+ return `- @${entry.document.shortRef} · ${entry.document.title} · ${entry.logicalPath}`;
1268
1309
  }
1269
1310
  function searchDocuments(snapshot, args, surface, locale) {
1270
1311
  const tool = authoringToolName(surface, "search");
@@ -1285,20 +1326,90 @@ function searchDocuments(snapshot, args, surface, locale) {
1285
1326
  const within = searchScope(args.within, surface);
1286
1327
  if (within === invalidScope)
1287
1328
  return failure(`Invalid ${tool} within scope`, locale);
1288
- const result = snapshot.query({
1289
- kind: "literal_search",
1329
+ const matches = [];
1330
+ let storeCursor = null;
1331
+ let snapshotId;
1332
+ do {
1333
+ const result = snapshot.query({
1334
+ kind: "literal_search",
1335
+ query,
1336
+ caseSensitive: args.caseSensitive === true,
1337
+ ...(within === undefined ? {} : { within }),
1338
+ limit: 100,
1339
+ ...(storeCursor === null ? {} : { cursor: storeCursor }),
1340
+ });
1341
+ if (result.kind === "error")
1342
+ return renderQueryFailure(result, surface, locale);
1343
+ if (result.kind !== "literal_search")
1344
+ throw new Error("Unexpected literal search result");
1345
+ matches.push(...result.matches);
1346
+ snapshotId = result.snapshotId;
1347
+ storeCursor = result.page.nextCursor;
1348
+ } while (storeCursor !== null);
1349
+ // Pages count whole document lines so one line never spans two pages.
1350
+ const groups = groupMatchesByLine(matches);
1351
+ const scope = createHash("sha256")
1352
+ .update(JSON.stringify({
1290
1353
  query,
1291
1354
  caseSensitive: args.caseSensitive === true,
1292
- ...(within === undefined ? {} : { within }),
1355
+ within: within ?? null,
1293
1356
  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)}`);
1357
+ snapshotId,
1358
+ }))
1359
+ .digest("base64url")
1360
+ .slice(0, 22);
1361
+ const offset = args.cursor === undefined ? 0 : decodeLineCursor(args.cursor, scope);
1362
+ if (offset === null || offset > groups.length)
1363
+ return failure(`Invalid ${tool} cursor`, locale);
1364
+ const page = groups.slice(offset, offset + limit);
1365
+ const end = offset + page.length;
1366
+ const complete = end >= groups.length;
1367
+ const lines = page.map(({ shortRef, line, columns, excerpt }) => `- @${shortRef} · ${line}:${columns.join(",")}\n ${JSON.stringify(excerpt)}`);
1368
+ 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({
1369
+ start: offset,
1370
+ end,
1371
+ total: groups.length,
1372
+ complete,
1373
+ nextCursor: complete ? null : encodeLineCursor(scope, end),
1374
+ }, locale)}`);
1375
+ }
1376
+ function encodeLineCursor(scope, offset) {
1377
+ return Buffer.from(JSON.stringify({ scope, offset })).toString("base64url");
1378
+ }
1379
+ /** Returns the grouped-line offset, or null when the cursor is not for this scope. */
1380
+ function decodeLineCursor(cursor, scope) {
1381
+ if (typeof cursor !== "string")
1382
+ return null;
1383
+ try {
1384
+ const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
1385
+ if (!isRecord(decoded) || decoded.scope !== scope)
1386
+ return null;
1387
+ return typeof decoded.offset === "number" &&
1388
+ Number.isInteger(decoded.offset) &&
1389
+ decoded.offset >= 0
1390
+ ? decoded.offset
1391
+ : null;
1392
+ }
1393
+ catch {
1394
+ return null;
1395
+ }
1396
+ }
1397
+ /** Hits on one line share a single entry; document order is preserved. */
1398
+ function groupMatchesByLine(matches) {
1399
+ const groups = [];
1400
+ for (const { document: { shortRef }, range: { start }, referenceProjection: { excerpt }, } of matches) {
1401
+ const group = groups.at(-1);
1402
+ if (group?.shortRef === shortRef && group.line === start.line)
1403
+ group.columns.push(start.column);
1404
+ else
1405
+ groups.push({
1406
+ shortRef,
1407
+ line: start.line,
1408
+ columns: [start.column],
1409
+ excerpt,
1410
+ });
1411
+ }
1412
+ return groups;
1302
1413
  }
1303
1414
  const invalidScope = Symbol("invalid-scope");
1304
1415
  function searchScope(value, surface) {
@@ -1332,7 +1443,7 @@ function readDocument(snapshot, args, reads, surface, locale) {
1332
1443
  return failure("Special files require one complete text read", locale);
1333
1444
  reads.opaquePaths.add(path);
1334
1445
  const projected = projectOpaqueDocumentIds(snapshot, path, opaque.contents);
1335
- return success(`# ${localized(locale, "Special-file source", "专用文件原文")} ${path}\n\n${projected}\n\n---\nComplete: yes`);
1446
+ return success(`# ${localized(locale, "Special-file source", "专用文件原文")} ${path}\n\n${projected}`);
1336
1447
  }
1337
1448
  const selector = documentSelector(path, surface);
1338
1449
  if (selector === null)
@@ -1352,7 +1463,7 @@ function readDocument(snapshot, args, reads, surface, locale) {
1352
1463
  if (result.kind !== "read_document")
1353
1464
  throw new Error("Unexpected read result");
1354
1465
  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`);
1466
+ 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
1467
  }
1357
1468
  function repairableDamagedWorldRead(snapshot, path, surface, result) {
1358
1469
  if (!path.startsWith(`${surface.root}/`) ||
@@ -1512,6 +1623,22 @@ function safePath(path, surface) {
1512
1623
  throw new SettingAuthoringError(`Unsafe ${surface.phrases.en.slug} path: ${path}`);
1513
1624
  return path;
1514
1625
  }
1626
+ const patchOps = [
1627
+ "add",
1628
+ "replace",
1629
+ "append",
1630
+ "remove",
1631
+ "set_metadata",
1632
+ ];
1633
+ function isPatchOp(value) {
1634
+ return patchOps.includes(value);
1635
+ }
1636
+ function requiredPatchOp(args, surface) {
1637
+ const op = args.op;
1638
+ if (isPatchOp(op))
1639
+ return op;
1640
+ throw new SettingAuthoringError(`${authoringToolName(surface, "patch")} document ${JSON.stringify(args.document)} received op ${JSON.stringify(op)}; valid ops: ${patchOps.join(", ")}`);
1641
+ }
1515
1642
  function requiredString(value, name) {
1516
1643
  if (typeof value !== "string" || value.length === 0)
1517
1644
  throw new SettingAuthoringError(`${name} must be a non-empty string`);
@@ -1589,3 +1716,140 @@ function sortFiles(files) {
1589
1716
  function localized(locale, english, chinese) {
1590
1717
  return locale === "zh-CN" ? chinese : english;
1591
1718
  }
1719
+ /**
1720
+ * Builds the complete-read preload for one bootstrap. Every document body goes
1721
+ * through the same read path the model's read tool uses, so the text matches it
1722
+ * byte for byte and the read authority is exactly what that path would grant.
1723
+ */
1724
+ export function buildAuthoringPreload(input) {
1725
+ const { snapshot, surface, locale, sourcePrefix } = input;
1726
+ const reads = {
1727
+ snapshotId: snapshot.id,
1728
+ worldDocumentIds: new Set(),
1729
+ damagedWorldPaths: new Set(),
1730
+ opaquePaths: new Set(),
1731
+ };
1732
+ const blocks = [
1733
+ {
1734
+ source: `${sourcePrefix}/directory`,
1735
+ markdown: preloadDirectory(snapshot, surface, locale),
1736
+ },
1737
+ ];
1738
+ const controlPaths = snapshot.files
1739
+ .map(({ path }) => path)
1740
+ .filter(isPreloadControlPath)
1741
+ .sort((left, right) => controlRank(left) - controlRank(right) || left.localeCompare(right));
1742
+ for (const path of controlPaths) {
1743
+ const result = readDocument(snapshot, { path }, reads, surface, locale);
1744
+ if (result.ok)
1745
+ blocks.push({
1746
+ source: `${sourcePrefix}/${path}`,
1747
+ markdown: result.markdown,
1748
+ });
1749
+ }
1750
+ const seen = new Set();
1751
+ for (const { shortRef, slot } of input.injected) {
1752
+ if (seen.has(shortRef))
1753
+ continue;
1754
+ seen.add(shortRef);
1755
+ const document = snapshot.query({
1756
+ kind: "read_document",
1757
+ document: { shortRef },
1758
+ referenceProjection: "short_ref",
1759
+ });
1760
+ if (document.kind !== "read_document")
1761
+ continue;
1762
+ const result = readDocument(snapshot, { path: `@${shortRef}` }, reads, surface, locale);
1763
+ if (!result.ok)
1764
+ continue;
1765
+ blocks.push({
1766
+ source: `${sourcePrefix}/${document.document.logicalPath}`,
1767
+ markdown: `${localized(locale, `Injected by slot ${slot}`, `由槽位 ${slot} 注入`)}\n\n${result.markdown}`,
1768
+ });
1769
+ }
1770
+ if (input.injectedUnavailable !== undefined)
1771
+ blocks.push({
1772
+ source: `${sourcePrefix}/injected-unavailable`,
1773
+ 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}),因此未预载世界框架注入的文档。写入前请先读取它们。`),
1774
+ });
1775
+ blocks.push({
1776
+ source: `${sourcePrefix}/runtime`,
1777
+ 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} 的快照。`),
1778
+ });
1779
+ return {
1780
+ blocks,
1781
+ record: {
1782
+ revision: input.revision,
1783
+ worldDocumentIds: [...reads.worldDocumentIds].sort(),
1784
+ opaquePaths: [...reads.opaquePaths].sort(),
1785
+ },
1786
+ };
1787
+ }
1788
+ /** Merges a preload's read authority into the authorization of the same revision. */
1789
+ export function authoringPreloadAuthorization(current, preload) {
1790
+ const same = current?.revision === preload.revision;
1791
+ return {
1792
+ revision: preload.revision,
1793
+ readWorldDocumentIds: sortedUnion(same ? current.readWorldDocumentIds : [], preload.worldDocumentIds),
1794
+ readableDamagedWorldPaths: same
1795
+ ? [...current.readableDamagedWorldPaths]
1796
+ : [],
1797
+ readOpaquePaths: sortedUnion(same ? current.readOpaquePaths : [], preload.opaquePaths),
1798
+ };
1799
+ }
1800
+ function sortedUnion(left, right) {
1801
+ return [...new Set([...left, ...right])].sort();
1802
+ }
1803
+ const preloadControlFiles = [
1804
+ "control/frame.yaml",
1805
+ "control/player-views.yaml",
1806
+ "control/gameplay.json",
1807
+ "control/followups.yaml",
1808
+ ];
1809
+ function isPreloadControlPath(path) {
1810
+ return (preloadControlFiles.includes(path) ||
1811
+ /^control\/blocks\/[^/]+\.md$/u.test(path));
1812
+ }
1813
+ function controlRank(path) {
1814
+ const index = preloadControlFiles.indexOf(path);
1815
+ return index === -1 ? preloadControlFiles.length : index;
1816
+ }
1817
+ function preloadDirectory(snapshot, surface, locale) {
1818
+ const entries = snapshot.files
1819
+ .filter(({ path }) => !path.startsWith(`${surface.root}/`))
1820
+ .map(({ path, encoding }) => encoding === undefined ? `- [special] ${path}` : `- [binary] ${path}`);
1821
+ entries.push(...preloadCatalogLines(snapshot, surface.root, ""));
1822
+ return `# ${localized(locale, surface.phrases.en.directoryHeading, surface.phrases["zh-CN"].directoryHeading)}\n\n${entries.join("\n") || localized(locale, "(empty)", "(空)")}\n\nComplete: yes`;
1823
+ }
1824
+ /**
1825
+ * Depth-first walk of one directory: documents are listed in place, and each
1826
+ * subdirectory is expanded. A subdirectory yields a `[dir]` line only when it
1827
+ * holds nothing, since every non-empty listing produces at least one line.
1828
+ */
1829
+ function preloadCatalogLines(snapshot, root, directory) {
1830
+ const lines = [];
1831
+ let cursor = null;
1832
+ do {
1833
+ const result = snapshot.query({
1834
+ kind: "catalog",
1835
+ directory,
1836
+ limit: 100,
1837
+ ...(cursor === null ? {} : { cursor }),
1838
+ });
1839
+ if (result.kind === "error")
1840
+ throw new Error("The authoring directory could not be listed");
1841
+ if (result.kind !== "catalog")
1842
+ throw new Error("Unexpected catalog result");
1843
+ for (const entry of result.entries) {
1844
+ if (entry.kind === "document") {
1845
+ lines.push(catalogEntryLine(entry));
1846
+ continue;
1847
+ }
1848
+ const child = entry.logicalPath.slice(`${root}/`.length);
1849
+ const childLines = preloadCatalogLines(snapshot, root, child);
1850
+ lines.push(...(childLines.length === 0 ? [catalogEntryLine(entry)] : childLines));
1851
+ }
1852
+ cursor = result.page.nextCursor;
1853
+ } while (cursor !== null);
1854
+ return lines;
1855
+ }