narraeon 0.6.2 → 0.7.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.
@@ -131,16 +131,64 @@ function assembleRetainedTrace(source, endpoint, selection) {
131
131
  export function restorePlayDocuments(files, context, events) {
132
132
  const documents = new FileNativePlayDocuments(files);
133
133
  try {
134
- // Read authorizations are memory-only: a restored prefix always starts from
135
- // its bootstrap, and stale write authority is caught by the commit's hash.
134
+ // Read authorizations are memory-only (ADR-0044). A restored prefix starts
135
+ // from its bootstrap, then replays the exact reads the retained transcript
136
+ // still shows the model, so it is not asked to read again what it has
137
+ // already seen. Stale write authority is caught by the commit's hash.
136
138
  const runs = playPromptRunsThroughEvents(context, events);
137
139
  documents.bindBootstrap(currentPlayPrompt({ ...context, promptRuns: runs }).bootstrap);
140
+ for (const read of completedReadsThroughEvents(context, events))
141
+ documents.replayRead(read.shortRef, read.locator);
138
142
  }
139
143
  catch (error) {
140
144
  throw new PlayCallChainError(`Call-chain document authorization could not be restored: ${error instanceof Error ? error.message : "invalid checkpoint"}。`);
141
145
  }
142
146
  return documents;
143
147
  }
148
+ /**
149
+ * The exact document scopes successfully read by context_read within the
150
+ * selected event prefix, in event order. Tool results are keyed by exchange and
151
+ * call ID, and the exchange is the one of the nearest preceding assistant event.
152
+ */
153
+ function completedReadsThroughEvents(context, events) {
154
+ const completed = new Map(context.completedTools.map((tool) => [tool.key, tool.result]));
155
+ const reads = [];
156
+ let exchange = 0;
157
+ for (const event of events) {
158
+ if (event.kind === "assistant") {
159
+ exchange = event.exchange;
160
+ continue;
161
+ }
162
+ if (event.kind !== "tool_result" || !event.ok)
163
+ continue;
164
+ const result = completed.get(`${exchange}:${event.callId}`);
165
+ const authorization = result?.readAuthorization;
166
+ if (!result?.ok || !isReplayableReadAuthorization(authorization))
167
+ continue;
168
+ reads.push({
169
+ shortRef: authorization.shortRef,
170
+ locator: structuredClone(authorization.locator),
171
+ });
172
+ }
173
+ return reads;
174
+ }
175
+ function isReplayableReadAuthorization(value) {
176
+ if (typeof value !== "object" || value === null)
177
+ return false;
178
+ const { shortRef, locator } = value;
179
+ if (typeof shortRef !== "string")
180
+ return false;
181
+ if (locator === null)
182
+ return true;
183
+ if (typeof locator !== "object" || locator === null)
184
+ return false;
185
+ const { yaml, markdown } = locator;
186
+ if (Array.isArray(yaml))
187
+ return yaml.every((segment) => typeof segment === "string" ||
188
+ (Number.isSafeInteger(segment) && Number(segment) >= 0));
189
+ return (Array.isArray(markdown) &&
190
+ markdown.every((segment) => typeof segment === "string"));
191
+ }
144
192
  export function independentContextCopy(source) {
145
193
  const context = structuredClone(source);
146
194
  context.continuityContextId ??= source.chainId;
@@ -5,7 +5,8 @@ import { hasLegacyHistoryTools } from "./FileNativeToolRegistry.js";
5
5
  import { effectiveFollowupDefinitions } from "../play/OrderedFollowups.js";
6
6
  import { legacyAuthorPrompts } from "../play/FileNativePlayPresetStore.js";
7
7
  import { builtinAuthorPrompts, authoringMechanics, } from "../../shared/ordered-author-prompts.js";
8
- import { builtinPlayPrompts, orderedPromptScope, promptScopeApplies, } from "../../shared/ordered-play-prompts.js";
8
+ import { builtinPlayPrompts, orderedPromptScope, orderedPromptTurnGuide, promptScopeApplies, } from "../../shared/ordered-play-prompts.js";
9
+ import { turnGuideHeader, turnGuideText, } from "../../shared/turn-guide.js";
9
10
  import { parseOrderedPlayPrompts } from "../play/OrderedPlayPrompts.js";
10
11
  import { renderDocumentWritePosition } from "./WorldMaintenanceReport.js";
11
12
  import { parseDocument } from "yaml";
@@ -330,7 +331,7 @@ export class FileNativePromptCompiler {
330
331
  const worldFrame = readYamlRecord(worldFiles["control/frame.yaml"], "world frame");
331
332
  requireFormat(hostFrame, "narraeon.host-frame/v1", "host frame");
332
333
  requireFormat(worldFrame, "narraeon.world-frame/v1", "world frame");
333
- const instructions = readWorldInstructions(worldFiles, worldFrame);
334
+ const instructions = readWorldInstructions(worldFiles, worldFrame).blocks;
334
335
  const { coverage, blocks: context, maintenance, } = this.playMaterials(effectiveInput);
335
336
  const hostRoles = compileHostRoles(effectiveInput.hostBinding.files, hostFrame, instructions, context, coverage, this.#locale);
336
337
  const blocks = {
@@ -511,6 +512,7 @@ export class FileNativePromptCompiler {
511
512
  };
512
513
  const playCatalog = builtinPlayPrompts(this.#locale);
513
514
  const gameplay = playGameplay(effective.world.documentSnapshot);
515
+ const turnGuideLines = [];
514
516
  for (const entry of parseOrderedPlayPrompts(binding.definition.playPrompts)) {
515
517
  // A scoped entry is absent from the request, not an empty message.
516
518
  if (!promptScopeApplies(orderedPromptScope(entry, playCatalog), gameplay))
@@ -519,10 +521,26 @@ export class FileNativePromptCompiler {
519
521
  messageRole: entry.messageRole ?? "system",
520
522
  promptId: entry.id,
521
523
  };
524
+ // The turn guide follows list order: each enabled entry's line, and
525
+ // the world frame's lines at the world placeholder.
526
+ if (entry.kind === "world")
527
+ turnGuideLines.push(...instructions.turnGuide);
528
+ else if (entry.enabled) {
529
+ const line = orderedPromptTurnGuide(entry, playCatalog);
530
+ if (line !== undefined)
531
+ turnGuideLines.push({
532
+ source: entry.kind === "builtin"
533
+ ? `preset:builtin/${entry.builtin}`
534
+ : `preset:prompt/${entry.id}`,
535
+ text: line,
536
+ });
537
+ }
538
+ if (entry.kind === "tail")
539
+ continue;
522
540
  if (entry.kind === "user" && !entry.body.trim())
523
541
  continue;
524
542
  if (entry.kind === "world") {
525
- append("author_instruction", instructions);
543
+ append("author_instruction", instructions.blocks);
526
544
  append("world_context", [
527
545
  ...materials.blocks,
528
546
  {
@@ -550,7 +568,26 @@ export class FileNativePromptCompiler {
550
568
  append("player_input", [
551
569
  { source: "player:input", markdown: effective.playerInput },
552
570
  ]);
553
- scanRuntimeLeakage(logicalMessages);
571
+ const turnGuide = turnGuideLines.length === 0
572
+ ? undefined
573
+ : {
574
+ text: turnGuideText(this.#locale, turnGuideLines),
575
+ lines: turnGuideLines,
576
+ };
577
+ scanRuntimeLeakage([
578
+ ...logicalMessages,
579
+ ...(turnGuide === undefined
580
+ ? []
581
+ : [
582
+ {
583
+ role: "author_instruction",
584
+ markdown: turnGuide.text,
585
+ blocks: [
586
+ { source: "runtime:turn-guide", markdown: turnGuide.text },
587
+ ],
588
+ },
589
+ ]),
590
+ ]);
554
591
  const tools = runtimeToolsForNames(runtimeToolNamesForGameplay(playGameplay(effective.world.documentSnapshot)), this.#locale);
555
592
  const toolStrategy = this.#toolStrategyOverride ??
556
593
  defaultRuntimeToolDefinitionStrategy(effective.modelBinding.provider);
@@ -569,6 +606,7 @@ export class FileNativePromptCompiler {
569
606
  ...(materials.maintenance === undefined
570
607
  ? {}
571
608
  : { maintenance: materials.maintenance }),
609
+ ...(turnGuide === undefined ? {} : { turnGuide }),
572
610
  budget: disabledPromptBudget(effective),
573
611
  cache: {
574
612
  ...stableCacheBoundary(cacheStableText(logicalMessages, provider), tools, toolStrategy),
@@ -837,7 +875,7 @@ function settingImprovementPresetReference(binding, locale, target, gameplay) {
837
875
  : "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.",
838
876
  },
839
877
  ];
840
- if (!entry.enabled)
878
+ if (entry.kind === "tail" || !entry.enabled)
841
879
  return [];
842
880
  if (!promptScopeApplies(orderedPromptScope(entry, builtinPlayPrompts(locale)), gameplay))
843
881
  return [];
@@ -856,6 +894,37 @@ function settingImprovementPresetReference(binding, locale, target, gameplay) {
856
894
  ];
857
895
  });
858
896
  const narrativeBlocks = ordered === undefined ? playNarrativeBlocks(binding) : [];
897
+ // The preset half of the turn guide: the world frame's lines are read from
898
+ // the package or world itself through the authoring tools.
899
+ const turnGuideLines = ordered === undefined
900
+ ? []
901
+ : parseOrderedPlayPrompts(ordered).flatMap((entry) => {
902
+ if (entry.kind === "world" || !entry.enabled)
903
+ return [];
904
+ if (!promptScopeApplies(orderedPromptScope(entry, builtinPlayPrompts(locale)), gameplay))
905
+ return [];
906
+ const line = orderedPromptTurnGuide(entry, builtinPlayPrompts(locale));
907
+ return line === undefined
908
+ ? []
909
+ : [
910
+ `- ${line} (${entry.kind === "builtin" ? `preset:builtin/${entry.builtin}` : `preset:prompt/${entry.id}`})`,
911
+ ];
912
+ });
913
+ const turnGuideBlocks = turnGuideLines.length === 0
914
+ ? []
915
+ : [
916
+ {
917
+ source: "play-preset:turn-guide",
918
+ markdown: [
919
+ turnGuideHeader(locale),
920
+ ...turnGuideLines,
921
+ "",
922
+ locale === "zh-CN"
923
+ ? "世界 frame 的 instructions 可用 turnGuide 在世界占位处补充一到两行本世界特有、最容易丢的约束;不要复制上述跨世界规则。"
924
+ : "The world frame's instructions may add one or two world-specific, easily lost constraints with turnGuide at the world placeholder; do not copy the cross-world lines above.",
925
+ ].join("\n"),
926
+ },
927
+ ];
859
928
  const heading = locale === "zh-CN"
860
929
  ? `# ${revision ? "继续游玩" : "创建后游玩"}语义边界(只读参考)`
861
930
  : `# ${revision ? "Continued" : "Post-creation"} play semantics (read-only reference)`;
@@ -880,6 +949,11 @@ function settingImprovementPresetReference(binding, locale, target, gameplay) {
880
949
  : "Player-visible narrative semantics", locale === "zh-CN"
881
950
  ? "这些块只在未来游玩生成玩家可见正文时生效;不得把它们当作本轮 YAML 或 Markdown 设定正文的范文。"
882
951
  : "These blocks apply only when future play produces player-visible story. Do not use them as prose models for YAML or Markdown setting documents in this conversation.", narrativeBlocks),
952
+ settingImprovementReferenceGroup(locale === "zh-CN"
953
+ ? "本轮提示(每次发送的尾部)"
954
+ : "Turn guide (per-send suffix)", locale === "zh-CN"
955
+ ? "这些行在每次游玩发送时追加在玩家输入之后,由已启用块的尾部行按同一顺序拼成;它们是提醒,不是世界事实。"
956
+ : "These lines are appended after the player input on every play send, assembled from the enabled blocks' turn-guide lines in the same order. They are reminders, not world facts.", turnGuideBlocks),
883
957
  ].filter((group) => group !== null);
884
958
  return [
885
959
  heading,
@@ -958,6 +1032,21 @@ function promptPreview(input, playPreset, compilation, playCompilation) {
958
1032
  initialAppend: {
959
1033
  logical: { kind: "player", text: input.playerInput },
960
1034
  provider: { role: "user", content: input.playerInput },
1035
+ ...(compilation.turnGuide === undefined
1036
+ ? {}
1037
+ : {
1038
+ afterPlayer: {
1039
+ logical: {
1040
+ kind: "runtime_notice",
1041
+ notice: "turn_guide",
1042
+ text: compilation.turnGuide.text,
1043
+ },
1044
+ provider: {
1045
+ role: "user",
1046
+ content: compilation.turnGuide.text,
1047
+ },
1048
+ },
1049
+ }),
961
1050
  },
962
1051
  }
963
1052
  : {}),
@@ -1761,18 +1850,40 @@ function isPathPrefix(prefix, path) {
1761
1850
  return (prefix.length <= path.length &&
1762
1851
  prefix.every((segment, index) => segment === path[index]));
1763
1852
  }
1853
+ /**
1854
+ * A world instruction is `{ markdown }`, `{ markdown, turnGuide }` or a bare
1855
+ * `{ turnGuide }`: the block joins the prefix and the line joins the turn
1856
+ * guide at the world placeholder's position.
1857
+ */
1764
1858
  function readWorldInstructions(files, frame) {
1765
1859
  if (!Array.isArray(frame.instructions))
1766
1860
  throw new PromptCompilationError("world_frame_invalid", "world frame.instructions must be an array");
1767
- return frame.instructions.map((entry) => {
1768
- if (!isRecord(entry) || typeof entry.markdown !== "string")
1769
- throw new PromptCompilationError("world_frame_invalid", "A world instruction must reference a Markdown block");
1770
- const path = `control/${entry.markdown}`;
1771
- return {
1772
- source: `world:${path}`,
1773
- markdown: readMarkdown(files, path, "world prompt block"),
1774
- };
1861
+ const result = { blocks: [], turnGuide: [] };
1862
+ frame.instructions.forEach((entry, index) => {
1863
+ const valid = isRecord(entry) &&
1864
+ Object.keys(entry).every((key) => key === "markdown" || key === "turnGuide") &&
1865
+ (entry.markdown === undefined || typeof entry.markdown === "string") &&
1866
+ (entry.turnGuide === undefined ||
1867
+ (typeof entry.turnGuide === "string" &&
1868
+ !/[\r\n]/u.test(entry.turnGuide))) &&
1869
+ (entry.markdown !== undefined || entry.turnGuide !== undefined);
1870
+ if (!valid)
1871
+ throw new PromptCompilationError("world_frame_invalid", "A world instruction must reference a Markdown block, carry a turn-guide line, or both");
1872
+ if (typeof entry.markdown === "string") {
1873
+ const path = `control/${entry.markdown}`;
1874
+ result.blocks.push({
1875
+ source: `world:${path}`,
1876
+ markdown: readMarkdown(files, path, "world prompt block"),
1877
+ });
1878
+ }
1879
+ const line = typeof entry.turnGuide === "string" ? entry.turnGuide.trim() : "";
1880
+ if (line)
1881
+ result.turnGuide.push({
1882
+ source: `world:control/frame.yaml#instructions[${index}]`,
1883
+ text: line,
1884
+ });
1775
1885
  });
1886
+ return result;
1776
1887
  }
1777
1888
  function compileHostRoles(files, frame, worldInstructions, worldContext, coverage, locale) {
1778
1889
  if (!isRecord(frame.roles))
@@ -1,4 +1,3 @@
1
- import { historyUseGuidance } from "../../shared/history-use-guidance.js";
2
1
  import { defaultAppLocale, } from "../../protocol/appPreferences.js";
3
2
  import { resolutionCheckToolForScheme, resolutionToolDefinition, resolutionToolNames, } from "../play/TabletopResolution.js";
4
3
  /**
@@ -125,7 +124,7 @@ function createToolDefinitions(locale) {
125
124
  }, ["parent"]),
126
125
  },
127
126
  history_list: {
128
- description: descriptions.history_list + " " + historyUseGuidance(locale),
127
+ description: descriptions.history_list,
129
128
  inputSchema: object({
130
129
  before: string,
131
130
  after: string,
@@ -134,7 +133,7 @@ function createToolDefinitions(locale) {
134
133
  }, []),
135
134
  },
136
135
  history_search: {
137
- description: descriptions.history_search + " " + historyUseGuidance(locale),
136
+ description: descriptions.history_search,
138
137
  inputSchema: object({
139
138
  queries: {
140
139
  type: "array",
@@ -152,7 +151,7 @@ function createToolDefinitions(locale) {
152
151
  }, []),
153
152
  },
154
153
  history_read: {
155
- description: descriptions.history_read + " " + historyUseGuidance(locale),
154
+ description: descriptions.history_read,
156
155
  inputSchema: object({
157
156
  latest: { type: "integer", minimum: 1, maximum: 21 },
158
157
  ref: string,
@@ -272,7 +271,7 @@ function resolutionDefinitions(locale) {
272
271
  }
273
272
  const toolDescriptions = {
274
273
  en: {
275
- world_checkpoint: "Register a gap between events: the previous event has naturally settled at this turn's stopping point, or the scene has actually changed, suggesting that the player start a fresh context here. Arguments must be an empty object. Registration takes effect only after the subsequent nonempty tool-free final prose successfully commits, and includes that prose. A fresh context replays only committed player inputs and final narratives after this boundary, so save still-needed outcomes before registration. Consolidation and maintenance do not require this tool. Do not advance the story to register a checkpoint; registration neither switches context nor ends an in-world event. Further tool calls may follow registration; Runtime does not verify semantic completeness.",
274
+ world_checkpoint: "Register a gap between events and suggest that the player start a fresh context here. Arguments must be an empty object. The effect and replay scope are defined by the tools-and-settlement rules; when to register is defined by the checkpoint policy. Consolidation and maintenance do not require this tool; do not advance the story to register a checkpoint.",
276
275
  state_list: "List document and state directory handles known to Runtime. parent must be @dir-/ for the state root or an @dir-* handle from the initial directory index or an earlier state_list result; no preliminary listing is needed for an already supplied handle. Catalog directories declared by the frozen world frame remain listed and usable when empty. 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.",
277
276
  history_list: "Browse committed history on the current timeline. Omit anchors for the latest page; use only before or after with a Runtime ref (including refs in injected history) for the immediately adjacent page, excluding the anchor. Every page is oldest to newest. limit defaults to 20 (1–100 messages). Previews are only the first 120 Unicode code points. Use independent earlierCursor/laterCursor with {cursor} alone; no first-query parameters on continuation. Cursors bind world, tool, query and immutable ordered history. After history_changed rerun the query. Opening messages are queryable. Read full originals with history_read; positions are snapshot locations, not identities or turns.",
278
277
  history_search: "Search committed originals on the current timeline with queries: 1–5 nonempty literal strings (max 256 code points each); no query alias or semantic filters. match defaults to any, or all within one message. NFKC normalization always applies; caseSensitive defaults to false (locale-und case folding), normalized duplicate queries collapse. order defaults to newest_first, or oldest_first. limit defaults to 10 (1–50 matching messages). Optional after/before refs exclude timeline boundaries independently of order; after must precede before. Each result has at most 3 partial original fragments, query occurrence counts and complete-original length. Read a hit and nearby context with history_read. Continue with {cursor} alone; history_changed requires a new query.",
@@ -286,7 +285,7 @@ const toolDescriptions = {
286
285
  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.",
287
286
  },
288
287
  "zh-CN": {
289
- world_checkpoint: "登记事件间隙:上一事件已在本轮停笔位置自然告一段落,或场景已实际转换,建议玩家从这里开始全新上下文。参数为空对象。仅在随后非空且无工具调用的最终正文成功提交后生效,边界包含该正文;全新上下文只补入边界之后的已提交玩家原文与最终叙事,因此登记前应已保存后续仍需要的结果。整理与归并不需要调用本工具。不要为了登记而推进剧情;登记不切换上下文,也不结束世界内事件。登记后仍可继续工具调用;Runtime 不认证语义完整。",
288
+ world_checkpoint: "登记事件间隙,向玩家建议从这里开始全新上下文。参数为空对象。生效条件与补入范围见“工具与响应结算”;何时登记见检查点政策。整理与归并不需要调用本工具;不要为了登记而推进剧情。",
290
289
  state_list: "列出 Runtime 已知的文档与状态目录句柄。状态根目录的 parent 使用 @dir-/;继续下级时使用初始目录索引或 state_list 返回的 @dir-*;已有句柄无需再次列目录确认。冻结世界 frame 声明的 catalog 目录即使为空,也会继续列出并可用。cursor 只对同一状态快照、parent 和 limit 有效。返回的文档句柄交给 context_read;本工具不列出已提交历史。",
291
290
  history_list: "浏览当前时间线已提交历史。无锚点时取最新一页;before 或 after 只能选一个,取锚点紧邻前/后的一页且不含锚点。页内始终由旧到新。limit 默认20,整数1–100。预览仅含前120个Unicode码点;完整原文用history_read。earlierCursor/laterCursor独立翻页,续页只传{cursor},不可混用首次参数。游标绑定世界、工具、查询和有序历史快照;history_changed须重新查询。开场白可查询。提示词注入中的ref可直接使用,无需再次列举;位置不是身份或回合数。",
292
291
  history_search: "搜索当前时间线已提交原文。queries必填:1–5个非空字面词,每词最多256个Unicode码点,不接受query别名或语义筛选。match默认any,all限于同一消息。始终NFKC规范化,caseSensitive默认false(locale-und大小写折叠),规范化重复词合并。order默认newest_first,可选oldest_first;limit默认10,整数1–50条命中消息。after/before始终按时间线先后排除边界,after必须早于before,与结果排序无关。每项最多3个部分原文片段、逐词命中次数及完整原文长度;可用history_read读取前后文。续页只传{cursor};history_changed须重新查询。",
@@ -139,6 +139,17 @@ function validArtifactPayloadContract(value) {
139
139
  return (isRecord(value.properties) &&
140
140
  Object.values(value.properties).every(validArtifactPayloadContract));
141
141
  }
142
+ function validTurnGuide(value) {
143
+ return (isRecord(value) &&
144
+ hasExactKeys(value, ["text", "lines"]) &&
145
+ typeof value.text === "string" &&
146
+ Array.isArray(value.lines) &&
147
+ value.lines.length > 0 &&
148
+ value.lines.every((line) => isRecord(line) &&
149
+ hasExactKeys(line, ["source", "text"]) &&
150
+ typeof line.source === "string" &&
151
+ typeof line.text === "string"));
152
+ }
142
153
  export function validPromptCompilation(value) {
143
154
  return (isRecord(value) &&
144
155
  hasExactKeys(value, [
@@ -150,9 +161,10 @@ export function validPromptCompilation(value) {
150
161
  "coverage",
151
162
  "budget",
152
163
  "cache",
153
- ], ["maintenance"]) &&
164
+ ], ["maintenance", "turnGuide"]) &&
154
165
  (value.maintenance === undefined ||
155
166
  isWorldPromptMaintenance(value.maintenance)) &&
167
+ (value.turnGuide === undefined || validTurnGuide(value.turnGuide)) &&
156
168
  validLogicalMessages(value.logicalMessages) &&
157
169
  validPromptProvider(value.provider) &&
158
170
  validPromptTools(value.tools) &&
@@ -516,7 +516,8 @@ function validModelItemShape(value, storedToolChanges) {
516
516
  return (hasExactKeys(value, ["kind", "notice", "text"]) &&
517
517
  (value.notice === "checkpoint_rounds" ||
518
518
  value.notice === "continuation" ||
519
- value.notice === "tool_step") &&
519
+ value.notice === "tool_step" ||
520
+ value.notice === "turn_guide") &&
520
521
  typeof value.text === "string");
521
522
  if (value.kind === "prompt_delta")
522
523
  return (hasExactKeys(value, ["kind", "logicalMessages"]) &&
@@ -721,17 +722,21 @@ function validDiagnosticBinding(value) {
721
722
  (value.playPresetRevision === undefined ||
722
723
  typeof value.playPresetRevision === "string"));
723
724
  }
725
+ function validNoticeAppend(value) {
726
+ return (value === undefined ||
727
+ (isRecord(value) &&
728
+ hasExactKeys(value, ["logical", "provider"]) &&
729
+ validLegacyModelItem(value.logical) &&
730
+ isRecord(value.provider) &&
731
+ hasExactKeys(value.provider, ["role", "content"]) &&
732
+ value.provider.role === "user" &&
733
+ typeof value.provider.content === "string"));
734
+ }
724
735
  function validInitialAppend(value) {
725
736
  return (isRecord(value) &&
726
- hasExactKeys(value, ["logical", "provider"], ["beforePlayer"]) &&
727
- (value.beforePlayer === undefined ||
728
- (isRecord(value.beforePlayer) &&
729
- hasExactKeys(value.beforePlayer, ["logical", "provider"]) &&
730
- validLegacyModelItem(value.beforePlayer.logical) &&
731
- isRecord(value.beforePlayer.provider) &&
732
- hasExactKeys(value.beforePlayer.provider, ["role", "content"]) &&
733
- value.beforePlayer.provider.role === "user" &&
734
- typeof value.beforePlayer.provider.content === "string")) &&
737
+ hasExactKeys(value, ["logical", "provider"], ["beforePlayer", "afterPlayer"]) &&
738
+ validNoticeAppend(value.beforePlayer) &&
739
+ validNoticeAppend(value.afterPlayer) &&
735
740
  isRecord(value.logical) &&
736
741
  hasExactKeys(value.logical, ["kind", "text"]) &&
737
742
  value.logical.kind === "player" &&
@@ -103,7 +103,7 @@ export function settingImprovementRuntimeContract(locale, gameplay) {
103
103
 
104
104
  ## 游玩怎样读取并使用设定
105
105
 
106
- - control/frame.yaml 的 instructions 把世界专属提示块作为作者指令;context 只按声明顺序执行确定性材料选择。
106
+ - control/frame.yaml 的 instructions 把世界专属提示块作为作者指令;每项可带 turnGuide(单行),或只含 turnGuide:这些行成为每次游玩发送追加在玩家输入之后的本轮提示中的世界尾部行,只放本世界特有且最容易丢的约束,一到两行,不复制跨世界规则。context 只按声明顺序执行确定性材料选择。
107
107
  - 可在 frame.maintenance.documents 中用 @短引用声明正文建议 UTF-8 字节数;slot 可声明唯一稳定 id 和 advisoryBytes。超限只提示,不截断或拒绝写入。frame.maintenance.clock 可明确绑定 {document: "@ref", locator: {yaml: [时间]}};只记录该次写入时的标量或 Markdown 文本,不推断时钟。
108
108
  - current_situation、document 和 reference_targets 选择的整份正文会直接注入;node 只注入精确节点。catalog 只注入该目录直接子文档的 title、summary 与 @短引用,不注入正文;history 与 additional_materials 也只按精确声明注入。
109
109
  - frame 声明的 catalog 目录即使当前没有文档,也会作为空状态目录由 state_list 返回,并可作为 world_create 目标;目录声明不是另一份持久状态。
@@ -139,7 +139,7 @@ export function settingImprovementRuntimeContract(locale, gameplay) {
139
139
 
140
140
  ## How play reads and uses the setting
141
141
 
142
- - control/frame.yaml instructions insert world-specific prompt blocks as author instructions. Its context entries perform deterministic material selections in their declared order.
142
+ - control/frame.yaml instructions insert world-specific prompt blocks as author instructions. Each entry may carry a single-line turnGuide, or consist of turnGuide alone: these lines become the world's tail lines in the turn guide appended after the player input on every play send. Keep them to one or two world-specific, easily lost constraints; never copy cross-world rules. Its context entries perform deterministic material selections in their declared order.
143
143
  - Optional frame.maintenance.documents maps @short-refs to advisory body UTF-8 byte counts; slots can declare a unique stable id and advisoryBytes. Excess size only produces advice. Optional frame.maintenance.clock binds {document: "@ref", locator: {yaml: [time]}} to record scalar or Markdown world time at actual writes, without inferring a clock.
144
144
  - current_situation, document, and reference_targets selections inject whole bodies; node injects only one exact node. A catalog injects only title, summary, and @short-ref for direct child documents, never their bodies. History and additional materials are likewise exact selections.
145
145
  - A catalog directory declared by the frame remains available through state_list as an empty state directory and as a world_create destination even when it currently contains no documents. The declaration is not a second piece of persistent state.
@@ -1,69 +1,25 @@
1
+ import { defaultPresetHostFilesForLocale } from "./default-preset-host.js";
1
2
  /**
2
- * Canonical author-owned narrative prompt shared by the default and
3
- * first-party presets. Portable presets still receive ordinary editable file
4
- * contents; this module only prevents the shipped starting points from
5
- * drifting apart. The general adjudication and state-maintenance criteria live
6
- * in independently editable host recommendation blocks.
3
+ * The legacy v1 narrative prompt (`prompts/narrate.md`). Ordered v2 presets
4
+ * compile the individual blocks directly; v1 presets, which list only host
5
+ * frame blocks, receive the blocks the frame cannot express (the default
6
+ * person, continuation, stopping, ending and output choices) concatenated into
7
+ * one author prompt so the shipped starting points never drift apart.
7
8
  */
8
- export const defaultNarrationPromptZhCN = `# 玩家可见叙事规则
9
-
10
- 世界内游玩输出互动式小说正文,让玩家读到这次经历。以已加载材料、已经裁决的结果和成功提交的变化为依据;表达方式可以丰富,不能改变谁在哪、拿着什么、是否受伤或承诺是否成立。尝试不自动成为成功,角色声称不自动成为事实。
11
-
12
- ## 承接与表现
13
-
14
- 有新玩家输入时,把其中明确行动或台词自然接入场景,补足不改变选择的表达细节,再写相应反应。已经演过的部分不用重述,不以“你说要……”机械复读。原生对话末尾已有主持正文、其后没有新玩家输入时,本次是空输入续写:从那段正文的最后局面继续,只推进既有授权动作、NPC 和环境。不重新回答已经回应的问题,不复位已完成的递接或走动,不把旧玩家输入再演一次,也不替玩家增加决定。
15
-
16
- 本轮新发生且玩家能感知的结果,要成为具体可读的事:物品怎样递接、人物如何离开、态度变化怎样在话语或行动中显露。文档中补录旧结果、去重和整理不要求再次演出。界面数值不能代替事件表现,叙事也不能与当前值冲突。未向玩家表露的判断和场外事实无需用旁白揭露。
17
-
18
- 具体表演细节随最终叙事保存,可按需追溯;不要假定每次请求自动带着全部旧段落。必须持续保存的结果按状态维护政策处理,不能只留在检查点覆盖的最终原文中。
19
-
20
- ## 停在哪里
21
-
22
- 先按本次玩家授权和互动需要确定停笔位置。持续活动中,新反馈会影响玩家如何接话、调整或继续时,呈现反馈并留下回应机会,即使不涉及重大决定。开始按腿、教学或交谈不默认授权包办全过程;普通走路、敲门、递送的连带动作可以自然完成,明确要求略过过程或做到某个终点时则在授权内推进。
23
-
24
- 当前行动与反应形成有内容的局面便可以停笔;具体动作、原话或环境变化都可以承接下一段,有意义的沉默、独处、休息和等待也可以自然结束。回复结束与截至此刻整理完成都不要求世界内事件结束,检查点适应停笔位置。不要为了结尾形式让 NPC 硬添动作、邀请或承诺。
25
-
26
- 避免“轮到你了”“你打算怎么做”及把同一句套话改成“众人等你开口”的机械交接,也不要在结尾列选项或重复总结局势。真正发生的注视或等待可以描写,不必强行消除。
27
-
28
- ## 输出边界
29
-
30
- 世界内叙事只写小说正文,可以分多个自然段,不加标题、清单、裁决报告、工具日志或幕后说明。篇幅由场面的分量决定,停笔位置限制推进范围,不要求把已经授权的场面写得潦草。失败、NPC 自主行动和没有文档变化都可以形成完整叙事。
31
-
32
- 本次只有世界外查询或维护时,按请求简洁回答或说明整理结果,不为满足小说形式添加动作、对话或时间推进。混合请求中的维护要求不成为角色台词或行动。
33
-
34
- 玩家的决定与回应仍归玩家;表现自然感官与动作细节不能替他表达立场、感情、同意或承诺。已经提交的行动不能用一句普通改口当作从未发生。
35
- `;
36
- export const defaultNarrationPromptEn = `# Player-visible narrative rules
37
-
38
- For in-world play, write the body of an interactive novel so the player experiences this passage. Ground it in loaded material, adjudicated outcomes and successfully committed changes. Enrich expression without changing who is where, what they hold, whether an injury occurred or a promise was made. Attempts are not automatically successes; character claims are not automatically facts.
39
-
40
- ## Continuation and dramatization
41
-
42
- When there is new player input, naturally stage its explicit action or dialogue with details that preserve the choice, then its reactions. Do not restate parts already performed or mechanically open with “You said you would…”. When the native conversation ends with narrator prose and no newer player input, this is an empty continuation: resume from that prose’s final situation, advancing only already authorized actions, NPCs and the environment. Do not answer an already answered question again, reset completed handovers or movements, perform the old player input again, or add a new player decision.
43
-
44
- Make new perceivable outcomes this turn concrete: an object handed over, a person leaving, an attitude expressed through words or action. Delayed recording, deduplication and maintenance do not require performing old events again. Interface values do not replace dramatization, and prose must agree with current values. Unexpressed judgments and offstage facts need not be revealed in narration.
45
-
46
- Specific performance details are saved with final narrative and can be retrieved when needed; do not assume every request automatically includes every earlier passage. Handle required durable results under the state-maintenance policy instead of leaving them only in the final prose covered by a checkpoint.
47
-
48
- ## Where to stop
49
-
50
- First choose where to stop from the player's authorization and the interaction's needs. During an ongoing activity, when new feedback could affect how the player replies, adjusts or continues, present it and leave room to respond even without a consequential decision. Starting a leg massage, lesson or conversation does not by itself authorize the entire activity; ordinary supporting actions in walking, knocking or delivering can complete naturally. Explicit requests to skip the process or reach an endpoint allow advancement within that authorization.
51
-
52
- You may stop once action and reactions form a meaningful situation. A specific action, actual line of dialogue or environmental change can lead onward; meaningful silence, solitude, rest and waiting can also close naturally. Ending a reply or consolidating records through this moment does not require ending the in-world event; fit checkpoint maintenance to the stopping point. Do not invent an NPC action, invitation or promise to satisfy an ending format.
53
-
54
- Avoid mechanical handoffs such as “Your turn”, “What do you do?” or their stock substitute “Everyone waits for you to speak”. Do not end by listing options or repeating a situation summary. Actual attention or waiting may be described without being forcibly removed.
55
-
56
- ## Output boundaries
57
-
58
- In-world narrative uses only the novel's prose, in several natural paragraphs if useful, without headings, lists, adjudication reports, tool logs or backstage notes. Let the weight of the moment determine length. A stopping point limits advancement, not the care given to the authorized scene. Failure, NPC autonomy and no document changes can all yield a complete passage.
59
-
60
- If this request is solely out-of-world inquiry or maintenance, answer or explain the consolidation briefly as requested without adding actions, dialogue or time passage to satisfy novel form. Maintenance instructions within a mixed request do not become character speech or actions.
61
-
62
- Decisions and responses belong to the player. Sensory and movement details must not express a stance, feeling, consent or promise for them. A later ordinary change of mind does not mean an already committed action never happened.
63
- `;
64
- export const defaultNarrationPrompt = defaultNarrationPromptEn;
9
+ const legacyNarrationBlocks = [
10
+ "person-second",
11
+ "continue-stage",
12
+ "stop-beat",
13
+ "ending",
14
+ "output",
15
+ ];
65
16
  export function defaultNarrationPromptForLocale(locale) {
66
- return locale === "zh-CN"
67
- ? defaultNarrationPromptZhCN
68
- : defaultNarrationPromptEn;
17
+ const host = defaultPresetHostFilesForLocale(locale);
18
+ return legacyNarrationBlocks
19
+ .map((block) => host[`blocks/${block}.md`].trimEnd())
20
+ .join("\n\n")
21
+ .concat("\n");
69
22
  }
23
+ export const defaultNarrationPromptZhCN = defaultNarrationPromptForLocale("zh-CN");
24
+ export const defaultNarrationPromptEn = defaultNarrationPromptForLocale("en");
25
+ export const defaultNarrationPrompt = defaultNarrationPromptEn;