narraeon 0.5.4 → 0.5.6

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.
@@ -116,6 +116,7 @@ export class V1Runtime {
116
116
  controlFingerprint: fingerprintControl(Object.fromEntries(snapshot.files.map(({ path, contents }) => [path, contents]))),
117
117
  documentSnapshot: snapshot,
118
118
  history: structuredClone(binding.history),
119
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
119
120
  narrativeCheckpoint: binding.narrativeCheckpoint,
120
121
  ...maintenance,
121
122
  additionalMaterials: structuredClone(binding.additionalMaterials),
@@ -653,6 +654,7 @@ export class V1Runtime {
653
654
  }),
654
655
  additionalMaterials: structuredClone(binding.additionalMaterials),
655
656
  history: structuredClone(binding.history),
657
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
656
658
  narrativeCheckpoint: baseline.narrativeCheckpoint,
657
659
  historyAlreadyAppended: Object.keys(binding.history).filter((key) => !(key in baseline.history)),
658
660
  ...(await this.#worlds.inspectDocumentMaintenance(request.worldId, binding.parentHead)),
@@ -0,0 +1,84 @@
1
+ import { parseDocument } from "yaml";
2
+ import { renderWorldYamlSource } from "../world/WorldYamlRendering.js";
3
+ export function captureNarrativeOrigin(snapshot, firstEventId) {
4
+ const source = snapshot.files.find(({ path }) => path === "control/frame.yaml")?.contents;
5
+ if (source === undefined)
6
+ throw new Error("Current situation frame is missing");
7
+ const parsed = parseDocument(source, { schema: "core", uniqueKeys: true });
8
+ if (parsed.errors.length > 0)
9
+ throw new Error("Current situation frame is invalid");
10
+ const frame = parsed.toJS({ maxAliasCount: 0 });
11
+ const binding = record(frame) && record(frame.bindings)
12
+ ? frame.bindings.currentSituation
13
+ : undefined;
14
+ if (typeof binding !== "string" || binding.length === 0)
15
+ throw new Error("Current situation binding is invalid");
16
+ const read = snapshot.query({
17
+ kind: "read_document",
18
+ document: binding.startsWith("@")
19
+ ? { shortRef: binding.slice(1) }
20
+ : { documentId: binding },
21
+ });
22
+ if (read.kind !== "read_document")
23
+ throw new Error("Bound current situation cannot be read");
24
+ const documentId = read.document.documentId;
25
+ return {
26
+ firstEventId,
27
+ documentId,
28
+ value: readBackground(snapshot, documentId),
29
+ };
30
+ }
31
+ export function readBackground(snapshot, documentId) {
32
+ const selected = snapshot.query({
33
+ kind: "select_node",
34
+ document: { documentId },
35
+ locator: { yaml: ["背景"] },
36
+ });
37
+ if (selected.kind !== "select_node" || selected.node.codec !== "yaml")
38
+ return null;
39
+ const value = selected.node.value;
40
+ if (value === null ||
41
+ (typeof value === "string" && value.trim() === "") ||
42
+ (typeof value === "object" && Object.keys(value).length === 0))
43
+ return null;
44
+ // Quoted multiline scalars retain trailing newlines when display framing trims.
45
+ return renderWorldYamlSource(value, { blockQuote: false });
46
+ }
47
+ export function snapshotBackground(value) {
48
+ return value === null ? undefined : { kind: "snapshot", value };
49
+ }
50
+ export function narrativeBackground(origin, snapshot) {
51
+ // A released frozen request without an origin stays unrecorded on recovery.
52
+ if (origin === undefined)
53
+ return undefined;
54
+ const after = readBackground(snapshot, origin.documentId);
55
+ return origin.value === null && after === null
56
+ ? undefined
57
+ : { kind: "narrative", before: origin.value, after };
58
+ }
59
+ export function isHistoryBackground(value) {
60
+ if (!record(value))
61
+ return false;
62
+ if (value.kind === "snapshot")
63
+ return Object.keys(value).length === 2 && nonempty(value.value);
64
+ return (value.kind === "narrative" &&
65
+ Object.keys(value).length === 3 &&
66
+ (value.before === null || nonempty(value.before)) &&
67
+ (value.after === null || nonempty(value.after)) &&
68
+ (value.before !== null || value.after !== null));
69
+ }
70
+ export function isNarrativeOrigin(value) {
71
+ return (record(value) &&
72
+ Object.keys(value).length === 3 &&
73
+ Number.isSafeInteger(value.firstEventId) &&
74
+ Number(value.firstEventId) > 0 &&
75
+ typeof value.documentId === "string" &&
76
+ value.documentId.length > 0 &&
77
+ (value.value === null || nonempty(value.value)));
78
+ }
79
+ function nonempty(value) {
80
+ return typeof value === "string" && value.trim().length > 0;
81
+ }
82
+ function record(value) {
83
+ return value !== null && typeof value === "object" && !Array.isArray(value);
84
+ }
@@ -12,6 +12,9 @@ export class HistoryQuery {
12
12
  const refs = inputs.map(({ id }) => historyMessageRef(id));
13
13
  this.messages = inputs.map((message, index) => Object.freeze({
14
14
  ...message,
15
+ ...(message.background === undefined
16
+ ? {}
17
+ : { background: Object.freeze(structuredClone(message.background)) }),
15
18
  ref: refs[index],
16
19
  position: index + 1,
17
20
  isOpening: message.isOpening === true,
@@ -20,11 +23,12 @@ export class HistoryQuery {
20
23
  length: Array.from(message.text).length,
21
24
  }));
22
25
  this.snapshotId = createHash("sha256")
23
- .update(JSON.stringify(inputs.map(({ id, role, isOpening, text }) => [
26
+ .update(JSON.stringify(inputs.map(({ id, role, isOpening, text, background }) => [
24
27
  id,
25
28
  role,
26
29
  isOpening === true,
27
30
  text,
31
+ background ?? null,
28
32
  ])))
29
33
  .digest("hex");
30
34
  }
@@ -380,11 +384,12 @@ export function historyMessageRef(id) {
380
384
  }
381
385
  /** Decode storage identity once at the boundary; never infer order from it. */
382
386
  export function historyInputs(entries) {
383
- return entries.map(({ path, contents }) => {
387
+ return entries.map(({ path, contents, background }) => {
384
388
  const id = path.replace(/\.md$/u, "");
385
389
  return {
386
390
  id,
387
391
  text: contents,
392
+ ...(background === undefined ? {} : { background }),
388
393
  role: /(?:\.|-)player(?:-|$)/u.test(id) ? "player" : "narrator",
389
394
  isOpening: id.endsWith("message.genesis.narrator"),
390
395
  };
@@ -1,3 +1,5 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { parseDocument } from "yaml";
1
3
  /** Shared metadata keeps injected refs and tool results directly navigable. */
2
4
  export function historyMessageLabel(message, locale) {
3
5
  const zh = locale === "zh-CN";
@@ -12,7 +14,7 @@ export function historyRoleLabel(message, locale) {
12
14
  return locale === "zh-CN" ? "主持叙事" : "Host narrative";
13
15
  }
14
16
  export function renderHistoryMessage(message, locale) {
15
- return `## ${historyMessageLabel(message, locale)}\n\n${locale === "zh-CN" ? "[原文开始]" : "[Original text begins]"}\n${message.text}\n${locale === "zh-CN" ? "[原文结束;正文完整]" : "[Original text ends; body complete]"}`;
17
+ return `## ${historyMessageLabel(message, locale)}\n${renderHistoryBackground(message.background, locale)}\n${locale === "zh-CN" ? "[原文开始]" : "[Original text begins]"}\n${message.text}\n${locale === "zh-CN" ? "[原文结束;正文完整]" : "[Original text ends; body complete]"}`;
16
18
  }
17
19
  export function renderHistoryInjectionNotice(total, selected, kind, locale) {
18
20
  const zh = locale === "zh-CN";
@@ -51,7 +53,7 @@ export function renderHistoryResult(result, locale = "en", legacyRead = false) {
51
53
  if (result.kind === "legacy_list")
52
54
  return {
53
55
  ok: true,
54
- markdown: `# Directory listing\n\n${header}\nOrder: ${result.order}\n${result.messages.map((message) => `- ${message.ref}, position ${message.position}, role=${message.role}${message.isOpening ? ", isOpening=true" : ""}, ${message.length} Unicode code points`).join("\n") || "(empty)"}\n\n---\nThis page: ${result.offset}..${result.offset + result.messages.length} / ${result.total} items\nComplete: ${result.nextCursor === null ? "yes" : "no"}${result.nextCursor === null ? "" : `\nNext-page cursor: ${result.nextCursor}`}`,
56
+ markdown: `# Directory listing\n\n${header}\nOrder: ${result.order}\n${result.messages.map((message) => `- ${message.ref}, position ${message.position}, role=${message.role}${message.isOpening ? ", isOpening=true" : ""}, ${message.length} Unicode code points${message.background === undefined ? "" : `\n${renderHistoryBackground(message.background, locale)}`}`).join("\n") || "(empty)"}\n\n---\nThis page: ${result.offset}..${result.offset + result.messages.length} / ${result.total} items\nComplete: ${result.nextCursor === null ? "yes" : "no"}${result.nextCursor === null ? "" : `\nNext-page cursor: ${result.nextCursor}`}`,
55
57
  };
56
58
  if (result.kind === "list")
57
59
  return {
@@ -59,7 +61,7 @@ export function renderHistoryResult(result, locale = "en", legacyRead = false) {
59
61
  markdown: `# ${zh ? "已提交历史列表" : "Committed history listing"}\n\n${header}\nOrder: oldest_first (${zh ? "页内始终由旧到新" : "every page oldest to newest"})\n${result.messages
60
62
  .map((message) => {
61
63
  const preview = Array.from(message.text).slice(0, 120).join("");
62
- return `### ${historyMessageLabel(message, locale)}\n${message.length} Unicode code points\n[${zh ? "部分原文预览" : "Partial original preview"}]\n${preview}\n[${message.length > 120 ? (zh ? "预览有省略" : "preview omitted remaining text") : zh ? "预览结束" : "preview ends"}]`;
64
+ return `### ${historyMessageLabel(message, locale)}\n${message.length} Unicode code points\n${renderHistoryBackground(message.background, locale)}[${zh ? "部分原文预览" : "Partial original preview"}]\n${preview}\n[${message.length > 120 ? (zh ? "预览有省略" : "preview omitted remaining text") : zh ? "预览结束" : "preview ends"}]`;
63
65
  })
64
66
  .join("\n\n") || (zh ? "(空页)" : "(empty page)")}\n\n---\n${zh ? "本页位置/条数" : "Page positions / count"}: ${range(result.messages)} / ${result.messages.length}\n${cursorLine("earlierCursor", result.earlierCursor)}\n${cursorLine("laterCursor", result.laterCursor)}\n${zh ? "续页只传 {cursor}。" : "Continue with only {cursor}."}${result.messages.length === 0 && result.anchor !== null ? `\n${zh ? "可从锚点转向浏览" : "Browse from the anchor in the other direction"}: {before:"${result.anchor}"} / {after:"${result.anchor}"}` : ""}`,
65
67
  };
@@ -70,7 +72,7 @@ export function renderHistoryResult(result, locale = "en", legacyRead = false) {
70
72
  };
71
73
  return {
72
74
  ok: true,
73
- markdown: `# ${zh ? "历史字面搜索" : "History literal search"}\n\n${header}${result.within === null ? "" : `\nLegacy selected scope: ${result.within}`}\n${zh ? "搜索位置范围(排除边界)" : "Search position range (boundaries excluded)"}: ${result.range.start === null ? "empty" : `${result.range.start}..${result.range.end}`}\nOrder: ${result.order}\nMatch: ${result.match} (${zh ? "同一消息内" : "within one message"})\nNormalization: NFKC${result.caseSensitive ? " (case sensitive)" : " + case folding (locale und)"}\n${zh ? "查询词" : "Queries"}: ${JSON.stringify(result.queries)}\n${zh ? "命中消息总数" : "Total matching messages"}: ${result.matches}\n\n${result.messages.map(({ message, hits, fragments, omittedMatches }) => `### ${historyMessageLabel(message, locale)}\n${message.length} Unicode code points\n${zh ? "逐词命中次数" : "Matches per query"}: ${hits.map(({ query, count }) => `${JSON.stringify(query)}: ${count}`).join("; ")}\n${fragments.map((fragment) => `[${zh ? "部分原文片段" : "Partial original fragment"}: ${fragment.start}..${fragment.end}${fragment.omittedBefore ? "; prefix omitted" : ""}${fragment.omittedAfter ? "; suffix omitted" : ""}]\n${fragment.text}\n[${zh ? "片段结束" : "fragment ends"}]`).join("\n")}\n${omittedMatches ? (zh ? "另有命中未展示;可完整读取核实。" : "Additional matches omitted; read the complete original to verify.") : zh ? "全部命中均在所示片段内。" : "All matches are covered by these fragments."}`).join("\n\n") || (result.legacy ? (zh ? "零字面命中不证明事件没有发生;可改用其他 query 或读取相邻原文。" : "Zero literal matches do not prove an event never happened. Try another query or read adjacent originals.") : zh ? "零字面命中不证明事件没有发生;可改词、从 all 放宽为 any 或读取相邻上下文。" : "Zero literal matches do not prove an event never happened. Try other words, relax all to any, or read nearby context.")}\n\n---\n${cursorLine(result.legacy ? "Next-page cursor" : "nextCursor", result.nextCursor)}\n${result.legacy ? (zh ? "续页保留原source=history、query、caseSensitive、within与limit并使用返回cursor。" : "Legacy continuation keeps the original source=history, query, caseSensitive, within and limit with the returned cursor.") : zh ? "续页只传 {cursor};已有原文足以判断时停止检索。all 仅限同一消息;相邻消息可改用 any 后读前后文。" : "Continue with only {cursor}; stop when the originals suffice. all applies to one message; use any then read context for terms in adjacent messages."}`,
75
+ markdown: `# ${zh ? "历史字面搜索" : "History literal search"}\n\n${header}${result.within === null ? "" : `\nLegacy selected scope: ${result.within}`}\n${zh ? "搜索位置范围(排除边界)" : "Search position range (boundaries excluded)"}: ${result.range.start === null ? "empty" : `${result.range.start}..${result.range.end}`}\nOrder: ${result.order}\nMatch: ${result.match} (${zh ? "同一消息内" : "within one message"})\nNormalization: NFKC${result.caseSensitive ? " (case sensitive)" : " + case folding (locale und)"}\n${zh ? "查询词" : "Queries"}: ${JSON.stringify(result.queries)}\n${zh ? "命中消息总数" : "Total matching messages"}: ${result.matches}\n\n${result.messages.map(({ message, hits, fragments, omittedMatches }) => `### ${historyMessageLabel(message, locale)}\n${message.length} Unicode code points\n${renderHistoryBackground(message.background, locale)}${zh ? "逐词命中次数" : "Matches per query"}: ${hits.map(({ query, count }) => `${JSON.stringify(query)}: ${count}`).join("; ")}\n${fragments.map((fragment) => `[${zh ? "部分原文片段" : "Partial original fragment"}: ${fragment.start}..${fragment.end}${fragment.omittedBefore ? "; prefix omitted" : ""}${fragment.omittedAfter ? "; suffix omitted" : ""}]\n${fragment.text}\n[${zh ? "片段结束" : "fragment ends"}]`).join("\n")}\n${omittedMatches ? (zh ? "另有命中未展示;可完整读取核实。" : "Additional matches omitted; read the complete original to verify.") : zh ? "全部命中均在所示片段内。" : "All matches are covered by these fragments."}`).join("\n\n") || (result.legacy ? (zh ? "零字面命中不证明事件没有发生;可改用其他 query 或读取相邻原文。" : "Zero literal matches do not prove an event never happened. Try another query or read adjacent originals.") : zh ? "零字面命中不证明事件没有发生;可改词、从 all 放宽为 any 或读取相邻上下文。" : "Zero literal matches do not prove an event never happened. Try other words, relax all to any, or read nearby context.")}\n\n---\n${cursorLine(result.legacy ? "Next-page cursor" : "nextCursor", result.nextCursor)}\n${result.legacy ? (zh ? "续页保留原source=history、query、caseSensitive、within与limit并使用返回cursor。" : "Legacy continuation keeps the original source=history, query, caseSensitive, within and limit with the returned cursor.") : zh ? "续页只传 {cursor};已有原文足以判断时停止检索。all 仅限同一消息;相邻消息可改用 any 后读前后文。" : "Continue with only {cursor}; stop when the originals suffice. all applies to one message; use any then read context for terms in adjacent messages."}`,
74
76
  };
75
77
  }
76
78
  function localizedRecovery(result, zh) {
@@ -84,3 +86,28 @@ function localizedRecovery(result, zh) {
84
86
  return "该 ref 不在当前时间线中,可能仍保存在旧端点;请重新查询当前历史。";
85
87
  return `参数不符合要求,请按工具定义修正。${result.recovery}`;
86
88
  }
89
+ /** Every result carries its own annotation; no implicit neighboring inheritance. */
90
+ export function renderHistoryBackground(background, locale) {
91
+ if (background === undefined)
92
+ return "";
93
+ const zh = locale === "zh-CN";
94
+ const node = (value) => value === null
95
+ ? zh
96
+ ? "未记录"
97
+ : "not recorded"
98
+ : `\n[${zh ? "背景值开始" : "Background value begins"}]\n${value}\n[${zh ? "背景值结束" : "Background value ends"}]`;
99
+ const notice = zh
100
+ ? "历史背景仅说明采集位置的状态,不是当前世界状态;回忆、梦境或段内局部时间结合原文解释,不认证事实或授予角色知识。"
101
+ : "Historical background describes the captured state, not current world state; interpret memories, dreams and local times with the original text. It does not certify facts or grant character knowledge.";
102
+ if (background.kind === "snapshot" ||
103
+ equalBackground(background.before, background.after))
104
+ return `\n${zh ? "历史背景" : "Historical background"}:${node(background.kind === "snapshot" ? background.value : background.before)}\n${notice}\n`;
105
+ return `\n${zh ? "推进前背景" : "Background before advancement"}:${node(background.before)}\n${zh ? "推进后背景" : "Background after advancement"}:${node(background.after)}\n${notice}\n`;
106
+ }
107
+ function equalBackground(before, after) {
108
+ if (before === after)
109
+ return true;
110
+ if (before === null || after === null)
111
+ return false;
112
+ return isDeepStrictEqual(parseDocument(before).toJS({ maxAliasCount: 0 }), parseDocument(after).toJS({ maxAliasCount: 0 }));
113
+ }
@@ -83,6 +83,7 @@ export class FileNativeContinuityCorrection {
83
83
  materials: structuredClone(binding.additionalMaterials),
84
84
  originalMaterials: structuredClone(binding.additionalMaterials),
85
85
  history: structuredClone(binding.history),
86
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
86
87
  reads: new Map(),
87
88
  previewedVersion: null,
88
89
  stateOperationClaim: claimed.handle,
@@ -236,6 +237,7 @@ export class FileNativeContinuityCorrection {
236
237
  documentSnapshot: candidate.snapshot,
237
238
  additionalMaterials: materials,
238
239
  history: candidate.history,
240
+ historyBackgrounds: candidate.historyBackgrounds,
239
241
  documentMaintenance: candidate.documentMaintenance,
240
242
  documentMaintenanceUnavailableReason: candidate.documentMaintenanceUnavailableReason,
241
243
  narrativeCheckpoint: candidate.narrativeCheckpoint,
@@ -1,3 +1,4 @@
1
+ import { isNarrativeOrigin, isHistoryBackground, } from "../history/HistoryBackground.js";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
3
  import { link, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises";
3
4
  import { dirname, join, resolve } from "node:path";
@@ -119,6 +120,8 @@ function assertBase(value) {
119
120
  value.schemaVersion !== 1 ||
120
121
  value.kind !== "play_advance" ||
121
122
  (value.advanceKind !== "player" && value.advanceKind !== "response") ||
123
+ (value.narrativeOrigin !== undefined &&
124
+ !isNarrativeOrigin(value.narrativeOrigin)) ||
122
125
  typeof value.worldId !== "string" ||
123
126
  typeof value.chainId !== "string" ||
124
127
  typeof value.advanceId !== "string" ||
@@ -170,6 +173,8 @@ function assertSettlementPrepared(value) {
170
173
  (value.schemaVersion !== 1 ||
171
174
  value.stage !== "settlement_prepared" ||
172
175
  !isRecord(value.settlement) ||
176
+ (value.settlement.background !== undefined &&
177
+ !isHistoryBackground(value.settlement.background)) ||
173
178
  typeof value.recordedAt !== "number"))
174
179
  throw new Error("Settlement-prepared play fact has an invalid shape");
175
180
  }
@@ -1,3 +1,4 @@
1
+ import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
1
2
  import { encodePlayPromptRun, decodePlayPromptRun, } from "./PlayPromptRun.js";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { link, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises";
@@ -162,6 +163,9 @@ export class FileNativePlayTimelineStore {
162
163
  chainId: value.chainId,
163
164
  nextMaterials: structuredClone(value.nextMaterials),
164
165
  lastRequestDigest,
166
+ ...(value.narrativeOrigin === undefined
167
+ ? {}
168
+ : { narrativeOrigin: structuredClone(value.narrativeOrigin) }),
165
169
  };
166
170
  const continuationDigest = createHash("sha256")
167
171
  .update(JSON.stringify(continuation))
@@ -313,6 +317,11 @@ export class FileNativePlayTimelineStore {
313
317
  promptRuns,
314
318
  changedDocuments: structuredClone(stateValue.changedDocuments),
315
319
  nextMaterials: structuredClone(continuationValue.nextMaterials),
320
+ ...(continuationValue.narrativeOrigin === undefined
321
+ ? {}
322
+ : {
323
+ narrativeOrigin: structuredClone(continuationValue.narrativeOrigin),
324
+ }),
316
325
  nextEventId: stateValue.nextEventId,
317
326
  exchange: stateValue.exchange,
318
327
  lastRequest,
@@ -767,6 +776,8 @@ function assertContextContinuation(value) {
767
776
  if (!isRecord(value) ||
768
777
  value.schemaVersion !== 3 ||
769
778
  value.kind !== "play_context_continuation" ||
779
+ (value.narrativeOrigin !== undefined &&
780
+ !isNarrativeOrigin(value.narrativeOrigin)) ||
770
781
  typeof value.chainId !== "string" ||
771
782
  !Array.isArray(value.nextMaterials) ||
772
783
  (value.lastRequestDigest !== null &&
@@ -1,3 +1,5 @@
1
+ import { captureNarrativeOrigin, snapshotBackground, narrativeBackground, } from "../history/HistoryBackground.js";
2
+ import { isDeepStrictEqual } from "node:util";
1
3
  import { hasLegacyHistoryTools } from "../prompt/FileNativeToolRegistry.js";
2
4
  import { PlayCallChainError } from "./PlayCallChainError.js";
3
5
  import { preparePlayerRevisionTrace, prepareForkTrace, restorePlayDocuments, independentContextCopy, } from "./PlayTraceRetention.js";
@@ -88,6 +90,7 @@ export class PlayCallChain {
88
90
  documentSnapshot: documents.snapshot,
89
91
  additionalMaterials: structuredClone(binding.additionalMaterials),
90
92
  history: structuredClone(binding.history),
93
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
91
94
  narrativeCheckpoint: binding.narrativeCheckpoint,
92
95
  documentMaintenance,
93
96
  documentMaintenanceUnavailableReason: documents.maintenanceWarning,
@@ -144,7 +147,7 @@ export class PlayCallChain {
144
147
  lastFailure: null,
145
148
  updatedAt: now,
146
149
  documents,
147
- history: historyEntries(binding.history),
150
+ history: historyEntries(binding.history, binding.historyBackgrounds),
148
151
  narrativeCheckpoint: binding.narrativeCheckpoint,
149
152
  completedToolMap: new Map(),
150
153
  persistenceCursor: {
@@ -182,8 +185,10 @@ export class PlayCallChain {
182
185
  const last = session.transcript.at(-1);
183
186
  if (last?.kind === "assistant" &&
184
187
  last.toolCalls.length === 0 &&
185
- last.text.trim().length > 0)
188
+ last.text.trim().length > 0) {
189
+ session.narrativeOrigin = await this.#captureOrigin(session);
186
190
  session.transcript.push(continuationNotice(this.#compiler.locale));
191
+ }
187
192
  return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, signal, input.observer);
188
193
  }
189
194
  if (duplicatePlayerExchange(session, input.exchangeId, input.playerText, "append"))
@@ -213,6 +218,7 @@ export class PlayCallChain {
213
218
  documentSnapshot: session.documents.snapshot,
214
219
  additionalMaterials: structuredClone(binding.additionalMaterials),
215
220
  history: structuredClone(binding.history),
221
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
216
222
  narrativeCheckpoint: binding.narrativeCheckpoint,
217
223
  },
218
224
  playerInputPlacement: "append",
@@ -221,7 +227,7 @@ export class PlayCallChain {
221
227
  }, playPreset);
222
228
  // The native conversation already contains successful writes and exact reads.
223
229
  // Rebinding initial coverage here would incorrectly reauthorize stale scopes.
224
- session.history = historyEntries(binding.history);
230
+ session.history = historyEntries(binding.history, binding.historyBackgrounds);
225
231
  session.narrativeCheckpoint = binding.narrativeCheckpoint;
226
232
  session.nextMaterials = structuredClone(binding.additionalMaterials);
227
233
  (session.promptRuns ??= []).push({
@@ -719,6 +725,7 @@ export class PlayCallChain {
719
725
  documentSnapshot: documents.snapshot,
720
726
  additionalMaterials: structuredClone(binding.additionalMaterials),
721
727
  history: structuredClone(binding.history),
728
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
722
729
  narrativeCheckpoint: binding.narrativeCheckpoint,
723
730
  documentMaintenance,
724
731
  documentMaintenanceUnavailableReason: documents.maintenanceWarning,
@@ -788,6 +795,10 @@ export class PlayCallChain {
788
795
  canRetry: false,
789
796
  bootstrap: structuredClone(fresh.bootstrap),
790
797
  tools: structuredClone(fresh.tools),
798
+ narrativeOrigin: captureNarrativeOrigin(WorldDocumentStore.open({
799
+ layout: "world_state",
800
+ files: Object.entries(input.binding.files).map(([path, contents]) => ({ path, contents })),
801
+ }), 1),
791
802
  transcript: playerInputAppend({
792
803
  history: fresh.binding.history,
793
804
  checkpoint: fresh.binding.narrativeCheckpoint,
@@ -928,11 +939,24 @@ export class PlayCallChain {
928
939
  this.#active.delete(activeId);
929
940
  this.#worldChains.delete(worldId);
930
941
  }
942
+ async #captureOrigin(session) {
943
+ const binding = await this.#worlds.bindPlayCallChain(session.worldId);
944
+ if (binding.parentHead !== session.parentHead)
945
+ throw new PlayCallChainError("World advanced before background capture; use a fresh context.");
946
+ return captureNarrativeOrigin(WorldDocumentStore.open({
947
+ layout: "world_state",
948
+ files: Object.entries(binding.files).map(([path, contents]) => ({
949
+ path,
950
+ contents,
951
+ })),
952
+ }), session.nextEventId);
953
+ }
931
954
  async #submitPlayer(session, exchangeId, playerText, context, modelHost, signal, observer) {
932
955
  if (duplicatePlayerExchange(session, exchangeId, playerText, context))
933
956
  return projectView(session);
934
957
  if (session.status === "running")
935
958
  throw new PlayCallChainError("The call chain is waiting for the model to return.");
959
+ session.narrativeOrigin = await this.#captureOrigin(session);
936
960
  const exchange = session.exchange + 1;
937
961
  const nextRequest = createRequest(session, modelHost, exchange, [
938
962
  ...session.transcript,
@@ -950,6 +974,7 @@ export class PlayCallChain {
950
974
  schemaVersion: 1,
951
975
  kind: "play_advance",
952
976
  advanceKind: "player",
977
+ narrativeOrigin: structuredClone(session.narrativeOrigin),
953
978
  playContext: session.continuityContextId ?? session.chainId,
954
979
  worldId: session.worldId,
955
980
  chainId: session.chainId,
@@ -1003,7 +1028,17 @@ export class PlayCallChain {
1003
1028
  operationId: advance.operationId,
1004
1029
  worldId: session.worldId,
1005
1030
  parentHead: advance.parentHead,
1006
- historyAppend: [{ role: "player", exactText: advance.playerText }],
1031
+ historyAppend: [
1032
+ {
1033
+ role: "player",
1034
+ exactText: advance.playerText,
1035
+ ...(advance.narrativeOrigin?.value == null
1036
+ ? {}
1037
+ : {
1038
+ background: snapshotBackground(advance.narrativeOrigin.value),
1039
+ }),
1040
+ },
1041
+ ],
1007
1042
  nextMaterials: structuredClone(session.nextMaterials),
1008
1043
  stateChanges: [],
1009
1044
  });
@@ -1031,6 +1066,10 @@ export class PlayCallChain {
1031
1066
  session.nextEventId = advance.eventId + 1;
1032
1067
  session.parentHead = outcome.head;
1033
1068
  appendCommittedHistory(session, outcome.head, outcome.historyAppend);
1069
+ if (advance.narrativeOrigin === undefined)
1070
+ delete session.narrativeOrigin;
1071
+ else
1072
+ session.narrativeOrigin = structuredClone(advance.narrativeOrigin);
1034
1073
  session.exchange = advance.exchange;
1035
1074
  session.lastRequest = structuredClone(advance.nextRequest);
1036
1075
  session.lastRequestAttempt = 0;
@@ -1167,7 +1206,11 @@ export class PlayCallChain {
1167
1206
  : { providerState: response.providerState }),
1168
1207
  toolCalls: structuredClone(calls),
1169
1208
  };
1209
+ const background = visibleText
1210
+ ? narrativeBackground(advance.narrativeOrigin, session.documents.snapshot)
1211
+ : undefined;
1170
1212
  return {
1213
+ ...(background === undefined ? {} : { background }),
1171
1214
  assistantEvent,
1172
1215
  playContext: session.continuityContextId ?? session.chainId,
1173
1216
  ...(worldClock === undefined || stateChanges.length === 0
@@ -1226,6 +1269,9 @@ export class PlayCallChain {
1226
1269
  {
1227
1270
  role: "narrator",
1228
1271
  exactText: settlement.assistantEvent.text,
1272
+ ...(settlement.background === undefined
1273
+ ? {}
1274
+ : { background: settlement.background }),
1229
1275
  },
1230
1276
  ]
1231
1277
  : [],
@@ -1287,7 +1333,7 @@ export class PlayCallChain {
1287
1333
  const binding = await this.#worlds.bindPlayCallChain(session.worldId);
1288
1334
  session.documents = new FileNativePlayDocuments(binding.files);
1289
1335
  session.documents.restoreAuthorizationCheckpoint(settlement.authorizationCheckpoint);
1290
- session.history = historyEntries(binding.history);
1336
+ session.history = historyEntries(binding.history, binding.historyBackgrounds);
1291
1337
  session.nextMaterials = structuredClone(binding.additionalMaterials);
1292
1338
  }
1293
1339
  mergeChangedDocuments(session, settlement.stateChanges);
@@ -1382,6 +1428,9 @@ export class PlayCallChain {
1382
1428
  schemaVersion: 1,
1383
1429
  kind: "play_advance",
1384
1430
  advanceKind: "response",
1431
+ ...(session.narrativeOrigin === undefined
1432
+ ? {}
1433
+ : { narrativeOrigin: structuredClone(session.narrativeOrigin) }),
1385
1434
  worldId: session.worldId,
1386
1435
  chainId: session.chainId,
1387
1436
  advanceId: responseOperationId,
@@ -1784,7 +1833,7 @@ export class PlayCallChain {
1784
1833
  documentAuthorizationCheckpoints: persisted.documentAuthorizationCheckpoints?.map((checkpoint) => structuredClone(checkpoint)) ?? [],
1785
1834
  nextMaterials: structuredClone(binding.additionalMaterials),
1786
1835
  documents,
1787
- history: historyEntries(binding.history),
1836
+ history: historyEntries(binding.history, binding.historyBackgrounds),
1788
1837
  narrativeCheckpoint: binding.narrativeCheckpoint,
1789
1838
  completedToolMap: new Map(persisted.completedTools.map((item) => [
1790
1839
  item.key,
@@ -1935,11 +1984,18 @@ function appendCommittedHistory(session, head, messages) {
1935
1984
  const path = `message.${sequence}.${index + 1}.${message.role}`;
1936
1985
  const existing = session.history.find((item) => item.path === path);
1937
1986
  if (existing !== undefined) {
1938
- if (existing.contents !== message.exactText)
1987
+ if (existing.contents !== message.exactText ||
1988
+ !isDeepStrictEqual(existing.background, message.background))
1939
1989
  throw new PlayCallChainError("Recovered Authority history conflicts with the call-chain history.");
1940
1990
  continue;
1941
1991
  }
1942
- session.history.push({ path, contents: message.exactText });
1992
+ session.history.push({
1993
+ path,
1994
+ contents: message.exactText,
1995
+ ...(message.background === undefined
1996
+ ? {}
1997
+ : { background: structuredClone(message.background) }),
1998
+ });
1943
1999
  }
1944
2000
  }
1945
2001
  function prepareTool(session, working, exchange, call, locale) {
@@ -2219,10 +2275,13 @@ function playerRevisionTimelineGeneration(sourceGeneration, operationId, worldId
2219
2275
  .digest("hex")
2220
2276
  .slice(0, 40)}`;
2221
2277
  }
2222
- function historyEntries(history) {
2278
+ function historyEntries(history, backgrounds = {}) {
2223
2279
  return Object.entries(history).map(([path, contents]) => ({
2224
2280
  path,
2225
2281
  contents,
2282
+ ...(backgrounds[path] === undefined
2283
+ ? {}
2284
+ : { background: structuredClone(backgrounds[path]) }),
2226
2285
  }));
2227
2286
  }
2228
2287
  function operationId(chainId, key) {
@@ -1,3 +1,4 @@
1
+ import { captureNarrativeOrigin } from "../history/HistoryBackground.js";
1
2
  import { FileNativePlayDocuments, } from "./PlayDocumentTools.js";
2
3
  import { currentPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
3
4
  import { isPlayerRoundMarker, playerInputAppend } from "./PlayContinuity.js";
@@ -93,6 +94,7 @@ function assembleRetainedTrace(source, endpoint, selection) {
93
94
  ? null
94
95
  : completedToolKeys(selection.toolEvents);
95
96
  const documents = restorePlayDocuments(endpoint.binding.files, source, selection.authorizationEvents);
97
+ const lastEvent = selection.events.at(-1);
96
98
  return {
97
99
  continuityContextId: source.continuityContextId ?? source.chainId,
98
100
  baselineHead: source.baselineHead,
@@ -111,6 +113,11 @@ function assembleRetainedTrace(source, endpoint, selection) {
111
113
  ...(source.modelBinding === undefined
112
114
  ? {}
113
115
  : { modelBinding: structuredClone(source.modelBinding) }),
116
+ ...(lastEvent?.kind === "player" || lastEvent?.kind === "tool_result"
117
+ ? {
118
+ narrativeOrigin: captureNarrativeOrigin(documents.snapshot, lastEvent.kind === "player" ? lastEvent.id : lastEvent.id + 1),
119
+ }
120
+ : {}),
114
121
  status: "ready",
115
122
  canRetry: false,
116
123
  bootstrap: structuredClone(source.bootstrap),
@@ -8,7 +8,9 @@ import { builtinAuthorPrompts, authoringMechanics, } from "../../shared/ordered-
8
8
  import { builtinPlayPrompts, } from "../../shared/ordered-play-prompts.js";
9
9
  import { parseOrderedPlayPrompts } from "../play/OrderedPlayPrompts.js";
10
10
  import { renderDocumentWritePosition } from "./WorldMaintenanceReport.js";
11
- import { parseDocument, stringify } from "yaml";
11
+ import { parseDocument } from "yaml";
12
+ import { renderWorldYamlSource } from "../world/WorldYamlRendering.js";
13
+ export { renderWorldYamlSource } from "../world/WorldYamlRendering.js";
12
14
  import { measureWorldMaterials, refreshMaintenanceTotals, } from "./WorldMaintenanceReport.js";
13
15
  import { renderPlayerViewBindings } from "../world/PlayerViewBindings.js";
14
16
  import { checkpointHistory, checkpointReplayBlocks, playerInputAppend, isPlayerRoundMarker, } from "../play/PlayContinuity.js";
@@ -52,7 +54,7 @@ ${historyUseGuidance("en")}`,
52
54
 
53
55
  A nonempty tool-free response ends the model/tool loop started by the current player submission. A response that calls any tool is an intermediate step; its text has not yet been displayed or committed as final story. After all tool results, use the receipts to present this turn's still-unshown content in a later tool-free response consistent with committed state.
54
56
 
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 the subsequent nonempty tool-free final prose successfully commits, including that prose in its boundary; further tool calls may follow registration. Normal replies need no checkpoint; registration neither ends an in-world event nor switches context. Author instructions decide save timing.
57
+ 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 the subsequent nonempty tool-free final prose successfully commits, including that prose in its boundary; further tool calls may follow registration. Registration neither ends an in-world event nor switches context. Author instructions decide save timing.
56
58
 
57
59
  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
60
 
@@ -83,7 +85,7 @@ ${historyUseGuidance("zh-CN")}`,
83
85
 
84
86
  非空且不调用工具的响应会结束本次玩家提交触发的模型/工具循环。只要响应调用了任何工具,它就是工具中间步,其中的文字尚未展示或提交为最终故事。收到全部工具结果后,根据回执在后续无工具响应中呈现本轮尚未展示的内容,并与已提交状态一致。
85
87
 
86
- 下一次玩家提交可以选择“全新上下文”;旧模型 transcript 不会进入那个请求。新上下文会补入最近一次已生效 world_checkpoint 之后的全部已提交玩家原文与最终叙事,不含工具、推理和开场白。检查点登记仅在随后非空且无工具调用的最终正文成功提交后生效,边界包含该正文;登记后仍可继续工具调用。正常回复无需检查点,登记不结束世界内事件,也不自动切换上下文。具体保存时机由作者提示规定。
88
+ 下一次玩家提交可以选择“全新上下文”;旧模型 transcript 不会进入那个请求。新上下文会补入最近一次已生效 world_checkpoint 之后的全部已提交玩家原文与最终叙事,不含工具、推理和开场白。检查点登记仅在随后非空且无工具调用的最终正文成功提交后生效,边界包含该正文;登记后仍可继续工具调用。登记不结束世界内事件,也不自动切换上下文。具体保存时机由作者提示规定。
87
89
 
88
90
  Runtime 只执行真实工具定义、文件校验和权威提交;本段不规定故事、人称、文风、玩家代理权或状态语义。
89
91
 
@@ -1250,45 +1252,11 @@ function renderSnapshotDocument(snapshot, selector) {
1250
1252
  markdown: renderYamlDocument(read.descriptor, selected.node.value),
1251
1253
  };
1252
1254
  }
1253
- /**
1254
- * Serialize a YAML node exactly the way context_read does, so what bootstrap
1255
- * injects is what a later read would hand back. Internal document ids become
1256
- * the @shortRef handles the model can actually pass to a tool.
1257
- */
1258
- export function renderWorldYamlSource(value) {
1259
- return stringify(promptYamlValue(value), {
1260
- indent: 2,
1261
- lineWidth: 0,
1262
- }).trimEnd();
1263
- }
1264
1255
  function renderYamlDocument(descriptor, value) {
1265
1256
  if (!isRecord(value))
1266
1257
  throw new PromptCompilationError("world_document_query_failed", "YAML world-document root node must be a map");
1267
1258
  return `## ${descriptor.title} [ref: @${descriptor.shortRef} · YAML]\n\n> ${descriptor.summary}\n\n${renderWorldYamlSource(value)}`;
1268
1259
  }
1269
- function projectedReference(value) {
1270
- if (!isRecord(value) ||
1271
- Object.keys(value).length !== 2 ||
1272
- typeof value.$ref !== "string" ||
1273
- !isRecord(value.target))
1274
- return null;
1275
- return typeof value.target.shortRef === "string"
1276
- ? { shortRef: value.target.shortRef }
1277
- : null;
1278
- }
1279
- function promptYamlValue(value) {
1280
- const reference = projectedReference(value);
1281
- if (reference !== null)
1282
- return { $ref: `@${reference.shortRef}` };
1283
- if (Array.isArray(value))
1284
- return value.map(promptYamlValue);
1285
- if (isRecord(value))
1286
- return Object.fromEntries(Object.entries(value).map(([key, child]) => [
1287
- key,
1288
- promptYamlValue(child),
1289
- ]));
1290
- return value;
1291
- }
1292
1260
  function throwQueryFailure(failure, operation) {
1293
1261
  throw new PromptCompilationError("world_document_query_failed", `World-document snapshot could not complete the ${operation} query`, {
1294
1262
  requestKind: failure.requestKind,
@@ -1310,6 +1278,9 @@ function resolveContext(input, worldFrame, snapshot, coverage, locale) {
1310
1278
  const history = new HistoryQuery("prompt-compilation", historyInputs(Object.entries(input.world.history ?? {}).map(([path, contents]) => ({
1311
1279
  path,
1312
1280
  contents,
1281
+ ...(input.world.historyBackgrounds?.[path] === undefined
1282
+ ? {}
1283
+ : { background: input.world.historyBackgrounds[path] }),
1313
1284
  }))));
1314
1285
  const excluded = new Set(input.world.historyAlreadyAppended ?? []);
1315
1286
  const checkpointEntries = input.world.replayHistory === true