narraeon 0.4.0 → 0.5.1

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 (76) hide show
  1. package/dist/node/protocol/appPreferences.js +25 -0
  2. package/dist/node/protocol/conversationObservation.js +67 -0
  3. package/dist/node/protocol/v1.js +186 -34
  4. package/dist/node/protocol/worldExtensions.js +16 -0
  5. package/dist/node/protocol/worldMaintenance.js +77 -0
  6. package/dist/node/protocol/worldPromptDiagnostics.js +1 -0
  7. package/dist/node/runtime/ConversationChanges.js +24 -0
  8. package/dist/node/runtime/V1Runtime.js +445 -121
  9. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +326 -12
  10. package/dist/node/runtime/authoring/AuthoringRequestSnapshot.js +63 -0
  11. package/dist/node/runtime/authoring/ModelToolConversation.js +218 -0
  12. package/dist/node/runtime/config/AppPreferencesStore.js +46 -17
  13. package/dist/node/runtime/content/ContentTreeFingerprint.js +13 -0
  14. package/dist/node/runtime/content/ContentWorkspace.js +43 -13
  15. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +128 -46
  16. package/dist/node/runtime/content/FileNativeContentTree.js +28 -10
  17. package/dist/node/runtime/content/PackageFollowups.js +80 -0
  18. package/dist/node/runtime/extension/FrontendExtensionBundle.js +19 -14
  19. package/dist/node/runtime/extension/PackageScriptPermissions.js +70 -0
  20. package/dist/node/runtime/extension/WorldExtensionControls.js +205 -0
  21. package/dist/node/runtime/extension/WorldExtensionRequests.js +64 -0
  22. package/dist/node/runtime/model/FileNativeModelAdapters.js +21 -466
  23. package/dist/node/runtime/model/ModelConnectionStore.js +13 -0
  24. package/dist/node/runtime/model/ModelHost.js +31 -1
  25. package/dist/node/runtime/model/ModelHostStream.js +51 -5
  26. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +55 -45
  27. package/dist/node/runtime/play/FileNativePlayPresetStore.js +256 -238
  28. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +118 -9
  29. package/dist/node/runtime/play/OrderedFollowups.js +71 -0
  30. package/dist/node/runtime/play/OrderedPlayPrompts.js +180 -0
  31. package/dist/node/runtime/play/PlayCallChain.js +814 -61
  32. package/dist/node/runtime/play/PlayContinuity.js +88 -0
  33. package/dist/node/runtime/play/PlayDocumentTools.js +205 -287
  34. package/dist/node/runtime/play/PlayFollowupRequests.js +34 -3
  35. package/dist/node/runtime/play/PlayPresetWorkbench.js +45 -54
  36. package/dist/node/runtime/play/PlayPromptRun.js +86 -0
  37. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +670 -93
  38. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -21
  39. package/dist/node/runtime/prompt/PromptCompilationCodec.js +322 -0
  40. package/dist/node/runtime/prompt/WorldMaintenanceReport.js +251 -0
  41. package/dist/node/runtime/prompt/WorldMaterialCoverage.js +83 -0
  42. package/dist/node/runtime/prompt/WorldPromptDiagnostics.js +185 -0
  43. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +1027 -0
  44. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +1339 -0
  45. package/dist/node/runtime/setting/SettingImprovementSession.js +714 -0
  46. package/dist/node/runtime/world/FileNativeAuthorityV3.js +88 -1
  47. package/dist/node/runtime/world/FileNativeWorldStore.js +292 -27
  48. package/dist/node/runtime/world/PlayerViewBindings.js +30 -0
  49. package/dist/node/runtime/world/PlayerViewRenderer.js +27 -34
  50. package/dist/node/runtime/world/WorldDocumentMaintenance.js +174 -0
  51. package/dist/node/runtime/world/WorldDocumentStore.js +166 -96
  52. package/dist/node/runtime/world/WorldOperationCoordinator.js +103 -2
  53. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +696 -0
  54. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +380 -0
  55. package/dist/node/runtime/world-revision/WorldRevisionSession.js +709 -0
  56. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +494 -0
  57. package/dist/node/server/ConversationStream.js +109 -0
  58. package/dist/node/server/createServer.js +5 -1
  59. package/dist/node/shared/artifact-payload-contract.js +123 -0
  60. package/dist/node/shared/default-play-prompts.js +25 -45
  61. package/dist/node/shared/default-preset-host.js +114 -138
  62. package/dist/node/shared/default-setting-improvement-prompt.js +52 -38
  63. package/dist/node/shared/display-regex.js +114 -0
  64. package/dist/node/shared/first-party-action-choices.js +1 -6
  65. package/dist/node/shared/first-party-player-view.js +1 -6
  66. package/dist/node/shared/ordered-author-prompts.js +64 -0
  67. package/dist/node/shared/ordered-followups.js +42 -0
  68. package/dist/node/shared/ordered-play-prompts.js +73 -0
  69. package/dist/web/assets/index-DG85hpWC.css +1 -0
  70. package/dist/web/assets/index-DfxvCjb6.js +1207 -0
  71. package/dist/web/index.html +2 -2
  72. package/package.json +1 -1
  73. package/dist/node/runtime/model/SettingAuthorStream.js +0 -21
  74. package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +0 -1731
  75. package/dist/web/assets/index-D8YKfmNG.js +0 -1192
  76. package/dist/web/assets/index-Xa0s35CJ.css +0 -1
@@ -1,1731 +0,0 @@
1
- import { defaultAppLocale, } from "../../protocol/appPreferences.js";
2
- import { aggregateModelUsage, emptyAggregatedModelUsage, } from "../../protocol/modelUsage.js";
3
- import { defaultSettingImprovementPromptForLocale } from "../../shared/default-setting-improvement-prompt.js";
4
- import { inspectContentPackageCurrentTree, } from "../content/FileNativeContentTree.js";
5
- import { WorldDocumentStore, } from "../world/WorldDocumentStore.js";
6
- export const readOnlyDocumentCandidateSettingTools = [
7
- "setting_list",
8
- "setting_search",
9
- "setting_read",
10
- ];
11
- export const documentCandidateSettingTools = [
12
- ...readOnlyDocumentCandidateSettingTools,
13
- "setting_write_file",
14
- "setting_patch",
15
- "setting_move",
16
- "setting_preview_candidate",
17
- "setting_finish_candidate",
18
- ];
19
- const readOnlySettingToolNames = new Set(readOnlyDocumentCandidateSettingTools);
20
- // setting_write_file is a world revision only when it targets world/. Control
21
- // files and opening.md are opaque to WorldDocumentStore and go through
22
- // executeTool, so the batch boundary follows the path, not the tool name.
23
- function isWorldRevisionCall(call) {
24
- if (call.name === "setting_patch" || call.name === "setting_move")
25
- return true;
26
- if (call.name !== "setting_write_file")
27
- return false;
28
- const { path } = call.arguments;
29
- return typeof path === "string" && path.startsWith("world/");
30
- }
31
- function writableControlPath(path) {
32
- return (path === "control/frame.yaml" ||
33
- path === "control/player-views.yaml" ||
34
- /^control\/blocks\/[a-z0-9][a-z0-9/_-]*\.md$/u.test(path));
35
- }
36
- // Reasoning-capable providers count hidden reasoning against max_tokens. A
37
- // 4k cap can therefore return an empty visible plan even for a short request.
38
- const planningOutputTokens = 16_384;
39
- const editingOutputTokens = 16_384;
40
- // The contract's control-file examples are the author's only specification of
41
- // these shapes, so they are held as constants and checked against the real
42
- // content-tree validator in tests. An example that drifts out of the schema
43
- // teaches the author to write files the candidate self-check will reject.
44
- const frameExample = `format: narraeon.world-frame/v1
45
- bindings:
46
- currentSituation: "@current-situation"
47
- instructions:
48
- - markdown: blocks/world.md
49
- context:
50
- - slot: { kind: catalog, directory: characters, maxEntries: 24 }
51
- - slot: { kind: document, document: "@cultivation" }
52
- - slot: { kind: node, document: "@alex", locator: { yaml: [关系] } }
53
- - slot:
54
- kind: reference_targets
55
- from: { document: "@current-situation", locator: { yaml: [人物] } }
56
- maxEntries: 8
57
- - slot: { kind: current_situation }
58
- - slot: { kind: history, recent: 2 }
59
- - slot: { kind: additional_materials }
60
- `;
61
- const emptyPlayerViewsExample = `format: narraeon.player-views/v1
62
- views: []
63
- `;
64
- const playerViewsExample = `format: narraeon.player-views/v1
65
- views:
66
- - id: player-status
67
- title: 当前状态
68
- items:
69
- - id: alex-clothes
70
- label: Alex 衣着
71
- select:
72
- document: "@alex"
73
- locator:
74
- yaml: [衣着]
75
- - id: jindan-rule
76
- label: 金丹
77
- select:
78
- document: "@cultivation"
79
- locator:
80
- markdown: [金丹]
81
- `;
82
- const frameExampleEn = `format: narraeon.world-frame/v1
83
- bindings:
84
- currentSituation: "@current-situation"
85
- instructions:
86
- - markdown: blocks/world.md
87
- context:
88
- - slot: { kind: catalog, directory: characters, maxEntries: 24 }
89
- - slot: { kind: document, document: "@cultivation" }
90
- - slot: { kind: node, document: "@alex", locator: { yaml: [relationships] } }
91
- - slot:
92
- kind: reference_targets
93
- from: { document: "@current-situation", locator: { yaml: [characters] } }
94
- maxEntries: 8
95
- - slot: { kind: current_situation }
96
- - slot: { kind: history, recent: 2 }
97
- - slot: { kind: additional_materials }
98
- `;
99
- const playerViewsExampleEn = `format: narraeon.player-views/v1
100
- views:
101
- - id: player-status
102
- title: Current status
103
- items:
104
- - id: alex-clothes
105
- label: Alex's clothing
106
- select:
107
- document: "@alex"
108
- locator:
109
- yaml: [clothing]
110
- - id: gold-core-rule
111
- label: Gold Core
112
- select:
113
- document: "@cultivation"
114
- locator:
115
- markdown: [Gold Core]
116
- `;
117
- /** Control-file examples from the author contract, validated by tests. */
118
- export const settingAuthorContractExamples = {
119
- frame: frameExampleEn,
120
- emptyPlayerViews: emptyPlayerViewsExample,
121
- playerViews: playerViewsExampleEn,
122
- };
123
- const settingAuthorRuntimeContractZhCN = `# Runtime 设定完善工具与机械契约
124
-
125
- 你只编辑固定内容包的隔离候选,不得修改运行中世界、历史、认知或权威提交。
126
- Runtime 可能把用户选定文件的完整原文直接注入为“当前设定文件”;这些原文已经完整读取,不需要再调用读取工具取得写授权。
127
- 计划优先路径中,只能使用精确 list、原文字面 search 和精确 read 了解现有设定,再输出一份以“# 创作计划”开头的可见计划;这个阶段不得写候选。setting_list 的 directory 使用 world 或 world/ 下目录;list/search/read 返回的 cursor 只属于产生它的固定候选快照和原查询条件。
128
- 用户可以明确跳过可见计划并直接生成候选。进入候选阶段后,才可使用 \`setting_write_file\`、\`setting_patch\`、\`setting_move\` 和终态工具。\`setting_write_file\` 的 path 只接受这几类:\`world/\` 下以 \`.yaml\` 或 \`.md\` 结尾的世界文档、\`control/frame.yaml\`、\`control/player-views.yaml\`、\`control/blocks/\` 下的 \`.md\`,以及根级 \`opening.md\`;其余路径一律拒绝。\`world/\` 下的目录和文件名可以用中文,\`control/blocks/\` 下的文件名不行——它只接受小写字母开头、由小写字母、数字、连字符、下划线组成的名字,例如 \`control/blocks/world-style.md\`。修改既有文件前必须完整读取它;用户直接注入的完整文件视为已读,你自己创建或修改过的文件同样视为已读,都不需要为了再次修改而重读。同一模型响应里的 \`setting_write_file\`、\`setting_patch\`、\`setting_move\` 会合成一次原子 revision:其中任何一个被拒,整批都不生效,未被拒的调用也要连同修复一起重发,不要以为它们已经写进去了。每次成功 revision 都会替换候选快照并使旧 cursor 失效,需要继续分页时重新发起查询。所有工具只能看到逻辑路径,不能取得宿主路径。
129
- 内容包中的 world/ 文档会在创建世界时原样成为 state/。人物、地点、关键物品、规则和当前情境使用 YAML 或 Markdown;提示词框架、主持方法和玩家视图放在 control/。不要创建 schema、record 或 JSON 材料 DTO。
130
- 根级 opening.md 是玩家进入新世界后立即看到的开场白,内容包必须有且只有一份。先用 setting_list 检查它:缺失时用 setting_write_file 创建;如果本次修改改变开局地点、在场人物、眼前局面、叙事语气或行动钩子,必须先用 setting_read 完整读取既有 opening.md,再用 setting_write_file 更新;不受影响时保留原文,不要为了展示工作量而改写。
131
- 不得替玩家决定行动、台词或内心:此刻还没有任何玩家输入可以承接,写进去的每一个玩家动作都是凭空替他做主。开场白中会继续约束首次行动的事实,必须同时写入下面规则指定的世界文档,不能只存在于 opening.md。
132
-
133
- ## 这些设定将怎样被使用
134
-
135
- - 游玩使用模型主导的调用链。全新上下文从最新世界端点重新编译 \`control/frame.yaml\` 的 context slot;追加上下文沿用当前 transcript。能持续约束后续行动的重要事实必须写进世界文档,不能只存在于开场白或某段叙事里。
136
- - catalog slot 只注入每份文档的 title、summary 和 \`@ref\`,不注入正文。正文未读时,summary 是主持者判断“要不要读这份文档”的主要语义依据(title 也参与),所以要写成能支持这个判断的一句话,而不是“某某的资料”。
137
- - 通用的裁决与状态维护判据由主持预设提供,并进入每条全新调用链的稳定 bootstrap。\`control/blocks/\` 只写这个世界特有的部分:题材边界、专属文风、本世界有哪些文档类型、某类结果该写进哪一份、专属规则。不要在这里重复通用判据,也不要重写人称与文风的通用默认值。
138
- - \`control/blocks/\` 的内容会作为世界专属作者指令进入稳定 bootstrap。不要在其中描述 Runtime 编排;玩法预设负责叙事规则、工具契约和后置请求。
139
- - \`control/player-views.yaml\` 决定玩家界面常驻显示哪些精确节点。空视图合法,但界面将没有常驻状态显示;如果这个世界有玩家应当随时看到的信息,按下面最小合法格式里的两层结构列出:视图负责分组和标题,视图的 \`items\` 里每个条目用 \`select\` 指向一份文档中的一个精确节点。文档和选择器只能写在条目上,不能写在视图上。它不是权限系统,未列出不等于秘密。
140
-
141
- ## 世界事实与状态维护
142
-
143
- - 世界文档只写创建世界时已经成立的事实和稳定规则。愿望、意图、尝试、计划、可能性、预测、计划中的转折和未来分支都不是已经发生的事实;主持方法与创作要求写入 control/blocks/,不要伪装成世界事实。
144
- - 运行时的保存判据与保存位置原则由主持预设承担,你不需要在内容包里复述它们。你在这里只决定**创建世界时已经成立的事实**该不该写、写成哪份文档,并在 \`control/blocks/\` 里说明这个世界有哪些文档类型、某类结果该写进哪一份。
145
- - 普通上衣等临时对象不必独立建档,只在已有承载文档中记录后续真正需要的归属与意义。只有对象需要独立引用、转移或追踪生命周期时,才创建独立文档。
146
- - 当前情境表示所有开场动作和即时反应结束后仍然成立的局面:只保留此刻地点、仍在场的人物、仍在进行的事件,以及首次行动若忽略就会立刻冲突的少量限制;删除背景回顾、已完成动作、已离场人物、已解决问题、已被取代的描述、计划、分支和预测。它不是事件日志。正文变化后,title 和 summary 也必须准确指向当前场景。
147
- 候选会接受文件安全、文档身份、引用、控制格式、必需 slot、玩家 selector 和真实 Prompt Preview 检查;Runtime 不解释好感度、关系方向或修为顺序。
148
-
149
- ## control/frame.yaml 的拼接与路径关联
150
-
151
- - \`instructions\` 中的 Markdown 块按从上到下的顺序拼进 \`author_instruction\`;\`context\` 中的 slot 按从上到下的顺序拼进 \`world_context\`。移动条目只改变注入顺序,不会推断语义。如果固定 \`document\` 与 \`reference_targets\` 等 slot 恰好选择了同一整份文档,编译器保留第一次出现的位置并只注入一次;这只合并提示材料,不会改变当前情境中的在场引用或其他世界状态。整份文档与其中节点等范围重叠仍会被拒绝。
152
- - 拼好的提示词按这个顺序送进模型,未变动的前缀可以命中缓存。当前情境会随权威提交变化,\`current_situation\` slot 已经排在后面承载它;你要做的是别让动态事实渗进前面的静态材料——用 \`document\` 或 \`node\` 固定注入正文的文档,以及 catalog 的 title 和 summary,都必须是稳定不变的。会持续改写的事实写进当前情境;确实需要额外的高频材料时另建文档,并把它的 slot 排在当前情境附近。
153
- - 候选里的 \`world/\` 会在创建世界时改名为 \`state/\`,其后的相对路径保持不变。例如 \`world/states/sam.yaml\` 会成为 \`state/states/sam.yaml\`。
154
- - \`catalog.directory: states\` 只匹配候选中 \`world/states/\` 下的直接子文档,也就是运行时 \`state/states/\` 下的直接子文档;它不按文档 id、ref、类型或文件名前缀匹配。特别是 \`world/state.sam.yaml\` 位于 \`world/\` 根级,不能被 \`states\` catalog 关联;用 \`setting_move\` 把路径改为真实目录,不要复制一份并遗留错误文件。更深的 \`world/states/group/sam.yaml\` 需要 \`directory: states/group\`。
155
- - catalog 只注入每份文档的 title、summary 和 \`@ref\` 索引,不注入正文;需要固定注入整份正文时用 \`document\`,只注入局部时用 \`node\`,从 YAML 节点展开一层显式引用时用 \`reference_targets\`。bindings.currentSituation 决定 \`current_situation\` 的精确文档。这几种 slot 各自接受哪些参数由下面最小合法格式里的白名单规定,凭直觉多写一个键就会被判错。
156
- - \`history\` slot 向全新上下文注入最近若干条已提交叙事(\`recent\` 默认 2,上限 32)。玩家可见叙事可能包含没有另行写入世界文档的具体细节——某个道具被拿起来、某句台词。少了这个 slot,重新编译的上下文只能看到当前情境,玩家顺着上一段叙事说话时会失去这些细节。除非这个世界确实不需要跨上下文的叙述连贯性,否则保留它。
157
- - catalog 默认是必需的,必须至少关联一份直接子文档;只有明确允许目录暂时为空时才写 \`required: false\`。不要用可选空 catalog 掩盖路径拼错。
158
-
159
- 所有最终修改完成后调用 \`setting_preview_candidate\`;自检通过后才能调用 \`setting_finish_candidate\`,两者可以放在同一模型响应里先后调用。\`setting_finish_candidate\` 必须是该响应的最后一个工具调用,且该响应里不能有任何被拒绝的调用,否则结束请求不被接受。如果自检返回路径、slot、引用或 Prompt Preview 诊断,先修复并重新自检;自检未通过是正常的迭代,不是失败。用户只可整批应用或整批放弃。
160
-
161
- ## 最小合法格式
162
-
163
- YAML 世界文档(ref 是 2 到 32 个字符,小写字母开头,其余只能是小写字母、数字或连字符;title 最多 120 字、summary 最多 240 字,两者必需;aliases 是可省略的别名数组,会连同 title、summary 一起进入 \`setting_search\` 的检索范围,写常用的口语称呼即可):
164
-
165
- 例如把下面的 YAML 文档保存为 \`world/characters/alex.yaml\`,它才会进入 \`directory: characters\` 的 catalog:
166
-
167
- \`\`\`yaml
168
- $document:
169
- id: character.alex
170
- ref: alex
171
- title: Alex
172
- summary: 篮球队前锋,直率护短。
173
- aliases: [小艾]
174
- 衣着: 白色运动背心,运动短裤,拖鞋
175
- 关系:
176
- Sam: 熟悉但仍在试探
177
- \`\`\`
178
-
179
- Markdown 世界文档:
180
-
181
- \`\`\`markdown
182
- ---
183
- $document:
184
- id: rule.cultivation
185
- ref: cultivation
186
- title: 修炼境界
187
- summary: 境界顺序和突破方式。
188
- aliases: []
189
- ---
190
- # 修炼境界
191
-
192
- 炼气之后是筑基,随后是金丹、元婴。金丹意味着……
193
- \`\`\`
194
-
195
- 跨文档引用只使用精确对象,并且**写 Runtime 返回的 @短引用**,例如 \`{ $ref: "@alex" }\`;Runtime 存盘时会把它解析成目标文档的身份。绝对不要自己编一个像 \`character.alex\` 的 id 填进 \`$ref\`:文档 id 由 Runtime 分配,新建文档拿到的是 \`doc.7316210301cb48e2ae9a43954312b87d\` 这样的随机值,猜出来的 id 会被判成引用了不存在的身份。\`setting_read\` 返回的正文里 \`$ref\` 已经是解析后的 id 形式,原样保留写回即可,不必改写成短引用。也不要把文件路径冒充引用。
196
-
197
- 上面这样的完整原文就是 \`setting_write_file\` 的 contents,\`path\` 是完整逻辑路径。\`$document\` 里的 \`id\` 和 \`ref\` 由 Runtime 决定:新建时分配一个新 id 并在 \`ref\` 冲突时改写 \`ref\`,覆盖既有文档时保留原值;你照写即可,写成什么都不会被判错,实际生效的 id 和 \`@短引用\` 以工具返回为准。你不需要知道 id 具体是什么:文件正文的 \`$ref\`、\`control/frame.yaml\` 和 \`control/player-views.yaml\` 一律用 \`@短引用\` 寻址,Runtime 负责解析成身份。覆盖既有文档时也可以整段省略 \`$document\`,直接把 \`setting_read\` 返回的正文改好写回,Runtime 会沿用该文档原有的身份、title、summary 和 aliases;只有新建文档必须自带 \`$document\`。\`setting_patch\` 必须用 op 明确区分新增节点的 add 与替换既有节点的 replace,Runtime 不猜测有序批次执行到该命令时的意图;它的 locator 是一串 map-key,例如 \`["关系","Sam"]\`,不能用下标指向列表中的某一项——要改列表就 replace 承载它的那个键,整份新列表作为 value。Markdown 文档没有分节 patch,改动用 \`setting_write_file\` 整份重写。
198
-
199
- control/frame.yaml(下面列出了全部七种 slot,按这个世界实际需要挑选,不必都用):
200
-
201
- \`\`\`yaml
202
- ${frameExample}\`\`\`
203
-
204
- \`context\` 的每一项都只能是 \`- slot: {...}\`,slot 的参数是白名单,写入白名单以外的键会被判错。可用的 kind 和它们接受的全部参数:
205
-
206
- - \`current_situation\`:只接受 kind。注入哪份文档由 \`bindings.currentSituation\` 决定,这里不写文档,写 required 也会被判错。
207
- - \`additional_materials\`:只接受 kind,同样不接受 required。
208
- - \`document\`:kind、document、required。\`document\` 写工具返回的 \`@短引用\`(必须加双引号),不是文件路径。
209
- - \`node\`:kind、document、locator、required。\`locator\` 恰好写 \`yaml: [键, 子键]\` 或 \`markdown: [标题]\` 之一,YAML 文档只能用 yaml、Markdown 文档只能用 markdown,且该节点必须在文档里真实存在。
210
- - \`catalog\`:kind、directory、maxEntries、required。maxEntries 必填,取 1 到 100。
211
- - \`history\`:kind、recent。\`recent\` 可选,默认 2,取 1 到 32;不接受 required。
212
- - \`reference_targets\`:kind、from、maxEntries、required。\`from\` 恰好包含 document 和 locator 两个键,其 locator 只支持 \`yaml\` 形式;maxEntries 必填,取 1 到 64。
213
-
214
- \`required\` 是可选布尔值,默认 true,只有 document、node、catalog、reference_targets 接受它。内容包里能用的 slot 就是以上七种,不要发明别的 kind。
215
-
216
- \`bindings.currentSituation\` 以及 document、node、reference_targets 里的 document 都用 \`@短引用\` 指向文档,取自 setting_list、setting_search、setting_read 或修改回执。**\`@\` 是 YAML 保留字符,这些值必须写成带双引号的 \`"@alex"\`**;漏掉引号整份控制文件会被判成不安全 YAML。
217
-
218
- control/player-views.yaml 可以从空视图开始:
219
-
220
- \`\`\`yaml
221
- ${emptyPlayerViewsExample}\`\`\`
222
-
223
- 需要常驻显示时按这个两层结构写:
224
-
225
- \`\`\`yaml
226
- ${playerViewsExample}\`\`\`
227
-
228
- 视图对象只接受 id、title、items 三个字段,最多 128 个条目;不要把 document 或选择器写在视图上。条目只接受 id、label、select 三个字段。\`select.document\` 与 frame 一样写带双引号的 \`@短引用\`,不是文件路径;\`select.locator\` 与 node slot 同规则,恰好写 \`yaml\` 或 \`markdown\` 数组之一,整份省略 locator 表示显示整份文档。指向不存在的文档不会被自检拦下,只会让该条目在玩家界面上静默消失,所以短引用要从工具输出里取,不要凭印象写。
229
-
230
- 根级 opening.md 是普通 Markdown 原文,不带 $document 头。例如:
231
-
232
- \`\`\`markdown
233
- 雨水沿着廊檐砸在你脚边。紧闭的药铺门内传来第二声撞击,街角巡夜人的灯正朝这里靠近。你只有片刻决定如何回应。
234
- \`\`\``;
235
- const settingAuthorRuntimeContractEn = `# Runtime setting-improvement tools and mechanical contract
236
-
237
- You edit only an isolated candidate for one fixed content package. You cannot change a running world, history, character knowledge, or an authority commit.
238
- Runtime may inject the complete source of user-selected files under “Current setting files.” Those files already count as completely read and need no additional read call for write authorization.
239
-
240
- In the plan-first path, use only precise list, literal search, and precise read operations to understand the existing setting. Then produce a visible plan whose first level-one heading begins with “# Creation plan.” Do not write the candidate during this phase. setting_list accepts world or a directory under world/. Cursors returned by list, search, or read belong only to the fixed candidate snapshot and exact query that produced them.
241
-
242
- The user may explicitly skip the visible plan. Only after entering the candidate phase may you use setting_write_file, setting_patch, setting_move, and the final-state tools. setting_write_file accepts only these paths: .yaml or .md world documents under world/; control/frame.yaml; control/player-views.yaml; .md files under control/blocks/; and the root opening.md. Every other path is rejected. world/ paths may use Unicode names. A control/blocks/ file name must begin with a lowercase ASCII letter and contain only lowercase letters, digits, hyphens, underscores, and slashes, for example control/blocks/world-style.md.
243
-
244
- Read an existing file completely before changing it. A complete file injected by the user, or a file you created or changed yourself, already counts as read and need not be read again before another edit. Consecutive setting_write_file, setting_patch, and setting_move calls in one model response form one atomic revision. If any call is rejected, none of them take effect; resend the entire batch with the repair. Each successful revision replaces the candidate snapshot and invalidates old cursors. Tools expose logical paths only, never host paths.
245
-
246
- Documents under world/ become state/ unchanged when a world is created. Use YAML or Markdown for characters, places, key items, rules, and the current situation. Put prompt frames, hosting methods, and player views under control/. Do not create schema records or JSON material DTOs.
247
-
248
- The root opening.md is the prose shown immediately after the player enters a new world. A content package must contain exactly one. Use setting_list to check it. Create it when missing. If the requested change affects the opening location, characters present, immediate situation, narrative tone, or action hook, read the existing opening completely and update it. Preserve an unaffected opening instead of rewriting it merely to demonstrate work.
249
-
250
- Do not decide the player's action, dialogue, or inner thoughts: no player input exists yet. Any fact in the opening that will constrain the first action must also be written to the world document that naturally owns it; it cannot live only in opening.md.
251
-
252
- ## How the setting is used
253
-
254
- - Play uses a model-directed call chain. A fresh context recompiles the context slots in control/frame.yaml from the latest world endpoint; append continues the current transcript. Important facts that must constrain later actions belong in world documents, not only in the opening or one narrative passage.
255
- - A catalog slot injects only each document's title, summary, and @ref, never its body. Before a body is read, the summary is the host's main signal for whether reading it is worthwhile, so write a useful one-sentence summary rather than “information about X.”
256
- - The host preset supplies general adjudication and state-maintenance criteria to every fresh call chain. control/blocks/ contains only world-specific material: genre boundaries, distinctive style, document types in this world, where each result belongs, and special rules. Do not repeat the general criteria or redefine the default point of view and style.
257
- - control/blocks/ enters the stable bootstrap as world-specific author instruction. Do not describe Runtime orchestration there; the play preset owns narrative rules, tool contracts, and follow-up requests.
258
- - control/player-views.yaml selects precise nodes for persistent player-facing display. An empty view set is valid. When persistent information should be visible, use the two-level structure in the example below: views group and title items, while every item has a select that targets one exact node. Documents and selectors belong on items, never on the view itself. Player views are not a permission system; material omitted from them is not automatically secret.
259
-
260
- ## World facts and state
261
-
262
- - World documents contain facts and stable rules that already hold when the world is created. Wishes, intentions, attempts, plans, possibilities, predictions, planned turns, and future branches are not established facts. Hosting and creative requirements belong in control/blocks/, not disguised as world facts.
263
- - General save criteria and ownership rules come from the host preset. Here, decide which facts already hold at world creation, which documents own them, and which world-specific document types and destinations control/blocks/ must explain.
264
- - Ordinary temporary objects such as a shirt do not need separate documents. Record only ownership and meaning that must persist in an existing owner document. Create a separate document only when an object needs an independent reference, transfer, or lifecycle.
265
- - The current situation contains only what remains true after opening actions and immediate reactions finish: the present location, characters still present, events still in progress, and the few constraints whose omission would immediately conflict with the first action. Remove background recap, completed actions, departed characters, resolved problems, superseded descriptions, plans, branches, and predictions. It is not an event log. Keep its title and summary aligned with the current scene.
266
-
267
- The candidate is checked for file safety, document identity, references, control formats, required slots, player selectors, and the real Prompt Preview. Runtime does not interpret affinity scores, relationship direction, or progression order.
268
-
269
- ## control/frame.yaml assembly and path association
270
-
271
- - Markdown blocks in instructions enter author_instruction from top to bottom. Slots in context enter world_context from top to bottom. Moving an entry changes only injection order. If a fixed document slot and reference_targets select the same whole document, the compiler keeps the first position and injects it once; this deduplicates prompt material without changing presence references or other world state. A whole document overlapping one of its nodes remains invalid.
272
- - The compiled prompt reaches the model in this order, and an unchanged prefix may be cached. The current situation changes with authority commits and belongs later in the current_situation slot. Keep dynamic facts out of earlier static material. Documents or nodes injected permanently, and catalog titles and summaries, must stay stable. Put frequently changing facts in the current situation; when another high-frequency material document is genuinely necessary, place its slot near the current situation.
273
- - Candidate world/ paths become state/ paths when the world is created; the remainder stays unchanged. world/states/alex.yaml becomes state/states/alex.yaml.
274
- - catalog.directory: states matches only direct child documents under world/states/, which become direct children under state/states/. It does not match by document id, ref, type, or file-name prefix. world/state.alex.yaml is at the world root and does not belong to that catalog; use setting_move to fix the path rather than copying the document and leaving the wrong one behind. world/states/group/alex.yaml requires directory: states/group.
275
- - A catalog injects title, summary, and @ref only. Use document for a whole body, node for a precise section, and reference_targets to expand one layer of explicit references from a YAML node. bindings.currentSituation identifies the exact document for current_situation. The example below contains the full parameter allowlist; extra keys are rejected.
276
- - A history slot injects recent committed narrative into a fresh context (recent defaults to 2 and accepts 1 through 32). Narrative may contain precise details that were never duplicated into a world document. Removing history can break prose continuity when the player responds to the preceding passage, so keep it unless this world truly does not need continuity across contexts.
277
- - A catalog is required by default and must associate at least one direct child document. Use required: false only when the directory is intentionally allowed to be empty. Do not hide a path error behind an optional empty catalog.
278
-
279
- After all final edits, call setting_preview_candidate. Only after it passes may you call setting_finish_candidate. Both calls may appear in one model response in that order. setting_finish_candidate must be the last tool call in that response, and the response may contain no rejected call. Repair path, slot, reference, or Prompt Preview diagnostics and preview again. A failed preview is normal iteration, not a terminal failure. The user can only apply or discard the candidate as a whole.
280
-
281
- ## Minimal valid formats
282
-
283
- A YAML world document uses a ref of 2–32 characters beginning with a lowercase ASCII letter and continuing with lowercase letters, digits, or hyphens. title is required and at most 120 characters; summary is required and at most 240. aliases is an optional list of ordinary spoken names and participates in setting_search together with title and summary.
284
-
285
- For example, saving this as world/characters/alex.yaml makes it a direct member of the characters catalog:
286
-
287
- \`\`\`yaml
288
- $document:
289
- id: character.alex
290
- ref: alex
291
- title: Alex
292
- summary: A direct, loyal basketball forward.
293
- aliases: [Al]
294
- clothing: White athletic top, shorts, and sandals
295
- relationships:
296
- Morgan: Familiar, but still testing the relationship
297
- \`\`\`
298
-
299
- A Markdown world document:
300
-
301
- \`\`\`markdown
302
- ---
303
- $document:
304
- id: rule.cultivation
305
- ref: cultivation
306
- title: Cultivation stages
307
- summary: The order of stages and how breakthroughs work.
308
- aliases: []
309
- ---
310
- # Cultivation stages
311
-
312
- Foundation Establishment follows Qi Refining, then Gold Core and Nascent Soul. Gold Core means…
313
- \`\`\`
314
-
315
- Cross-document references use an exact object and **an @short-ref returned by Runtime**, for example \`{ $ref: "@alex" }\`. Runtime resolves it to the target identity when saving. Never invent a document id such as character.alex for $ref. Runtime allocates random ids for new documents, and a guessed id refers to nothing. A body returned by setting_read already contains resolved $ref identities; preserve them exactly when writing it back. Never use a file path as a reference.
316
-
317
- The complete source above is the contents for setting_write_file, whose path is the full logical path. Runtime decides $document.id and $document.ref. It allocates them for new documents, rewrites a conflicting ref, and preserves existing values on replacement. The effective id and @short-ref are those returned by the tool. You do not need to know the id: file-body $ref values, control/frame.yaml, and control/player-views.yaml all address documents with @short-refs, which Runtime resolves. When replacing an existing document, you may omit the entire $document block and edit the body returned by setting_read; Runtime preserves identity, title, summary, and aliases. Only new documents require $document.
318
-
319
- setting_patch distinguishes add for a new node from replace for an existing node. Runtime does not guess intent from earlier commands in the batch. A locator is a sequence of map keys such as ["relationships","Morgan"]; it cannot select an element by list index. Replace the map key that owns a list with a complete new list value. Markdown has no section patch in setting improvement; rewrite it in full with setting_write_file.
320
-
321
- control/frame.yaml (the example lists all seven slot kinds; choose only those the world needs):
322
-
323
- \`\`\`yaml
324
- ${frameExampleEn}\`\`\`
325
-
326
- Every context entry has exactly the form \`- slot: {...}\`. Slot parameters are allowlisted:
327
-
328
- - current_situation: kind only. bindings.currentSituation selects the document. required is not accepted.
329
- - additional_materials: kind only. required is not accepted.
330
- - document: kind, document, required. document is a quoted @short-ref returned by a tool, not a file path.
331
- - node: kind, document, locator, required. locator contains exactly one of yaml: [key, child] or markdown: [Heading], matches the document codec, and identifies a node that exists.
332
- - catalog: kind, directory, maxEntries, required. maxEntries is required and ranges from 1 to 100.
333
- - history: kind, recent. recent is optional, defaults to 2, and ranges from 1 to 32. required is not accepted.
334
- - reference_targets: kind, from, maxEntries, required. from contains exactly document and locator, and its locator supports only yaml. maxEntries is required and ranges from 1 to 64.
335
-
336
- required is an optional boolean defaulting to true and is accepted only by document, node, catalog, and reference_targets. These seven kinds are the entire supported slot set.
337
-
338
- bindings.currentSituation and every document field in document, node, and reference_targets use @short-refs returned by setting_list, setting_search, setting_read, or a write receipt. **@ is reserved in YAML, so values must be quoted, such as "@alex".** Omitting quotes makes the control file unsafe YAML.
339
-
340
- control/player-views.yaml may begin empty:
341
-
342
- \`\`\`yaml
343
- ${emptyPlayerViewsExample}\`\`\`
344
-
345
- For persistent display, use the two-level structure:
346
-
347
- \`\`\`yaml
348
- ${playerViewsExampleEn}\`\`\`
349
-
350
- A view accepts only id, title, and items, with at most 128 items. Do not place a document or selector on the view. An item accepts only id, label, and select. select.document is a quoted @short-ref, not a file path. select.locator follows the node-slot rule and contains exactly one yaml or markdown array. Omitting locator displays the whole document. A nonexistent document does not fail candidate validation; the item simply disappears from the player UI, so always copy short refs from tool output.
351
-
352
- The root opening.md is ordinary Markdown prose with no $document header. For example:
353
-
354
- \`\`\`markdown
355
- Rain strikes the pavement beneath the eaves. A second crash sounds behind the locked apothecary door while the night watchman's lantern turns into the street. You have only a moment to respond.
356
- \`\`\``;
357
- const maxSettingStreamTail = 160;
358
- const maxRecentSettingActions = 12;
359
- export class SettingModelError extends Error {
360
- code;
361
- constructor(code, message) {
362
- super(message);
363
- this.name = "SettingModelError";
364
- this.code = code;
365
- }
366
- }
367
- export class DocumentCandidateSettingImprovement {
368
- #adapter;
369
- #preview;
370
- #authorPrompt;
371
- #locale;
372
- #failureLog;
373
- #baseFiles;
374
- #baseSnapshot;
375
- #currentFiles;
376
- #candidateFiles = null;
377
- #candidateSnapshot = null;
378
- #candidateReads = freshReadAuthorizations(WorldDocumentStore.open({ layout: "content_package", files: [] }));
379
- #candidateWorldChanges = new Map();
380
- #messages = [];
381
- #baseReads;
382
- #progress = idleSettingProgress();
383
- #state = "idle";
384
- constructor(input) {
385
- this.#baseSnapshot = WorldDocumentStore.open({
386
- layout: "content_package",
387
- files: input.files,
388
- });
389
- this.#baseFiles = cloneFiles(this.#baseSnapshot.files);
390
- this.#currentFiles = cloneFiles(this.#baseSnapshot.files);
391
- this.#baseReads = freshReadAuthorizations(this.#baseSnapshot);
392
- this.#adapter = input.adapter;
393
- this.#preview = input.preview;
394
- this.#failureLog = input.failureLog;
395
- this.#locale = input.locale ?? defaultAppLocale;
396
- this.#authorPrompt = requiredAuthorPrompt(input.authorPrompt ??
397
- defaultSettingImprovementPromptForLocale(this.#locale));
398
- }
399
- progress() {
400
- return structuredClone(this.#progress);
401
- }
402
- #beginPhase(phase) {
403
- this.#progress = { ...idleSettingProgress(), phase, updatedAt: Date.now() };
404
- }
405
- #settlePhase(failure) {
406
- this.#progress.phase = "settled";
407
- this.#progress.failure = failure;
408
- this.#progress.updatedAt = Date.now();
409
- }
410
- #recordExchange(usage) {
411
- this.#progress.round += 1;
412
- this.#progress.usage = aggregateModelUsage(this.#progress.usage, usage);
413
- this.#progress.streaming = null;
414
- this.#progress.updatedAt = Date.now();
415
- }
416
- #recordDelta(delta) {
417
- const now = Date.now();
418
- const streaming = this.#progress.streaming ?? {
419
- reasoningChars: 0,
420
- textChars: 0,
421
- toolChars: 0,
422
- tail: "",
423
- receivedAt: now,
424
- };
425
- const size = [...delta.text].length;
426
- if (delta.kind === "reasoning")
427
- streaming.reasoningChars += size;
428
- else if (delta.kind === "text")
429
- streaming.textChars += size;
430
- else
431
- streaming.toolChars += size;
432
- // Raw argument JSON counts as liveness but reads as noise, so the visible
433
- // tail follows prose only.
434
- if (delta.kind !== "tool")
435
- streaming.tail = [...(streaming.tail + delta.text)]
436
- .slice(-maxSettingStreamTail)
437
- .join("");
438
- streaming.receivedAt = now;
439
- this.#progress.streaming = streaming;
440
- this.#progress.updatedAt = now;
441
- }
442
- // Counts are published before the guard may throw, so a run that trips the
443
- // ceiling still reports the count that tripped it instead of one less.
444
- #countRepair(current, phase) {
445
- this.#progress.repairs = current + 1;
446
- this.#progress.updatedAt = Date.now();
447
- return nextRepairCount(current, phase);
448
- }
449
- #countFailedCheck(current) {
450
- this.#progress.failedChecks = current + 1;
451
- this.#progress.updatedAt = Date.now();
452
- return nextFailedCheckCount(current);
453
- }
454
- #recordAction(call, ok) {
455
- const target = settingCallTarget(call);
456
- this.#progress.toolCalls += 1;
457
- if (ok && call.name === "setting_write_file")
458
- this.#progress.writing = target;
459
- this.#progress.recentActions.push({ tool: call.name, target, ok });
460
- if (this.#progress.recentActions.length > maxRecentSettingActions)
461
- this.#progress.recentActions.shift();
462
- this.#progress.updatedAt = Date.now();
463
- }
464
- async #observeResponse(response) {
465
- if (response.diagnostics !== undefined)
466
- await this.#failureLog?.recordExchangeIfActive(response.diagnostics);
467
- }
468
- async #recordResponseFailure(response, failure) {
469
- if (response.diagnostics !== undefined)
470
- await this.#failureLog?.recordFailure({
471
- exchange: response.diagnostics,
472
- failures: [failure],
473
- });
474
- }
475
- async #resolveResponseFailures(response, message) {
476
- if (response.diagnostics !== undefined)
477
- await this.#failureLog?.resolve({
478
- exchange: response.diagnostics,
479
- message,
480
- });
481
- }
482
- async start(input) {
483
- if (this.#state !== "idle")
484
- throw new Error("The setting-improvement session has already started");
485
- if (input.mode !== "plan_first" && input.mode !== "direct_candidate")
486
- throw new Error("Invalid setting-improvement start mode");
487
- const goal = input.goal.trim();
488
- if (goal.length === 0)
489
- throw new Error("The setting-improvement goal cannot be empty");
490
- const injectedFiles = selectInjectedFiles(this.#baseFiles, input.contextPaths);
491
- this.#baseReads = freshReadAuthorizations(this.#baseSnapshot);
492
- for (const file of injectedFiles)
493
- authorizeInjectedFile(this.#baseSnapshot, this.#baseReads, file);
494
- this.#messages = [
495
- {
496
- role: "system",
497
- content: settingAuthorSystemPrompt(this.#authorPrompt, this.#locale),
498
- },
499
- ...(injectedFiles.length === 0
500
- ? []
501
- : [
502
- {
503
- role: "user",
504
- content: injectedContext(injectedFiles, this.#locale),
505
- },
506
- ]),
507
- {
508
- role: "user",
509
- content: `${this.#locale === "zh-CN" ? "# 本次完善目标" : "# Current improvement goal"}\n\n${goal}`,
510
- },
511
- ];
512
- this.#state = "starting";
513
- if (input.mode === "direct_candidate")
514
- return this.#generateCandidate("direct");
515
- return this.#createPlan();
516
- }
517
- async #createPlan(entry = "initial") {
518
- let repairs = 0;
519
- let lastResponse;
520
- this.#beginPhase("planning");
521
- this.#messages.push({
522
- role: "user",
523
- content: entry === "initial"
524
- ? localized(this.#locale, "This is the read-only planning phase. Treat the goal as an improvement to the existing setting. Use setting_list, setting_search, and setting_read as needed to understand the fixed current tree, then output a visible creation plan. Do not create, change, or finish a candidate in this phase.", "这是只读计划阶段。把目标理解为对已有设定的完善;先用 setting_list、setting_search、setting_read 按需了解固定当前树,再输出可见创作计划。此阶段不得创建、修改或结束候选。")
525
- : localized(this.#locale, "Adjust the direction from the feedback above, then output a complete replacement creation plan. Preserve what still holds instead of writing only a delta. This remains a read-only phase: you may continue checking with setting_list, setting_search, and setting_read, but may not create, change, or finish a candidate.", "按上面的修改意见调整方向,然后重新输出一份完整的创作计划;沿用仍然成立的部分,不要只写增量。仍是只读阶段,可以继续用 setting_list、setting_search、setting_read 核对,但不得创建、修改或结束候选。"),
526
- });
527
- for (let round = 0; round < 64; round += 1) {
528
- const response = await this.#adapter.next({
529
- messages: publicMessages(this.#messages),
530
- tools: readOnlyDocumentCandidateSettingTools,
531
- maxOutputTokens: planningOutputTokens,
532
- onDelta: (delta) => this.#recordDelta(delta),
533
- });
534
- lastResponse = response;
535
- await this.#observeResponse(response);
536
- this.#recordExchange(response.usage);
537
- this.#messages.push(assistantMessage(response));
538
- if (response.toolCalls.length === 0) {
539
- try {
540
- assertVisiblePlan(response.content, this.#locale);
541
- }
542
- catch (error) {
543
- if (!(error instanceof SettingModelError))
544
- throw error;
545
- await this.#recordResponseFailure(response, {
546
- kind: "format_validation",
547
- message: error.message,
548
- details: { phase: "planning", content: response.content },
549
- });
550
- this.#messages.push({
551
- role: "user",
552
- content: renderSettingRepair(error.message, this.#locale),
553
- });
554
- repairs = this.#countRepair(repairs, "plan");
555
- continue;
556
- }
557
- this.#state = "planned";
558
- this.#settlePhase(null);
559
- await this.#resolveResponseFailures(response, "The setting-improvement plan passed format validation in a later model exchange.");
560
- return { kind: "plan", markdown: response.content };
561
- }
562
- for (const call of response.toolCalls) {
563
- let result;
564
- if (!readOnlySettingToolNames.has(call.name))
565
- result = settingToolFailure(this.#baseSnapshot, localized(this.#locale, "The planning phase allows only setting_list, setting_search, and setting_read", "计划阶段只允许 setting_list、setting_search、setting_read"), this.#locale);
566
- else {
567
- try {
568
- result = executeTool(this.#baseSnapshot, call, this.#baseReads, this.#locale);
569
- }
570
- catch (error) {
571
- if (!(error instanceof SettingModelError))
572
- throw error;
573
- result = settingToolFailure(this.#baseSnapshot, error.message, this.#locale);
574
- }
575
- }
576
- this.#messages.push({
577
- role: "tool",
578
- toolCallId: call.id,
579
- content: result.markdown,
580
- ...(result.ok ? {} : { isError: true }),
581
- });
582
- this.#recordAction(call, result.ok);
583
- if (!result.ok) {
584
- await this.#recordResponseFailure(response, {
585
- kind: "tool_execution",
586
- message: "A model tool call in the setting-improvement planning phase was rejected.",
587
- details: {
588
- call: structuredClone(call),
589
- result: result.markdown,
590
- },
591
- });
592
- repairs = this.#countRepair(repairs, "plan");
593
- }
594
- }
595
- }
596
- const message = "Setting-improvement planning exceeded the maximum read-only tool rounds";
597
- if (lastResponse !== undefined)
598
- await this.#recordResponseFailure(lastResponse, {
599
- kind: "format_validation",
600
- message,
601
- details: { phase: "planning", maximumRounds: 64 },
602
- });
603
- throw new Error(message);
604
- }
605
- async confirmPlan() {
606
- if (this.#state !== "planned")
607
- throw new Error("There is no creation plan to confirm");
608
- this.#state = "confirming";
609
- try {
610
- return await this.#generateCandidate("confirmed_plan");
611
- }
612
- catch (error) {
613
- this.#state = "planned";
614
- throw error;
615
- }
616
- }
617
- /**
618
- * Continues the same session with the user's note appended.
619
- *
620
- * Accept-all-or-discard-all forced a full restart over any disagreement,
621
- * throwing away everything the author had already read and got right. The
622
- * note lands in the existing transcript, so revision keeps that context.
623
- */
624
- async revisePlan(feedback) {
625
- if (this.#state !== "planned")
626
- throw new Error("There is no creation plan to revise");
627
- this.#messages.push({
628
- role: "user",
629
- content: `${this.#locale === "zh-CN" ? "# 用户对创作计划的修改意见" : "# User feedback on the creation plan"}\n\n${requiredFeedback(feedback)}`,
630
- });
631
- this.#state = "starting";
632
- try {
633
- return await this.#createPlan("revision");
634
- }
635
- catch (error) {
636
- this.#state = "planned";
637
- throw error;
638
- }
639
- }
640
- async reviseCandidate(feedback) {
641
- if (this.#state !== "ready" || this.#candidateSnapshot === null)
642
- throw new Error("There is no candidate to revise");
643
- this.#messages.push({
644
- role: "user",
645
- content: `${this.#locale === "zh-CN" ? "# 用户对候选的修改意见" : "# User feedback on the candidate"}\n\n${requiredFeedback(feedback)}`,
646
- });
647
- this.#state = "confirming";
648
- try {
649
- return await this.#generateCandidate("revision");
650
- }
651
- catch (error) {
652
- this.#state = "ready";
653
- throw error;
654
- }
655
- }
656
- async #generateCandidate(entry) {
657
- const preGenerationLength = this.#messages.length;
658
- try {
659
- const candidate = await this.#runCandidateGeneration(entry);
660
- this.#settlePhase(null);
661
- return candidate;
662
- }
663
- catch (error) {
664
- this.#messages.splice(preGenerationLength);
665
- // The counters survive the rollback: they are the only account of why
666
- // this run stopped once the conversation itself has been discarded.
667
- this.#settlePhase(error instanceof Error
668
- ? error.message
669
- : "Setting-improvement candidate generation was interrupted");
670
- throw error;
671
- }
672
- }
673
- async #runCandidateGeneration(entry) {
674
- // A revision resumes the candidate the user just looked at; starting from
675
- // the baseline again would make the author redo work it already got right.
676
- const resumed = entry === "revision" ? this.#candidateSnapshot : null;
677
- let candidate = resumed ?? this.#baseSnapshot;
678
- let lastFailedCheck = null;
679
- let previewedSnapshotId = null;
680
- let previewedCheck = null;
681
- const reads = cloneReadAuthorizations(resumed === null ? this.#baseReads : this.#candidateReads);
682
- const worldChanges = new Map(resumed === null ? [] : this.#candidateWorldChanges);
683
- let repairs = 0;
684
- let failedChecks = 0;
685
- let lastResponse;
686
- this.#beginPhase("generating");
687
- this.#messages.push({
688
- role: "user",
689
- content: entry === "confirmed_plan"
690
- ? localized(this.#locale, "The plan is confirmed. Enter the candidate phase: list the isolated candidate and inspect opening.md first. Read files completely as needed before changing them, and preserve the confirmed direction. After all edits, call setting_preview_candidate and, once it passes, setting_finish_candidate. The two calls may appear in that order in one model response.", "计划已确认。现在进入候选阶段:先列出隔离候选并检查 opening.md;按需完整读取后再修改文件,不要改变已确认方向。最终修改后调用 setting_preview_candidate,自检通过后调用 setting_finish_candidate,两者可以在同一模型响应内先后调用。")
691
- : entry === "direct"
692
- ? localized(this.#locale, "The user explicitly skipped the visible creation plan. Enter the candidate phase directly: list the isolated candidate and inspect opening.md first, then produce a complete candidate from the current goal, injected files, and current setting read as needed. After all edits, call setting_preview_candidate and, once it passes, setting_finish_candidate. The two calls may appear in that order in one model response.", "用户明确选择跳过可见创作计划。现在直接进入候选阶段:先列出隔离候选并检查 opening.md,基于当前目标、已注入文件和按需读取的当前设定生成完整候选。最终修改后调用 setting_preview_candidate,自检通过后调用 setting_finish_candidate,两者可以在同一模型响应内先后调用。")
693
- : localized(this.#locale, "Continue revising the candidate from the feedback above. The current candidate is exactly the state from the end of the previous generation, and accepted changes are still present. Change only what needs correction instead of rebuilding it. Then call setting_preview_candidate and, once it passes, setting_finish_candidate.", "按上面的修改意见继续调整候选。当前候选就是上一次生成结束时的状态,已落地的修改都还在,只需改动需要改的部分,不要推倒重来。改完后照旧调用 setting_preview_candidate,自检通过后调用 setting_finish_candidate。"),
694
- });
695
- for (let round = 0; round < 64; round += 1) {
696
- const response = await this.#adapter.next({
697
- messages: publicMessages(this.#messages),
698
- tools: documentCandidateSettingTools,
699
- maxOutputTokens: editingOutputTokens,
700
- onDelta: (delta) => this.#recordDelta(delta),
701
- });
702
- lastResponse = response;
703
- await this.#observeResponse(response);
704
- this.#recordExchange(response.usage);
705
- this.#messages.push(assistantMessage(response));
706
- if (response.toolCalls.length === 0) {
707
- await this.#recordResponseFailure(response, {
708
- kind: "format_validation",
709
- message: lastFailedCheck === null
710
- ? "The setting-improvement candidate response did not call a final-state tool."
711
- : "The setting-improvement candidate response did not repair the failed mechanical check.",
712
- details: {
713
- phase: "candidate",
714
- content: response.content,
715
- lastFailedCheck,
716
- },
717
- });
718
- this.#messages.push({
719
- role: "user",
720
- content: renderSettingRepair(lastFailedCheck === null
721
- ? localized(this.#locale, "Setting improvement must finish the candidate with a final-state tool", "设定完善必须使用终态工具结束候选")
722
- : localized(this.#locale, `The candidate failed mechanical checks: ${lastFailedCheck.replace(/\s+/gu, " ")}`, `候选未通过机械检查:${lastFailedCheck.replace(/\s+/gu, " ")}`), this.#locale),
723
- });
724
- repairs = this.#countRepair(repairs, "candidate");
725
- continue;
726
- }
727
- let finished = false;
728
- let responseHadRepairError = false;
729
- for (let callIndex = 0; callIndex < response.toolCalls.length;) {
730
- const call = response.toolCalls[callIndex];
731
- if (isWorldRevisionCall(call)) {
732
- const revisionCalls = [];
733
- while (callIndex < response.toolCalls.length &&
734
- isWorldRevisionCall(response.toolCalls[callIndex])) {
735
- revisionCalls.push(response.toolCalls[callIndex]);
736
- callIndex += 1;
737
- }
738
- let revisionFailure = null;
739
- // Which call in the batch is actually at fault. The others only need
740
- // to know the batch did not land and where to look, not a copy of a
741
- // diagnostic about someone else's command.
742
- let failureIndex = null;
743
- try {
744
- const commands = [];
745
- for (const [index, revisionCall] of revisionCalls.entries()) {
746
- try {
747
- commands.push(worldRevisionCommand(revisionCall, this.#locale));
748
- }
749
- catch (error) {
750
- failureIndex = index;
751
- throw error;
752
- }
753
- }
754
- const revised = candidate.revise({ commands });
755
- if (!revised.ok) {
756
- failureIndex =
757
- revised.diagnostics.find(({ commandIndex }) => commandIndex !== null)?.commandIndex ?? null;
758
- throw new SettingModelError("revision_rejected", renderSettingRevisionFailure(revised.diagnostics));
759
- }
760
- staleSettingReadAuthorizationForTest(reads);
761
- assertRevisionChangesAuthorized(candidate.id, reads, revised.changes, this.#locale);
762
- candidate = revised.snapshot;
763
- mergeWorldRevisionChanges(worldChanges, revised.changes);
764
- rebaseReadAuthorizations(reads, candidate);
765
- for (const { documentId } of revised.changes)
766
- reads.worldDocumentIds.add(documentId);
767
- previewedSnapshotId = null;
768
- previewedCheck = null;
769
- for (const [index, revisionCall] of revisionCalls.entries())
770
- this.#messages.push({
771
- role: "tool",
772
- toolCallId: revisionCall.id,
773
- content: renderSettingCallSuccess(revised.changes, index, this.#locale),
774
- });
775
- }
776
- catch (error) {
777
- if (!(error instanceof SettingModelError))
778
- throw error;
779
- revisionFailure = settingToolFailure(candidate, error.message, this.#locale);
780
- }
781
- if (revisionFailure !== null) {
782
- const failed = revisionFailure;
783
- for (const [index, revisionCall] of revisionCalls.entries())
784
- this.#messages.push({
785
- role: "tool",
786
- toolCallId: revisionCall.id,
787
- content: failureIndex === null || failureIndex === index
788
- ? failed.markdown
789
- : renderSettingBatchRejected(revisionCalls, failureIndex, this.#locale),
790
- isError: true,
791
- });
792
- await this.#recordResponseFailure(response, {
793
- kind: "tool_execution",
794
- message: "The world-document revision for the setting-improvement candidate was rejected.",
795
- details: {
796
- calls: structuredClone(revisionCalls),
797
- failureIndex,
798
- result: failed.markdown,
799
- },
800
- });
801
- responseHadRepairError = true;
802
- repairs = this.#countRepair(repairs, "candidate");
803
- }
804
- for (const revisionCall of revisionCalls)
805
- this.#recordAction(revisionCall, revisionFailure === null);
806
- continue;
807
- }
808
- if (call.name === "setting_finish_candidate") {
809
- let rejection = null;
810
- if (call !== response.toolCalls.at(-1))
811
- rejection = localized(this.#locale, "The candidate final-state tool must be called last", "候选终态工具必须最后调用");
812
- else if (responseHadRepairError)
813
- rejection = localized(this.#locale, "This response contains an unresolved error; repair it before finishing the candidate", "本响应中有未处理的错误,请先修复再结束候选");
814
- else if (previewedSnapshotId !== candidate.id)
815
- rejection = localized(this.#locale, "setting_finish_candidate was rejected. Call setting_preview_candidate first and pass the complete check for the current candidate snapshot. Preview and finish may occur in that order in the same response.", "setting_finish_candidate 未被接受。必须先调用 setting_preview_candidate 并让当前候选快照的整体自检通过,才能结束候选;同一轮内先自检再结束也可以。");
816
- this.#messages.push({
817
- role: "tool",
818
- toolCallId: call.id,
819
- content: rejection === null
820
- ? localized(this.#locale, "# Candidate final state accepted\n\nRuntime accepted the current isolated candidate for user review. The content package is unchanged and will be replaced only if the user applies the entire candidate.", "# 候选终态已接受\n\nRuntime 已接受当前隔离候选供用户审阅;内容包尚未改变,只有用户整批应用后才会替换当前树。")
821
- : localized(this.#locale, `# Runtime tool rejected\n\n${rejection}`, `# Runtime 工具拒绝\n\n${rejection}`),
822
- ...(rejection === null ? {} : { isError: true }),
823
- });
824
- if (rejection === null)
825
- finished = true;
826
- else {
827
- await this.#recordResponseFailure(response, {
828
- kind: "tool_execution",
829
- message: rejection,
830
- details: { call: structuredClone(call) },
831
- });
832
- responseHadRepairError = true;
833
- repairs = this.#countRepair(repairs, "candidate");
834
- }
835
- this.#recordAction(call, rejection === null);
836
- callIndex += 1;
837
- continue;
838
- }
839
- if (call.name === "setting_preview_candidate") {
840
- const check = checkCandidateForAuthor(candidate, this.#preview, this.#locale);
841
- this.#messages.push({
842
- role: "tool",
843
- toolCallId: call.id,
844
- content: check.markdown,
845
- ...(check.passed ? {} : { isError: true }),
846
- });
847
- lastFailedCheck = check.passed ? null : check.markdown;
848
- previewedSnapshotId = check.passed ? candidate.id : null;
849
- previewedCheck = check.passed ? check : null;
850
- this.#progress.lastCheck = check.passed
851
- ? null
852
- : summarizeSettingCheck(check.markdown);
853
- this.#recordAction(call, check.passed);
854
- if (!check.passed) {
855
- await this.#recordResponseFailure(response, {
856
- kind: "format_validation",
857
- message: "The setting-improvement candidate failed mechanical checks.",
858
- details: {
859
- call: structuredClone(call),
860
- check: check.markdown,
861
- },
862
- });
863
- responseHadRepairError = true;
864
- failedChecks = this.#countFailedCheck(failedChecks);
865
- }
866
- callIndex += 1;
867
- continue;
868
- }
869
- let result;
870
- try {
871
- result = executeTool(candidate, call, reads, this.#locale);
872
- }
873
- catch (error) {
874
- if (!(error instanceof SettingModelError))
875
- throw error;
876
- result = settingToolFailure(candidate, error.message, this.#locale);
877
- }
878
- const previousSnapshotId = candidate.id;
879
- candidate = result.snapshot;
880
- if (candidate.id !== previousSnapshotId) {
881
- previewedSnapshotId = null;
882
- previewedCheck = null;
883
- }
884
- this.#messages.push({
885
- role: "tool",
886
- toolCallId: call.id,
887
- content: result.markdown,
888
- ...(result.ok ? {} : { isError: true }),
889
- });
890
- this.#recordAction(call, result.ok);
891
- if (!result.ok) {
892
- await this.#recordResponseFailure(response, {
893
- kind: "tool_execution",
894
- message: "A model tool call for the setting-improvement candidate was rejected.",
895
- details: {
896
- call: structuredClone(call),
897
- result: result.markdown,
898
- },
899
- });
900
- responseHadRepairError = true;
901
- repairs = this.#countRepair(repairs, "candidate");
902
- }
903
- callIndex += 1;
904
- }
905
- if (!finished)
906
- continue;
907
- const candidateFiles = cloneFiles(candidate.files);
908
- const check = previewedCheck;
909
- if (check === null || previewedSnapshotId !== candidate.id)
910
- throw new Error("The candidate final state lacks a complete passing check for the current snapshot");
911
- const review = {
912
- status: check.inspection.status,
913
- diff: candidateDiffs(this.#baseFiles, candidateFiles, worldChanges),
914
- diagnostics: structuredClone(check.inspection.issues),
915
- preview: structuredClone(check.preview),
916
- };
917
- this.#candidateFiles = candidateFiles;
918
- // Kept so a later revision can resume from here instead of the baseline.
919
- this.#candidateSnapshot = candidate;
920
- this.#candidateReads = cloneReadAuthorizations(reads);
921
- this.#candidateWorldChanges = new Map(worldChanges);
922
- this.#state = "ready";
923
- await this.#resolveResponseFailures(response, "The setting-improvement candidate was repaired and passed complete checks in a later model exchange.");
924
- return { kind: "candidate", files: cloneFiles(candidateFiles), review };
925
- }
926
- const message = "Setting improvement exceeded the maximum tool rounds";
927
- if (lastResponse !== undefined)
928
- await this.#recordResponseFailure(lastResponse, {
929
- kind: "format_validation",
930
- message,
931
- details: { phase: "candidate", maximumRounds: 64 },
932
- });
933
- throw new Error(message);
934
- }
935
- currentFiles() {
936
- return cloneFiles(this.#currentFiles);
937
- }
938
- async apply(replaceAtomically) {
939
- if (this.#state !== "ready" || this.#candidateFiles === null)
940
- throw new Error("There is no complete candidate to apply");
941
- const files = cloneFiles(this.#candidateFiles);
942
- this.#state = "applying";
943
- try {
944
- await replaceAtomically(files);
945
- }
946
- catch (error) {
947
- this.#state = "ready";
948
- throw error;
949
- }
950
- this.#currentFiles = files;
951
- this.#candidateFiles = null;
952
- this.#state = "applied";
953
- }
954
- discard() {
955
- if (this.#state === "applied")
956
- throw new Error("An applied candidate cannot be discarded");
957
- if (this.#state === "starting" ||
958
- this.#state === "confirming" ||
959
- this.#state === "applying")
960
- throw new Error("The setting-improvement operation is running and cannot be discarded");
961
- this.#candidateFiles = null;
962
- this.#state = "discarded";
963
- }
964
- }
965
- function checkCandidateForAuthor(snapshot, preview, locale) {
966
- const inspection = inspectContentPackageCurrentTree(snapshot.files, {
967
- worldDocumentSnapshot: snapshot,
968
- });
969
- if (inspection.status !== "usable")
970
- return {
971
- passed: false,
972
- markdown: [
973
- locale === "zh-CN" ? "# 候选自检未通过" : "# Candidate check failed",
974
- "",
975
- locale === "zh-CN"
976
- ? "先修复以下 Runtime 内容树诊断,再重新调用 setting_preview_candidate:"
977
- : "Repair these Runtime content-tree diagnostics, then call setting_preview_candidate again:",
978
- "",
979
- ...inspection.issues.map(({ code, path, message }) => `- ${code} · ${path} · ${message}`),
980
- ].join("\n"),
981
- };
982
- try {
983
- const result = preview(snapshot);
984
- return {
985
- passed: true,
986
- inspection,
987
- preview: result,
988
- markdown: [
989
- locale === "zh-CN" ? "# 候选自检通过" : "# Candidate check passed",
990
- "",
991
- locale === "zh-CN"
992
- ? "真实 Prompt Preview 已成功编译。材料覆盖如下:"
993
- : "The real Prompt Preview compiled successfully. Material coverage:",
994
- "",
995
- ...result.compilation.coverage.map(({ slot, source, status, complete }) => `- ${slot} · ${source} · ${status} · ${complete ? (locale === "zh-CN" ? "完整" : "complete") : locale === "zh-CN" ? "未完整" : "incomplete"}`),
996
- "",
997
- locale === "zh-CN"
998
- ? "若这就是最终候选,可以调用 setting_finish_candidate;继续修改后必须重新自检。"
999
- : "If this is the final candidate, call setting_finish_candidate. Any further change requires another check.",
1000
- ].join("\n"),
1001
- };
1002
- }
1003
- catch (error) {
1004
- return {
1005
- passed: false,
1006
- markdown: [
1007
- locale === "zh-CN" ? "# 候选自检未通过" : "# Candidate check failed",
1008
- "",
1009
- locale === "zh-CN"
1010
- ? "真实 Prompt Preview 编译失败。先修复后重新调用 setting_preview_candidate:"
1011
- : "The real Prompt Preview failed to compile. Repair it, then call setting_preview_candidate again:",
1012
- "",
1013
- `- ${error instanceof Error
1014
- ? error.message
1015
- : locale === "zh-CN"
1016
- ? "未知 Prompt Preview 错误"
1017
- : "Unknown Prompt Preview error"}`,
1018
- ].join("\n"),
1019
- };
1020
- }
1021
- }
1022
- function assistantMessage(response) {
1023
- return {
1024
- role: "assistant",
1025
- content: response.content,
1026
- ...(response.reasoningContent === undefined
1027
- ? {}
1028
- : { reasoningContent: response.reasoningContent }),
1029
- ...(response.providerState === undefined
1030
- ? {}
1031
- : { providerState: structuredClone(response.providerState) }),
1032
- toolCalls: structuredClone(response.toolCalls),
1033
- };
1034
- }
1035
- function selectInjectedFiles(files, contextPaths) {
1036
- if (!Array.isArray(contextPaths))
1037
- throw new Error("Injected file paths must be an array");
1038
- const selected = [];
1039
- const seen = new Set();
1040
- for (const candidatePath of contextPaths) {
1041
- if (typeof candidatePath !== "string")
1042
- throw new Error("An injected file path must be a string");
1043
- const path = safeInputPath(candidatePath);
1044
- if (seen.has(path))
1045
- throw new Error(`Duplicate injected file path: ${path}`);
1046
- seen.add(path);
1047
- const file = files.find((candidate) => candidate.path === path);
1048
- if (file === undefined)
1049
- throw new Error(`Injected file does not exist: ${path}`);
1050
- if (file.encoding === "base64")
1051
- throw new Error(`A binary file cannot be injected into a model prompt: ${path}`);
1052
- selected.push(structuredClone(file));
1053
- }
1054
- return selected.sort((left, right) => left.path.localeCompare(right.path));
1055
- }
1056
- function injectedContext(files, locale) {
1057
- const sections = files.map((file) => {
1058
- const fence = markdownFence(file.contents);
1059
- return `## \`${file.path}\`\n\n${fence}${markdownLanguage(file.path)}\n${file.contents}${file.contents.endsWith("\n") ? "" : "\n"}${fence}`;
1060
- });
1061
- return [
1062
- locale === "zh-CN"
1063
- ? "# 用户选定的当前设定文件"
1064
- : "# User-selected current setting files",
1065
- "",
1066
- locale === "zh-CN"
1067
- ? "以下完整原文来自本次会话固定的内容包当前树,已经视为完整读取。它们是要保留或完善的已有设定,不是从零创作指令。未注入的文件仍可通过只读工具按需读取。"
1068
- : "The complete source below comes from the content package tree frozen for this session and already counts as completely read. These are existing setting files to preserve or improve, not instructions to create from scratch. Read uninjected files as needed with read-only tools.",
1069
- "",
1070
- ...sections.flatMap((section, index) => index === sections.length - 1 ? [section] : [section, ""]),
1071
- ].join("\n");
1072
- }
1073
- function markdownFence(contents) {
1074
- const longest = Math.max(0, ...(contents.match(/`+/gu) ?? []).map((run) => run.length));
1075
- return "`".repeat(Math.max(3, longest + 1));
1076
- }
1077
- function markdownLanguage(path) {
1078
- if (/\.ya?ml$/u.test(path))
1079
- return "yaml";
1080
- if (path.endsWith(".md"))
1081
- return "markdown";
1082
- return "text";
1083
- }
1084
- function freshReadAuthorizations(snapshot) {
1085
- return {
1086
- snapshotId: snapshot.id,
1087
- worldDocumentIds: new Set(),
1088
- damagedWorldPaths: new Set(),
1089
- opaquePaths: new Set(),
1090
- pendingWorldReads: new Map(),
1091
- };
1092
- }
1093
- function cloneReadAuthorizations(source) {
1094
- return {
1095
- snapshotId: source.snapshotId,
1096
- worldDocumentIds: new Set(source.worldDocumentIds),
1097
- damagedWorldPaths: new Set(source.damagedWorldPaths),
1098
- opaquePaths: new Set(source.opaquePaths),
1099
- pendingWorldReads: new Map([...source.pendingWorldReads].map(([key, value]) => [
1100
- key,
1101
- structuredClone(value),
1102
- ])),
1103
- };
1104
- }
1105
- function authorizeInjectedFile(snapshot, reads, file) {
1106
- if (!file.path.startsWith("world/")) {
1107
- reads.opaquePaths.add(file.path);
1108
- return;
1109
- }
1110
- const result = snapshot.query({
1111
- kind: "read_document",
1112
- document: { logicalPath: file.path },
1113
- maxBytes: 4,
1114
- });
1115
- if (result.kind === "read_document" && result.ok)
1116
- reads.worldDocumentIds.add(result.document.documentId);
1117
- else
1118
- reads.damagedWorldPaths.add(file.path);
1119
- }
1120
- function changedSnapshot(source, reads, files, writtenPath, markdown) {
1121
- const snapshot = WorldDocumentStore.open({
1122
- layout: "content_package",
1123
- files,
1124
- });
1125
- if (snapshot.id === source.id)
1126
- throw new Error("The candidate snapshot update did not produce a new snapshot identity");
1127
- rebaseReadAuthorizations(reads, snapshot);
1128
- reads.opaquePaths.add(writtenPath);
1129
- return { ok: true, snapshot, markdown };
1130
- }
1131
- // Read authorizations exist to stop the author from overwriting content it has
1132
- // never seen. A revision does not erase that knowledge: untouched documents
1133
- // keep their contents, and a document the author just wrote is the one it knows
1134
- // best. Only paged cursors are bound to a snapshot, so only they are dropped.
1135
- function rebaseReadAuthorizations(reads, snapshot) {
1136
- reads.snapshotId = snapshot.id;
1137
- reads.pendingWorldReads.clear();
1138
- }
1139
- function listSettingDocuments(snapshot, args, locale) {
1140
- if (!hasOnlyArguments(args, ["directory", "limit", "cursor"]))
1141
- return settingQueryArgumentError(localized(locale, "setting_list accepts only directory, limit, and cursor.", "setting_list 只接受 directory、limit 和 cursor。"), locale);
1142
- const requestedDirectory = args.directory ?? "world";
1143
- if (typeof requestedDirectory !== "string" ||
1144
- (requestedDirectory !== "world" && !requestedDirectory.startsWith("world/")))
1145
- return settingQueryArgumentError(localized(locale, "setting_list directory must be world or a directory under world/.", "setting_list directory 必须是 world 或 world/ 下的目录。"), locale);
1146
- const relativeDirectory = requestedDirectory === "world"
1147
- ? ""
1148
- : requestedDirectory.slice("world/".length);
1149
- const limit = args.limit ?? 100;
1150
- if (typeof limit !== "number" ||
1151
- !Number.isInteger(limit) ||
1152
- limit < 1 ||
1153
- limit > 100 ||
1154
- !validOptionalCursor(args.cursor))
1155
- return settingQueryArgumentError(localized(locale, "setting_list limit must be between 1 and 100, and cursor must come from the same snapshot and directory query.", "setting_list limit 必须为 1 到 100,cursor 必须来自同一快照和目录查询。"), locale);
1156
- const result = snapshot.query({
1157
- kind: "catalog",
1158
- directory: relativeDirectory,
1159
- limit,
1160
- ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
1161
- });
1162
- if (result.kind === "error")
1163
- return renderSettingQueryFailure(result, locale);
1164
- if (result.kind !== "catalog")
1165
- return unexpectedSettingQueryResult();
1166
- const entries = result.entries.map((entry) => {
1167
- if (entry.kind === "directory")
1168
- return localized(locale, `- [directory] ${entry.logicalPath}/`, `- [目录] ${entry.logicalPath}/`);
1169
- if (entry.document === undefined)
1170
- return localized(locale, `- [damaged document] ${entry.logicalPath} · ${entry.diagnostics
1171
- .map(({ code }) => code)
1172
- .join(", ")}`, `- [损坏文档] ${entry.logicalPath} · ${entry.diagnostics
1173
- .map(({ code }) => code)
1174
- .join(", ")}`);
1175
- return `- @${entry.document.shortRef} · ${entry.document.title} · ${entry.logicalPath}`;
1176
- });
1177
- const opaque = relativeDirectory === "" && result.page.start === 0
1178
- ? snapshot.files
1179
- .filter(({ path }) => !path.startsWith("world/"))
1180
- .map(({ path, encoding }) => encoding === undefined
1181
- ? localized(locale, `- [special file] ${path}`, `- [专用文件] ${path}`)
1182
- : localized(locale, `- [binary] ${path}`, `- [二进制] ${path}`))
1183
- : [];
1184
- return settingQuerySuccess(localized(locale, `# Setting directory\n\nScope: world · ${relativeDirectory || "/"}\nCoverage: ${result.coverage.status === "complete" ? "complete" : "partial"}\n${[...opaque, ...entries].join("\n") || "(empty)"}\n\n---\nThis page: ${result.page.start}..${result.page.end} / ${result.page.total} items\nComplete: ${result.page.complete ? "yes" : "no"}${result.page.nextCursor === null ? "" : `\nNext-page cursor: ${result.page.nextCursor}`}`, `# 设定目录\n\n范围:world · ${relativeDirectory || "/"}\n覆盖:${result.coverage.status === "complete" ? "完整" : "部分"}\n${[...opaque, ...entries].join("\n") || "(空)"}\n\n---\n本页:${result.page.start}..${result.page.end} / ${result.page.total} 项\n完整:${result.page.complete ? "是" : "否"}${result.page.nextCursor === null ? "" : `\n下一页 cursor:${result.page.nextCursor}`}`));
1185
- }
1186
- function searchSettingDocuments(snapshot, args, locale) {
1187
- if (!hasOnlyArguments(args, [
1188
- "query",
1189
- "within",
1190
- "caseSensitive",
1191
- "limit",
1192
- "cursor",
1193
- ]) ||
1194
- typeof args.query !== "string" ||
1195
- args.query.length < 1 ||
1196
- args.query.length > 256 ||
1197
- (args.within !== undefined && typeof args.within !== "string") ||
1198
- (args.caseSensitive !== undefined &&
1199
- typeof args.caseSensitive !== "boolean") ||
1200
- (args.limit !== undefined && typeof args.limit !== "number") ||
1201
- !validOptionalCursor(args.cursor))
1202
- return settingQueryArgumentError(localized(locale, "setting_search accepts only a literal query in world scope plus within, caseSensitive, limit, and cursor.", "setting_search 只接受 world 范围内的字面 query、within、caseSensitive、limit 和 cursor。"), locale);
1203
- const limit = args.limit ?? 20;
1204
- if (!Number.isInteger(limit) || limit < 1 || limit > 100)
1205
- return settingQueryArgumentError(localized(locale, "setting_search limit must be between 1 and 100.", "setting_search limit 必须为 1 到 100。"), locale);
1206
- const within = settingSearchScope(args.within);
1207
- if (within === invalidSettingSearchScope)
1208
- return settingQueryArgumentError(localized(locale, "setting_search within must be a world/ directory, world/ document path, @short-ref, or document identity.", "setting_search within 必须是 world/ 目录、world/ 文档路径、@短引用或文档身份。"), locale);
1209
- const result = snapshot.query({
1210
- kind: "literal_search",
1211
- query: args.query,
1212
- caseSensitive: args.caseSensitive === true,
1213
- ...(within === undefined ? {} : { within }),
1214
- limit,
1215
- ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
1216
- });
1217
- if (result.kind === "error")
1218
- return renderSettingQueryFailure(result, locale);
1219
- if (result.kind !== "literal_search")
1220
- return unexpectedSettingQueryResult();
1221
- const matches = result.matches
1222
- .map(({ document, referenceProjection, range }) => localized(locale, `- @${document.shortRef} · ${document.title} · ${document.logicalPath} · line ${range.start.line}, column ${range.start.column}\n Exact match: ${JSON.stringify(referenceProjection.text)}\n Exact-match excerpt: ${JSON.stringify(referenceProjection.excerpt)}`, `- @${document.shortRef} · ${document.title} · ${document.logicalPath} · 第 ${range.start.line} 行第 ${range.start.column} 列\n 原始命中:${JSON.stringify(referenceProjection.text)}\n 原始命中片段:${JSON.stringify(referenceProjection.excerpt)}`))
1223
- .join("\n");
1224
- const scope = args.within === undefined ? "world" : `world · ${args.within}`;
1225
- return settingQuerySuccess(localized(locale, `# Literal setting search\n\nScope: ${scope}\nNormalization: ${args.caseSensitive === true ? "original text" : "NFKC + case folding"}\nCoverage: ${result.coverage.status === "complete" ? "complete" : `partial (${result.coverage.excludedDocuments} damaged documents excluded)`}\nTotal matches: ${result.page.total}\n${matches || "Zero literal matches do not prove that the fact is absent from the setting."}\n\n---\nThis page: ${result.page.start}..${result.page.end} / ${result.page.total} matches\nComplete: ${result.page.complete ? "yes" : "no"}${result.page.nextCursor === null ? "" : `\nNext-page cursor: ${result.page.nextCursor}`}`, `# 设定字面搜索\n\n范围:${scope}\nnormalization:${args.caseSensitive === true ? "原文" : "NFKC + 大小写折叠"}\n覆盖:${result.coverage.status === "complete" ? "完整" : `部分(排除 ${result.coverage.excludedDocuments} 份损坏文档)`}\n命中总数:${result.page.total}\n${matches || "0 个字面命中不证明设定中不存在该事实。"}\n\n---\n本页:${result.page.start}..${result.page.end} / ${result.page.total} 个命中\n完整:${result.page.complete ? "是" : "否"}${result.page.nextCursor === null ? "" : `\n下一页 cursor:${result.page.nextCursor}`}`));
1226
- }
1227
- const invalidSettingSearchScope = Symbol("invalid-setting-search-scope");
1228
- function settingSearchScope(value) {
1229
- if (value === undefined)
1230
- return undefined;
1231
- if (typeof value !== "string" || value.length === 0)
1232
- return invalidSettingSearchScope;
1233
- if (value.startsWith("@"))
1234
- return value.length > 1
1235
- ? { document: { shortRef: value.slice(1) } }
1236
- : invalidSettingSearchScope;
1237
- if (value === "world")
1238
- return { directory: "" };
1239
- if (value.startsWith("world/")) {
1240
- const safe = safePath(value);
1241
- return /\.(?:ya?ml|md)$/u.test(safe)
1242
- ? { document: { logicalPath: safe } }
1243
- : { directory: safe.slice("world/".length) };
1244
- }
1245
- return { document: { documentId: value } };
1246
- }
1247
- function readSettingDocument(snapshot, args, reads, locale) {
1248
- if (!hasOnlyArguments(args, ["path", "maxBytes", "cursor"]) ||
1249
- typeof args.path !== "string" ||
1250
- args.path.length === 0 ||
1251
- (args.maxBytes !== undefined && typeof args.maxBytes !== "number") ||
1252
- !validOptionalCursor(args.cursor))
1253
- return settingQueryArgumentError(localized(locale, "setting_read accepts only an exact path, maxBytes, and cursor.", "setting_read 只接受精确 path、maxBytes 和 cursor。"), locale);
1254
- const exactOpaque = snapshot.files.find(({ path }) => path === args.path && !path.startsWith("world/"));
1255
- if (exactOpaque !== undefined) {
1256
- if (args.cursor !== undefined || args.maxBytes !== undefined)
1257
- return settingQueryArgumentError(localized(locale, "opening, control, and opaque special files support only one complete read and do not accept pagination arguments.", "opening/control/opaque 专用文件只支持一次完整读取,不接受分页参数。"), locale);
1258
- if (exactOpaque.encoding !== undefined)
1259
- return settingQueryArgumentError(localized(locale, `Binary files cannot be read: ${exactOpaque.path}`, `二进制文件不能读取:${exactOpaque.path}`), locale);
1260
- reads.opaquePaths.add(exactOpaque.path);
1261
- return settingQuerySuccess(localized(locale, `# Special-file source ${exactOpaque.path}\n\n${exactOpaque.contents}\n\n---\nScope: opaque · ${exactOpaque.path}\nComplete: yes`, `# 专用文件原文 ${exactOpaque.path}\n\n${exactOpaque.contents}\n\n---\n范围:opaque · ${exactOpaque.path}\n完整:是`));
1262
- }
1263
- const selector = settingDocumentSelector(args.path);
1264
- if (selector === null)
1265
- return settingQueryArgumentError(localized(locale, "setting_read path must be a world/ document path, @short-ref, document identity, or existing special-file path.", "setting_read path 必须是 world/ 文档路径、@短引用、文档身份或存在的专用文件路径。"), locale);
1266
- const maxBytes = args.maxBytes ?? 8192;
1267
- if (typeof maxBytes !== "number" ||
1268
- !Number.isInteger(maxBytes) ||
1269
- maxBytes < 4 ||
1270
- maxBytes > 65_536)
1271
- return settingQueryArgumentError(localized(locale, "setting_read maxBytes must be between 4 and 65536.", "setting_read maxBytes 必须为 4 到 65536。"), locale);
1272
- const result = snapshot.query({
1273
- kind: "read_document",
1274
- document: selector,
1275
- maxBytes,
1276
- ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
1277
- });
1278
- if (result.kind === "error")
1279
- return renderSettingQueryFailure(result, locale);
1280
- if (result.kind !== "read_document")
1281
- return unexpectedSettingQueryResult();
1282
- authorizeWorldReadPage(reads, result.document, selector, maxBytes, result.page);
1283
- return settingQuerySuccess(localized(locale, `# Exact read @${result.document.shortRef}\n\n${renderSettingDocumentMetadata(result.document, locale)}\n[Writable body starts; locators are relative to this point]\n${result.body.trimEnd()}\n[Writable body ${result.page.complete ? "ends" : "continues"}]\n\n---\nScope: world · @${result.document.shortRef}\nThis page: ${result.page.start}..${result.page.end} / ${result.page.total} bytes\nComplete: ${result.page.complete ? "yes" : "no"}${result.page.nextCursor === null ? "" : `\nNext-page cursor: ${result.page.nextCursor}`}`, `# 精确读取 @${result.document.shortRef}\n\n${renderSettingDocumentMetadata(result.document, locale)}\n[可写正文开始;locator 相对于这里]\n${result.body.trimEnd()}\n[可写正文${result.page.complete ? "结束" : "继续"}]\n\n---\n范围:world · @${result.document.shortRef}\n本页:${result.page.start}..${result.page.end} / ${result.page.total} bytes\n完整:${result.page.complete ? "是" : "否"}${result.page.nextCursor === null ? "" : `\n下一页 cursor:${result.page.nextCursor}`}`));
1284
- }
1285
- function settingDocumentSelector(value) {
1286
- if (value.startsWith("@"))
1287
- return value.length > 1 ? { shortRef: value.slice(1) } : null;
1288
- if (value.startsWith("world/"))
1289
- return /\.(?:ya?ml|md)$/u.test(safePath(value))
1290
- ? { logicalPath: value }
1291
- : null;
1292
- return value.includes("/") ? null : { documentId: value };
1293
- }
1294
- function authorizeWorldReadPage(reads, document, selector, maxBytes, page) {
1295
- const key = JSON.stringify({ selector, maxBytes });
1296
- if (page.start === 0) {
1297
- reads.pendingWorldReads.set(key, {
1298
- documentId: document.documentId,
1299
- nextOffset: page.end,
1300
- totalBytes: page.total,
1301
- });
1302
- }
1303
- else {
1304
- const pending = reads.pendingWorldReads.get(key);
1305
- if (pending?.documentId !== document.documentId ||
1306
- pending?.nextOffset !== page.start ||
1307
- pending?.totalBytes !== page.total)
1308
- return;
1309
- pending.nextOffset = page.end;
1310
- }
1311
- const pending = reads.pendingWorldReads.get(key);
1312
- if (pending?.nextOffset !== pending?.totalBytes || !page.complete)
1313
- return;
1314
- reads.worldDocumentIds.add(document.documentId);
1315
- reads.pendingWorldReads.delete(key);
1316
- }
1317
- function renderSettingDocumentMetadata(document, locale) {
1318
- return [
1319
- `title: ${document.title}`,
1320
- `summary: ${document.summary}`,
1321
- `${locale === "zh-CN" ? "aliases:" : "aliases: "}${document.aliases.length === 0 ? localized(locale, "(none)", "(无)") : document.aliases.join(locale === "zh-CN" ? "、" : ", ")}`,
1322
- `codec: ${document.codec}`,
1323
- `logicalPath: ${document.logicalPath}`,
1324
- ].join("\n");
1325
- }
1326
- function renderSettingQueryFailure(result, locale) {
1327
- return {
1328
- ok: false,
1329
- markdown: [
1330
- localized(locale, "# WorldDocumentStore query rejected", "# WorldDocumentStore 查询未接受"),
1331
- "",
1332
- ...result.diagnostics.map(({ code, logicalPath, message }) => `- ${code}${logicalPath === undefined ? "" : ` · ${logicalPath}`} · ${message}`),
1333
- ].join("\n"),
1334
- };
1335
- }
1336
- function unexpectedSettingQueryResult() {
1337
- throw new Error("WorldDocumentStore returned an incompatible query result");
1338
- }
1339
- function settingQueryArgumentError(message, locale) {
1340
- return {
1341
- ok: false,
1342
- markdown: `${localized(locale, "# Runtime argument error", "# Runtime 参数错误")}\n\n${message}`,
1343
- };
1344
- }
1345
- function settingQuerySuccess(markdown) {
1346
- return { ok: true, markdown };
1347
- }
1348
- function hasOnlyArguments(args, allowed) {
1349
- return Object.keys(args).every((key) => allowed.includes(key));
1350
- }
1351
- function validOptionalCursor(value) {
1352
- return value === undefined || value === null || typeof value === "string";
1353
- }
1354
- function worldRevisionCommand(call, locale) {
1355
- if (call.name === "setting_write_file")
1356
- return {
1357
- kind: "write",
1358
- logicalPath: safePath(requiredString(call.arguments.path, "path")),
1359
- contents: requiredString(call.arguments.contents, "contents"),
1360
- };
1361
- if (call.name === "setting_move")
1362
- return {
1363
- kind: "move",
1364
- document: settingRevisionTarget(requiredString(call.arguments.from, "from"), locale),
1365
- toLogicalPath: safePath(requiredString(call.arguments.to, "to")),
1366
- };
1367
- if (call.name === "setting_patch") {
1368
- const target = settingRevisionTarget(requiredString(call.arguments.document, "document"), locale);
1369
- const op = requiredString(call.arguments.op, "op");
1370
- if (op !== "add" && op !== "replace")
1371
- throw new SettingModelError("tool_argument_invalid", localized(locale, "Tool argument op must be add or replace", "工具参数 op 必须是 add 或 replace"));
1372
- const locator = requiredStringArray(call.arguments.locator, "locator", false, locale);
1373
- return {
1374
- kind: "patch",
1375
- document: target,
1376
- edits: [
1377
- {
1378
- op,
1379
- locator: { yaml: [...locator] },
1380
- value: structuredClone(call.arguments.value),
1381
- },
1382
- ],
1383
- };
1384
- }
1385
- throw new SettingModelError("tool_not_allowed", localized(locale, `Unsupported world-document revision tool: ${call.name}`, `不支持的世界文档 revision 工具:${call.name}`));
1386
- }
1387
- function settingRevisionTarget(value, locale) {
1388
- const selector = settingDocumentSelector(value);
1389
- if (selector === null)
1390
- throw new SettingModelError("tool_argument_invalid", localized(locale, `Invalid world-document selector: ${value}`, `世界文档选择器无效:${value}`));
1391
- return selector;
1392
- }
1393
- function assertRevisionChangesAuthorized(snapshotId, reads, changes, locale) {
1394
- if (reads.snapshotId !== snapshotId)
1395
- throw new Error("World-document read authorization does not belong to the current candidate snapshot");
1396
- for (const change of changes) {
1397
- if (change.before === null ||
1398
- reads.worldDocumentIds.has(change.documentId) ||
1399
- reads.damagedWorldPaths.has(change.before.logicalPath))
1400
- continue;
1401
- throw new SettingModelError("read_required", localized(locale, `Read @${change.shortRef} completely before changing it`, `修改 @${change.shortRef} 前必须先完整读取该文档`));
1402
- }
1403
- }
1404
- function staleSettingReadAuthorizationForTest(reads) {
1405
- if (process.env.NODE_ENV === "test" &&
1406
- process.env.NARRAEON_INTERNAL_TEST_STALE_SETTING_READ_AUTHORIZATION === "1")
1407
- reads.snapshotId = `${reads.snapshotId}:stale-test`;
1408
- }
1409
- function mergeWorldRevisionChanges(accumulated, changes) {
1410
- for (const change of changes) {
1411
- const previous = accumulated.get(change.documentId);
1412
- const combined = {
1413
- ...change,
1414
- before: previous === undefined ? change.before : previous.before,
1415
- };
1416
- if (combined.before !== null &&
1417
- combined.before.logicalPath === combined.after.logicalPath &&
1418
- combined.before.mechanicalHash === combined.after.mechanicalHash)
1419
- accumulated.delete(change.documentId);
1420
- else
1421
- accumulated.set(change.documentId, combined);
1422
- }
1423
- }
1424
- /**
1425
- * Reports what one call did, not what the batch did.
1426
- *
1427
- * A batch lands atomically, but each tool call still asked its own question.
1428
- * Echoing the entire batch verdict into every call repeated the whole list N
1429
- * times in the transcript and never told any single call its own outcome.
1430
- */
1431
- function renderSettingCallSuccess(changes, callIndex, locale) {
1432
- const mine = changes.filter(({ commandIndex }) => commandIndex === callIndex);
1433
- const others = changes.length - mine.length;
1434
- return [
1435
- localized(locale, "# WorldDocumentStore revision accepted", "# WorldDocumentStore revision 已接受"),
1436
- "",
1437
- ...(mine.length === 0
1438
- ? [
1439
- localized(locale, "- This call did not change any files", "- 本次调用没有改变任何文件"),
1440
- ]
1441
- : mine.map((change) => describeSettingChange(change, locale))),
1442
- ...(others === 0
1443
- ? []
1444
- : [
1445
- "",
1446
- localized(locale, `${others} other document(s) in the same batch were committed together.`, `同批次另有 ${others} 份文档一并提交。`),
1447
- ]),
1448
- "",
1449
- localized(locale, "The candidate snapshot has been replaced. Old cursors are invalid; read authorizations are preserved, and changed documents count as read.", "候选快照已替换;旧 cursor 已失效,读取授权保留,改动过的文档视为已读。"),
1450
- ].join("\n");
1451
- }
1452
- function describeSettingChange({ shortRef, before, after }, locale) {
1453
- if (before === null)
1454
- return localized(locale, `- Created @${shortRef} · ${after.logicalPath}`, `- 创建 @${shortRef} · ${after.logicalPath}`);
1455
- return before.logicalPath === after.logicalPath
1456
- ? localized(locale, `- Updated @${shortRef} · ${after.logicalPath}`, `- 修改 @${shortRef} · ${after.logicalPath}`)
1457
- : localized(locale, `- Moved @${shortRef} · ${before.logicalPath} → ${after.logicalPath}`, `- 移动 @${shortRef} · ${before.logicalPath} → ${after.logicalPath}`);
1458
- }
1459
- function renderSettingBatchRejected(calls, failureIndex, locale) {
1460
- const detail = calls[failureIndex];
1461
- const target = detail === undefined ? null : settingCallTarget(detail);
1462
- const named = detail === undefined
1463
- ? ""
1464
- : `${locale === "zh-CN" ? ":" : ": "}${detail.name}${target === null ? "" : ` · ${target}`}`;
1465
- return localized(locale, `# Runtime tool rejected\n\nThis call was valid, but the batch did not take effect. Call ${failureIndex + 1} of ${calls.length}${named} was rejected; see that call's result for diagnostics.`, `# Runtime 工具拒绝\n\n本次调用本身没有问题,但整批未生效。被拒绝的是本批次第 ${failureIndex + 1} 个调用(共 ${calls.length} 个)${named},诊断见该调用的结果。`);
1466
- }
1467
- function renderSettingRevisionFailure(diagnostics) {
1468
- return [
1469
- "WorldDocumentStore revision was rejected:",
1470
- ...diagnostics.map(({ commandIndex, code, logicalPath, message }) => `[${commandIndex ?? "batch"}] ${code}${logicalPath === undefined ? "" : ` · ${logicalPath}`} · ${message}`),
1471
- ].join("\n");
1472
- }
1473
- function executeTool(snapshot, call, reads, locale) {
1474
- if (call.name === "setting_list")
1475
- return {
1476
- snapshot,
1477
- ...listSettingDocuments(snapshot, call.arguments, locale),
1478
- };
1479
- if (call.name === "setting_search")
1480
- return {
1481
- snapshot,
1482
- ...searchSettingDocuments(snapshot, call.arguments, locale),
1483
- };
1484
- if (call.name === "setting_read")
1485
- return {
1486
- snapshot,
1487
- ...readSettingDocument(snapshot, call.arguments, reads, locale),
1488
- };
1489
- if (call.name === "setting_write_file") {
1490
- const path = safePath(requiredString(call.arguments.path, "path"));
1491
- if (path !== "opening.md" && !writableControlPath(path))
1492
- throw new SettingModelError("tool_argument_invalid", localized(locale, "setting_write_file path accepts only .yaml or .md documents under world/, control/frame.yaml, control/player-views.yaml, control/blocks/*.md, or the root opening.md", "setting_write_file 的 path 只接受 world/ 下的 .yaml 或 .md 文档、control/frame.yaml、control/player-views.yaml、control/blocks/*.md 或根级 opening.md"));
1493
- const contents = requiredString(call.arguments.contents, "contents");
1494
- const next = cloneFiles(snapshot.files);
1495
- const existing = next.find((candidate) => candidate.path === path);
1496
- if (path === "opening.md" &&
1497
- existing !== undefined &&
1498
- !reads.opaquePaths.has(path))
1499
- throw new SettingModelError("read_required", localized(locale, "Read opening.md in full before updating the existing opening text", "更新既有开场白前必须完整读取 opening.md"));
1500
- if (existing === undefined)
1501
- next.push({ path, contents });
1502
- else {
1503
- existing.contents = contents;
1504
- delete existing.encoding;
1505
- }
1506
- return changedSnapshot(snapshot, reads, sorted(next), path, localized(locale, `${existing === undefined ? "Created" : "Updated"} ${path} in the isolated candidate`, `已在隔离候选${existing === undefined ? "创建" : "写入"} ${path}`));
1507
- }
1508
- throw new SettingModelError("tool_not_allowed", localized(locale, `Unsupported setting-improvement tool: ${call.name}`, `不支持的设定完善工具:${call.name}`));
1509
- }
1510
- function assertVisiblePlan(markdown, locale) {
1511
- const firstHeading = firstMarkdownLevelOneHeading(markdown);
1512
- const expectedHeading = locale === "zh-CN" ? "创作计划" : "Creation plan";
1513
- if (markdown.trim().length < 40 || !firstHeading?.startsWith(expectedHeading))
1514
- throw new SettingModelError("plan_invalid", locale === "zh-CN"
1515
- ? "创作计划的首个围栏外一级标题必须以“创作计划”开头,且可见 Markdown 不得少于 40 字"
1516
- : 'The first level-one heading outside a code fence must begin with "Creation plan", and the visible Markdown must contain at least 40 characters');
1517
- }
1518
- function requiredAuthorPrompt(prompt) {
1519
- const trimmed = prompt.trim();
1520
- if (trimmed.length === 0)
1521
- throw new Error("The setting-improvement author prompt cannot be empty");
1522
- return trimmed;
1523
- }
1524
- function settingAuthorSystemPrompt(authorPrompt, locale) {
1525
- // The editable author semantics come first. The non-editable Runtime/tool
1526
- // contract is deliberately appended afterwards so a portable preset cannot
1527
- // replace the actual authority, tool descriptions or settlement protocol.
1528
- return `${authorPrompt}\n\n---\n\n${locale === "zh-CN"
1529
- ? settingAuthorRuntimeContractZhCN
1530
- : settingAuthorRuntimeContractEn}`;
1531
- }
1532
- function publicMessages(messages) {
1533
- return structuredClone(messages);
1534
- }
1535
- function safePath(path) {
1536
- if (path.startsWith("/") ||
1537
- path.includes("\\") ||
1538
- path
1539
- .split("/")
1540
- .some((part) => part === ".." || part === "." || part.length === 0))
1541
- throw new SettingModelError("tool_argument_invalid", `Unsafe candidate path: ${path}`);
1542
- return path;
1543
- }
1544
- function safeInputPath(path) {
1545
- try {
1546
- return safePath(path);
1547
- }
1548
- catch (error) {
1549
- if (error instanceof SettingModelError)
1550
- throw new Error(error.message, { cause: error });
1551
- throw error;
1552
- }
1553
- }
1554
- function requiredFeedback(feedback) {
1555
- const trimmed = feedback.trim();
1556
- if (trimmed.length === 0)
1557
- throw new Error("Revision feedback cannot be empty");
1558
- return trimmed;
1559
- }
1560
- function requiredString(value, name) {
1561
- if (typeof value !== "string" || value.length === 0)
1562
- throw new SettingModelError("tool_argument_invalid", `Tool argument ${name} must be a non-empty string`);
1563
- return value;
1564
- }
1565
- function requiredStringArray(value, name, allowEmpty = true, locale = defaultAppLocale) {
1566
- if (!Array.isArray(value))
1567
- throw new SettingModelError("tool_argument_invalid", `Tool argument ${name} must be an array of strings`);
1568
- const result = [];
1569
- for (const item of value) {
1570
- if (typeof item !== "string" || item.length === 0)
1571
- throw new SettingModelError("tool_argument_invalid", localized(locale, `Tool argument ${name} must be an array of strings`, `工具参数 ${name} 必须是字符串数组`));
1572
- result.push(item);
1573
- }
1574
- if (!allowEmpty && result.length === 0)
1575
- throw new SettingModelError("tool_argument_invalid", localized(locale, `Tool argument ${name} must be a non-empty array of strings`, `工具参数 ${name} 必须是非空字符串数组`));
1576
- return result;
1577
- }
1578
- function firstMarkdownLevelOneHeading(markdown) {
1579
- let fence = null;
1580
- for (const line of markdown.split(/\r?\n/u)) {
1581
- const fenceMatch = /^[ \t]{0,3}(`{3,}|~{3,})(.*)$/u.exec(line);
1582
- if (fenceMatch !== null) {
1583
- const run = fenceMatch[1];
1584
- const marker = run[0];
1585
- if (fence === null) {
1586
- fence = { marker, length: run.length };
1587
- continue;
1588
- }
1589
- if (marker === fence.marker &&
1590
- run.length >= fence.length &&
1591
- fenceMatch[2].trim() === "")
1592
- fence = null;
1593
- continue;
1594
- }
1595
- if (fence !== null)
1596
- continue;
1597
- const heading = /^[ \t]{0,3}#[ \t]+(.+?)\s*$/u.exec(line);
1598
- if (heading !== null)
1599
- return heading[1].replace(/[ \t]+#+[ \t]*$/u, "").trimEnd();
1600
- }
1601
- return null;
1602
- }
1603
- function settingToolFailure(snapshot, message, locale) {
1604
- return {
1605
- ok: false,
1606
- snapshot,
1607
- markdown: `${localized(locale, "# Runtime tool rejected", "# Runtime 工具拒绝")}\n\n${message}`,
1608
- };
1609
- }
1610
- function renderSettingRepair(message, locale) {
1611
- return `${localized(locale, "# Runtime repair required", "# Runtime 修复要求")}\n\n${message}`;
1612
- }
1613
- function idleSettingProgress() {
1614
- return {
1615
- phase: "idle",
1616
- round: 0,
1617
- maxRounds: 64,
1618
- toolCalls: 0,
1619
- repairs: 0,
1620
- failedChecks: 0,
1621
- usage: emptyAggregatedModelUsage(),
1622
- writing: null,
1623
- recentActions: [],
1624
- lastCheck: null,
1625
- failure: null,
1626
- streaming: null,
1627
- updatedAt: 0,
1628
- };
1629
- }
1630
- // The check renders as a Markdown report; the panel only has room for the
1631
- // diagnostic lines, which are the bullet list under the heading.
1632
- function summarizeSettingCheck(markdown) {
1633
- const bullets = markdown
1634
- .split(/\r?\n/u)
1635
- .filter((line) => line.startsWith("- "))
1636
- .map((line) => line.slice(2).trim());
1637
- return bullets.length === 0
1638
- ? markdown.split(/\r?\n/u)[0].replace(/^#+\s*/u, "")
1639
- : bullets.join("; ");
1640
- }
1641
- // The one argument worth showing per call: what the author is acting on.
1642
- function settingCallTarget(call) {
1643
- for (const key of ["path", "document", "from", "directory", "query"]) {
1644
- const value = call.arguments[key];
1645
- if (typeof value === "string" && value.length > 0)
1646
- return value;
1647
- }
1648
- return null;
1649
- }
1650
- function nextRepairCount(current, phase) {
1651
- const next = current + 1;
1652
- if (next > 8)
1653
- throw new Error(`The setting-improvement ${phase} phase exceeded its repair limit`);
1654
- return next;
1655
- }
1656
- // A candidate that fails its own inspection is this system working as designed:
1657
- // inspect, repair, inspect again. It gets its own, looser budget so that honest
1658
- // iteration cannot exhaust the allowance meant for protocol errors.
1659
- function nextFailedCheckCount(current) {
1660
- const next = current + 1;
1661
- if (next > 16)
1662
- throw new Error("The setting-improvement candidate failed too many checks");
1663
- return next;
1664
- }
1665
- function localized(locale, english, chinese) {
1666
- return locale === "zh-CN" ? chinese : english;
1667
- }
1668
- function candidateDiffs(baseFiles, candidateFiles, worldChanges) {
1669
- const worldDiffs = [...worldChanges.values()].flatMap(({ before, after }) => {
1670
- if (before === null)
1671
- return [
1672
- {
1673
- path: after.logicalPath,
1674
- kind: "create",
1675
- before: null,
1676
- after: after.contents,
1677
- },
1678
- ];
1679
- if (before.logicalPath === after.logicalPath)
1680
- return [
1681
- {
1682
- path: after.logicalPath,
1683
- kind: "modify",
1684
- before: before.contents,
1685
- after: after.contents,
1686
- },
1687
- ];
1688
- return [
1689
- {
1690
- path: before.logicalPath,
1691
- kind: "delete",
1692
- before: before.contents,
1693
- after: null,
1694
- },
1695
- {
1696
- path: after.logicalPath,
1697
- kind: "create",
1698
- before: null,
1699
- after: after.contents,
1700
- },
1701
- ];
1702
- });
1703
- const left = new Map(baseFiles
1704
- .filter(({ path }) => !path.startsWith("world/"))
1705
- .map((file) => [file.path, file]));
1706
- const right = new Map(candidateFiles
1707
- .filter(({ path }) => !path.startsWith("world/"))
1708
- .map((file) => [file.path, file]));
1709
- const opaqueDiffs = [...new Set([...left.keys(), ...right.keys()])].flatMap((path) => {
1710
- const before = left.get(path) ?? null;
1711
- const after = right.get(path) ?? null;
1712
- if (before?.contents === after?.contents &&
1713
- before?.encoding === after?.encoding)
1714
- return [];
1715
- return [
1716
- {
1717
- path,
1718
- kind: before === null ? "create" : after === null ? "delete" : "modify",
1719
- before: before?.contents ?? null,
1720
- after: after?.contents ?? null,
1721
- },
1722
- ];
1723
- });
1724
- return [...worldDiffs, ...opaqueDiffs].sort((left, right) => left.path.localeCompare(right.path));
1725
- }
1726
- function cloneFiles(files) {
1727
- return files.map((file) => structuredClone(file));
1728
- }
1729
- function sorted(files) {
1730
- return files.sort((a, b) => a.path.localeCompare(b.path));
1731
- }