narraeon 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/node/protocol/appPreferences.js +4 -2
  4. package/dist/node/protocol/playFailure.js +13 -0
  5. package/dist/node/protocol/protagonistTemplates.js +13 -0
  6. package/dist/node/protocol/v1.js +25 -19
  7. package/dist/node/protocol/worldArchives.js +7 -0
  8. package/dist/node/runtime/V1Runtime.js +48 -6
  9. package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
  10. package/dist/node/runtime/archive/WorldArchives.js +389 -0
  11. package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
  12. package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
  13. package/dist/node/runtime/content/ContentWorkspace.js +5 -0
  14. package/dist/node/runtime/history/HistoryBackground.js +84 -0
  15. package/dist/node/runtime/history/HistoryQuery.js +7 -2
  16. package/dist/node/runtime/history/HistoryRendering.js +31 -4
  17. package/dist/node/runtime/history/StoryExport.js +33 -0
  18. package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
  19. package/dist/node/runtime/model/ModelHost.js +12 -1
  20. package/dist/node/runtime/model/ModelHostStream.js +11 -3
  21. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +2 -0
  22. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +5 -0
  23. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +91 -4
  24. package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
  25. package/dist/node/runtime/play/PlayCallChain.js +212 -61
  26. package/dist/node/runtime/play/PlayCallChainError.js +3 -1
  27. package/dist/node/runtime/play/PlayContinuity.js +3 -20
  28. package/dist/node/runtime/play/PlayFailure.js +20 -0
  29. package/dist/node/runtime/play/PlayTraceRetention.js +10 -11
  30. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +12 -59
  31. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +2 -2
  32. package/dist/node/runtime/world/FileNativeAuthorityV3.js +7 -4
  33. package/dist/node/runtime/world/FileNativeHistoryProjection.js +11 -0
  34. package/dist/node/runtime/world/FileNativeWorldStore.js +250 -55
  35. package/dist/node/runtime/world/WorldActivity.js +143 -0
  36. package/dist/node/runtime/world/WorldYamlRendering.js +28 -0
  37. package/dist/node/server/createServer.js +48 -0
  38. package/dist/node/shared/default-preset-host.js +18 -8
  39. package/dist/node/shared/default-setting-improvement-prompt.js +4 -0
  40. package/dist/node/shared/history-use-guidance.js +2 -2
  41. package/dist/node/shared/ordered-play-prompts.js +2 -2
  42. package/dist/node/shared/sample-packages/index.js +10 -0
  43. package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
  44. package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
  45. package/dist/web/assets/index-DxNtxzLR.js +1255 -0
  46. package/dist/web/assets/{index-CVN48xrw.css → index-DzOnu9uv.css} +1 -1
  47. package/dist/web/index.html +2 -2
  48. package/package.json +6 -2
  49. package/dist/web/assets/index-DTFZQan4.js +0 -1223
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ export function exportStory(title, messages, format, locale) {
2
+ const player = locale === "zh-CN" ? "我" : "Me";
3
+ const separator = locale === "zh-CN" ? ":" : ": ";
4
+ const blocks = messages.map(({ role, text }) => {
5
+ if (role === "narrator")
6
+ return text;
7
+ if (format === "text")
8
+ return `${player}${separator}${text}`;
9
+ return text
10
+ .split("\n")
11
+ .map((line, index) => index === 0 ? `> **${player}**${separator}${line}` : `> ${line}`)
12
+ .join("\n");
13
+ });
14
+ return {
15
+ fileName: storyFileName(title, format),
16
+ content: [format === "markdown" ? `# ${title}` : title, ...blocks].join("\n\n"),
17
+ };
18
+ }
19
+ function storyFileName(title, format) {
20
+ const extension = format === "markdown" ? ".md" : ".txt";
21
+ let stem = title
22
+ .replace(/[<>:"/\\|?*\p{Cc}]/gu, "")
23
+ .trim()
24
+ .replace(/[. ]+$/u, "");
25
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(stem))
26
+ stem = `_${stem}`;
27
+ const characters = Array.from(stem).slice(0, 80 - extension.length);
28
+ // Also fit filesystems that limit a filename to 255 UTF-8 bytes.
29
+ while (Buffer.byteLength(characters.join("") + extension, "utf8") > 255)
30
+ characters.pop();
31
+ stem = characters.join("").replace(/[. ]+$/u, "");
32
+ return `${stem || "story"}${extension}`;
33
+ }
@@ -1,8 +1,9 @@
1
+ var _a;
1
2
  import { createHash } from "node:crypto";
2
3
  import { appendFile, chmod, mkdir, stat } from "node:fs/promises";
3
4
  import { dirname } from "node:path";
4
5
  import { hasCLIProxyThinkingSuffix, modelReasoningPolicyIssue, } from "../../protocol/modelConnections.js";
5
- import { ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "./ModelHost.js";
6
+ import { assertModelOutputComplete, ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "./ModelHost.js";
6
7
  import { renderPromptDeltaMessage, } from "../prompt/FileNativePromptCompiler.js";
7
8
  import { aggregateAnthropicModelStream, aggregateChatModelStream, aggregateResponsesModelStream, } from "./ModelHostStream.js";
8
9
  import { defaultRuntimeToolDefinitionStrategy, portableRuntimeToolInputSchema, } from "../prompt/FileNativeToolRegistry.js";
@@ -77,7 +78,31 @@ export class FileNativeModelHost {
77
78
  .digest("hex"),
78
79
  };
79
80
  }
80
- async exchange(request, observer) {
81
+ freezeRequest(request) {
82
+ const frozen = structuredClone(request);
83
+ if (frozen.modelConfig === undefined) {
84
+ const { baseUrl: _baseUrl, apiKey: _apiKey, ...config } = this.#connection;
85
+ void _baseUrl;
86
+ void _apiKey;
87
+ frozen.modelConfig = config;
88
+ }
89
+ return frozen;
90
+ }
91
+ #requestHost(request) {
92
+ if (request.modelConfig === undefined)
93
+ return this;
94
+ if (request.modelConfig.provider !== this.#connection.provider)
95
+ throw new ModelHostBindingMismatchError("The request protocol does not match the selected model host");
96
+ return new _a({
97
+ ...request.modelConfig,
98
+ baseUrl: this.#connection.baseUrl,
99
+ apiKey: this.#connection.apiKey,
100
+ }, this.#fetch, this.#failureLog);
101
+ }
102
+ exchange(request, observer) {
103
+ return this.#requestHost(request).#exchange(request, observer);
104
+ }
105
+ async #exchange(request, observer) {
81
106
  assertExchangeCompatibility(this.#connection, request);
82
107
  await trace("model_host_exchange", modelHostTrace(request));
83
108
  const signal = observer?.signal;
@@ -99,6 +124,9 @@ export class FileNativeModelHost {
99
124
  }
100
125
  /** Uses the production encoders and deliberately omits credentials. */
101
126
  previewRequest(request) {
127
+ return this.#requestHost(request).#previewRequest(request);
128
+ }
129
+ #previewRequest(request) {
102
130
  assertExchangeCompatibility(this.#connection, request);
103
131
  const provider = this.#connection.provider;
104
132
  const body = provider === "chat_completions"
@@ -183,6 +211,7 @@ export class FileNativeModelHost {
183
211
  await trace("chat_response", payload);
184
212
  const message = payload.choices?.[0]?.message;
185
213
  const finishReason = payload.choices?.[0]?.finish_reason;
214
+ assertModelOutputComplete(finishReason);
186
215
  if (message === undefined)
187
216
  throw unknownProviderResponse("Chat Completions");
188
217
  if (finishReason !== undefined &&
@@ -271,6 +300,7 @@ export class FileNativeModelHost {
271
300
  if (!isRecord(rawPayload) || !Array.isArray(rawPayload.content))
272
301
  throw unknownProviderResponse("Anthropic Messages");
273
302
  const payload = rawPayload;
303
+ assertModelOutputComplete(payload.stop_reason);
274
304
  const content = payload.content;
275
305
  if (!validAnthropicContent(content))
276
306
  throw unknownProviderResponse("Anthropic Messages");
@@ -310,6 +340,7 @@ export class FileNativeModelHost {
310
340
  });
311
341
  }
312
342
  }
343
+ _a = FileNativeModelHost;
313
344
  function assertExchangeCompatibility(connection, request) {
314
345
  if (request.bootstrap.provider.protocol !== connection.provider)
315
346
  throw new ModelHostBindingMismatchError("The compiled prompt protocol does not match the selected model host");
@@ -1048,6 +1079,7 @@ async function openAIResponsesRequest(input) {
1048
1079
  await trace(`${input.tracePrefix}_response`, payload);
1049
1080
  if (!isRecord(payload) || !Array.isArray(payload.output))
1050
1081
  throw unknownProviderResponse("Responses API");
1082
+ assertModelOutputComplete(responsesStopReason(payload));
1051
1083
  const output = cloneResponseOutput(payload.output);
1052
1084
  if (!validResponsesOutput(output))
1053
1085
  throw unknownProviderResponse("Responses API");
@@ -33,10 +33,21 @@ export class ModelHostFailureError extends Error {
33
33
  this.details = options.details;
34
34
  }
35
35
  }
36
- /** A Provider has rejected the complete request because its context is full. */
36
+ /** Reject provider-confirmed truncation before accepting text or tool calls. */
37
+ export function assertModelOutputComplete(stopReason) {
38
+ if (stopReason === "length" ||
39
+ stopReason === "max_tokens" ||
40
+ stopReason === "max_output_tokens" ||
41
+ stopReason === "incomplete:max_output_tokens") {
42
+ throw new ModelHostFailureError("The model response reached the output token limit before completion. Increase maxOutputTokens and start a fresh context; reasoning may also consume the output budget.", { details: { code: "model_output_limit", stopReason } });
43
+ }
44
+ }
45
+ /** Replaying the frozen request cannot recover a context or output limit. */
37
46
  export function modelHostFailureRequiresFreshContext(error) {
38
47
  if (!(error instanceof ModelHostFailureError))
39
48
  return false;
49
+ if (failureDetailStrings(error.details).includes("model_output_limit"))
50
+ return true;
40
51
  const evidence = [error.message, ...failureDetailStrings(error.details)]
41
52
  .join("\n")
42
53
  .toLocaleLowerCase("und");
@@ -1,4 +1,4 @@
1
- import { ModelHostFailureError } from "./ModelHost.js";
1
+ import { assertModelOutputComplete, ModelHostFailureError, } from "./ModelHost.js";
2
2
  import { providerStreamEvents } from "./ProviderStream.js";
3
3
  export async function aggregateChatModelStream(body, onDelta) {
4
4
  let content = "";
@@ -110,6 +110,7 @@ export async function aggregateChatModelStream(body, onDelta) {
110
110
  }
111
111
  if (!completed || !sawPayload)
112
112
  throw new Error("Chat SSE ended before a complete response");
113
+ assertModelOutputComplete(stopReason);
113
114
  const toolCalls = [...calls.entries()]
114
115
  .sort(([left], [right]) => left - right)
115
116
  .map(([, call]) => {
@@ -259,8 +260,6 @@ export async function aggregateAnthropicModelStream(body, onDelta) {
259
260
  if (block === undefined || block.stopped)
260
261
  throw new Error("Anthropic SSE content block stop is invalid");
261
262
  block.stopped = true;
262
- if (block.value.type === "tool_use" && block.partialJson !== "")
263
- block.value.input = parseStreamJson(block.partialJson);
264
263
  break;
265
264
  }
266
265
  case "message_delta": {
@@ -289,11 +288,14 @@ export async function aggregateAnthropicModelStream(body, onDelta) {
289
288
  }
290
289
  if (!started || !stopped)
291
290
  throw new Error("Anthropic SSE ended before a complete response");
291
+ assertModelOutputComplete(stopReason);
292
292
  const content = [...blocks.entries()]
293
293
  .sort(([left], [right]) => left - right)
294
294
  .map(([, block]) => {
295
295
  if (!block.stopped)
296
296
  throw new Error("Anthropic SSE content block did not end");
297
+ if (block.value.type === "tool_use" && block.partialJson !== "")
298
+ block.value.input = parseStreamJson(block.partialJson);
297
299
  return block.value;
298
300
  });
299
301
  const text = content
@@ -364,6 +366,12 @@ export async function aggregateResponsesModelStream(body, onDelta) {
364
366
  payload.type === "response.incomplete" ||
365
367
  payload.type === "response.cancelled" ||
366
368
  payload.type === "error") {
369
+ if (payload.type === "response.incomplete" &&
370
+ isRecord(payload.response)) {
371
+ const details = payload.response.incomplete_details;
372
+ if (isRecord(details))
373
+ assertModelOutputComplete(details.reason);
374
+ }
367
375
  throw new ModelHostFailureError("Responses SSE returned an explicit failure");
368
376
  }
369
377
  }
@@ -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,6 @@
1
+ import { legacyModelBindingFailure, projectLegacyModelBindingFailure, } from "./LegacyModelBindingFailure.js";
2
+ import { isPlayFailureKind, } from "../../protocol/playFailure.js";
3
+ import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
1
4
  import { encodePlayPromptRun, decodePlayPromptRun, } from "./PlayPromptRun.js";
2
5
  import { createHash, randomUUID } from "node:crypto";
3
6
  import { link, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises";
@@ -70,7 +73,8 @@ export class FileNativePlayTimelineStore {
70
73
  const eventCount = prefixLength(input.source.events, input.target.events);
71
74
  const completedToolCount = prefixLength(input.source.completedTools, input.target.completedTools);
72
75
  const authorizationCheckpointCount = prefixLength(input.source.documentAuthorizationCheckpoints ?? [], input.target.documentAuthorizationCheckpoints ?? []);
73
- if (input.target.events.some((event) => event.kind === "assistant" && event.status === "completed")) {
76
+ if (input.retainCompleteContext ||
77
+ input.target.events.some((event) => event.kind === "assistant" && event.status === "completed")) {
74
78
  const encoding = await this.readInitialEncoding(input.sourceWorldId, input.source.chainId).catch(() => null);
75
79
  if (encoding !== null)
76
80
  await publishImmutableJson(join(targetRoot, "initial-request-encoding.json"), encoding);
@@ -162,6 +166,9 @@ export class FileNativePlayTimelineStore {
162
166
  chainId: value.chainId,
163
167
  nextMaterials: structuredClone(value.nextMaterials),
164
168
  lastRequestDigest,
169
+ ...(value.narrativeOrigin === undefined
170
+ ? {}
171
+ : { narrativeOrigin: structuredClone(value.narrativeOrigin) }),
165
172
  };
166
173
  const continuationDigest = createHash("sha256")
167
174
  .update(JSON.stringify(continuation))
@@ -203,6 +210,9 @@ export class FileNativePlayTimelineStore {
203
210
  continuationDigest,
204
211
  lastRequestAttempt: value.lastRequestAttempt,
205
212
  lastFailure: value.lastFailure,
213
+ ...(value.lastFailureKind === undefined
214
+ ? {}
215
+ : { lastFailureKind: value.lastFailureKind }),
206
216
  updatedAt: value.updatedAt,
207
217
  };
208
218
  await publishJson(join(root, "state.json"), state);
@@ -231,6 +241,20 @@ export class FileNativePlayTimelineStore {
231
241
  throw new Error("Play timeline head generation is inconsistent");
232
242
  return loaded;
233
243
  }
244
+ /** Read only the small active context index, never its transcript or bootstrap. */
245
+ async currentContextRequiresFreshContext(worldId) {
246
+ assertIdentity(worldId, "World ID");
247
+ const head = await this.#readTimelineHead(worldId);
248
+ if (head === null)
249
+ return false;
250
+ const index = await readJson(join(this.#contextRoot(worldId, head.chainId), "index.json"));
251
+ assertContextIndex(index);
252
+ if (index.worldId !== worldId ||
253
+ index.chainId !== head.chainId ||
254
+ index.timelineGeneration !== head.generation)
255
+ throw new Error("Play context durable identity is inconsistent");
256
+ return index.importedFromArchive === true;
257
+ }
234
258
  async readContext(worldId, chainId) {
235
259
  assertIdentity(worldId, "World ID");
236
260
  assertIdentity(chainId, "Call-chain ID");
@@ -273,6 +297,7 @@ export class FileNativePlayTimelineStore {
273
297
  chainId,
274
298
  previousChainId: indexValue.previousChainId,
275
299
  timelineGeneration: indexValue.timelineGeneration,
300
+ ...(indexValue.importedFromArchive ? { importedFromArchive: true } : {}),
276
301
  ...(baseValue.continuityContextId === undefined
277
302
  ? {}
278
303
  : { continuityContextId: baseValue.continuityContextId }),
@@ -313,11 +338,19 @@ export class FileNativePlayTimelineStore {
313
338
  promptRuns,
314
339
  changedDocuments: structuredClone(stateValue.changedDocuments),
315
340
  nextMaterials: structuredClone(continuationValue.nextMaterials),
341
+ ...(continuationValue.narrativeOrigin === undefined
342
+ ? {}
343
+ : {
344
+ narrativeOrigin: structuredClone(continuationValue.narrativeOrigin),
345
+ }),
316
346
  nextEventId: stateValue.nextEventId,
317
347
  exchange: stateValue.exchange,
318
348
  lastRequest,
319
349
  lastRequestAttempt: stateValue.lastRequestAttempt,
320
350
  lastFailure: stateValue.lastFailure,
351
+ ...(stateValue.lastFailureKind === undefined
352
+ ? {}
353
+ : { lastFailureKind: stateValue.lastFailureKind }),
321
354
  updatedAt: stateValue.updatedAt,
322
355
  };
323
356
  return {
@@ -436,7 +469,10 @@ export class FileNativePlayTimelineStore {
436
469
  kind: "event",
437
470
  chainId: pointer.chainId,
438
471
  current: pointer.chainId === head?.chainId,
439
- event: summary,
472
+ ...(context.index.importedFromArchive
473
+ ? { requiresFreshContext: true }
474
+ : {}),
475
+ event: projectLegacyModelBindingFailure(summary),
440
476
  });
441
477
  pointer = { ...pointer, eventId: pointer.eventId - 1 };
442
478
  continue;
@@ -489,6 +525,9 @@ export class FileNativePlayTimelineStore {
489
525
  activeStatus: active?.state.status ?? null,
490
526
  activeCanRetry: active?.state.canRetry ?? false,
491
527
  activeLastFailure: active?.state.lastFailure ?? null,
528
+ ...(active?.state.lastFailureKind === undefined
529
+ ? {}
530
+ : { activeLastFailureKind: active.state.lastFailureKind }),
492
531
  items: reverse.reverse(),
493
532
  nextCursor: pointer.chainId === null && pointer.genesisIndex < 0
494
533
  ? null
@@ -507,7 +546,7 @@ export class FileNativePlayTimelineStore {
507
546
  assertPlayEvent(event);
508
547
  if (event.id !== eventId)
509
548
  throw new Error("Play timeline event identity is inconsistent");
510
- return normalizePlayEvent(event);
549
+ return projectLegacyModelBindingFailure(normalizePlayEvent(event));
511
550
  }
512
551
  newGeneration() {
513
552
  return `timeline:${randomUUID()}`;
@@ -588,11 +627,48 @@ export class FileNativePlayTimelineStore {
588
627
  indexValue.chainId !== chainId ||
589
628
  stateValue.chainId !== chainId)
590
629
  throw new Error("Play context durable identity is inconsistent");
630
+ // Old versions persisted a configuration preflight failure as a terminal
631
+ // context failure. Recover only the disposition proven by its event prefix.
632
+ // Archive contexts and genuine provider failures retain their restrictions.
633
+ if (!indexValue.importedFromArchive &&
634
+ stateValue.status === "interrupted" &&
635
+ stateValue.lastFailure === legacyModelBindingFailure &&
636
+ (stateValue.lastFailureKind === undefined ||
637
+ stateValue.lastFailureKind === "context_unavailable")) {
638
+ for (let id = stateValue.eventCount; id > 0; id -= 1) {
639
+ const event = await readJson(join(root, "events", numbered(id)));
640
+ assertPlayEvent(event);
641
+ if (event.kind === "followup" ||
642
+ (event.kind === "failure" &&
643
+ event.message === legacyModelBindingFailure))
644
+ continue;
645
+ if (event.kind === "assistant" &&
646
+ event.status === "completed" &&
647
+ event.responseKind === "narrative" &&
648
+ event.continuation === "available" &&
649
+ event.committedHead === stateValue.parentHead) {
650
+ stateValue.status = "ready";
651
+ stateValue.canRetry = false;
652
+ stateValue.lastFailure = null;
653
+ delete stateValue.lastFailureKind;
654
+ }
655
+ else if (event.kind === "failure") {
656
+ stateValue.canRetry = event.failureKind === "retryable";
657
+ stateValue.lastFailure = event.message;
658
+ if (event.failureKind === undefined)
659
+ delete stateValue.lastFailureKind;
660
+ else
661
+ stateValue.lastFailureKind = event.failureKind;
662
+ }
663
+ break;
664
+ }
665
+ }
591
666
  return { root, index: indexValue, state: stateValue };
592
667
  }
593
668
  }
594
669
  function contextIndex(value) {
595
670
  return {
671
+ ...(value.importedFromArchive ? { importedFromArchive: true } : {}),
596
672
  schemaVersion: 3,
597
673
  kind: "play_context_index",
598
674
  worldId: value.worldId,
@@ -715,6 +791,8 @@ function assertContextIndex(value) {
715
791
  if (!isRecord(value) ||
716
792
  value.schemaVersion !== 3 ||
717
793
  value.kind !== "play_context_index" ||
794
+ (value.importedFromArchive !== undefined &&
795
+ value.importedFromArchive !== true) ||
718
796
  typeof value.worldId !== "string" ||
719
797
  typeof value.chainId !== "string" ||
720
798
  (value.previousChainId !== null &&
@@ -760,6 +838,8 @@ function assertContextState(value) {
760
838
  !/^[a-f0-9]{64}$/u.test(value.continuationDigest) ||
761
839
  !validCount(value.lastRequestAttempt) ||
762
840
  (value.lastFailure !== null && typeof value.lastFailure !== "string") ||
841
+ (value.lastFailureKind !== undefined &&
842
+ !isPlayFailureKind(value.lastFailureKind)) ||
763
843
  typeof value.updatedAt !== "number")
764
844
  throw new Error("Play context state has an invalid shape");
765
845
  }
@@ -767,6 +847,8 @@ function assertContextContinuation(value) {
767
847
  if (!isRecord(value) ||
768
848
  value.schemaVersion !== 3 ||
769
849
  value.kind !== "play_context_continuation" ||
850
+ (value.narrativeOrigin !== undefined &&
851
+ !isNarrativeOrigin(value.narrativeOrigin)) ||
770
852
  typeof value.chainId !== "string" ||
771
853
  !Array.isArray(value.nextMaterials) ||
772
854
  (value.lastRequestDigest !== null &&
@@ -828,6 +910,8 @@ function isReleasedPlayContext(value) {
828
910
  (value.lastRequest !== null && !isRecord(value.lastRequest)) ||
829
911
  !validCount(value.lastRequestAttempt) ||
830
912
  (value.lastFailure !== null && typeof value.lastFailure !== "string") ||
913
+ (value.lastFailureKind !== undefined &&
914
+ !isPlayFailureKind(value.lastFailureKind)) ||
831
915
  typeof value.updatedAt !== "number")
832
916
  return false;
833
917
  try {
@@ -965,7 +1049,10 @@ function assertPlayEvent(value) {
965
1049
  return;
966
1050
  }
967
1051
  if ((value.kind !== "failure" && value.kind !== "cancellation") ||
968
- typeof value.message !== "string")
1052
+ typeof value.message !== "string" ||
1053
+ (value.kind === "failure" &&
1054
+ value.failureKind !== undefined &&
1055
+ !isPlayFailureKind(value.failureKind)))
969
1056
  throw new Error("Play timeline event has an invalid kind");
970
1057
  }
971
1058
  function assertTimelineEventSummary(value) {