narraeon 0.5.1 → 0.5.3

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.
@@ -1,7 +1,11 @@
1
+ import { HistoryQuery, historyInputs, historySelectionMatches, } from "../history/HistoryQuery.js";
2
+ import { renderHistoryMessage, renderHistoryInjectionNotice, historyRoleLabel, } from "../history/HistoryRendering.js";
3
+ import { historyUseGuidance } from "../../shared/history-use-guidance.js";
4
+ import { hasLegacyHistoryTools } from "./FileNativeToolRegistry.js";
1
5
  import { effectiveFollowupDefinitions } from "../play/OrderedFollowups.js";
2
6
  import { legacyAuthorPrompts } from "../play/FileNativePlayPresetStore.js";
3
7
  import { builtinAuthorPrompts, authoringMechanics, } from "../../shared/ordered-author-prompts.js";
4
- import { builtinPlayPrompts } from "../../shared/ordered-play-prompts.js";
8
+ import { builtinPlayPrompts, } from "../../shared/ordered-play-prompts.js";
5
9
  import { parseOrderedPlayPrompts } from "../play/OrderedPlayPrompts.js";
6
10
  import { renderDocumentWritePosition } from "./WorldMaintenanceReport.js";
7
11
  import { parseDocument, stringify } from "yaml";
@@ -11,6 +15,7 @@ import { checkpointHistory, checkpointReplayBlocks, playerInputAppend, isPlayerR
11
15
  import { defaultAppLocale, } from "../../protocol/appPreferences.js";
12
16
  import { WorldDocumentStore, } from "../world/WorldDocumentStore.js";
13
17
  import { defaultRuntimeToolDefinitionStrategy, registeredRuntimeToolNames, runtimeToolsForNames, } from "./FileNativeToolRegistry.js";
18
+ import { stateDirectoryHandle } from "../world/StateDirectoryHandles.js";
14
19
  export class PromptCompilationError extends Error {
15
20
  code;
16
21
  details;
@@ -34,7 +39,9 @@ const runtimeContracts = {
34
39
  - A document marked \`full body injected\` in the material-coverage report already has its complete writable body in the request and carries write authorization. Do not read it again merely to confirm body structure, check body fields, or “be safe.” context_read additionally exposes the current title, summary, and aliases. world_patch preserves metadata fields omitted from set_metadata, so do not read merely to copy unchanged metadata. Read only when a decision depends on unseen metadata or material the report says is not covered.
35
40
  - A YAML world_patch locator may mix map keys and zero-based array indexes; remove deletes exactly one existing key or array item. Retire an entity that should leave future frame catalogs with world_retire instead of destroying its document; retired documents remain listed, readable, referenceable, and restorable.
36
41
  - Zero literal-search matches do not prove that a fact does not exist. Tools, directories, documents, archives, searches, matches, handles, Runtime, and failure processes are private adjudication details. Player-visible narrative must not mention internal phrases such as “nothing was found.” When information is insufficient, preserve uncertainty inside the world instead of inventing an internal process.
37
- - World-write tools change only an uncommitted working copy. Their changes are not official world facts until Runtime accepts and completes the commit.`,
42
+ - World-write tools change only an uncommitted working copy. Their changes are not official world facts until Runtime accepts and completes the commit.
43
+
44
+ ${historyUseGuidance("en")}`,
38
45
  operation: `# Runtime call-chain rules
39
46
 
40
47
  - A response with no tool calls may contain player-visible story text and finish the current call-chain step.
@@ -47,7 +54,9 @@ A nonempty tool-free response ends the model/tool loop started by the current pl
47
54
 
48
55
  The player's next submission may choose a fresh context; the old model transcript will not enter that request. Fresh contexts include all committed original player inputs and final narratives after the last effective world_checkpoint, excluding tools, reasoning, and the opening. A checkpoint declaration takes effect only after its final narrative commits. Author instructions decide save timing.
49
56
 
50
- 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.`,
57
+ 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.
58
+
59
+ ${historyUseGuidance("en")}`,
51
60
  },
52
61
  "zh-CN": {
53
62
  play: `# Runtime 权限边界
@@ -61,7 +70,9 @@ Runtime executes only real tool definitions, file validation, and authority comm
61
70
  - 材料覆盖报告标为 \`已注入完整正文\` 的文档,其完整可写正文已经在本次请求里,写入资格同样已经具备。不要为确认正文结构、核对正文字段或“保险起见”重读它。context_read 会额外显示当前 title、summary 和 aliases;world_patch 会保留 set_metadata 中未提供的元数据字段,因此不要只为照抄未改变的元数据而读取。只有后续决策确实依赖尚未显示的元数据,或材料覆盖报告未覆盖的内容时才读。
62
71
  - YAML world_patch 的 locator 可混合 map key 与从 0 开始的数组下标;remove 只删除一个精确的已有键或数组项。对象需要退出后续 frame catalog 时用 world_retire 退役,不要销毁文档;退役文档仍可列出、读取、引用和恢复。
63
72
  - 字面搜索 0 命中不证明事实不存在。工具、目录、文档、档案、检索、命中、句柄、Runtime 及失败过程只供私下裁决;玩家可见叙事不得出现“没搜到/没找到资料”等内部措辞。信息不足时保持世界内的不确定性,不编造内部过程。
64
- - 世界写入工具只修改尚未提交的工作副本;Runtime 接受并完成提交前,这些修改都不是正式世界事实。`,
73
+ - 世界写入工具只修改尚未提交的工作副本;Runtime 接受并完成提交前,这些修改都不是正式世界事实。
74
+
75
+ ${historyUseGuidance("zh-CN")}`,
65
76
  operation: `# Runtime 调用链规则
66
77
 
67
78
  - 不调用工具的响应可以输出玩家可见故事正文,并结束当前调用链步骤。
@@ -74,12 +85,16 @@ Runtime executes only real tool definitions, file validation, and authority comm
74
85
 
75
86
  下一次玩家提交可以选择“全新上下文”;旧模型 transcript 不会进入那个请求。新上下文会补入最近一次已生效 world_checkpoint 之后的全部已提交玩家原文与最终叙事,不含工具、推理和开场白。检查点登记只在其最终叙事提交后生效。具体保存时机由作者提示规定。
76
87
 
77
- Runtime 只执行真实工具定义、文件校验和权威提交;本段不规定故事、人称、文风、玩家代理权或状态语义。`,
88
+ Runtime 只执行真实工具定义、文件校验和权威提交;本段不规定故事、人称、文风、玩家代理权或状态语义。
89
+
90
+ ${historyUseGuidance("zh-CN")}`,
78
91
  },
79
92
  };
80
93
  const playCallChainToolNames = new Set([
81
94
  "state_list",
82
95
  "history_list",
96
+ "history_search",
97
+ "history_read",
83
98
  "context_search",
84
99
  "context_read",
85
100
  "world_patch",
@@ -190,7 +205,11 @@ export class FileNativePromptCompiler {
190
205
  markdown: presetReference,
191
206
  },
192
207
  ];
208
+ if (entry.kind === "user" && !entry.body.trim())
209
+ continue;
193
210
  logicalMessages.push({
211
+ messageRole: entry.messageRole ?? "system",
212
+ promptId: entry.id,
194
213
  role: builtin?.required
195
214
  ? "runtime_system"
196
215
  : builtin?.id === "author.play-reference"
@@ -207,11 +226,12 @@ export class FileNativePromptCompiler {
207
226
  (input.modelBinding.provider === "anthropic_messages"
208
227
  ? "explicit_anthropic_blocks"
209
228
  : "provider_managed");
210
- const stableText = cacheStableText(logicalMessages);
229
+ const provider = mapProvider(input.modelBinding.provider, logicalMessages, cacheStrategy, true, input.playPreset.definition.mergeConsecutiveMessages ?? true);
230
+ const stableText = cacheStableText(logicalMessages, provider);
211
231
  const cache = stableCacheBoundary(stableText, tools, toolStrategy);
212
232
  return {
213
233
  logicalMessages,
214
- provider: mapProvider(input.modelBinding.provider, logicalMessages, cacheStrategy, true),
234
+ provider,
215
235
  tools: structuredClone(tools),
216
236
  toolUniverse: structuredClone(tools),
217
237
  toolStrategy,
@@ -245,11 +265,14 @@ export class FileNativePromptCompiler {
245
265
  return rendered.markdown;
246
266
  }
247
267
  /** The same selection used by bootstrap, without model or host instructions. */
248
- inspectWorldMaterials(world) {
268
+ inspectWorldMaterials(world, playerInputPlacement) {
249
269
  const frame = readYamlRecord(snapshotFiles(world.documentSnapshot)["control/frame.yaml"], "world frame");
250
270
  requireFormat(frame, "narraeon.world-frame/v1", "world frame");
251
271
  const coverage = [];
252
- const materials = resolveContext({ world }, frame, world.documentSnapshot, coverage, this.#locale);
272
+ const materials = resolveContext({
273
+ world,
274
+ ...(playerInputPlacement === undefined ? {} : { playerInputPlacement }),
275
+ }, frame, world.documentSnapshot, coverage, this.#locale);
253
276
  if (world.documentMaintenanceUnavailableReason !== undefined) {
254
277
  materials.push({
255
278
  key: "runtime:document-write-positions",
@@ -290,7 +313,7 @@ export class FileNativePromptCompiler {
290
313
  requireFormat(hostFrame, "narraeon.host-frame/v1", "host frame");
291
314
  requireFormat(worldFrame, "narraeon.world-frame/v1", "world frame");
292
315
  const instructions = readWorldInstructions(worldFiles, worldFrame);
293
- const { coverage, blocks: context, maintenance, } = this.inspectWorldMaterials(effectiveInput.world);
316
+ const { coverage, blocks: context, maintenance, } = this.playMaterials(effectiveInput);
294
317
  const hostRoles = compileHostRoles(effectiveInput.hostBinding.files, hostFrame, instructions, context, coverage, this.#locale);
295
318
  const blocks = {
296
319
  ...hostRoles,
@@ -329,7 +352,7 @@ export class FileNativePromptCompiler {
329
352
  toolUniverse: structuredClone(tools),
330
353
  toolStrategy,
331
354
  coverage,
332
- maintenance,
355
+ ...(maintenance === undefined ? {} : { maintenance }),
333
356
  budget,
334
357
  cache: {
335
358
  ...cache,
@@ -362,12 +385,43 @@ export class FileNativePromptCompiler {
362
385
  */
363
386
  compilePlayCallChain(input, binding) {
364
387
  const presetCompilation = this.compilePlayPreset(input, binding);
365
- const toolUniverse = presetCompilation.toolUniverse.filter(({ name }) => playCallChainToolNames.has(name));
366
- const logicalMessages = binding.definition.playPrompts === undefined
388
+ const frozenTools = input.frozenWorldContext?.toolUniverse ?? input.frozenWorldContext?.tools;
389
+ const toolUniverse = frozenTools !== undefined && hasLegacyHistoryTools(frozenTools)
390
+ ? structuredClone(frozenTools)
391
+ : presetCompilation.toolUniverse.filter(({ name }) => playCallChainToolNames.has(name));
392
+ const initialMessages = binding.definition.playPrompts === undefined
367
393
  ? playCallChainNarrativeGuidance(presetCompilation.bootstrap.logicalMessages, binding)
368
394
  : presetCompilation.bootstrap.logicalMessages;
369
- const provider = mapProvider(input.modelBinding.provider, logicalMessages, presetCompilation.bootstrap.cache.strategy, binding.definition.playPrompts !== undefined);
370
- const stableText = cacheStableText(logicalMessages);
395
+ const selectedMessages = frozenTools !== undefined && hasLegacyHistoryTools(frozenTools)
396
+ ? initialMessages.map((message) => {
397
+ if (message.role !== "runtime_system")
398
+ return message;
399
+ const blocks = message.blocks.map((block) => ({
400
+ ...block,
401
+ markdown: block.markdown.replace(historyUseGuidance(this.#locale), historyUseGuidance(this.#locale, true)),
402
+ }));
403
+ return { ...message, blocks, markdown: joinBlocks(blocks) };
404
+ })
405
+ : initialMessages;
406
+ // Instructions keep author order; initial reads follow as protocol tool results.
407
+ const logicalMessages = [
408
+ ...selectedMessages.filter(({ role }) => role !== "world_context" && role !== "player_input"),
409
+ ...selectedMessages.filter(({ role }) => role === "world_context"),
410
+ ...selectedMessages.filter(({ role }) => role === "player_input"),
411
+ ];
412
+ const provider = mapProvider(input.modelBinding.provider, logicalMessages.filter(({ role }) => role !== "world_context"), presetCompilation.bootstrap.cache.strategy, binding.definition.playPrompts !== undefined, binding.definition.mergeConsecutiveMessages ?? true);
413
+ provider.preload = {
414
+ callId: "runtime_initial_world_context",
415
+ markdown: logicalMessages
416
+ .filter(({ role }) => role === "world_context")
417
+ .map(({ markdown }) => markdown)
418
+ .join("\n\n"),
419
+ };
420
+ const lastInstructionRole = logicalMessages
421
+ .filter(({ role }) => role === "runtime_system" || role === "author_instruction")
422
+ .at(-1).role;
423
+ const strategy = presetCompilation.bootstrap.cache.strategy;
424
+ const stableText = cacheStableText(logicalMessages, binding.definition.playPrompts ? provider : undefined);
371
425
  const bootstrap = {
372
426
  ...structuredClone(presetCompilation.bootstrap),
373
427
  logicalMessages,
@@ -376,6 +430,22 @@ export class FileNativePromptCompiler {
376
430
  toolUniverse: structuredClone(toolUniverse),
377
431
  cache: {
378
432
  ...presetCompilation.bootstrap.cache,
433
+ breakpoints: strategy === "explicit_anthropic_blocks"
434
+ ? [
435
+ ...new Set([
436
+ ...(binding.definition.playPrompts === undefined
437
+ ? ["runtime_system", "author_instruction"]
438
+ : [lastInstructionRole]),
439
+ "world_context",
440
+ ]),
441
+ ]
442
+ : strategy === "explicit_cliproxyapi_message"
443
+ ? [
444
+ input.modelBinding.provider === "chat_completions"
445
+ ? "world_context"
446
+ : lastInstructionRole,
447
+ ]
448
+ : [],
379
449
  ...stableCacheBoundary(stableText, toolUniverse, presetCompilation.toolStrategy),
380
450
  },
381
451
  };
@@ -409,13 +479,25 @@ export class FileNativePromptCompiler {
409
479
  const files = snapshotFiles(effective.world.documentSnapshot);
410
480
  const frame = readYamlRecord(files["control/frame.yaml"], "world frame");
411
481
  const instructions = readWorldInstructions(files, frame);
412
- const materials = this.inspectWorldMaterials(effective.world);
482
+ const materials = this.playMaterials(effective);
413
483
  const logicalMessages = [];
484
+ let prompt;
414
485
  const append = (role, blocks) => {
415
486
  if (blocks.length > 0)
416
- logicalMessages.push({ role, blocks, markdown: joinBlocks(blocks) });
487
+ logicalMessages.push({
488
+ role,
489
+ blocks,
490
+ markdown: joinBlocks(blocks),
491
+ ...prompt,
492
+ });
417
493
  };
418
494
  for (const entry of parseOrderedPlayPrompts(binding.definition.playPrompts)) {
495
+ prompt = {
496
+ messageRole: entry.messageRole ?? "system",
497
+ promptId: entry.id,
498
+ };
499
+ if (entry.kind === "user" && !entry.body.trim())
500
+ continue;
419
501
  if (entry.kind === "world") {
420
502
  append("author_instruction", instructions);
421
503
  append("world_context", [
@@ -440,6 +522,7 @@ export class FileNativePromptCompiler {
440
522
  ]);
441
523
  }
442
524
  }
525
+ prompt = undefined;
443
526
  if (effective.playerInputPlacement === "bootstrap")
444
527
  append("player_input", [
445
528
  { source: "player:input", markdown: effective.playerInput },
@@ -452,17 +535,20 @@ export class FileNativePromptCompiler {
452
535
  (effective.modelBinding.provider === "anthropic_messages"
453
536
  ? "explicit_anthropic_blocks"
454
537
  : "provider_managed");
538
+ const provider = mapProvider(effective.modelBinding.provider, logicalMessages, strategy, true, binding.definition.mergeConsecutiveMessages ?? true);
455
539
  return {
456
540
  logicalMessages,
457
- provider: mapProvider(effective.modelBinding.provider, logicalMessages, strategy, true),
541
+ provider,
458
542
  tools,
459
543
  toolUniverse: structuredClone(tools),
460
544
  toolStrategy,
461
545
  coverage: materials.coverage,
462
- maintenance: materials.maintenance,
546
+ ...(materials.maintenance === undefined
547
+ ? {}
548
+ : { maintenance: materials.maintenance }),
463
549
  budget: disabledPromptBudget(effective),
464
550
  cache: {
465
- ...stableCacheBoundary(cacheStableText(logicalMessages), tools, toolStrategy),
551
+ ...stableCacheBoundary(cacheStableText(logicalMessages, provider), tools, toolStrategy),
466
552
  strategy,
467
553
  breakpoints: strategy === "explicit_anthropic_blocks"
468
554
  ? [
@@ -474,6 +560,66 @@ export class FileNativePromptCompiler {
474
560
  },
475
561
  };
476
562
  }
563
+ playMaterials(input) {
564
+ if (input.frozenWorldContext !== undefined) {
565
+ const frozen = input.frozenWorldContext;
566
+ return structuredClone({
567
+ blocks: frozen.logicalMessages
568
+ .filter(({ role }) => role === "world_context")
569
+ .flatMap(({ blocks }) => blocks)
570
+ .filter(({ source }) => source !== "runtime:world-placeholder/coverage" &&
571
+ source !== "runtime:builtin/runtime.coverage"),
572
+ coverage: frozen.coverage,
573
+ ...(frozen.maintenance === undefined
574
+ ? {}
575
+ : { maintenance: frozen.maintenance }),
576
+ });
577
+ }
578
+ const materials = this.inspectWorldMaterials(input.world, input.playerInputPlacement);
579
+ const snapshot = input.world.documentSnapshot;
580
+ const declaredDirectories = materials.coverage
581
+ .filter(({ slot }) => slot === "catalog")
582
+ .map(({ source }) => source);
583
+ const directories = new Set([""]);
584
+ const pending = [""];
585
+ for (const directory of pending) {
586
+ let cursor = null;
587
+ do {
588
+ const result = snapshot.query({
589
+ kind: "catalog",
590
+ directory,
591
+ declaredDirectories,
592
+ includeRetired: true,
593
+ limit: 100,
594
+ cursor,
595
+ });
596
+ if (result.kind === "error")
597
+ throwQueryFailure(result, "directory index");
598
+ if (result.kind !== "catalog")
599
+ throw new PromptCompilationError("world_document_query_failed", "Invalid directory index query result");
600
+ for (const entry of result.entries) {
601
+ if (entry.kind !== "directory")
602
+ continue;
603
+ const directory = entry.logicalPath.slice(`${snapshot.logicalRoot}/`.length);
604
+ if (!directories.has(directory)) {
605
+ directories.add(directory);
606
+ pending.push(directory);
607
+ }
608
+ }
609
+ cursor = result.page.nextCursor;
610
+ } while (cursor !== null);
611
+ }
612
+ materials.blocks.unshift({
613
+ source: "runtime:initial-directories",
614
+ markdown: `${this.#locale === "zh-CN" ? "# 初始目录索引\n\n全部目录句柄,可直接使用;文档正文仍按需读取。" : "# Initial directory index\n\nAll directory handles, ready to use; read document bodies as needed."}\n${[
615
+ ...directories,
616
+ ]
617
+ .sort()
618
+ .map((directory) => `- ${stateDirectoryHandle(directory)}`)
619
+ .join("\n")}`,
620
+ });
621
+ return materials;
622
+ }
477
623
  /**
478
624
  * A play preset owns the editable story policy. Its bootstrap keeps only the
479
625
  * mechanical Runtime shell; narrative semantics remain author instructions.
@@ -659,8 +805,8 @@ function settingImprovementPresetReference(binding, locale, target) {
659
805
  {
660
806
  ...worldInstructionPlaceholder,
661
807
  markdown: locale === "zh-CN"
662
- ? "完整世界提示占位在这里连续展开世界指令和 frame 选定材料(不是整棵树);请通过本轮创作工具检查实际内容。"
663
- : "The complete world placeholder expands world instructions and frame-selected material continuously here, not the entire tree. Inspect actual content through the current authoring tools.",
808
+ ? "世界指令在此展开;frame 选定材料仅在全新游玩上下文中作为初始工具结果提供。请通过本轮创作工具检查实际内容。"
809
+ : "World instructions expand here; frame-selected material is supplied as an initial tool result only for a fresh play context. Inspect actual content through the current authoring tools.",
664
810
  },
665
811
  ];
666
812
  if (!entry.enabled)
@@ -748,7 +894,7 @@ function withoutAppendedContextGenesis(input) {
748
894
  if (material.kind !== "history_message" &&
749
895
  material.kind !== "history_commit")
750
896
  return true;
751
- const matches = Object.keys(history).filter((key) => historyMaterialMatches(material, key));
897
+ const matches = Object.keys(history).filter((key) => historySelectionMatches(material, key));
752
898
  // Invalid references still reach the compiler's required-slot validation.
753
899
  return matches.length === 0 || matches.some((key) => !excluded.has(key));
754
900
  });
@@ -757,7 +903,7 @@ function withoutAppendedContextGenesis(input) {
757
903
  world: {
758
904
  ...input.world,
759
905
  additionalMaterials,
760
- history: Object.fromEntries(Object.entries(history).filter(([key]) => !excluded.has(key))),
906
+ history,
761
907
  },
762
908
  };
763
909
  }
@@ -842,10 +988,7 @@ function createPlayPresetPreview(binding, compilation) {
842
988
  function compilePlayPresetCompilation(input, bootstrap, binding, locale) {
843
989
  const toolUniverse = fileNativeToolsForNames(registeredRuntimeToolNames, locale);
844
990
  const toolStrategy = bootstrap.toolStrategy;
845
- const stableMessages = bootstrap.logicalMessages.filter(({ role }) => role !== "player_input");
846
- const stableText = stableMessages
847
- .map(({ role, markdown }) => `${role}\n${markdown}`)
848
- .join("\n");
991
+ const stableText = cacheStableText(bootstrap.logicalMessages, binding.definition.playPrompts ? bootstrap.provider : undefined);
849
992
  const sessionBootstrap = {
850
993
  ...bootstrap,
851
994
  tools: structuredClone(toolUniverse),
@@ -866,7 +1009,7 @@ function compilePlayPresetCompilation(input, bootstrap, binding, locale) {
866
1009
  }
867
1010
  function cloneLogicalMessages(messages) {
868
1011
  return messages.map((message) => ({
869
- role: message.role,
1012
+ ...message,
870
1013
  markdown: message.markdown,
871
1014
  blocks: message.blocks.map(({ source, markdown }) => ({
872
1015
  source,
@@ -1164,9 +1307,15 @@ function resolveContext(input, worldFrame, snapshot, coverage, locale) {
1164
1307
  // The model's own list must yield to every author slot, however the frame
1165
1308
  // happens to order them, so resolve it last and splice it back into place.
1166
1309
  let additionalMaterialsAt = null;
1167
- const replay = input.world.replayHistory === true
1168
- ? checkpointReplayBlocks(input.world.history ?? {}, input.world.narrativeCheckpoint, locale)
1310
+ const history = new HistoryQuery("prompt-compilation", historyInputs(Object.entries(input.world.history ?? {}).map(([path, contents]) => ({
1311
+ path,
1312
+ contents,
1313
+ }))));
1314
+ const excluded = new Set(input.world.historyAlreadyAppended ?? []);
1315
+ const checkpointEntries = input.world.replayHistory === true
1316
+ ? checkpointHistory(input.world.history ?? {}, input.world.narrativeCheckpoint)
1169
1317
  : [];
1318
+ const replayEntries = checkpointEntries.filter(([id]) => !excluded.has(id));
1170
1319
  for (const entry of context) {
1171
1320
  const selectionStart = selected.length;
1172
1321
  const slot = isRecord(entry) && isRecord(entry.slot) ? entry.slot : null;
@@ -1178,10 +1327,10 @@ function resolveContext(input, worldFrame, snapshot, coverage, locale) {
1178
1327
  resolveReferenceTargets(slot, snapshot, selected, coverage, locale);
1179
1328
  else if (slot.kind === "catalog")
1180
1329
  resolveCatalog(slot, snapshot, selected, coverage, locale);
1181
- else if (slot.kind === "history" && replay.length > 0)
1330
+ else if (slot.kind === "history" && checkpointEntries.length > 0)
1182
1331
  continue;
1183
1332
  else if (slot.kind === "history")
1184
- resolveRecentHistory(slot, input.world.history ?? {}, selected, coverage, locale);
1333
+ resolveRecentHistory(slot, history, selected, coverage, locale, excluded);
1185
1334
  else if (slot.kind === "additional_materials")
1186
1335
  additionalMaterialsAt ??= {
1187
1336
  selected: selected.length,
@@ -1206,9 +1355,13 @@ function resolveContext(input, worldFrame, snapshot, coverage, locale) {
1206
1355
  source: "runtime:player-view-bindings",
1207
1356
  markdown: bindings,
1208
1357
  });
1209
- if (replay.length > 0) {
1210
- const entries = checkpointHistory(input.world.history ?? {}, input.world.narrativeCheckpoint);
1211
- selected.push(...replay.map((block, index) => ({
1358
+ if (checkpointEntries.length > 0) {
1359
+ const selectedIds = new Set(selected.flatMap(({ key }) => key.startsWith("history_message:")
1360
+ ? [key.slice("history_message:".length)]
1361
+ : []));
1362
+ const entries = replayEntries.filter(([id]) => !selectedIds.has(id));
1363
+ const deduplicatedReplay = checkpointReplayBlocks(input.world.history ?? {}, input.world.narrativeCheckpoint, locale, new Set([...excluded, ...selectedIds]), history);
1364
+ selected.push(...deduplicatedReplay.map((block, index) => ({
1212
1365
  ...block,
1213
1366
  key: index === 0
1214
1367
  ? "history:checkpoint-notice"
@@ -1227,7 +1380,7 @@ function resolveContext(input, worldFrame, snapshot, coverage, locale) {
1227
1380
  if (additionalMaterialsAt !== null) {
1228
1381
  const withMaterials = [...selected];
1229
1382
  const materialCoverage = [];
1230
- resolveAdditionalMaterials(input.world.additionalMaterials, snapshot, input.world.history ?? {}, withMaterials, materialCoverage, locale);
1383
+ resolveAdditionalMaterials(input.world.additionalMaterials, snapshot, history, withMaterials, materialCoverage, locale, excluded, input.playerInputPlacement === "append");
1231
1384
  selected.splice(additionalMaterialsAt.selected, 0, ...withMaterials.slice(selected.length).map((material) => ({
1232
1385
  ...material,
1233
1386
  ...(additionalMaterialsAt?.slotId === undefined
@@ -1406,7 +1559,7 @@ function validCatalogDirectory(directory) {
1406
1559
  * every detail into a world document, so recent history remains an explicit
1407
1560
  * source alongside the current state.
1408
1561
  */
1409
- function resolveRecentHistory(slot, history, selected, coverage, locale) {
1562
+ function resolveRecentHistory(slot, history, selected, coverage, locale, excluded) {
1410
1563
  const recent = Number(slot.recent ?? 2);
1411
1564
  if (!Number.isInteger(recent) || recent < 1 || recent > 32)
1412
1565
  throw new PromptCompilationError("world_frame_invalid", "history slot recent must be an integer from 1 to 32");
@@ -1414,15 +1567,15 @@ function resolveRecentHistory(slot, history, selected, coverage, locale) {
1414
1567
  // in commit order. Its keys are semantic message IDs, not sortable file
1415
1568
  // names: lexical sorting would put `message.genesis` after every numbered
1416
1569
  // message and `message.9` after `message.15`.
1417
- const ordered = Object.entries(history).filter(([ref]) => !ref.endsWith("message.genesis.narrator"));
1418
- const chosen = ordered.slice(-recent);
1570
+ const ordered = history.messages.filter(({ isOpening }) => !isOpening);
1571
+ const chosen = ordered.slice(-recent).filter(({ id }) => !excluded.has(id));
1419
1572
  if (chosen.length === 0) {
1420
1573
  selected.push({
1421
1574
  key: "history:empty",
1422
1575
  source: "slot:history:empty",
1423
1576
  markdown: locale === "zh-CN"
1424
- ? "# 最近已提交对话\n\n(空:当前世界没有更早的玩家原文或主持叙事;无需为寻找上一条记录调用历史检索工具。)"
1425
- : "# Recent committed conversation\n\n(Empty: this world has no earlier player input or host narrative. Do not call history tools merely to look for a previous message.)",
1577
+ ? `# 最近已提交对话\n\n${renderHistoryInjectionNotice(history.messages.length, [], "recent", locale)}\n(本次没有可注入的非开场白原文:可能只有开场白,或近期材料已在原生对话中。无需为寻找上一条记录例行调用历史检索工具。)`
1578
+ : `# Recent committed conversation\n\n${renderHistoryInjectionNotice(history.messages.length, [], "recent", locale)}\n(No non-opening originals selected for injection: only the opening exists, or recent entries are already in native conversation. Do not call history tools merely to look for a previous message.)`,
1426
1579
  });
1427
1580
  coverage.push({
1428
1581
  slot: "history",
@@ -1433,27 +1586,35 @@ function resolveRecentHistory(slot, history, selected, coverage, locale) {
1433
1586
  });
1434
1587
  return;
1435
1588
  }
1436
- for (const [index, [ref, text]] of chosen.entries()) {
1589
+ const missing = chosen.filter(({ id }) => overlappingSelection(selected, `history_message:${id}`) === null);
1590
+ if (missing.length > 0)
1591
+ selected.push({
1592
+ key: "history:recent-notice",
1593
+ source: "slot:history:notice",
1594
+ markdown: renderHistoryInjectionNotice(history.messages.length, missing, "recent", locale),
1595
+ });
1596
+ for (const [index, message] of missing.entries()) {
1597
+ const ref = message.id;
1437
1598
  const key = `history_message:${ref}`;
1438
1599
  if (overlappingSelection(selected, key) !== null)
1439
1600
  continue;
1440
1601
  selected.push({
1441
1602
  key,
1442
1603
  source: `slot:history:${ref}`,
1443
- markdown: renderHistoryMessage(ref, text, locale),
1604
+ markdown: renderHistoryMessage(message, locale),
1444
1605
  });
1445
1606
  coverage.push({
1446
1607
  slot: "history",
1447
1608
  source: locale === "zh-CN"
1448
- ? `${historyMessageLabel(ref, locale)}(最近记录 ${index + 1}/${chosen.length})`
1449
- : `${historyMessageLabel(ref, locale)} (recent ${index + 1}/${chosen.length})`,
1609
+ ? `${historyRoleLabel(message, locale)}(最近记录 ${index + 1}/${chosen.length})`
1610
+ : `${historyRoleLabel(message, locale)} (recent ${index + 1}/${chosen.length})`,
1450
1611
  status: "resolved",
1451
1612
  complete: true,
1452
1613
  continuation: "history_list",
1453
1614
  });
1454
1615
  }
1455
1616
  }
1456
- function resolveAdditionalMaterials(materials, snapshot, history, selected, coverage, locale) {
1617
+ function resolveAdditionalMaterials(materials, snapshot, history, selected, coverage, locale, excluded, excludeOpening) {
1457
1618
  if (materials.length > 32)
1458
1619
  throw new PromptCompilationError("material_limit_exceeded", "Additional material exceeds 32 items");
1459
1620
  for (const material of materials) {
@@ -1468,21 +1629,27 @@ function resolveAdditionalMaterials(materials, snapshot, history, selected, cove
1468
1629
  const ref = material.kind === "history_message"
1469
1630
  ? material.message
1470
1631
  : material.commit;
1471
- const matches = Object.entries(history).filter(([key]) => historyMaterialMatches(material, key));
1632
+ const matches = history.messages.filter(({ id }) => historySelectionMatches(material, id));
1472
1633
  if (matches.length === 0)
1473
1634
  throw new PromptCompilationError("required_slot_missing", `Additional history material does not exist: ${ref}`);
1474
- const missing = matches.filter(([id]) => !selected.some(({ key }) => key === `history_message:${id}`));
1635
+ const missing = matches.filter(({ id, isOpening }) => !excluded.has(id) &&
1636
+ !(excludeOpening && isOpening) &&
1637
+ !selected.some(({ key }) => key === `history_message:${id}`));
1475
1638
  if (missing.length === 0)
1476
1639
  continue;
1477
- selected.push(...missing.map(([id, text]) => ({
1478
- key: `history_message:${id}`,
1479
- source: `slot:additional_materials:${id}`,
1480
- markdown: renderHistoryMessage(id, text, locale),
1640
+ selected.push({
1641
+ key: `history:additional-notice:${selected.length}`,
1642
+ source: "slot:additional_materials:history-notice",
1643
+ markdown: renderHistoryInjectionNotice(history.messages.length, missing, "additional", locale),
1644
+ }, ...missing.map((message) => ({
1645
+ key: `history_message:${message.id}`,
1646
+ source: `slot:additional_materials:${message.id}`,
1647
+ markdown: renderHistoryMessage(message, locale),
1481
1648
  })));
1482
1649
  coverage.push({
1483
1650
  slot: "additional_materials",
1484
1651
  source: matches.length === 1
1485
- ? historyMessageLabel(matches[0][0], locale)
1652
+ ? historyRoleLabel(matches[0], locale)
1486
1653
  : locale === "zh-CN"
1487
1654
  ? `已选历史提交(${matches.length} 条)`
1488
1655
  : `selected history commit (${matches.length} messages)`,
@@ -1493,32 +1660,6 @@ function resolveAdditionalMaterials(materials, snapshot, history, selected, cove
1493
1660
  }
1494
1661
  }
1495
1662
  }
1496
- function historyMaterialMatches(material, key) {
1497
- if (material.kind === "history_message")
1498
- return (historyMaterialIdentity(key) === historyMaterialIdentity(material.message));
1499
- const ref = material.commit;
1500
- return ref.startsWith("commit:")
1501
- ? historyMaterialIdentity(key).startsWith(`message.${ref.slice(7)}.`)
1502
- : ref === "genesis"
1503
- ? historyMaterialIdentity(key).startsWith("message.genesis.")
1504
- : key.startsWith(ref.replace(/^@?history-commit-/u, "history-message-").concat("-"));
1505
- }
1506
- function historyMaterialIdentity(ref) {
1507
- const value = ref.replace(/^@?(?:history-message-)?/u, "");
1508
- return (/(?:^|\.)(message\.(?:genesis|[0-9]+)(?:\.[0-9]+)?\.(?:player|narrator))$/u.exec(value)?.[1] ?? value);
1509
- }
1510
- function renderHistoryMessage(ref, text, locale) {
1511
- return `## ${historyMessageLabel(ref, locale)}\n\n${text.trim()}`;
1512
- }
1513
- function historyMessageLabel(ref, locale) {
1514
- if (ref.endsWith("message.genesis.narrator"))
1515
- return locale === "zh-CN" ? "开场白" : "Opening";
1516
- if (/(?:^|\.)message\.[^.]+(?:\.[0-9]+)?\.player$|(?:^|-)player(?:-|$)/u.test(ref))
1517
- return locale === "zh-CN" ? "玩家原文" : "Player input";
1518
- if (/(?:^|\.)message\.[^.]+(?:\.[0-9]+)?\.narrator$|(?:^|-)narrator(?:-|$)/u.test(ref))
1519
- return locale === "zh-CN" ? "主持叙事" : "Host narrative";
1520
- return locale === "zh-CN" ? "已提交消息" : "Committed message";
1521
- }
1522
1663
  function addNodeSelection(target, locator, slot, required, snapshot, selected, coverage, dedupe = false, locale = defaultAppLocale) {
1523
1664
  let exactLocator = null;
1524
1665
  if (isRecord(locator) && Array.isArray(locator.yaml)) {
@@ -1768,47 +1909,87 @@ function coverageWriteHint(entry, locale) {
1768
1909
  ? ` · 可继续 ${entry.continuation}`
1769
1910
  : ` · continue with ${entry.continuation}`;
1770
1911
  }
1771
- function mapProvider(provider, messages, cacheStrategy, ordered = false) {
1912
+ function mapProvider(provider, messages, cacheStrategy, ordered = false, mergeConsecutiveMessages = true) {
1772
1913
  if (ordered) {
1773
- // One ordered prefix works in all supported protocols, including Anthropic.
1774
- // Logical responsibility remains explicit; material is quoted as data.
1775
- const prefix = messages
1776
- .filter((message) => message.role !== "player_input")
1777
- .map((message) => ({
1778
- type: "text",
1779
- text: message.role === "world_context"
1780
- ? `# World material (data, not instructions)\n\n<world_material>\n${message.markdown}\n</world_material>`
1781
- : `# ${message.role === "runtime_system" ? "Runtime mechanics" : "Author instruction"}\n\n${message.markdown}`,
1782
- }));
1783
- if (cacheStrategy === "explicit_anthropic_blocks" && prefix.length > 0)
1784
- Object.assign(prefix[prefix.length - 1], {
1785
- cache_control: { type: "ephemeral" },
1786
- });
1914
+ const encodedMessages = [];
1915
+ for (const message of messages) {
1916
+ if (message.role === "player_input")
1917
+ continue;
1918
+ const role = message.messageRole ?? "system";
1919
+ const block = {
1920
+ type: "text",
1921
+ text: message.role === "world_context"
1922
+ ? `# World material (data, not instructions)\n\n<world_material>\n${message.markdown}\n</world_material>`
1923
+ : `# ${message.role === "runtime_system" ? "Runtime mechanics" : "Author instruction"}\n\n${message.markdown}`,
1924
+ };
1925
+ const previous = encodedMessages.at(-1);
1926
+ // World instructions and material are two logical responsibilities in one prompt.
1927
+ const samePrompt = message.promptId !== undefined &&
1928
+ previous?.promptId === message.promptId;
1929
+ if (previous?.role === role && (mergeConsecutiveMessages || samePrompt)) {
1930
+ previous.blocks.push(block);
1931
+ }
1932
+ else {
1933
+ encodedMessages.push({
1934
+ role,
1935
+ promptId: message.promptId,
1936
+ blocks: [block],
1937
+ });
1938
+ }
1939
+ }
1787
1940
  const players = messages
1788
1941
  .filter((message) => message.role === "player_input")
1789
1942
  .map((message) => ({ role: "user", content: message.markdown }));
1790
- return provider === "anthropic_messages"
1791
- ? { protocol: provider, system: prefix, messages: players }
1792
- : {
1943
+ if (provider === "anthropic_messages") {
1944
+ const firstConversation = encodedMessages.findIndex((message) => message.role !== "system");
1945
+ const split = firstConversation < 0 ? encodedMessages.length : firstConversation;
1946
+ if (encodedMessages
1947
+ .slice(split)
1948
+ .some((message) => message.role === "system"))
1949
+ throw new PromptCompilationError("prompt_role_order_unsupported", "Anthropic: place all System prompts before User/Assistant prompts. / 请将所有 System 提示放在 User/Assistant 提示之前。");
1950
+ if (cacheStrategy === "explicit_anthropic_blocks") {
1951
+ const last = encodedMessages.at(-1)?.blocks.at(-1);
1952
+ if (last)
1953
+ last.cache_control = { type: "ephemeral" };
1954
+ }
1955
+ return {
1793
1956
  protocol: provider,
1957
+ system: encodedMessages
1958
+ .slice(0, split)
1959
+ .flatMap((message) => message.blocks),
1794
1960
  messages: [
1795
- {
1796
- role: "system",
1797
- content: prefix.map((block) => block.text).join("\n\n"),
1798
- },
1961
+ ...encodedMessages.slice(split).map((message) => ({
1962
+ role: message.role,
1963
+ content: message.blocks,
1964
+ })),
1799
1965
  ...players,
1800
1966
  ],
1801
1967
  };
1968
+ }
1969
+ return {
1970
+ protocol: provider,
1971
+ messages: [
1972
+ ...encodedMessages.map((message) => ({
1973
+ role: message.role,
1974
+ content: message.blocks.map((block) => block.text).join("\n\n"),
1975
+ })),
1976
+ ...players,
1977
+ ],
1978
+ };
1802
1979
  }
1803
1980
  const byRole = Object.fromEntries(messages.map((message) => [message.role, message.markdown]));
1804
1981
  const userBlocks = [
1805
- {
1806
- type: "text",
1807
- text: byRole.world_context,
1808
- ...(cacheStrategy === "provider_managed"
1809
- ? {}
1810
- : { cache_control: { type: "ephemeral" } }),
1811
- },
1982
+ ...(byRole.world_context === undefined
1983
+ ? []
1984
+ : [
1985
+ {
1986
+ type: "text",
1987
+ text: byRole.world_context,
1988
+ ...(cacheStrategy === "provider_managed"
1989
+ ? {}
1990
+ : { cache_control: { type: "ephemeral" } }),
1991
+ },
1992
+ ]),
1812
1993
  ...(typeof byRole.player_input === "string" &&
1813
1994
  byRole.player_input.length > 0
1814
1995
  ? [{ type: "text", text: byRole.player_input }]
@@ -1829,7 +2010,7 @@ function mapProvider(provider, messages, cacheStrategy, ordered = false) {
1829
2010
  cache_control: { type: "ephemeral" },
1830
2011
  },
1831
2012
  ],
1832
- messages: [{ role: "user", content: userBlocks }],
2013
+ messages: userBlocks.length === 0 ? [] : [{ role: "user", content: userBlocks }],
1833
2014
  };
1834
2015
  }
1835
2016
  return {
@@ -1839,7 +2020,9 @@ function mapProvider(provider, messages, cacheStrategy, ordered = false) {
1839
2020
  role: "system",
1840
2021
  content: `# Runtime System\n\n${byRole.runtime_system}\n\n# Author Instruction\n\n${byRole.author_instruction}`,
1841
2022
  },
1842
- { role: "user", content: userBlocks },
2023
+ ...(userBlocks.length === 0
2024
+ ? []
2025
+ : [{ role: "user", content: userBlocks }]),
1843
2026
  ],
1844
2027
  };
1845
2028
  }
@@ -1917,7 +2100,14 @@ function joinBlocks(blocks) {
1917
2100
  function utf8Bytes(value) {
1918
2101
  return new TextEncoder().encode(value).byteLength;
1919
2102
  }
1920
- function cacheStableText(messages) {
2103
+ function cacheStableText(messages, provider) {
2104
+ if (provider) {
2105
+ const playerCount = messages.filter((message) => message.role === "player_input").length;
2106
+ return JSON.stringify({
2107
+ ...provider,
2108
+ messages: provider.messages.slice(0, provider.messages.length - playerCount),
2109
+ });
2110
+ }
1921
2111
  return messages
1922
2112
  .filter(({ role }) => role !== "player_input")
1923
2113
  .map(({ role, markdown }) => `${role}\n${markdown}`)