narraeon 0.1.1 → 0.3.0

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 (29) hide show
  1. package/dist/node/protocol/modelConnections.js +49 -0
  2. package/dist/node/protocol/modelUsage.js +39 -0
  3. package/dist/node/protocol/v1.js +35 -0
  4. package/dist/node/runtime/FileNativePhysicalClone.js +86 -0
  5. package/dist/node/runtime/V1Runtime.js +66 -29
  6. package/dist/node/runtime/model/FileNativeModelAdapters.js +730 -287
  7. package/dist/node/runtime/model/ModelConnectionStore.js +123 -2
  8. package/dist/node/runtime/model/ModelHost.js +81 -9
  9. package/dist/node/runtime/model/ModelHostStream.js +61 -1
  10. package/dist/node/runtime/model/SettingAuthorStream.js +16 -113
  11. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +269 -0
  12. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +1128 -0
  13. package/dist/node/runtime/play/PlayCallChain.js +904 -369
  14. package/dist/node/runtime/play/PlayDocumentTools.js +35 -0
  15. package/dist/node/runtime/play/PlayFollowupRequests.js +43 -26
  16. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +50 -35
  17. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +55 -29
  18. package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +9 -4
  19. package/dist/node/runtime/world/FileNativeAuthorityV3.js +971 -0
  20. package/dist/node/runtime/world/FileNativeHistoryProjection.js +39 -0
  21. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +1067 -0
  22. package/dist/node/runtime/world/FileNativeWorldStore.js +615 -784
  23. package/dist/node/server/createServer.js +10 -2
  24. package/dist/web/assets/index-D5VqDvjY.js +1192 -0
  25. package/dist/web/assets/index-Xa0s35CJ.css +1 -0
  26. package/dist/web/index.html +2 -2
  27. package/package.json +1 -1
  28. package/dist/web/assets/index-CRGTZ9Ak.css +0 -1
  29. package/dist/web/assets/index-DDx1itzT.js +0 -1192
@@ -114,6 +114,12 @@ export class FileNativePlayDocuments {
114
114
  execute(call, history) {
115
115
  if (call.name === "context_search")
116
116
  return executeContextSearch(this.#candidate.snapshot, history, call.arguments);
117
+ if (call.name === "state_list")
118
+ return executeStateList(this.#candidate.snapshot, history, call.arguments);
119
+ if (call.name === "history_list")
120
+ return executeHistoryList(this.#candidate.snapshot, history, call.arguments);
121
+ // Frozen contexts created before runtime-tools-v5 retain this exact name
122
+ // and argument shape. New contexts never advertise it.
117
123
  if (call.name === "context_list")
118
124
  return executeContextList(this.#candidate.snapshot, history, call.arguments);
119
125
  if (call.name === "context_read") {
@@ -470,6 +476,10 @@ function toolFailure(message, failureKind = "protocol") {
470
476
  };
471
477
  }
472
478
  function previewContextToolResult(snapshot, history, call) {
479
+ if (call.name === "state_list")
480
+ return executeStateList(snapshot, history, call.arguments);
481
+ if (call.name === "history_list")
482
+ return executeHistoryList(snapshot, history, call.arguments);
473
483
  if (call.name === "context_list")
474
484
  return executeContextList(snapshot, history, call.arguments);
475
485
  if (call.name === "context_search")
@@ -640,6 +650,31 @@ function parseStateDirectoryHandle(handle) {
640
650
  return null;
641
651
  }
642
652
  }
653
+ function executeStateList(snapshot, history, args) {
654
+ if (!record(args) ||
655
+ !hasOnlyToolKeys(args, ["parent", "cursor", "limit"]) ||
656
+ typeof args.parent !== "string" ||
657
+ parseStateDirectoryHandle(args.parent) === null ||
658
+ (args.limit !== undefined && typeof args.limit !== "number") ||
659
+ !validOptionalCursor(args.cursor))
660
+ return {
661
+ ok: false,
662
+ markdown: "# Runtime argument error\n\nstate_list requires an @dir-* parent returned by Runtime and accepts only cursor and limit in addition; use history_list for committed history.",
663
+ };
664
+ return executeContextList(snapshot, history, { ...args, source: "state" });
665
+ }
666
+ function executeHistoryList(snapshot, history, args) {
667
+ if (!record(args) ||
668
+ !hasOnlyToolKeys(args, ["order", "cursor", "limit"]) ||
669
+ (args.order !== "newest_first" && args.order !== "oldest_first") ||
670
+ (args.limit !== undefined && typeof args.limit !== "number") ||
671
+ !validOptionalCursor(args.cursor))
672
+ return {
673
+ ok: false,
674
+ markdown: "# Runtime argument error\n\nhistory_list requires newest_first or oldest_first order and accepts only cursor and limit in addition; use state_list for state directories.",
675
+ };
676
+ return executeContextList(snapshot, history, { ...args, source: "history" });
677
+ }
643
678
  function executeContextList(snapshot, history, args) {
644
679
  if (!record(args) || (args.source !== "state" && args.source !== "history"))
645
680
  return {
@@ -1,3 +1,4 @@
1
+ import { ModelHostCancelledError } from "../model/ModelHost.js";
1
2
  import { errorDescription, } from "../model/AiFailureLog.js";
2
3
  /**
3
4
  * Run every follow-up declared by the frozen play preset exactly once after the
@@ -24,11 +25,15 @@ export async function runPlayFollowupRequests(input) {
24
25
  const completed = [];
25
26
  let failure;
26
27
  for (const followup of input.followups) {
27
- if (input.signal?.aborted === true) {
28
+ if (signalWasAborted(input.signal)) {
28
29
  failure ??= "The follow-up request was cancelled.";
29
30
  break;
30
31
  }
31
32
  const outcome = await runOne(input, followup, prefix);
33
+ if (signalWasAborted(input.signal)) {
34
+ failure ??= "The follow-up request was cancelled.";
35
+ break;
36
+ }
32
37
  outcomes.push(outcome);
33
38
  input.observer?.onOutcome?.(outcome);
34
39
  if (outcome.failure === undefined)
@@ -58,27 +63,39 @@ async function runOne(input, followup, prefix) {
58
63
  let responseDiagnostics;
59
64
  try {
60
65
  await input.artifacts.beginRequestAttempt(requestContext);
61
- const response = await input.modelHost.exchange({
62
- bootstrap: structuredClone(input.bootstrap),
63
- tools: structuredClone(followup.tools),
64
- toolUniverse: structuredClone(followup.tools),
65
- allowedTools: [...followup.allowedTools],
66
- toolStrategy: input.toolStrategy,
67
- // The prompt replaces the previous followup's prompt instead of being
68
- // appended after it: every request sees exactly the main-chain prefix.
69
- appended: [
70
- ...structuredClone(prefix),
71
- {
72
- kind: "prompt_delta",
73
- logicalMessages: structuredClone(followup.logicalMessages),
74
- },
75
- ],
76
- requestId: followup.id,
77
- operationId: input.context.operationId,
78
- requestAttempt: 1,
79
- exchange: 1,
80
- maxOutputTokens: input.maxOutputTokens,
81
- });
66
+ input.observer?.onProviderDispatch?.();
67
+ let response;
68
+ try {
69
+ response = await input.modelHost.exchange({
70
+ bootstrap: structuredClone(input.bootstrap),
71
+ tools: structuredClone(followup.tools),
72
+ toolUniverse: structuredClone(followup.tools),
73
+ allowedTools: [...followup.allowedTools],
74
+ toolStrategy: input.toolStrategy,
75
+ // The prompt replaces the previous followup's prompt instead of being
76
+ // appended after it: every request sees exactly the main-chain prefix.
77
+ appended: [
78
+ ...structuredClone(prefix),
79
+ {
80
+ kind: "prompt_delta",
81
+ logicalMessages: structuredClone(followup.logicalMessages),
82
+ },
83
+ ],
84
+ requestId: followup.id,
85
+ operationId: input.context.operationId,
86
+ requestAttempt: 1,
87
+ exchange: 1,
88
+ maxOutputTokens: input.maxOutputTokens,
89
+ }, {
90
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
91
+ onDelta: (delta) => input.observer?.onProviderDelta?.(delta),
92
+ });
93
+ if (input.signal?.aborted === true)
94
+ throw new ModelHostCancelledError("The player cancelled the follow-up model request; its response was not committed.");
95
+ }
96
+ finally {
97
+ input.observer?.onProviderSettled?.();
98
+ }
82
99
  responseDiagnostics = response.diagnostics;
83
100
  if (response.diagnostics !== undefined)
84
101
  await input.failureLog?.recordExchangeIfActive(response.diagnostics);
@@ -86,10 +103,7 @@ async function runOne(input, followup, prefix) {
86
103
  if (response.reasoningContent !== undefined)
87
104
  outcome.reasoning = response.reasoningContent;
88
105
  if (response.usage !== undefined)
89
- outcome.usage = {
90
- inputTokens: response.usage.inputTokens,
91
- outputTokens: response.usage.outputTokens,
92
- };
106
+ outcome.usage = structuredClone(response.usage);
93
107
  // One request, one pass: tool results are recorded for the trace but never
94
108
  // fed back for another exchange.
95
109
  for (const call of response.toolCalls ?? []) {
@@ -156,6 +170,9 @@ async function runOne(input, followup, prefix) {
156
170
  }
157
171
  return outcome;
158
172
  }
173
+ function signalWasAborted(signal) {
174
+ return signal?.aborted === true;
175
+ }
159
176
  async function executeArtifactCall(input, context, call) {
160
177
  if (call.name !== "artifact_emit" && call.name !== "artifact_clear")
161
178
  return {
@@ -27,12 +27,13 @@ const runtimeContracts = {
27
27
  - World-write tools change only an uncommitted working copy. Their changes are not official world facts until Runtime accepts and completes the commit.`,
28
28
  operation: `# Runtime call-chain rules
29
29
 
30
- - The model may output player-visible text directly, call attached read or world-write tools, and then continue from tool results.
30
+ - A response with no tool calls may contain player-visible story text and finish the current call-chain step.
31
+ - A response that calls any tool is an intermediate tool step. Do not include player-visible story text in that response. After receiving all tool results, return the story in a later response with no tool calls.
31
32
  - Obey only the real tool definitions attached to this request. A tool that is not defined cannot be called.
32
33
  - Runtime performs state commits and saves follow-up artifacts. Tool exchanges and internal processing must never appear in player-visible content.`,
33
34
  shell: `# Runtime play boundary
34
35
 
35
- The model may output player-visible text directly, call attached read or world-write tools as needed, or continue after receiving tool results. Runtime executes only real tool definitions, file validation, and authority commits. This block does not define story content, point of view, style, player agency, or state semantics.`,
36
+ A tool-free response may contain player-visible story text. A response that calls any tool is an intermediate step and must not contain player-visible story text; continue from the tool results, then narrate in a later tool-free response. Runtime executes only real tool definitions, file validation, and authority commits. This block does not define story content, point of view, style, player agency, or state semantics.`,
36
37
  },
37
38
  "zh-CN": {
38
39
  play: `# Runtime 权限边界
@@ -48,16 +49,18 @@ The model may output player-visible text directly, call attached read or world-w
48
49
  - 世界写入工具只修改尚未提交的工作副本;Runtime 接受并完成提交前,这些修改都不是正式世界事实。`,
49
50
  operation: `# Runtime 调用链规则
50
51
 
51
- - 模型可以直接输出玩家可见文本,或调用本次请求随附的读取和世界写入工具,再根据工具结果继续。
52
+ - 不调用工具的响应可以输出玩家可见故事正文,并结束当前调用链步骤。
53
+ - 只要响应调用了任何工具,它就是工具中间步;该响应不要输出玩家可见故事正文。收到全部工具结果后,再用一个不调用工具的后续响应完成叙事。
52
54
  - 只服从当前请求的真实工具定义;没有定义的工具不可调用。
53
55
  - 状态提交与后置产物保存由 Runtime 执行。工具交换与内部处理过程不得混入玩家可见内容。`,
54
56
  shell: `# Runtime 游玩边界
55
57
 
56
- 模型可以根据当前需要直接输出玩家可见文本、调用请求随附的读取或世界写入工具,或在取得工具结果后继续。Runtime 只执行真实工具定义、文件校验和权威提交;本段不规定故事、人称、文风、玩家代理权或状态语义。`,
58
+ 不调用工具的响应可以输出玩家可见故事正文。只要响应调用了任何工具,它就是工具中间步,不得同时输出玩家可见故事正文;先根据工具结果继续,再用一个不调用工具的后续响应完成叙事。Runtime 只执行真实工具定义、文件校验和权威提交;本段不规定故事、人称、文风、玩家代理权或状态语义。`,
57
59
  },
58
60
  };
59
61
  const playCallChainToolNames = new Set([
60
- "context_list",
62
+ "state_list",
63
+ "history_list",
61
64
  "context_search",
62
65
  "context_read",
63
66
  "world_patch",
@@ -123,12 +126,13 @@ export class FileNativePromptCompiler {
123
126
  const tools = runtimeToolsForNames(registeredRuntimeToolNames, this.#locale);
124
127
  const toolStrategy = this.#toolStrategyOverride ??
125
128
  defaultRuntimeToolDefinitionStrategy(effectiveInput.modelBinding.provider);
126
- const provider = mapProvider(effectiveInput.modelBinding.provider, logicalMessages);
129
+ const cacheStrategy = effectiveInput.modelBinding.cacheStrategy ??
130
+ (effectiveInput.modelBinding.provider === "anthropic_messages"
131
+ ? "explicit_anthropic_blocks"
132
+ : "provider_managed");
133
+ const provider = mapProvider(effectiveInput.modelBinding.provider, logicalMessages, cacheStrategy);
127
134
  const budget = disabledPromptBudget(effectiveInput);
128
- const stableMessages = logicalMessages.filter(({ role }) => role !== "player_input");
129
- const stableText = stableMessages
130
- .map(({ role, markdown }) => `${role}\n${markdown}`)
131
- .join("\n");
135
+ const stableText = cacheStableText(logicalMessages);
132
136
  const cache = stableCacheBoundary(stableText, tools, toolStrategy);
133
137
  return {
134
138
  logicalMessages,
@@ -140,7 +144,12 @@ export class FileNativePromptCompiler {
140
144
  budget,
141
145
  cache: {
142
146
  ...cache,
143
- breakpoints: ["runtime_system", "author_instruction", "world_context"],
147
+ strategy: cacheStrategy,
148
+ breakpoints: cacheStrategy === "explicit_anthropic_blocks"
149
+ ? ["runtime_system", "author_instruction", "world_context"]
150
+ : cacheStrategy === "explicit_cliproxyapi_message"
151
+ ? ["author_instruction", "world_context"]
152
+ : [],
144
153
  },
145
154
  };
146
155
  }
@@ -164,11 +173,8 @@ export class FileNativePromptCompiler {
164
173
  const presetCompilation = this.compilePlayPreset(input, binding);
165
174
  const toolUniverse = presetCompilation.toolUniverse.filter(({ name }) => playCallChainToolNames.has(name));
166
175
  const logicalMessages = playCallChainNarrativeGuidance(presetCompilation.bootstrap.logicalMessages, binding);
167
- const provider = mapProvider(input.modelBinding.provider, logicalMessages);
168
- const stableText = logicalMessages
169
- .filter(({ role }) => role !== "player_input")
170
- .map(({ role, markdown }) => `${role}\n${markdown}`)
171
- .join("\n");
176
+ const provider = mapProvider(input.modelBinding.provider, logicalMessages, presetCompilation.bootstrap.cache.strategy);
177
+ const stableText = cacheStableText(logicalMessages);
172
178
  const bootstrap = {
173
179
  ...structuredClone(presetCompilation.bootstrap),
174
180
  logicalMessages,
@@ -212,12 +218,9 @@ export class FileNativePromptCompiler {
212
218
  });
213
219
  return { role: message.role, blocks, markdown: joinBlocks(blocks) };
214
220
  });
215
- const provider = mapProvider(input.modelBinding.provider, logicalMessages);
221
+ const provider = mapProvider(input.modelBinding.provider, logicalMessages, base.cache.strategy);
216
222
  const budget = disabledPromptBudget(input);
217
- const stableMessages = logicalMessages.filter(({ role }) => role !== "player_input");
218
- const stableText = stableMessages
219
- .map(({ role, markdown }) => `${role}\n${markdown}`)
220
- .join("\n");
223
+ const stableText = cacheStableText(logicalMessages);
221
224
  const cache = stableCacheBoundary(stableText, base.tools, base.toolStrategy);
222
225
  return {
223
226
  ...base,
@@ -330,7 +333,7 @@ function withoutAppendedContextGenesis(input) {
330
333
  if (input.playerInputPlacement !== "append")
331
334
  return input;
332
335
  const additionalMaterials = input.world.additionalMaterials.filter((material) => material.kind !== "history_message" ||
333
- !material.message.endsWith(".message.genesis.narrator"));
336
+ !material.message.endsWith("message.genesis.narrator"));
334
337
  if (additionalMaterials.length === input.world.additionalMaterials.length)
335
338
  return input;
336
339
  return {
@@ -729,7 +732,7 @@ function addDocumentSelection(target, slot, required, snapshot, selected, covera
729
732
  source: modelVisibleDocumentSource(target, document, locale),
730
733
  status: "optional_missing",
731
734
  complete: false,
732
- continuation: "context_list",
735
+ continuation: "state_list",
733
736
  });
734
737
  return;
735
738
  }
@@ -860,7 +863,7 @@ function resolveCatalog(slot, snapshot, selected, coverage, locale) {
860
863
  ? "paged_catalog"
861
864
  : "resolved",
862
865
  complete: matches.length > 0 && matches.length <= max && damaged.length === 0,
863
- continuation: "context_list",
866
+ continuation: "state_list",
864
867
  });
865
868
  }
866
869
  function validCatalogDirectory(directory) {
@@ -883,7 +886,7 @@ function resolveRecentHistory(slot, history, selected, coverage, locale) {
883
886
  // in commit order. Its keys are semantic message IDs, not sortable file
884
887
  // names: lexical sorting would put `message.genesis` after every numbered
885
888
  // message and `message.9` after `message.15`.
886
- const ordered = Object.entries(history).filter(([ref]) => !ref.endsWith(".message.genesis.narrator"));
889
+ const ordered = Object.entries(history).filter(([ref]) => !ref.endsWith("message.genesis.narrator"));
887
890
  const chosen = ordered.slice(-recent);
888
891
  if (chosen.length === 0) {
889
892
  selected.push({
@@ -918,7 +921,7 @@ function resolveRecentHistory(slot, history, selected, coverage, locale) {
918
921
  : `${historyMessageLabel(ref, locale)} (recent ${index + 1}/${chosen.length})`,
919
922
  status: "resolved",
920
923
  complete: true,
921
- continuation: "context_list",
924
+ continuation: "history_list",
922
925
  });
923
926
  }
924
927
  }
@@ -963,7 +966,7 @@ function resolveAdditionalMaterials(materials, snapshot, history, selected, cove
963
966
  : `selected history commit (${matches.length} messages)`,
964
967
  status: "resolved",
965
968
  complete: true,
966
- continuation: "context_list",
969
+ continuation: "history_list",
967
970
  });
968
971
  }
969
972
  }
@@ -972,11 +975,11 @@ function renderHistoryMessage(ref, text, locale) {
972
975
  return `## ${historyMessageLabel(ref, locale)}\n\n${text.trim()}`;
973
976
  }
974
977
  function historyMessageLabel(ref, locale) {
975
- if (ref.endsWith(".message.genesis.narrator"))
978
+ if (ref.endsWith("message.genesis.narrator"))
976
979
  return locale === "zh-CN" ? "开场白" : "Opening";
977
- if (/(?:\.message\.[^.]+(?:\.[0-9]+)?\.|-)(player)(?:-|$)/u.test(ref))
980
+ if (/(?:^|\.)message\.[^.]+(?:\.[0-9]+)?\.player$|(?:^|-)player(?:-|$)/u.test(ref))
978
981
  return locale === "zh-CN" ? "玩家原文" : "Player input";
979
- if (/(?:\.message\.[^.]+(?:\.[0-9]+)?\.|-)(narrator)(?:-|$)/u.test(ref))
982
+ if (/(?:^|\.)message\.[^.]+(?:\.[0-9]+)?\.narrator$|(?:^|-)narrator(?:-|$)/u.test(ref))
980
983
  return locale === "zh-CN" ? "主持叙事" : "Host narrative";
981
984
  return locale === "zh-CN" ? "已提交消息" : "Committed message";
982
985
  }
@@ -1223,10 +1226,16 @@ function coverageWriteHint(entry, locale) {
1223
1226
  ? ` · 可继续 ${entry.continuation}`
1224
1227
  : ` · continue with ${entry.continuation}`;
1225
1228
  }
1226
- function mapProvider(provider, messages) {
1229
+ function mapProvider(provider, messages, cacheStrategy) {
1227
1230
  const byRole = Object.fromEntries(messages.map((message) => [message.role, message.markdown]));
1228
1231
  const userBlocks = [
1229
- { type: "text", text: byRole.world_context },
1232
+ {
1233
+ type: "text",
1234
+ text: byRole.world_context,
1235
+ ...(cacheStrategy === "provider_managed"
1236
+ ? {}
1237
+ : { cache_control: { type: "ephemeral" } }),
1238
+ },
1230
1239
  ...(typeof byRole.player_input === "string" &&
1231
1240
  byRole.player_input.length > 0
1232
1241
  ? [{ type: "text", text: byRole.player_input }]
@@ -1328,9 +1337,15 @@ function readMarkdown(files, path, label) {
1328
1337
  function joinBlocks(blocks) {
1329
1338
  return blocks.map(({ markdown }) => markdown.trim()).join("\n\n");
1330
1339
  }
1331
- function utf8Tokens(value) {
1340
+ function utf8Bytes(value) {
1332
1341
  return new TextEncoder().encode(value).byteLength;
1333
1342
  }
1343
+ function cacheStableText(messages) {
1344
+ return messages
1345
+ .filter(({ role }) => role !== "player_input")
1346
+ .map(({ role, markdown }) => `${role}\n${markdown}`)
1347
+ .join("\n");
1348
+ }
1334
1349
  function stableCacheBoundary(stableText, toolUniverse, toolStrategy) {
1335
1350
  const stableTools = JSON.stringify({ toolUniverse, toolStrategy });
1336
1351
  const stablePrefix = JSON.stringify({
@@ -1338,10 +1353,10 @@ function stableCacheBoundary(stableText, toolUniverse, toolStrategy) {
1338
1353
  toolUniverse,
1339
1354
  toolStrategy,
1340
1355
  });
1341
- const stableBytes = utf8Tokens(stableText) + utf8Tokens(stableTools);
1356
+ const stableBytes = utf8Bytes(stableText) + utf8Bytes(stableTools);
1342
1357
  return {
1343
1358
  stablePrefixFingerprint: `sha256:${sha256(stablePrefix)}`,
1344
- estimatedCacheableTokens: stableBytes,
1359
+ estimatedCacheableBytes: stableBytes,
1345
1360
  firstDynamicByte: stableBytes,
1346
1361
  };
1347
1362
  }
@@ -7,7 +7,8 @@ import { defaultAppLocale, } from "../../protocol/appPreferences.js";
7
7
  * tool contract.
8
8
  */
9
9
  export const registeredRuntimeToolNames = [
10
- "context_list",
10
+ "state_list",
11
+ "history_list",
11
12
  "context_search",
12
13
  "context_read",
13
14
  "world_patch",
@@ -16,7 +17,7 @@ export const registeredRuntimeToolNames = [
16
17
  "artifact_clear",
17
18
  ];
18
19
  /** Changes only when the executable Runtime tool universe or schemas change. */
19
- export const runtimeToolRegistryRevision = "runtime-tools-v3";
20
+ export const runtimeToolRegistryRevision = "runtime-tools-v5";
20
21
  export function defaultRuntimeToolDefinitionStrategy(provider) {
21
22
  return provider === "openai_responses"
22
23
  ? "native_allowed_subset"
@@ -39,14 +40,41 @@ export function runtimeToolsForNames(names, locale = defaultAppLocale) {
39
40
  inputSchema: toolDefinitions[name].inputSchema,
40
41
  }));
41
42
  }
42
- function createToolDefinitions(locale) {
43
- const descriptions = toolDescriptions[locale];
44
- const object = (properties, required) => ({
43
+ /**
44
+ * Return a Provider-portable root-object schema for a Runtime-owned tool.
45
+ *
46
+ * Released call-chain contexts keep their exact logical tool definitions. The
47
+ * legacy context_list branch therefore lives here as a wire-only compatibility
48
+ * projection: adapters may repair its old root union without adding it back to
49
+ * the tool universe exposed to new sessions.
50
+ */
51
+ export function portableRuntimeToolInputSchema(name) {
52
+ if (name === "context_list")
53
+ return legacyContextListInputSchema();
54
+ if (!isRegisteredRuntimeToolName(name))
55
+ return null;
56
+ return structuredClone(createToolDefinitions(defaultAppLocale)[name].inputSchema);
57
+ }
58
+ function objectSchema(properties, required) {
59
+ return {
45
60
  type: "object",
46
61
  additionalProperties: false,
47
62
  required,
48
63
  properties,
49
- });
64
+ };
65
+ }
66
+ function legacyContextListInputSchema() {
67
+ return objectSchema({
68
+ source: { enum: ["state", "history"] },
69
+ parent: { type: "string", minLength: 1 },
70
+ order: { enum: ["newest_first", "oldest_first"] },
71
+ cursor: { type: ["string", "null"] },
72
+ limit: { type: "integer", minimum: 1, maximum: 100 },
73
+ }, ["source"]);
74
+ }
75
+ function createToolDefinitions(locale) {
76
+ const descriptions = toolDescriptions[locale];
77
+ const object = objectSchema;
50
78
  const string = { type: "string", minLength: 1 };
51
79
  const yamlLocator = object({
52
80
  yaml: {
@@ -56,25 +84,21 @@ function createToolDefinitions(locale) {
56
84
  }, ["yaml"]);
57
85
  const markdownLocator = object({ markdown: { type: "array", items: { type: "string" }, minItems: 1 } }, ["markdown"]);
58
86
  const definitions = {
59
- context_list: {
60
- description: descriptions.context_list,
61
- inputSchema: {
62
- type: "object",
63
- oneOf: [
64
- object({
65
- source: { const: "state" },
66
- parent: string,
67
- cursor: { type: ["string", "null"] },
68
- limit: { type: "integer", minimum: 1, maximum: 100 },
69
- }, ["source", "parent"]),
70
- object({
71
- source: { const: "history" },
72
- order: { enum: ["newest_first", "oldest_first"] },
73
- cursor: { type: ["string", "null"] },
74
- limit: { type: "integer", minimum: 1, maximum: 100 },
75
- }, ["source", "order"]),
76
- ],
77
- },
87
+ state_list: {
88
+ description: descriptions.state_list,
89
+ inputSchema: object({
90
+ parent: string,
91
+ cursor: { type: ["string", "null"] },
92
+ limit: { type: "integer", minimum: 1, maximum: 100 },
93
+ }, ["parent"]),
94
+ },
95
+ history_list: {
96
+ description: descriptions.history_list,
97
+ inputSchema: object({
98
+ order: { enum: ["newest_first", "oldest_first"] },
99
+ cursor: { type: ["string", "null"] },
100
+ limit: { type: "integer", minimum: 1, maximum: 100 },
101
+ }, ["order"]),
78
102
  },
79
103
  context_search: {
80
104
  description: descriptions.context_search,
@@ -172,20 +196,22 @@ function createToolDefinitions(locale) {
172
196
  }
173
197
  const toolDescriptions = {
174
198
  en: {
175
- context_list: 'List handles known to Runtime. State-directory requests have the fixed shape {source:"state", parent:"@dir-/"}; when descending, parent must be an @dir-* handle returned by a prior result. History requests have the fixed shape {source:"history", order:"newest_first"}. The two shapes are mutually exclusive: never send parent and order together.',
199
+ state_list: "List document and state directory handles known to Runtime. parent must be @dir-/ for the state root or an @dir-* handle returned by an earlier state_list result; descend only through returned directory handles. A cursor is valid only for the same state snapshot, parent, and limit. Pass returned document handles to context_read. This tool never lists committed history.",
200
+ history_list: "List handles for committed history messages known to Runtime. order must be newest_first or oldest_first. A cursor is valid only for the same committed-history snapshot, order, and limit. Pass returned @history-message-* handles to context_read. This tool never lists the mutable state directory.",
176
201
  context_search: "Search literal source text in state or history. Zero matches means only that the supplied text had no literal match; it does not prove that a world fact does not exist. Player-visible narrative must not recount the search process. within accepts only an @document or @dir-* handle returned by Runtime.",
177
202
  context_read: "Precisely read an @document, @node, or @history-message-* handle previously returned by Runtime. Read headings, page markers, metadata boundaries, and body boundaries in a whole-document result are not source text. Update metadata only as one set_metadata operation and update the writable body between the boundaries with locators. YAML bodies omit the $document technical header; Markdown bodies begin at the document's level-one heading. Never pass file paths, natural-language names, or an invented world/... value.",
178
203
  world_patch: 'Update a document that has been read precisely. target must be an @short-ref. When title, summary, or aliases are stale, update all three with {op:"set_metadata",title,summary,aliases}, copying unchanged values from the read result. A YAML edit uses locator:{yaml:["field","child"]}, for example {op:"replace",locator:{yaml:["status"]},value:"new value"}. A YAML value may reference a whole document only as {$ref:"@short-ref"}; the short reference must come from a Runtime list, read, or create result. Never invent a document id. If no handle is available, use ordinary text when semantically correct or call world_create first and use its returned @short-ref. To add an item to the end of an existing sequence, use append with the locator pointing to that sequence; add creates only a map key or list index that does not exist. A Markdown locator excludes the document level-one heading: {markdown:["Responsibilities"]} points exactly to ## Responsibilities, and replace_section.markdown must begin with the same heading at the same level. Use replace_preamble for text after the level-one heading and before the first level-two heading; send only that text. Use replace_body to replace the whole Markdown body and retain the original level-one heading. Success reports only whether the document changed and does not echo the body. Call context_read again only when a later decision depends on Runtime\'s exact serialized body. Do not use path, JSON Pointer, set, or a file name.',
179
- world_create: 'Create a document inside a state directory returned by Runtime. parent must be an @dir-* handle returned by context_list, such as @dir-/characters; use @dir-/ for the root. refHint is a lowercase ASCII short-reference hint, not a world/ path. A YAML body may reference a whole document only as {$ref:"@short-ref"}, and the short reference must come from a Runtime list, read, or create result. Never invent a document id.',
204
+ world_create: 'Create a document inside a state directory returned by Runtime. parent must be an @dir-* handle returned by state_list, such as @dir-/characters; use @dir-/ for the root. refHint is a lowercase ASCII short-reference hint, not a world/ path. A YAML body may reference a whole document only as {$ref:"@short-ref"}, and the short reference must come from a Runtime list, read, or create result. Never invent a document id.',
180
205
  artifact_emit: "Submit an artifact declared in advance for this follow-up request. Supply only the output name and payload; Runtime fixes the channel, key, content type, renderer, retention policy, and authority meaning in the contract.",
181
206
  artifact_clear: "Clear the projection of an artifact that this follow-up declared in advance and explicitly allows to be cleared. The call cannot choose a channel, key, or any other output contract.",
182
207
  },
183
208
  "zh-CN": {
184
- context_list: '列出 Runtime 已知句柄。状态目录形状固定为 {source:"state", parent:"@dir-/"},继续下级时 parent 只能使用结果返回的 @dir-*;历史形状固定为 {source:"history", order:"newest_first"}。两种形状互斥,不要同时传 parent 和 order。',
209
+ state_list: "列出 Runtime 已知的文档与状态目录句柄。状态根目录的 parent 使用 @dir-/;继续下级时只能使用先前 state_list 结果返回的 @dir-*。cursor 只对同一状态快照、parent 和 limit 有效。返回的文档句柄交给 context_read;本工具不列出已提交历史。",
210
+ history_list: "列出 Runtime 已知的已提交历史消息句柄。order 必须是 newest_first 或 oldest_first;cursor 只对同一历史快照、order 和 limit 有效。返回的 @history-message-* 交给 context_read;本工具不列出可变状态目录。",
185
211
  context_search: "在 state 或 history 的原文字面中搜索。0 命中只表示给定文本没有字面命中,不证明世界事实不存在;玩家可见叙事不得复述搜索过程。within 只能使用 Runtime 已返回的 @文档或 @dir-* 句柄。",
186
212
  context_read: "精确读取 Runtime 先前返回的 @文档、@节点或 @history-message-* 句柄。整文档结果中的读取标题、分页线、元信息边界和正文边界不属于源文;元信息只能用 set_metadata 整组更新,中间的可写正文用 locator 更新。YAML 正文不含 $document 技术头,Markdown 正文从文档 # 一级标题开始。不要传文件路径、自然语言名称或自行拼造的 world/...。",
187
213
  world_patch: '更新已精确读取的文档。target 必须使用 @短引用;文档 title、summary 或 aliases 过时时用 {op:"set_metadata",title,summary,aliases} 整组更新,未改项照抄读取结果。YAML edit 使用 locator:{yaml:["字段","子字段"]},例如 {op:"replace",locator:{yaml:["情况"]},value:"新值"}。YAML value 需要引用整份文档时只能写 {$ref:"@短引用"},短引用必须来自 Runtime 的 list、read 或 create 结果;不得自行编造文档 id。没有可用句柄时,根据语义使用普通文本,或先 world_create 后使用其返回的 @短引用。向已存在的 sequence 末尾加一项必须使用 append 并把 locator 指向该 sequence;add 只创建尚不存在的 map key 或 list index。Markdown locator 不包含文档 # 一级标题:{markdown:["职责"]} 精确指向 ## 职责,replace_section.markdown 必须从同级同名标题开始。修改 # 标题下、第一个 ## 前的文字用 replace_preamble(只传该段文字);替换整个 Markdown 正文用 replace_body,且 markdown 必须保留原 # 一级标题。成功结果只报告文档是否发生变化,不回显正文;只有后续决策依赖 Runtime 序列化后的精确正文时才重新 context_read。不要使用 path、JSON Pointer、set 或文件名。',
188
- world_create: '在 Runtime 返回的状态目录中创建文档。parent 必须是 context_list 返回的 @dir-*,例如 @dir-/characters;根目录使用 @dir-/。refHint 是小写 ASCII 短引用,不要传 world/ 路径。YAML body 需要引用整份文档时只能写 {$ref:"@短引用"},且短引用必须来自 Runtime 的 list、read 或 create 结果;不得自行编造文档 id。',
214
+ world_create: '在 Runtime 返回的状态目录中创建文档。parent 必须是 state_list 返回的 @dir-*,例如 @dir-/characters;根目录使用 @dir-/。refHint 是小写 ASCII 短引用,不要传 world/ 路径。YAML body 需要引用整份文档时只能写 {$ref:"@短引用"},且短引用必须来自 Runtime 的 list、read 或 create 结果;不得自行编造文档 id。',
189
215
  artifact_emit: "提交本次后置请求预先声明的产物。只能传 output name 与 payload;频道、key、内容类型、renderer、保存策略和权威含义由 Runtime contract 固定。",
190
216
  artifact_clear: "清除本次后置请求预先声明且允许 clear 的产物投影;不能选择频道、key 或其他输出契约。",
191
217
  },
@@ -1,4 +1,5 @@
1
1
  import { defaultAppLocale, } from "../../protocol/appPreferences.js";
2
+ import { aggregateModelUsage, emptyAggregatedModelUsage, } from "../../protocol/modelUsage.js";
2
3
  import { defaultSettingImprovementPromptForLocale } from "../../shared/default-setting-improvement-prompt.js";
3
4
  import { inspectContentPackageCurrentTree, } from "../content/FileNativeContentTree.js";
4
5
  import { WorldDocumentStore, } from "../world/WorldDocumentStore.js";
@@ -408,8 +409,7 @@ export class DocumentCandidateSettingImprovement {
408
409
  }
409
410
  #recordExchange(usage) {
410
411
  this.#progress.round += 1;
411
- this.#progress.usage.inputTokens += usage?.inputTokens ?? 0;
412
- this.#progress.usage.outputTokens += usage?.outputTokens ?? 0;
412
+ this.#progress.usage = aggregateModelUsage(this.#progress.usage, usage);
413
413
  this.#progress.streaming = null;
414
414
  this.#progress.updatedAt = Date.now();
415
415
  }
@@ -577,6 +577,7 @@ export class DocumentCandidateSettingImprovement {
577
577
  role: "tool",
578
578
  toolCallId: call.id,
579
579
  content: result.markdown,
580
+ ...(result.ok ? {} : { isError: true }),
580
581
  });
581
582
  this.#recordAction(call, result.ok);
582
583
  if (!result.ok) {
@@ -786,6 +787,7 @@ export class DocumentCandidateSettingImprovement {
786
787
  content: failureIndex === null || failureIndex === index
787
788
  ? failed.markdown
788
789
  : renderSettingBatchRejected(revisionCalls, failureIndex, this.#locale),
790
+ isError: true,
789
791
  });
790
792
  await this.#recordResponseFailure(response, {
791
793
  kind: "tool_execution",
@@ -817,6 +819,7 @@ export class DocumentCandidateSettingImprovement {
817
819
  content: rejection === null
818
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 已接受当前隔离候选供用户审阅;内容包尚未改变,只有用户整批应用后才会替换当前树。")
819
821
  : localized(this.#locale, `# Runtime tool rejected\n\n${rejection}`, `# Runtime 工具拒绝\n\n${rejection}`),
822
+ ...(rejection === null ? {} : { isError: true }),
820
823
  });
821
824
  if (rejection === null)
822
825
  finished = true;
@@ -839,6 +842,7 @@ export class DocumentCandidateSettingImprovement {
839
842
  role: "tool",
840
843
  toolCallId: call.id,
841
844
  content: check.markdown,
845
+ ...(check.passed ? {} : { isError: true }),
842
846
  });
843
847
  lastFailedCheck = check.passed ? null : check.markdown;
844
848
  previewedSnapshotId = check.passed ? candidate.id : null;
@@ -881,6 +885,7 @@ export class DocumentCandidateSettingImprovement {
881
885
  role: "tool",
882
886
  toolCallId: call.id,
883
887
  content: result.markdown,
888
+ ...(result.ok ? {} : { isError: true }),
884
889
  });
885
890
  this.#recordAction(call, result.ok);
886
891
  if (!result.ok) {
@@ -1023,7 +1028,7 @@ function assistantMessage(response) {
1023
1028
  : { reasoningContent: response.reasoningContent }),
1024
1029
  ...(response.providerState === undefined
1025
1030
  ? {}
1026
- : { providerState: response.providerState }),
1031
+ : { providerState: structuredClone(response.providerState) }),
1027
1032
  toolCalls: structuredClone(response.toolCalls),
1028
1033
  };
1029
1034
  }
@@ -1613,7 +1618,7 @@ function idleSettingProgress() {
1613
1618
  toolCalls: 0,
1614
1619
  repairs: 0,
1615
1620
  failedChecks: 0,
1616
- usage: { inputTokens: 0, outputTokens: 0 },
1621
+ usage: emptyAggregatedModelUsage(),
1617
1622
  writing: null,
1618
1623
  recentActions: [],
1619
1624
  lastCheck: null,