narraeon 0.7.2 → 0.7.5

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 (27) hide show
  1. package/dist/node/protocol/v1.js +8 -2
  2. package/dist/node/runtime/V1Runtime.js +12 -3
  3. package/dist/node/runtime/archive/WorldArchives.js +17 -5
  4. package/dist/node/runtime/content/ContentWorkspace.js +2 -2
  5. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +98 -165
  6. package/dist/node/runtime/content/FileNativeContentTree.js +16 -13
  7. package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
  8. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +104 -21
  9. package/dist/node/runtime/play/PlayAdvanceLifecycle.js +211 -0
  10. package/dist/node/runtime/play/PlayCallChain.js +200 -374
  11. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +312 -167
  12. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +5 -0
  13. package/dist/node/runtime/world/FileNativeWorldStore.js +151 -108
  14. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -1
  15. package/dist/node/runtime/world/WorldDocumentStore.js +1 -1
  16. package/dist/node/runtime/world/WorldLedgerAuthority.js +37 -3
  17. package/dist/node/runtime/world/WorldTurnLedger.js +17 -4
  18. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -4
  19. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +19 -241
  20. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +8 -27
  21. package/dist/node/server/createServer.js +44 -20
  22. package/dist/web/assets/index-9N9kO8kY.js +1408 -0
  23. package/dist/web/assets/index-BjxBYYQi.css +1 -0
  24. package/dist/web/index.html +2 -2
  25. package/package.json +1 -1
  26. package/dist/web/assets/index-BUCslLEK.css +0 -1
  27. package/dist/web/assets/index-DkamBH6c.js +0 -1408
@@ -18,6 +18,16 @@ export const settingImprovementToolNames = [
18
18
  "setting_move",
19
19
  "setting_delete",
20
20
  ];
21
+ const authoringOperations = [
22
+ "list",
23
+ "search",
24
+ "read",
25
+ "create",
26
+ "write_file",
27
+ "patch",
28
+ "move",
29
+ "delete",
30
+ ];
21
31
  const frameExample = `format: narraeon.world-frame/v1
22
32
  bindings:
23
33
  currentSituation: "@current-situation"
@@ -153,10 +163,13 @@ export function settingImprovementRuntimeContract(locale, gameplay) {
153
163
  - Passing mechanical checks does not make content discoverable during play. Whenever important information is created or reorganized, decide its natural owner, initial injection or discovery path, future update location, and player-visible projection together.`;
154
164
  }
155
165
  export function settingImprovementToolDefinitions(locale, gameplay) {
166
+ return authoringToolDefinitions(contentPackageAuthoringSurface, locale, gameplay);
167
+ }
168
+ /** Tool definitions a surface exposes; names, paths and descriptions come from the surface. */
169
+ export function authoringToolDefinitions(surface, locale, gameplay) {
156
170
  const tabletop = gameplay.mode === "tabletop";
157
- const descriptions = locale === "zh-CN"
158
- ? toolDescriptionsZhCN(tabletop)
159
- : toolDescriptionsEn(tabletop);
171
+ const descriptions = surface.descriptions[locale](tabletop);
172
+ const name = (operation) => authoringToolName(surface, operation);
160
173
  const schema = (properties, required) => ({
161
174
  type: "object",
162
175
  additionalProperties: false,
@@ -169,17 +182,20 @@ export function settingImprovementToolDefinitions(locale, gameplay) {
169
182
  const limit = { type: "integer", minimum: 1, maximum: 100 };
170
183
  return [
171
184
  {
172
- name: "setting_list",
173
- description: descriptions.setting_list,
185
+ name: name("list"),
186
+ description: descriptions.list,
174
187
  inputSchema: schema({
175
- directory: { type: "string", pattern: "^world(?:/[^\\\\]+)?$" },
188
+ directory: {
189
+ type: "string",
190
+ pattern: `^${surface.root}(?:/[^\\\\]+)?$`,
191
+ },
176
192
  limit,
177
193
  cursor,
178
194
  }, []),
179
195
  },
180
196
  {
181
- name: "setting_search",
182
- description: descriptions.setting_search,
197
+ name: name("search"),
198
+ description: descriptions.search,
183
199
  inputSchema: schema({
184
200
  query: { type: "string", minLength: 1, maxLength: 256 },
185
201
  within: path,
@@ -189,17 +205,17 @@ export function settingImprovementToolDefinitions(locale, gameplay) {
189
205
  }, ["query"]),
190
206
  },
191
207
  {
192
- name: "setting_read",
193
- description: descriptions.setting_read,
208
+ name: name("read"),
209
+ description: descriptions.read,
194
210
  inputSchema: schema({ path }, ["path"]),
195
211
  },
196
212
  {
197
- name: "setting_create",
198
- description: descriptions.setting_create,
213
+ name: name("create"),
214
+ description: descriptions.create,
199
215
  inputSchema: schema({
200
216
  path: {
201
217
  type: "string",
202
- pattern: "^world/.+\\.(?:ya?ml|md)$",
218
+ pattern: `^${surface.root}/.+\\.(?:ya?ml|md)$`,
203
219
  },
204
220
  ref: { type: "string", pattern: "^[a-z][a-z0-9-]{1,31}$" },
205
221
  title: { type: "string", minLength: 1, maxLength: 120 },
@@ -213,8 +229,8 @@ export function settingImprovementToolDefinitions(locale, gameplay) {
213
229
  }, ["path", "ref", "title", "summary", "aliases", "body"]),
214
230
  },
215
231
  {
216
- name: "setting_write_file",
217
- description: descriptions.setting_write_file,
232
+ name: name("write_file"),
233
+ description: descriptions.write_file,
218
234
  inputSchema: schema({
219
235
  path,
220
236
  contents: text,
@@ -229,8 +245,8 @@ export function settingImprovementToolDefinitions(locale, gameplay) {
229
245
  }, ["path", "contents"]),
230
246
  },
231
247
  {
232
- name: "setting_patch",
233
- description: descriptions.setting_patch,
248
+ name: name("patch"),
249
+ description: descriptions.patch,
234
250
  inputSchema: schema({
235
251
  document: text,
236
252
  op: {
@@ -253,40 +269,130 @@ export function settingImprovementToolDefinitions(locale, gameplay) {
253
269
  }, ["document", "op"]),
254
270
  },
255
271
  {
256
- name: "setting_move",
257
- description: descriptions.setting_move,
272
+ name: name("move"),
273
+ description: descriptions.move,
258
274
  inputSchema: schema({ from: text, to: path }, ["from", "to"]),
259
275
  },
260
276
  {
261
- name: "setting_delete",
262
- description: descriptions.setting_delete,
277
+ name: name("delete"),
278
+ description: descriptions.delete,
263
279
  inputSchema: schema({ document: text }, ["document"]),
264
280
  },
265
281
  ];
266
282
  }
267
283
  const toolDescriptionsEn = (tabletop) => ({
268
- setting_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.",
269
- setting_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.",
270
- setting_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.",
271
- setting_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.",
272
- setting_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.`,
273
- setting_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.',
274
- setting_move: "Move a completely read world document to a new world/ .yaml or .md logical path while preserving its identity and contents.",
275
- setting_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.",
284
+ 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
+ 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.",
286
+ 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.",
287
+ 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.",
288
+ 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.`,
289
+ 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.',
290
+ move: "Move a completely read world document to a new world/ .yaml or .md logical path while preserving its identity and contents.",
291
+ 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.",
276
292
  });
277
293
  const toolDescriptionsZhCN = (tabletop) => ({
278
- setting_list: "列出内容包当前树的 world 根目录或 world/ 子目录;根目录同时列出 opening 和 control 专用文件。cursor 只对同一查询和当前树 revision 有效。",
279
- setting_search: "在内容包当前树的 world 文档中按原文字面搜索;within 可为 world、world/ 目录或路径或 @ref。",
280
- setting_read: "按 world/ 路径或 @ref 完整读取世界文档,或按精确路径完整读取 opening/control;既有文档选择值自动投影为 @ref。结果不会截断或分页。",
281
- setting_create: "创建一份 world/ 下的 .yaml 或 .md 文档;提供唯一的小写 ASCII ref、标题、摘要、别名和不含技术头的正文,Runtime 自行完成存储封装。创建成功的文档视为已经完整读取。",
282
- setting_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。`,
283
- setting_patch: '细粒度更新已完整读取的 YAML 世界文档,不重写无关内容。locator 是非空路径,每段可以是 map key 或从 0 开始的数组下标;add 新建 map key 或数组位置,replace 更新既有节点,append 追加既有数组,remove 删除既有 map key 或数组项。title、summary 或 aliases 过时时用 op "set_metadata" 整组更新三项。Markdown 正文用 setting_write_file 整份重写。',
284
- setting_move: "把已完整读取的世界文档移动到新的 world/ .yaml 或 .md 逻辑路径,并保留身份与内容。",
285
- setting_delete: "从内容包当前树永久删除一份已完整读取的 world 文档。若 currentSituation、frame slot、player-view selector 或其他文档的 $ref 仍指向它,Runtime 会拒绝并返回全部精确阻挡位置;请先删除或改向这些引用。",
294
+ list: "列出内容包当前树的 world 根目录或 world/ 子目录;根目录同时列出 opening 和 control 专用文件。cursor 只对同一查询和当前树 revision 有效。",
295
+ search: "在内容包当前树的 world 文档中按原文字面搜索;within 可为 world、world/ 目录或路径或 @ref。",
296
+ read: "按 world/ 路径或 @ref 完整读取世界文档,或按精确路径完整读取 opening/control;既有文档选择值自动投影为 @ref。结果不会截断或分页。",
297
+ create: "创建一份 world/ 下的 .yaml 或 .md 文档;提供唯一的小写 ASCII ref、标题、摘要、别名和不含技术头的正文,Runtime 自行完成存储封装。创建成功的文档视为已经完整读取。",
298
+ 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。`,
299
+ patch: '细粒度更新已完整读取的 YAML 世界文档,不重写无关内容。locator 是非空路径,每段可以是 map key 或从 0 开始的数组下标;add 新建 map key 或数组位置,replace 更新既有节点,append 追加既有数组,remove 删除既有 map key 或数组项。title、summary 或 aliases 过时时用 op "set_metadata" 整组更新三项。Markdown 正文用 setting_write_file 整份重写。',
300
+ move: "把已完整读取的世界文档移动到新的 world/ .yaml 或 .md 逻辑路径,并保留身份与内容。",
301
+ delete: "从内容包当前树永久删除一份已完整读取的 world 文档。若 currentSituation、frame slot、player-view selector 或其他文档的 $ref 仍指向它,Runtime 会拒绝并返回全部精确阻挡位置;请先删除或改向这些引用。",
286
302
  });
303
+ const worldRevisionDescriptionsEn = (tabletop) => ({
304
+ 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
+ search: "Search literal source text in world documents in the world revision worktree. within may be state, a state/ directory or path, or @ref.",
306
+ 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.",
307
+ 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.",
308
+ 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.`,
309
+ 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.',
310
+ move: "Move a completely read world document to a new state/ .yaml or .md logical path while preserving its identity and contents.",
311
+ 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.",
312
+ });
313
+ const worldRevisionDescriptionsZhCN = (tabletop) => ({
314
+ list: "列出世界修订工作树的 state 根目录或 state/ 子目录;根目录同时列出 control 文件。cursor 只对同一查询和工作树 revision 有效。",
315
+ search: "在世界修订工作树的 state 文档中按原文字面搜索;within 可为 state、state/ 目录或路径或 @ref。",
316
+ read: "按 state/ 路径或 @ref 完整读取世界文档,或按精确路径完整读取 control 文件;既有文档选择值自动投影为 @ref。结果不会截断或分页。",
317
+ create: "创建一份 state/ 下的 .yaml 或 .md 文档;提供唯一的小写 ASCII ref、标题、摘要、别名和不含技术头的正文,Runtime 自行完成存储封装。创建成功的文档视为已经完整读取。",
318
+ 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。`,
319
+ 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 整份重写。',
320
+ move: "把已完整读取的世界文档移动到新的 state/ .yaml 或 .md 逻辑路径,并保留身份与内容。",
321
+ delete: "从世界修订工作树永久删除一份已完整读取的 world 文档。若 currentSituation、frame slot、player-view selector 或其他文档的 $ref 仍指向它,Runtime 会拒绝并返回全部精确阻挡位置;请先删除或改向这些引用。",
322
+ });
323
+ function worldRevisionUnavailablePath(path, locale) {
324
+ if (path === "opening.md")
325
+ return localized(locale, "opening.md is immutable after a world has been created. Edit state/* or control/* instead.", "世界创建后 opening.md 已成为不可变创世叙事。请改写 state/* 或 control/*。");
326
+ if (path === "world" || path.startsWith("world/"))
327
+ return localized(locale, "World revision exposes only state/* and control/* logical paths; use those instead.", "世界修订只公开 state/* 与 control/* 逻辑路径;请改用这两类路径。");
328
+ return null;
329
+ }
330
+ /** The content package's setting improvement: templates it edits, `world/` paths, and `opening.md`. */
331
+ export const contentPackageAuthoringSurface = {
332
+ layout: "content_package",
333
+ root: "world",
334
+ toolPrefix: "setting",
335
+ opening: true,
336
+ phrases: {
337
+ en: {
338
+ tree: "the content package's current tree",
339
+ bareTree: "the content package current tree",
340
+ noun: "the content package",
341
+ slug: "content-package",
342
+ directoryHeading: "Setting directory",
343
+ },
344
+ "zh-CN": {
345
+ tree: "内容包当前树",
346
+ bareTree: "内容包当前树",
347
+ noun: "内容包",
348
+ slug: "content-package",
349
+ directoryHeading: "设定目录",
350
+ },
351
+ },
352
+ descriptions: {
353
+ en: toolDescriptionsEn,
354
+ "zh-CN": toolDescriptionsZhCN,
355
+ },
356
+ unavailablePath: () => null,
357
+ };
358
+ /** A world revision: the state/control worktree of a world already in play. */
359
+ export const worldRevisionAuthoringSurface = {
360
+ layout: "world_revision",
361
+ root: "state",
362
+ toolPrefix: "world_revision",
363
+ opening: false,
364
+ phrases: {
365
+ en: {
366
+ tree: "the world revision worktree",
367
+ bareTree: "the world revision worktree",
368
+ noun: "the world revision worktree",
369
+ slug: "world-revision",
370
+ directoryHeading: "World revision directory",
371
+ },
372
+ "zh-CN": {
373
+ tree: "世界修订工作树",
374
+ bareTree: "世界修订工作树",
375
+ noun: "世界修订工作树",
376
+ slug: "world-revision",
377
+ directoryHeading: "世界修订目录",
378
+ },
379
+ },
380
+ descriptions: {
381
+ en: worldRevisionDescriptionsEn,
382
+ "zh-CN": worldRevisionDescriptionsZhCN,
383
+ },
384
+ unavailablePath: worldRevisionUnavailablePath,
385
+ };
386
+ function authoringToolName(surface, operation) {
387
+ return `${surface.toolPrefix}_${operation}`;
388
+ }
389
+ function authoringOperation(surface, name) {
390
+ return (authoringOperations.find((operation) => authoringToolName(surface, operation) === name) ?? null);
391
+ }
287
392
  export class SettingAuthoringTransaction {
288
393
  #baseFiles;
289
394
  #locale;
395
+ #surface;
290
396
  #preview;
291
397
  #validateFiles;
292
398
  #snapshot;
@@ -295,10 +401,11 @@ export class SettingAuthoringTransaction {
295
401
  constructor(input) {
296
402
  this.#baseFiles = cloneFiles(input.baseFiles);
297
403
  this.#locale = input.locale ?? defaultAppLocale;
404
+ this.#surface = input.surface ?? contentPackageAuthoringSurface;
298
405
  this.#preview = input.preview;
299
406
  this.#validateFiles = input.validateFiles ?? (() => undefined);
300
407
  this.#snapshot = WorldDocumentStore.open({
301
- layout: "content_package",
408
+ layout: this.#surface.layout,
302
409
  files: cloneFiles(input.baseFiles),
303
410
  });
304
411
  const authorization = input.authorization?.revision === (input.revision ?? this.#snapshot.id)
@@ -327,13 +434,23 @@ export class SettingAuthoringTransaction {
327
434
  };
328
435
  }
329
436
  execute(calls) {
330
- const normalized = calls.map(normalizeCall);
437
+ const normalized = calls.map((call) => normalizeCall(call, this.#surface));
331
438
  const results = [];
332
439
  const responseBase = this.files();
333
440
  let lastSuccessfulWrite = -1;
334
441
  for (const call of normalized) {
335
442
  const before = this.files();
336
- if (isReadTool(call.name)) {
443
+ const unavailable = unavailableArgument(this.#surface, call.arguments, this.#locale);
444
+ if (unavailable !== null) {
445
+ results.push({
446
+ toolCallId: call.id,
447
+ markdown: `# Runtime tool rejected\n\n${unavailable}`,
448
+ isError: true,
449
+ changes: [],
450
+ });
451
+ continue;
452
+ }
453
+ if (call.operation !== null && isReadOperation(call.operation)) {
337
454
  results.push(toToolResult(call.id, this.#executeRead(call), []));
338
455
  continue;
339
456
  }
@@ -347,39 +464,41 @@ export class SettingAuthoringTransaction {
347
464
  const settled = results[lastSuccessfulWrite];
348
465
  // The stored review stays cumulative for the whole user request; the
349
466
  // model only needs what this tool response changed.
350
- settled.markdown = `${settled.markdown}\n\n${renderAutomaticReview(this.#review, fileDiff(responseBase, this.#snapshot.files), this.#snapshot, this.#locale)}`;
467
+ settled.markdown = `${settled.markdown}\n\n${renderAutomaticReview(this.#review, fileDiff(responseBase, this.#snapshot.files), this.#snapshot, this.#surface, this.#locale)}`;
351
468
  }
352
469
  return results;
353
470
  }
354
471
  #executeRead(call) {
355
- if (call.name === "setting_list")
356
- return listDocuments(this.#snapshot, call.arguments, this.#locale);
357
- if (call.name === "setting_search")
358
- return searchDocuments(this.#snapshot, call.arguments, this.#locale);
359
- if (call.name === "setting_read")
360
- return readDocument(this.#snapshot, call.arguments, this.#reads, this.#locale);
472
+ if (call.operation === "list")
473
+ return listDocuments(this.#snapshot, call.arguments, this.#surface, this.#locale);
474
+ if (call.operation === "search")
475
+ return searchDocuments(this.#snapshot, call.arguments, this.#surface, this.#locale);
476
+ if (call.operation === "read")
477
+ return readDocument(this.#snapshot, call.arguments, this.#reads, this.#surface, this.#locale);
361
478
  return failure(`Unsupported read tool: ${call.name}`, this.#locale);
362
479
  }
363
480
  #executeWrite(call) {
364
481
  const snapshot = this.#snapshot;
365
482
  const reads = cloneReads(this.#reads);
366
483
  try {
367
- if (isWorldWriteCall(call)) {
368
- if (call.name === "setting_delete") {
369
- const deleted = deleteWorldDocument(snapshot, call, reads, this.#locale);
484
+ if (isWorldWriteCall(call, this.#surface)) {
485
+ if (call.operation === "delete") {
486
+ const deleted = deleteWorldDocument(snapshot, call, reads, this.#surface, this.#locale);
370
487
  this.#validateFiles(deleted.snapshot.files);
371
488
  this.#snapshot = deleted.snapshot;
372
489
  this.#reads = reads;
373
490
  return success(deleted.markdown);
374
491
  }
375
- const requestedRef = call.name === "setting_create"
492
+ const requestedRef = call.operation === "create"
376
493
  ? requiredSettingRef(call.arguments.ref, this.#locale)
377
494
  : null;
378
495
  const revised = snapshot.revise({
379
- commands: [worldRevisionCommand(snapshot, call, this.#locale)],
496
+ commands: [
497
+ worldRevisionCommand(snapshot, call, this.#surface, this.#locale),
498
+ ],
380
499
  });
381
500
  if (!revised.ok)
382
- throw new SettingAuthoringError(renderRevisionFailure(revised.diagnostics, this.#locale));
501
+ throw new SettingAuthoringError(renderRevisionFailure(revised.diagnostics, this.#surface, this.#locale));
383
502
  if (requestedRef !== null &&
384
503
  revised.changes.some(({ shortRef }) => shortRef !== requestedRef))
385
504
  throw new SettingAuthoringError(localized(this.#locale, `ref @${requestedRef} already exists; choose a different ref`, `ref @${requestedRef} 已存在;请换一个 ref`));
@@ -392,9 +511,9 @@ export class SettingAuthoringTransaction {
392
511
  this.#reads = reads;
393
512
  return success(renderWorldWriteSuccess(revised.changes, this.#locale));
394
513
  }
395
- if (call.name !== "setting_write_file")
514
+ if (call.operation !== "write_file")
396
515
  throw new SettingAuthoringError(`Unsupported write tool: ${call.name}`);
397
- const changed = writeOpaque(snapshot, call, reads, this.#locale);
516
+ const changed = writeOpaque(snapshot, call, reads, this.#surface, this.#locale);
398
517
  this.#validateFiles(changed.snapshot.files);
399
518
  this.#snapshot = changed.snapshot;
400
519
  this.#reads = reads;
@@ -403,12 +522,13 @@ export class SettingAuthoringTransaction {
403
522
  catch (error) {
404
523
  const message = error instanceof Error
405
524
  ? error.message
406
- : "The content-package write failed";
525
+ : `The ${this.#surface.phrases.en.slug} write failed`;
407
526
  return failure(message, this.#locale);
408
527
  }
409
528
  }
410
529
  #inspect() {
411
530
  const inspection = inspectContentPackageCurrentTree(this.#snapshot.files, {
531
+ requireOpening: this.#surface.opening,
412
532
  worldDocumentSnapshot: this.#snapshot,
413
533
  });
414
534
  const diff = fileDiff(this.#baseFiles, this.#snapshot.files);
@@ -419,7 +539,7 @@ export class SettingAuthoringTransaction {
419
539
  diagnostics: inspection.issues.map(({ code, path, message }) => ({
420
540
  code,
421
541
  path,
422
- message: publicSettingDiagnosticMessage(code, message, this.#locale),
542
+ message: publicSettingDiagnosticMessage(code, message, this.#surface, this.#locale),
423
543
  })),
424
544
  preview: null,
425
545
  playCoverage: null,
@@ -431,7 +551,7 @@ export class SettingAuthoringTransaction {
431
551
  diff,
432
552
  diagnostics: [],
433
553
  preview,
434
- playCoverage: buildSettingAuthoringPlayCoverage(this.#snapshot, preview, diff),
554
+ playCoverage: buildSettingAuthoringPlayCoverage(this.#snapshot, preview, diff, this.#surface),
435
555
  };
436
556
  }
437
557
  catch (error) {
@@ -453,7 +573,7 @@ export class SettingAuthoringTransaction {
453
573
  }
454
574
  }
455
575
  }
456
- function renderAutomaticReview(review, responseDiff, snapshot, locale) {
576
+ function renderAutomaticReview(review, responseDiff, snapshot, surface, locale) {
457
577
  if (review.status === "usable" && review.preview !== null) {
458
578
  const changed = responseDiff.length;
459
579
  const cumulative = review.diff.length;
@@ -461,7 +581,7 @@ function renderAutomaticReview(review, responseDiff, snapshot, locale) {
461
581
  const changedShortRefs = new Set(listQueryableWorldDocuments(snapshot)
462
582
  .filter(({ logicalPath }) => changedPaths.has(logicalPath))
463
583
  .map(({ shortRef }) => shortRef));
464
- return `${localized(locale, `# Current-tree review passed\n\nContent-tree validation and the real Prompt Preview both passed. This tool response changed ${changed} file(s) in the content package${cumulative === changed ? "" : `; ${cumulative} file(s) changed so far in this user request`}. Use this review and the coverage below to decide whether the current user request is satisfied or further edits are needed.`, `# 当前树自动检查通过\n\n内容树校验和真实 Prompt Preview 均已通过;本次工具响应修改内容包中的 ${changed} 个文件${cumulative === changed ? "" : `,本次用户请求累计改动 ${cumulative} 个文件`}。请根据本次检查与下方覆盖报告判断当前用户要求是否已经满足,或是否还需修改。`)}\n\n${renderPlayCoverage(buildSettingAuthoringPlayCoverage(snapshot, review.preview, responseDiff), locale)}\n\n${renderWorldMaintenanceReport(review.preview.compilation.maintenance, changedShortRefs, locale)}`;
584
+ return `${localized(locale, `# Current-tree review passed\n\nContent-tree validation and the real Prompt Preview both passed. This tool response changed ${changed} file(s) in ${surface.phrases.en.noun}${cumulative === changed ? "" : `; ${cumulative} file(s) changed so far in this user request`}. Use this review and the coverage below to decide whether the current user request is satisfied or further edits are needed.`, `# 当前树自动检查通过\n\n内容树校验和真实 Prompt Preview 均已通过;本次工具响应修改${surface.phrases["zh-CN"].noun}中的 ${changed} 个文件${cumulative === changed ? "" : `,本次用户请求累计改动 ${cumulative} 个文件`}。请根据本次检查与下方覆盖报告判断当前用户要求是否已经满足,或是否还需修改。`)}\n\n${renderPlayCoverage(buildSettingAuthoringPlayCoverage(snapshot, review.preview, responseDiff, surface), surface, locale)}\n\n${renderWorldMaintenanceReport(review.preview.compilation.maintenance, changedShortRefs, locale)}`;
465
585
  }
466
586
  return [
467
587
  localized(locale, "# Current-tree review needs repair", "# 当前树自动检查需要修复"),
@@ -471,7 +591,7 @@ function renderAutomaticReview(review, responseDiff, snapshot, locale) {
471
591
  localized(locale, "Repair these diagnostics with the ordinary read/write tools. Runtime will run the review again after the next successful write call.", "请用普通读写工具修复这些诊断;下一个写调用成功后 Runtime 会再次自动检查。"),
472
592
  ].join("\n");
473
593
  }
474
- function buildSettingAuthoringPlayCoverage(snapshot, preview, diff) {
594
+ function buildSettingAuthoringPlayCoverage(snapshot, preview, diff, surface) {
475
595
  const documents = listQueryableWorldDocuments(snapshot);
476
596
  const byShortRef = new Map(documents.map((document) => [document.shortRef, document]));
477
597
  const { fullInjected, nodeInjected, catalogSummary, injectedSelections } = worldMaterialCoverage(preview.compilation.coverage);
@@ -524,7 +644,11 @@ function buildSettingAuthoringPlayCoverage(snapshot, preview, diff) {
524
644
  totals,
525
645
  changed: diff.map(({ path, after }) => {
526
646
  const access = changedPathPlayAccess(path, after, accessByPath, enabledWorldInstructionPaths);
527
- return { path, access, detail: playAccessDetail(access) };
647
+ return {
648
+ path,
649
+ access,
650
+ detail: playAccessDetail(access, surface),
651
+ };
528
652
  }),
529
653
  };
530
654
  }
@@ -555,7 +679,7 @@ function incrementPlayCoverageTotal(totals, access) {
555
679
  else if (access === "on_demand")
556
680
  totals.onDemand += 1;
557
681
  }
558
- function playAccessDetail(access) {
682
+ function playAccessDetail(access, surface) {
559
683
  const details = {
560
684
  full_injected: "complete body is injected into every fresh play context",
561
685
  node_injected: "only a selected node is injected into fresh play context",
@@ -566,11 +690,11 @@ function playAccessDetail(access) {
566
690
  play_control: "compiled as immutable play-author control",
567
691
  unused_control: "not enabled by control/frame.yaml and unavailable to ordinary play AI",
568
692
  player_view: "projected through exact player-view selectors",
569
- removed: "removed from the content package current tree",
693
+ removed: `removed from ${surface.phrases.en.bareTree}`,
570
694
  };
571
695
  return details[access];
572
696
  }
573
- function renderPlayCoverage(coverage, locale) {
697
+ function renderPlayCoverage(coverage, surface, locale) {
574
698
  const labels = locale === "zh-CN"
575
699
  ? {
576
700
  full_injected: "全文注入",
@@ -582,7 +706,7 @@ function renderPlayCoverage(coverage, locale) {
582
706
  play_control: "游玩控制",
583
707
  unused_control: "未被 control/frame.yaml 启用",
584
708
  player_view: "玩家视图精确投影",
585
- removed: "已从内容包当前树删除",
709
+ removed: `已从${surface.phrases["zh-CN"].tree}删除`,
586
710
  }
587
711
  : {
588
712
  full_injected: "full text injected",
@@ -594,7 +718,7 @@ function renderPlayCoverage(coverage, locale) {
594
718
  play_control: "play-author control",
595
719
  unused_control: "not enabled by control/frame.yaml",
596
720
  player_view: "exact player-view projection",
597
- removed: "removed from the content package current tree",
721
+ removed: `removed from ${surface.phrases.en.bareTree}`,
598
722
  };
599
723
  const maximumReportedChanges = 64;
600
724
  const reportedChanges = coverage.changed.slice(0, maximumReportedChanges);
@@ -634,32 +758,42 @@ class SettingAuthoringError extends Error {
634
758
  this.name = "SettingAuthoringError";
635
759
  }
636
760
  }
637
- function normalizeCall(call) {
638
- if (!isRecord(call.arguments))
639
- return { id: call.id, name: call.name, arguments: {} };
761
+ function normalizeCall(call, surface) {
640
762
  return {
641
763
  id: call.id,
642
764
  name: call.name,
643
- arguments: structuredClone(call.arguments),
765
+ operation: authoringOperation(surface, call.name),
766
+ arguments: isRecord(call.arguments) ? structuredClone(call.arguments) : {},
644
767
  };
645
768
  }
646
- function isReadTool(name) {
647
- return (name === "setting_list" ||
648
- name === "setting_search" ||
649
- name === "setting_read");
769
+ function isReadOperation(operation) {
770
+ return operation === "list" || operation === "search" || operation === "read";
650
771
  }
651
- function isWorldWriteCall(call) {
652
- if (call.name === "setting_create" ||
653
- call.name === "setting_patch" ||
654
- call.name === "setting_move" ||
655
- call.name === "setting_delete")
772
+ /**
773
+ * Rejects a call that names a path outside the surface before any store work.
774
+ * The check covers every path-like argument, so a read and a write fail alike.
775
+ */
776
+ function unavailableArgument(surface, args, locale) {
777
+ for (const field of ["path", "directory", "within", "from", "to", "document"])
778
+ if (typeof args[field] === "string") {
779
+ const message = surface.unavailablePath(args[field], locale);
780
+ if (message !== null)
781
+ return message;
782
+ }
783
+ return null;
784
+ }
785
+ function isWorldWriteCall(call, surface) {
786
+ if (call.operation === "create" ||
787
+ call.operation === "patch" ||
788
+ call.operation === "move" ||
789
+ call.operation === "delete")
656
790
  return true;
657
- return (call.name === "setting_write_file" &&
791
+ return (call.operation === "write_file" &&
658
792
  typeof call.arguments.path === "string" &&
659
- call.arguments.path.startsWith("world/"));
793
+ call.arguments.path.startsWith(`${surface.root}/`));
660
794
  }
661
- function worldRevisionCommand(snapshot, call, locale) {
662
- if (call.name === "setting_create") {
795
+ function worldRevisionCommand(snapshot, call, surface, locale) {
796
+ if (call.operation === "create") {
663
797
  if (!hasOnly(call.arguments, [
664
798
  "path",
665
799
  "ref",
@@ -668,8 +802,8 @@ function worldRevisionCommand(snapshot, call, locale) {
668
802
  "aliases",
669
803
  "body",
670
804
  ]))
671
- throw new SettingAuthoringError("setting_create accepts only path, ref, title, summary, aliases, and body");
672
- const logicalPath = safeWorldDocumentPath(requiredString(call.arguments.path, "path"), locale);
805
+ throw new SettingAuthoringError(`${authoringToolName(surface, "create")} accepts only path, ref, title, summary, aliases, and body`);
806
+ const logicalPath = safeWorldDocumentPath(requiredString(call.arguments.path, "path"), surface, locale);
673
807
  const ref = requiredSettingRef(call.arguments.ref, locale);
674
808
  const title = requiredString(call.arguments.title, "title");
675
809
  const summary = requiredString(call.arguments.summary, "summary");
@@ -688,9 +822,9 @@ function worldRevisionCommand(snapshot, call, locale) {
688
822
  body,
689
823
  };
690
824
  }
691
- if (call.name === "setting_write_file") {
692
- const logicalPath = safeWorldDocumentPath(requiredString(call.arguments.path, "path"), locale);
693
- const contents = worldWriteContents(snapshot, logicalPath, call.arguments, locale);
825
+ if (call.operation === "write_file") {
826
+ const logicalPath = safeWorldDocumentPath(requiredString(call.arguments.path, "path"), surface, locale);
827
+ const contents = worldWriteContents(snapshot, logicalPath, call.arguments, surface, locale);
694
828
  assertNoLiteralUnicodeEscapes(contents, locale);
695
829
  return {
696
830
  kind: "write",
@@ -698,17 +832,17 @@ function worldRevisionCommand(snapshot, call, locale) {
698
832
  contents,
699
833
  };
700
834
  }
701
- if (call.name === "setting_move") {
702
- const toLogicalPath = safePath(requiredString(call.arguments.to, "to"));
703
- if (!/^world\/.+\.(?:ya?ml|md)$/u.test(toLogicalPath))
704
- throw new SettingAuthoringError("setting_move.to must be a world/ .yaml or .md path");
835
+ if (call.operation === "move") {
836
+ const toLogicalPath = safePath(requiredString(call.arguments.to, "to"), surface);
837
+ if (!documentPathPattern(surface).test(toLogicalPath))
838
+ throw new SettingAuthoringError(`${authoringToolName(surface, "move")}.to must be a ${surface.root}/ .yaml or .md path`);
705
839
  return {
706
840
  kind: "move",
707
- document: revisionTarget(requiredString(call.arguments.from, "from")),
841
+ document: revisionTarget(requiredString(call.arguments.from, "from"), surface),
708
842
  toLogicalPath,
709
843
  };
710
844
  }
711
- if (call.name === "setting_patch") {
845
+ if (call.operation === "patch") {
712
846
  const op = requiredString(call.arguments.op, "op");
713
847
  if (op === "set_metadata") {
714
848
  const title = requiredString(call.arguments.title, "title");
@@ -717,31 +851,31 @@ function worldRevisionCommand(snapshot, call, locale) {
717
851
  assertNoLiteralUnicodeEscapes({ title, summary, aliases }, locale);
718
852
  return {
719
853
  kind: "patch",
720
- document: revisionTarget(requiredString(call.arguments.document, "document")),
854
+ document: revisionTarget(requiredString(call.arguments.document, "document"), surface),
721
855
  edits: [{ op: "set_metadata", title, summary, aliases }],
722
856
  };
723
857
  }
724
858
  if (op !== "add" && op !== "replace" && op !== "append" && op !== "remove")
725
- throw new SettingAuthoringError("setting_patch.op must be add, replace, append, remove, or set_metadata");
859
+ throw new SettingAuthoringError(`${authoringToolName(surface, "patch")}.op must be add, replace, append, remove, or set_metadata`);
726
860
  const locator = requiredYamlLocatorPath(call.arguments.locator, "locator");
727
861
  if (op === "remove") {
728
862
  if (!hasOnly(call.arguments, ["document", "op", "locator"]))
729
- throw new SettingAuthoringError("setting_patch remove accepts only document, op, and locator");
863
+ throw new SettingAuthoringError(`${authoringToolName(surface, "patch")} remove accepts only document, op, and locator`);
730
864
  assertNoLiteralUnicodeEscapes(locator, locale);
731
865
  return {
732
866
  kind: "patch",
733
- document: revisionTarget(requiredString(call.arguments.document, "document")),
867
+ document: revisionTarget(requiredString(call.arguments.document, "document"), surface),
734
868
  edits: [{ op: "remove", locator: { yaml: locator } }],
735
869
  };
736
870
  }
737
871
  if (!hasOnly(call.arguments, ["document", "op", "locator", "value"]) ||
738
872
  !Object.hasOwn(call.arguments, "value"))
739
- throw new SettingAuthoringError(`setting_patch ${op} requires document, op, locator, and value`);
873
+ throw new SettingAuthoringError(`${authoringToolName(surface, "patch")} ${op} requires document, op, locator, and value`);
740
874
  const value = structuredClone(call.arguments.value);
741
875
  assertNoLiteralUnicodeEscapes({ locator, value }, locale);
742
876
  return {
743
877
  kind: "patch",
744
- document: revisionTarget(requiredString(call.arguments.document, "document")),
878
+ document: revisionTarget(requiredString(call.arguments.document, "document"), surface),
745
879
  edits: [
746
880
  {
747
881
  op,
@@ -755,15 +889,19 @@ function worldRevisionCommand(snapshot, call, locale) {
755
889
  }
756
890
  throw new SettingAuthoringError(`Unsupported write tool: ${call.name}`);
757
891
  }
758
- function safeWorldDocumentPath(path, locale) {
759
- const logicalPath = safePath(path);
760
- if (!/^world\/.+\.(?:ya?ml|md)$/u.test(logicalPath))
761
- throw new SettingAuthoringError(localized(locale, "World files must be .yaml or .md documents below world/", "world 文件必须是 world/ 下的 .yaml 或 .md 文档"));
892
+ /** The world-document path pattern for a surface, e.g. `world/…yaml` or `state/…md`. */
893
+ function documentPathPattern(surface) {
894
+ return new RegExp(`^${surface.root}/.+\\.(?:ya?ml|md)$`, "u");
895
+ }
896
+ function safeWorldDocumentPath(path, surface, locale) {
897
+ const logicalPath = safePath(path, surface);
898
+ if (!documentPathPattern(surface).test(logicalPath))
899
+ throw new SettingAuthoringError(localized(locale, `World files must be .yaml or .md documents below ${surface.root}/`, `world 文件必须是 ${surface.root}/ 下的 .yaml 或 .md 文档`));
762
900
  return logicalPath;
763
901
  }
764
- function worldWriteContents(snapshot, logicalPath, args, locale) {
902
+ function worldWriteContents(snapshot, logicalPath, args, surface, locale) {
765
903
  if (!hasOnly(args, ["path", "contents", "ref", "title", "summary", "aliases"]))
766
- throw new SettingAuthoringError("setting_write_file accepts only its declared arguments");
904
+ throw new SettingAuthoringError(`${authoringToolName(surface, "write_file")} accepts only its declared arguments`);
767
905
  const body = requiredString(args.contents, "contents");
768
906
  const metadataKeys = ["ref", "title", "summary", "aliases"];
769
907
  const provided = metadataKeys.filter((key) => args[key] !== undefined);
@@ -771,7 +909,7 @@ function worldWriteContents(snapshot, logicalPath, args, locale) {
771
909
  const existingDocument = listQueryableWorldDocuments(snapshot).some(({ logicalPath: existingPath }) => existingPath === logicalPath);
772
910
  if (provided.length === 0) {
773
911
  if (!existingFile && !containsTechnicalDocumentHeader(logicalPath, body))
774
- throw new SettingAuthoringError(localized(locale, "Create a new world document with setting_create", "新建世界文档请使用 setting_create"));
912
+ throw new SettingAuthoringError(localized(locale, `Create a new world document with ${authoringToolName(surface, "create")}`, `新建世界文档请使用 ${authoringToolName(surface, "create")}`));
775
913
  if (existingFile &&
776
914
  !existingDocument &&
777
915
  !containsTechnicalDocumentHeader(logicalPath, body))
@@ -781,9 +919,9 @@ function worldWriteContents(snapshot, logicalPath, args, locale) {
781
919
  if (provided.length !== metadataKeys.length)
782
920
  throw new SettingAuthoringError(localized(locale, "Repair metadata must provide ref, title, summary, and aliases together", "修复元信息时必须同时提供 ref、title、summary 和 aliases"));
783
921
  if (!existingFile)
784
- throw new SettingAuthoringError(localized(locale, "Create a new world document with setting_create", "新建世界文档请使用 setting_create"));
922
+ throw new SettingAuthoringError(localized(locale, `Create a new world document with ${authoringToolName(surface, "create")}`, `新建世界文档请使用 ${authoringToolName(surface, "create")}`));
785
923
  if (existingDocument)
786
- throw new SettingAuthoringError(localized(locale, "Repair metadata is only for a damaged document; update valid metadata with setting_patch set_metadata", "修复元信息只用于损坏文档;有效文档请用 setting_patch set_metadata 更新元信息"));
924
+ 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 更新元信息`));
787
925
  const ref = requiredSettingRef(args.ref, locale);
788
926
  const title = requiredString(args.title, "title");
789
927
  const summary = requiredString(args.summary, "summary");
@@ -826,25 +964,26 @@ function recoverStoredDocumentId(snapshot, logicalPath) {
826
964
  ? id
827
965
  : null;
828
966
  }
829
- function revisionTarget(value) {
830
- const selector = documentSelector(value);
967
+ function revisionTarget(value, surface) {
968
+ const selector = documentSelector(value, surface);
831
969
  if (selector === null)
832
970
  throw new SettingAuthoringError(`Invalid world-document selector: ${value}`);
833
971
  return selector;
834
972
  }
835
- function deleteWorldDocument(snapshot, call, reads, locale) {
973
+ function deleteWorldDocument(snapshot, call, reads, surface, locale) {
974
+ const tool = authoringToolName(surface, "delete");
836
975
  if (!hasOnly(call.arguments, ["document"]))
837
- throw new SettingAuthoringError("setting_delete accepts only the document argument");
976
+ throw new SettingAuthoringError(`${tool} accepts only the document argument`);
838
977
  const supplied = requiredString(call.arguments.document, "document");
839
- const selector = documentSelector(supplied);
978
+ const selector = documentSelector(supplied, surface);
840
979
  if (selector === null)
841
- throw new SettingAuthoringError(localized(locale, "setting_delete.document must be an @ref or world/ document path", "setting_delete.document 必须是 @ref 或 world/ 文档路径"));
980
+ throw new SettingAuthoringError(localized(locale, `${tool}.document must be an @ref or ${surface.root}/ document path`, `${tool}.document 必须是 @ref 或 ${surface.root}/ 文档路径`));
842
981
  const resolved = snapshot.query({
843
982
  kind: "read_document",
844
983
  document: selector,
845
984
  });
846
985
  if (resolved.kind === "error")
847
- throw new SettingAuthoringError(renderRevisionFailure(resolved.diagnostics, locale));
986
+ throw new SettingAuthoringError(renderRevisionFailure(resolved.diagnostics, surface, locale));
848
987
  if (resolved.kind !== "read_document")
849
988
  throw new Error("Unexpected world-document delete lookup result");
850
989
  const target = resolved.document;
@@ -859,7 +998,7 @@ function deleteWorldDocument(snapshot, call, reads, locale) {
859
998
  throw new SettingAuthoringError(localized(locale, `Cannot delete @${target.shortRef}; remove or redirect every reference first:\n${locations}`, `无法删除 @${target.shortRef};请先删除或改向全部引用:\n${locations}`));
860
999
  }
861
1000
  const revised = WorldDocumentStore.open({
862
- layout: "content_package",
1001
+ layout: surface.layout,
863
1002
  files: sortFiles(snapshot.files.filter(({ path }) => path !== target.logicalPath)),
864
1003
  });
865
1004
  rebaseReads(reads, revised);
@@ -942,12 +1081,14 @@ function deletionLocator(locator) {
942
1081
  ? `yaml:${JSON.stringify(locator.yaml)}`
943
1082
  : `markdown:${JSON.stringify(locator.markdown)}`;
944
1083
  }
945
- function writeOpaque(snapshot, call, reads, locale) {
1084
+ function writeOpaque(snapshot, call, reads, surface, locale) {
946
1085
  if (!hasOnly(call.arguments, ["path", "contents"]))
947
1086
  throw new SettingAuthoringError("Special-file writes accept only path and contents");
948
- const path = safePath(requiredString(call.arguments.path, "path"));
949
- if (!writableOpaquePath(path))
950
- throw new SettingAuthoringError(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"));
1087
+ const path = safePath(requiredString(call.arguments.path, "path"), surface);
1088
+ if (!writableOpaquePath(path, surface))
1089
+ throw new SettingAuthoringError(surface.opening
1090
+ ? 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")
1091
+ : 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"));
951
1092
  const authoredContents = requiredString(call.arguments.contents, "contents");
952
1093
  assertNoLiteralUnicodeEscapes(authoredContents, locale);
953
1094
  const contents = projectOpaqueDocumentIds(snapshot, path, authoredContents);
@@ -964,7 +1105,7 @@ function writeOpaque(snapshot, call, reads, locale) {
964
1105
  delete existing.encoding;
965
1106
  }
966
1107
  const revised = WorldDocumentStore.open({
967
- layout: "content_package",
1108
+ layout: surface.layout,
968
1109
  files: sortFiles(next),
969
1110
  });
970
1111
  rebaseReads(reads, revised);
@@ -973,7 +1114,7 @@ function writeOpaque(snapshot, call, reads, locale) {
973
1114
  return {
974
1115
  snapshot: revised,
975
1116
  markdown: [
976
- localized(locale, `# Current-tree write accepted\n\n${existing === undefined ? "Created" : "Updated"} ${path} in the content package's current tree. This file is already fully read; do not read it again unless a later decision needs Runtime's exact serialization.`, `# 当前树写入已接受\n\n已在内容包当前树中${existing === undefined ? "创建" : "更新"} ${path}。该文件已视为完整读取;除非后续判断依赖 Runtime 的精确序列化结果,否则不要再次读取。`),
1117
+ 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 的精确序列化结果,否则不要再次读取。`),
977
1118
  ...(gameplay === null
978
1119
  ? []
979
1120
  : [
@@ -993,9 +1134,9 @@ function assertGameplayWriteKeepsMode(files, contents, locale) {
993
1134
  if (before.mode !== after.mode)
994
1135
  throw new SettingAuthoringError(localized(locale, "Gameplay mode is fixed; control/gameplay.json may change only the resolution scheme of a tabletop package", "玩法模式固定;control/gameplay.json 只能修改跑团的判定方案,不能在互动式小说与跑团之间切换"));
995
1136
  }
996
- function writableOpaquePath(path) {
1137
+ function writableOpaquePath(path, surface) {
997
1138
  return (isPackageFollowupControlPath(path) ||
998
- path === "opening.md" ||
1139
+ (surface.opening && path === "opening.md") ||
999
1140
  path === "control/frame.yaml" ||
1000
1141
  path === "control/player-views.yaml" ||
1001
1142
  path === gameplayPath ||
@@ -1029,25 +1170,27 @@ function renderWorldWriteSuccess(changes, locale) {
1029
1170
  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 的精确序列化结果,否则不要再次读取。"),
1030
1171
  ].join("\n");
1031
1172
  }
1032
- function renderRevisionFailure(diagnostics, locale) {
1173
+ function renderRevisionFailure(diagnostics, surface, locale) {
1033
1174
  return diagnostics
1034
- .map(({ code, logicalPath, message }) => `${code}${logicalPath === undefined ? "" : ` · ${logicalPath}`} · ${publicSettingDiagnosticMessage(code, message, locale)}`)
1175
+ .map(({ code, logicalPath, message }) => `${code}${logicalPath === undefined ? "" : ` · ${logicalPath}`} · ${publicSettingDiagnosticMessage(code, message, surface, locale)}`)
1035
1176
  .join("\n");
1036
1177
  }
1037
- function listDocuments(snapshot, args, locale) {
1178
+ function listDocuments(snapshot, args, surface, locale) {
1179
+ const tool = authoringToolName(surface, "list");
1180
+ const root = surface.root;
1038
1181
  if (!hasOnly(args, ["directory", "limit", "cursor"]))
1039
- return failure("setting_list accepts only directory, limit, and cursor", locale);
1040
- const directory = args.directory ?? "world";
1182
+ return failure(`${tool} accepts only directory, limit, and cursor`, locale);
1183
+ const directory = args.directory ?? root;
1041
1184
  const limit = args.limit ?? 100;
1042
1185
  if (typeof directory !== "string" ||
1043
- (directory !== "world" && !directory.startsWith("world/")) ||
1186
+ (directory !== root && !directory.startsWith(`${root}/`)) ||
1044
1187
  typeof limit !== "number" ||
1045
1188
  !Number.isInteger(limit) ||
1046
1189
  limit < 1 ||
1047
1190
  limit > 100 ||
1048
1191
  !validCursor(args.cursor))
1049
- return failure("Invalid setting_list directory, limit, or cursor", locale);
1050
- const relative = directory === "world" ? "" : directory.slice(6);
1192
+ return failure(`Invalid ${tool} directory, limit, or cursor`, locale);
1193
+ const relative = directory === root ? "" : directory.slice(root.length + 1);
1051
1194
  const result = snapshot.query({
1052
1195
  kind: "catalog",
1053
1196
  directory: relative,
@@ -1055,7 +1198,7 @@ function listDocuments(snapshot, args, locale) {
1055
1198
  ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
1056
1199
  });
1057
1200
  if (result.kind === "error")
1058
- return renderQueryFailure(result, locale);
1201
+ return renderQueryFailure(result, surface, locale);
1059
1202
  if (result.kind !== "catalog")
1060
1203
  throw new Error("Unexpected catalog result");
1061
1204
  const entries = result.entries.map((entry) => {
@@ -1067,11 +1210,12 @@ function listDocuments(snapshot, args, locale) {
1067
1210
  });
1068
1211
  if (relative === "" && result.page.start === 0)
1069
1212
  entries.unshift(...snapshot.files
1070
- .filter(({ path }) => !path.startsWith("world/"))
1213
+ .filter(({ path }) => !path.startsWith(`${root}/`))
1071
1214
  .map(({ path, encoding }) => encoding === undefined ? `- [special] ${path}` : `- [binary] ${path}`));
1072
- return success(`# ${localized(locale, "Setting directory", "设定目录")}\n\n${entries.join("\n") || localized(locale, "(empty)", "(空)")}\n\n${pageFooter(result.page, locale)}`);
1215
+ 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)}`);
1073
1216
  }
1074
- function searchDocuments(snapshot, args, locale) {
1217
+ function searchDocuments(snapshot, args, surface, locale) {
1218
+ const tool = authoringToolName(surface, "search");
1075
1219
  const query = args.query;
1076
1220
  const limit = args.limit ?? 20;
1077
1221
  if (!hasOnly(args, ["query", "within", "caseSensitive", "limit", "cursor"]) ||
@@ -1085,10 +1229,10 @@ function searchDocuments(snapshot, args, locale) {
1085
1229
  (args.caseSensitive !== undefined &&
1086
1230
  typeof args.caseSensitive !== "boolean") ||
1087
1231
  !validCursor(args.cursor))
1088
- return failure("Invalid setting_search arguments", locale);
1089
- const within = searchScope(args.within);
1232
+ return failure(`Invalid ${tool} arguments`, locale);
1233
+ const within = searchScope(args.within, surface);
1090
1234
  if (within === invalidScope)
1091
- return failure("Invalid setting_search within scope", locale);
1235
+ return failure(`Invalid ${tool} within scope`, locale);
1092
1236
  const result = snapshot.query({
1093
1237
  kind: "literal_search",
1094
1238
  query,
@@ -1098,39 +1242,39 @@ function searchDocuments(snapshot, args, locale) {
1098
1242
  ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
1099
1243
  });
1100
1244
  if (result.kind === "error")
1101
- return renderQueryFailure(result, locale);
1245
+ return renderQueryFailure(result, surface, locale);
1102
1246
  if (result.kind !== "literal_search")
1103
1247
  throw new Error("Unexpected literal search result");
1104
1248
  const lines = result.matches.map(({ document, referenceProjection, range }) => `- @${document.shortRef} · ${document.logicalPath} · ${range.start.line}:${range.start.column}\n ${JSON.stringify(referenceProjection.excerpt)}`);
1105
1249
  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)}`);
1106
1250
  }
1107
1251
  const invalidScope = Symbol("invalid-scope");
1108
- function searchScope(value) {
1252
+ function searchScope(value, surface) {
1109
1253
  if (value === undefined)
1110
1254
  return undefined;
1111
1255
  if (typeof value !== "string" || value.length === 0)
1112
1256
  return invalidScope;
1113
- if (value === "world")
1257
+ if (value === surface.root)
1114
1258
  return { directory: "" };
1115
1259
  if (value.startsWith("@"))
1116
1260
  return value.length > 1
1117
1261
  ? { document: { shortRef: value.slice(1) } }
1118
1262
  : invalidScope;
1119
- if (value.startsWith("world/")) {
1120
- const path = safePath(value);
1263
+ if (value.startsWith(`${surface.root}/`)) {
1264
+ const path = safePath(value, surface);
1121
1265
  return /\.(?:ya?ml|md)$/u.test(path)
1122
1266
  ? { document: { logicalPath: path } }
1123
- : { directory: path.slice(6) };
1267
+ : { directory: path.slice(surface.root.length + 1) };
1124
1268
  }
1125
1269
  return invalidScope;
1126
1270
  }
1127
- function readDocument(snapshot, args, reads, locale) {
1271
+ function readDocument(snapshot, args, reads, surface, locale) {
1128
1272
  const path = args.path;
1129
1273
  if (!hasOnly(args, ["path", "maxBytes", "cursor"]) ||
1130
1274
  typeof path !== "string" ||
1131
1275
  path.length === 0)
1132
- return failure("Invalid setting_read arguments", locale);
1133
- const opaque = snapshot.files.find((file) => file.path === path && !file.path.startsWith("world/"));
1276
+ return failure(`Invalid ${authoringToolName(surface, "read")} arguments`, locale);
1277
+ const opaque = snapshot.files.find((file) => file.path === path && !file.path.startsWith(`${surface.root}/`));
1134
1278
  if (opaque !== undefined) {
1135
1279
  if (opaque.encoding !== undefined)
1136
1280
  return failure("Special files require one complete text read", locale);
@@ -1138,28 +1282,28 @@ function readDocument(snapshot, args, reads, locale) {
1138
1282
  const projected = projectOpaqueDocumentIds(snapshot, path, opaque.contents);
1139
1283
  return success(`# ${localized(locale, "Special-file source", "专用文件原文")} ${path}\n\n${projected}\n\n---\nComplete: yes`);
1140
1284
  }
1141
- const selector = documentSelector(path);
1285
+ const selector = documentSelector(path, surface);
1142
1286
  if (selector === null)
1143
1287
  return failure(selector === null && typeof path === "string" && !path.includes("/")
1144
- ? "Use @ref or a world/ logical path"
1145
- : "Invalid setting_read path", locale);
1288
+ ? `Use @ref or a ${surface.root}/ logical path`
1289
+ : `Invalid ${authoringToolName(surface, "read")} path`, locale);
1146
1290
  const result = snapshot.query({
1147
1291
  kind: "read_document",
1148
1292
  document: selector,
1149
1293
  referenceProjection: "short_ref",
1150
1294
  });
1151
1295
  if (result.kind === "error") {
1152
- if (repairableDamagedWorldRead(snapshot, path, result))
1296
+ if (repairableDamagedWorldRead(snapshot, path, surface, result))
1153
1297
  reads.damagedWorldPaths.add(path);
1154
- return renderQueryFailure(result, locale);
1298
+ return renderQueryFailure(result, surface, locale);
1155
1299
  }
1156
1300
  if (result.kind !== "read_document")
1157
1301
  throw new Error("Unexpected read result");
1158
1302
  reads.worldDocumentIds.add(result.document.documentId);
1159
1303
  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`);
1160
1304
  }
1161
- function repairableDamagedWorldRead(snapshot, path, result) {
1162
- if (!path.startsWith("world/") ||
1305
+ function repairableDamagedWorldRead(snapshot, path, surface, result) {
1306
+ if (!path.startsWith(`${surface.root}/`) ||
1163
1307
  !snapshot.files.some(({ path: candidate }) => candidate === path))
1164
1308
  return false;
1165
1309
  // A failed exact read authorizes replacement only when the source itself is
@@ -1170,11 +1314,11 @@ function repairableDamagedWorldRead(snapshot, path, result) {
1170
1314
  code !== "document_not_found" &&
1171
1315
  code !== "document_ambiguous");
1172
1316
  }
1173
- function documentSelector(value) {
1317
+ function documentSelector(value, surface) {
1174
1318
  if (value.startsWith("@"))
1175
1319
  return value.length > 1 ? { shortRef: value.slice(1) } : null;
1176
- if (value.startsWith("world/")) {
1177
- const path = safePath(value);
1320
+ if (value.startsWith(`${surface.root}/`)) {
1321
+ const path = safePath(value, surface);
1178
1322
  return /\.(?:ya?ml|md)$/u.test(path) ? { logicalPath: path } : null;
1179
1323
  }
1180
1324
  return null;
@@ -1231,20 +1375,21 @@ function projectOpaqueDocumentIds(snapshot, path, source) {
1231
1375
  }
1232
1376
  return changed ? document.toString({ indent: 2, lineWidth: 0 }) : source;
1233
1377
  }
1234
- function renderQueryFailure(result, locale) {
1378
+ function renderQueryFailure(result, surface, locale) {
1235
1379
  return failure(result.diagnostics
1236
- .map(({ code, logicalPath, message }) => `${code}${logicalPath === undefined ? "" : ` · ${logicalPath}`} · ${publicSettingDiagnosticMessage(code, message, locale)}`)
1380
+ .map(({ code, logicalPath, message }) => `${code}${logicalPath === undefined ? "" : ` · ${logicalPath}`} · ${publicSettingDiagnosticMessage(code, message, surface, locale)}`)
1237
1381
  .join("\n"), locale);
1238
1382
  }
1239
- function publicSettingDiagnosticMessage(code, message, locale) {
1383
+ function publicSettingDiagnosticMessage(code, message, surface, locale) {
1384
+ const writeFile = authoringToolName(surface, "write_file");
1240
1385
  if (code === "document_header_invalid" ||
1241
1386
  code === "document_identity_invalid" ||
1242
1387
  code === "invalid_document_header")
1243
- return localized(locale, "Document storage metadata is missing or damaged. Repair this document with setting_write_file using its ref, title, summary, aliases, and body", "文档存储元信息缺失或损坏;请用 setting_write_file 提供 ref、title、summary、aliases 和正文进行修复");
1388
+ return localized(locale, `Document storage metadata is missing or damaged. Repair this document with ${writeFile} using its ref, title, summary, aliases, and body`, `文档存储元信息缺失或损坏;请用 ${writeFile} 提供 ref、title、summary、aliases 和正文进行修复`);
1244
1389
  if (code === "document_identity_duplicate" || code === "duplicate_id")
1245
1390
  return localized(locale, "Runtime-owned storage identity is duplicated; repair the affected document without supplying an identity", "Runtime 维护的存储身份发生重复;请修复受影响文档,不要提供内部身份");
1246
1391
  if (code === "document_short_ref_invalid")
1247
- return localized(locale, "Document ref must be a 2-to-32-character lowercase ASCII short handle; repair it with setting_write_file and public metadata", "文档 ref 必须是 2~32 位小写 ASCII 短句柄;请用 setting_write_file 和公开元信息修复");
1392
+ return localized(locale, `Document ref must be a 2-to-32-character lowercase ASCII short handle; repair it with ${writeFile} and public metadata`, `文档 ref 必须是 2~32 位小写 ASCII 短句柄;请用 ${writeFile} 和公开元信息修复`);
1248
1393
  if (code === "document_short_ref_duplicate" || code === "duplicate_ref")
1249
1394
  return localized(locale, "Document refs must be unique; choose a different ref for the affected document", "文档 ref 必须唯一;请为受影响文档选择另一个 ref");
1250
1395
  return message
@@ -1306,13 +1451,13 @@ function fileDiff(baseFiles, draftFiles) {
1306
1451
  ];
1307
1452
  });
1308
1453
  }
1309
- function safePath(path) {
1454
+ function safePath(path, surface) {
1310
1455
  if (path.startsWith("/") ||
1311
1456
  path.includes("\\") ||
1312
1457
  path
1313
1458
  .split("/")
1314
1459
  .some((part) => part === "." || part === ".." || part.length === 0))
1315
- throw new SettingAuthoringError(`Unsafe content-package path: ${path}`);
1460
+ throw new SettingAuthoringError(`Unsafe ${surface.phrases.en.slug} path: ${path}`);
1316
1461
  return path;
1317
1462
  }
1318
1463
  function requiredString(value, name) {