narraeon 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +3 -1
  3. package/dist/node/protocol/appPreferences.js +4 -2
  4. package/dist/node/protocol/gameplay.js +69 -0
  5. package/dist/node/protocol/playFailure.js +13 -0
  6. package/dist/node/protocol/protagonistTemplates.js +13 -0
  7. package/dist/node/protocol/resolution.js +274 -0
  8. package/dist/node/protocol/v1.js +40 -19
  9. package/dist/node/protocol/worldArchives.js +7 -0
  10. package/dist/node/runtime/RuntimeErrorLog.js +95 -0
  11. package/dist/node/runtime/V1Runtime.js +103 -8
  12. package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
  13. package/dist/node/runtime/archive/WorldArchives.js +389 -0
  14. package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
  15. package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
  16. package/dist/node/runtime/content/ContentWorkspace.js +15 -2
  17. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +43 -2
  18. package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
  19. package/dist/node/runtime/history/StoryExport.js +33 -0
  20. package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
  21. package/dist/node/runtime/model/ModelHost.js +12 -1
  22. package/dist/node/runtime/model/ModelHostStream.js +11 -3
  23. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +37 -1
  24. package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
  25. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +87 -4
  26. package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
  27. package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
  28. package/dist/node/runtime/play/PlayCallChain.js +177 -57
  29. package/dist/node/runtime/play/PlayCallChainError.js +3 -1
  30. package/dist/node/runtime/play/PlayContinuity.js +3 -20
  31. package/dist/node/runtime/play/PlayFailure.js +20 -0
  32. package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
  33. package/dist/node/runtime/play/TabletopResolution.js +372 -0
  34. package/dist/node/runtime/play/TabletopRules.js +141 -0
  35. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +49 -32
  36. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -4
  37. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +4 -1
  38. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
  39. package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
  40. package/dist/node/runtime/world/FileNativeWorldStore.js +207 -40
  41. package/dist/node/runtime/world/WorldActivity.js +143 -0
  42. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -0
  43. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
  44. package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
  45. package/dist/node/server/ConversationStream.js +2 -0
  46. package/dist/node/server/createServer.js +48 -0
  47. package/dist/node/shared/default-preset-host.js +10 -6
  48. package/dist/node/shared/ordered-author-prompts.js +8 -0
  49. package/dist/node/shared/ordered-play-prompts.js +25 -2
  50. package/dist/node/shared/sample-packages/index.js +10 -0
  51. package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
  52. package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
  53. package/dist/node/shared/tabletop-guidance.js +100 -0
  54. package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
  55. package/dist/web/assets/index-BgBL8G-A.js +1323 -0
  56. package/dist/web/index.html +2 -2
  57. package/package.json +6 -2
  58. package/dist/web/assets/index-BH8NyuE4.js +0 -1233
  59. package/dist/web/assets/index-CVN48xrw.css +0 -1
@@ -4,6 +4,7 @@ import { readFileSync, unlinkSync } from "node:fs";
4
4
  import { link, open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
5
5
  import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path";
6
6
  import { portableContentTreePathKey } from "../../protocol/contentTree.js";
7
+ import { assertSameGameplayMode, gameplayFile, gameplayPath, readGameplay, } from "../../protocol/gameplay.js";
7
8
  import { defaultContentWorkspaceLimits, } from "./ContentLimits.js";
8
9
  import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, worldDocumentTextRequirement, } from "./FileNativeContentTree.js";
9
10
  const localIdPattern = /^package-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
@@ -228,6 +229,29 @@ export class CurrentTreeContentLibrary {
228
229
  }
229
230
  });
230
231
  }
232
+ /**
233
+ * Change only the resolution scheme of a tabletop package. The edit reads the
234
+ * tree under the same lease as ordinary saves, so it publishes atomically and
235
+ * follows the same conflict rules.
236
+ */
237
+ setGameplayScheme(localId, scheme, options = {}) {
238
+ return this.#mutate(async () => {
239
+ const lease = await this.acquireOperationLease(localId, {
240
+ wait: options.waitForLease !== false,
241
+ });
242
+ try {
243
+ options.signal?.throwIfAborted();
244
+ const current = lease.package;
245
+ if (current.gameplay.mode !== "tabletop")
246
+ throw new InvalidContentTreeError("Only a tabletop content package has a resolution scheme / 只有跑团内容包有判定方案");
247
+ const replacement = gameplayFile({ mode: "tabletop", scheme });
248
+ return await lease.replace(current.files.map((file) => file.path === gameplayPath ? replacement : file), options);
249
+ }
250
+ finally {
251
+ lease.release();
252
+ }
253
+ });
254
+ }
231
255
  async acquireOperationLease(localId, options = {}) {
232
256
  this.#packageRoot(localId);
233
257
  await mkdir(this.#locksRoot, { recursive: true, mode: 0o700 });
@@ -281,6 +305,7 @@ export class CurrentTreeContentLibrary {
281
305
  throw new CurrentTreeContentPackageBusyError();
282
306
  options.signal?.throwIfAborted();
283
307
  const normalized = normalizeCurrentContentTreeFiles(files, this.#limits);
308
+ assertGameplayModeUnchanged(package_.files, normalized);
284
309
  return this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
285
310
  },
286
311
  release: () => {
@@ -476,7 +501,7 @@ export class CurrentTreeContentLibrary {
476
501
  }
477
502
  function detail(localId, title, files, inspection) {
478
503
  return {
479
- ...summary(localId, title, inspection),
504
+ ...summary(localId, title, readGameplay(files), inspection),
480
505
  files: files.map((file) => ({ ...file })),
481
506
  documents: structuredClone(inspection.documents),
482
507
  issues: structuredClone(inspection.issues),
@@ -484,8 +509,9 @@ function detail(localId, title, files, inspection) {
484
509
  control: structuredClone(inspection.control),
485
510
  };
486
511
  }
487
- function summary(localId, title, inspection) {
512
+ function summary(localId, title, gameplay, inspection) {
488
513
  return {
514
+ gameplay: { ...gameplay },
489
515
  localId,
490
516
  title,
491
517
  status: inspection.status,
@@ -495,6 +521,7 @@ function summary(localId, title, inspection) {
495
521
  }
496
522
  function packageSummary(package_) {
497
523
  return {
524
+ gameplay: { ...package_.gameplay },
498
525
  localId: package_.localId,
499
526
  title: package_.title,
500
527
  status: package_.status,
@@ -547,8 +574,22 @@ export function normalizeCurrentContentTreeFiles(files, limits = defaultLimits)
547
574
  ...(file.encoding === undefined ? {} : { encoding: file.encoding }),
548
575
  });
549
576
  }
577
+ try {
578
+ readGameplay(normalized);
579
+ }
580
+ catch (error) {
581
+ throw new InvalidContentTreeError(error instanceof Error ? error.message : "Invalid gameplay identity");
582
+ }
550
583
  return normalized.sort((left, right) => compareStrings(left.path, right.path));
551
584
  }
585
+ function assertGameplayModeUnchanged(before, after) {
586
+ try {
587
+ assertSameGameplayMode(before, after);
588
+ }
589
+ catch (error) {
590
+ throw new InvalidContentTreeError(error instanceof Error ? error.message : "Gameplay mode is fixed");
591
+ }
592
+ }
552
593
  function decodeCanonicalBase64(contents, path) {
553
594
  if (contents.length % 4 !== 0 ||
554
595
  !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(contents)) {
@@ -1,3 +1,4 @@
1
+ import { gameplayPath, readGameplay } from "../../protocol/gameplay.js";
1
2
  import { readPackageFollowups } from "./PackageFollowups.js";
2
3
  import { parseDocument } from "yaml";
3
4
  import { WorldDocumentStore, } from "../world/WorldDocumentStore.js";
@@ -45,7 +46,7 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
45
46
  }
46
47
  const unsupportedPaths = new Set();
47
48
  for (const file of snapshotFiles) {
48
- if (/\.json$/iu.test(file.path)) {
49
+ if (/\.json$/iu.test(file.path) && file.path !== gameplayPath) {
49
50
  unsupportedPaths.add(file.path);
50
51
  issue(issues, "unsupported_content_file", file.path, "The current content package does not accept manifest, seven-field JSON, schema, record, or revision files");
51
52
  }
@@ -116,6 +117,12 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
116
117
  issue(issues, "invalid_player_view", "control/player-views.yaml", `${formatProblems.length - maxReportedPlayerViewProblems} more player-view issues were omitted`);
117
118
  }
118
119
  }
120
+ try {
121
+ readGameplay(snapshotFiles);
122
+ }
123
+ catch (error) {
124
+ issue(issues, "invalid_gameplay", gameplayPath, error instanceof Error ? error.message : "Invalid gameplay");
125
+ }
119
126
  try {
120
127
  readPackageFollowups(snapshotFiles);
121
128
  }
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  import { isNarrativeOrigin, isHistoryBackground, } from "../history/HistoryBackground.js";
2
- import { createHash, randomUUID } from "node:crypto";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
3
  import { link, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
@@ -57,6 +57,33 @@ export class FileNativePlayAdvanceStore {
57
57
  recordedAt: durableBase.createdAt,
58
58
  });
59
59
  }
60
+ /**
61
+ * Return the advance's secret resolution seed, creating it on first need.
62
+ * Recovery reads the same fact, so every tool call in the advance draws from
63
+ * one persisted stream and a crashed batch cannot resample.
64
+ */
65
+ async resolutionSeed(base) {
66
+ const durableBase = await this.#assertCurrent(base);
67
+ const path = this.#factPath(base, "resolution-seed.json");
68
+ if ((await readOptionalJson(path)) === null) {
69
+ try {
70
+ await publishImmutableJson(path, {
71
+ schemaVersion: 1,
72
+ stage: "resolution_seed",
73
+ seed: randomBytes(32).toString("hex"),
74
+ recordedAt: durableBase.createdAt,
75
+ });
76
+ }
77
+ catch (error) {
78
+ // A concurrent writer may have published first; the stored fact wins.
79
+ if ((await readOptionalJson(path)) === null)
80
+ throw error;
81
+ }
82
+ }
83
+ const fact = await readJson(path);
84
+ assertResolutionSeedFact(fact);
85
+ return fact.seed;
86
+ }
60
87
  async markSettled(base, head) {
61
88
  const durableBase = await this.#assertCurrent(base);
62
89
  await publishImmutableJson(this.#factPath(base, "settled.json"), {
@@ -115,6 +142,15 @@ function sameAdvanceBase(left, right) {
115
142
  void rightCreatedAt;
116
143
  return isDeepStrictEqual(leftIdentity, rightIdentity);
117
144
  }
145
+ function assertResolutionSeedFact(value) {
146
+ if (!isRecord(value) ||
147
+ value.schemaVersion !== 1 ||
148
+ value.stage !== "resolution_seed" ||
149
+ typeof value.seed !== "string" ||
150
+ !/^[a-f0-9]{64}$/.test(value.seed) ||
151
+ typeof value.recordedAt !== "number")
152
+ throw new Error("Invalid persisted resolution seed");
153
+ }
118
154
  function assertBase(value) {
119
155
  if (!isRecord(value) ||
120
156
  value.schemaVersion !== 1 ||
@@ -3,7 +3,7 @@ export { validatePlayPresetArtifactPayload } from "../../shared/artifact-payload
3
3
  import { defaultFollowupItems, } from "../../shared/ordered-followups.js";
4
4
  import { parseFollowupItems } from "./OrderedFollowups.js";
5
5
  import { builtinAuthorPrompts, defaultOrderedAuthorPrompts, } from "../../shared/ordered-author-prompts.js";
6
- import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, } from "./OrderedPlayPrompts.js";
6
+ import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, withTabletopPromptBuiltins, } from "./OrderedPlayPrompts.js";
7
7
  import { defaultOrderedPlayPrompts, } from "../../shared/ordered-play-prompts.js";
8
8
  import { createHash, randomUUID } from "node:crypto";
9
9
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
@@ -203,12 +203,17 @@ export class FileNativePlayPresetStore {
203
203
  };
204
204
  this.#locale = options.locale ?? (() => defaultAppLocale);
205
205
  }
206
+ /**
207
+ * Startup is the only place that upgrades stored presets. Reads never write:
208
+ * list, bind and freeze observe the library as it was last saved.
209
+ */
206
210
  async initialize() {
207
211
  await this.#change(async () => {
208
212
  const document = await this.#read();
209
213
  const migrated = this.#markLegacyBuiltinDefault(document);
210
214
  const synchronized = this.#synchronizeBuiltinDefault(document);
211
- if (migrated || synchronized)
215
+ const tabletop = this.#migrateTabletopPromptBuiltins(document);
216
+ if (migrated || synchronized || tabletop)
212
217
  await this.#write(document);
213
218
  });
214
219
  }
@@ -452,7 +457,8 @@ export class FileNativePlayPresetStore {
452
457
  .map((path) => ({ path, contents: binding.files[path] }));
453
458
  }
454
459
  async importPortable(input) {
455
- const files = toFileMap(input.files, this.#limits);
460
+ // A released preset gains the tabletop builtins at import, as at upgrade.
461
+ const files = withTabletopPromptBuiltins(toFileMap(input.files, this.#limits));
456
462
  const parsed = parsePlayPresetFiles(files, this.#limits);
457
463
  if (parsed.kind === "invalid")
458
464
  throw parsed.error;
@@ -561,6 +567,45 @@ export class FileNativePlayPresetStore {
561
567
  preset.builtinDefault = true;
562
568
  return true;
563
569
  }
570
+ /**
571
+ * One-time upgrade for ordered presets saved before the tabletop builtins
572
+ * existed. An affected revision is replaced by a new revision that inserts
573
+ * them; the old revision stays for frozen requests. A pending draft gets the
574
+ * same insertion, because a draft that kept the old list would fail the
575
+ * builtin-retention rule on save. The library is one atomically written file,
576
+ * so an interrupted upgrade leaves the released bytes and reruns cleanly.
577
+ */
578
+ #migrateTabletopPromptBuiltins(document) {
579
+ let changed = false;
580
+ for (const stored of document.presets) {
581
+ const current = stored.revisions[stored.currentRevision];
582
+ if (current !== undefined) {
583
+ const migrated = withTabletopPromptBuiltins(current);
584
+ if (migrated !== current) {
585
+ const revision = revisionForFiles(migrated, this.#limits);
586
+ stored.revisions[revision] = cloneFiles(migrated);
587
+ stored.currentRevision = revision;
588
+ changed = true;
589
+ }
590
+ }
591
+ const draft = stored.draftRevision === undefined
592
+ ? undefined
593
+ : stored.revisions[stored.draftRevision];
594
+ if (draft !== undefined) {
595
+ const migrated = withTabletopPromptBuiltins(draft);
596
+ if (migrated !== draft) {
597
+ const revision = revisionForFiles(migrated, this.#limits);
598
+ stored.revisions[revision] = cloneFiles(migrated);
599
+ if (revision === stored.currentRevision)
600
+ delete stored.draftRevision;
601
+ else
602
+ stored.draftRevision = revision;
603
+ changed = true;
604
+ }
605
+ }
606
+ }
607
+ return changed;
608
+ }
564
609
  #synchronizeBuiltinDefault(document) {
565
610
  const preset = document.presets.find(({ builtinDefault }) => builtinDefault === true);
566
611
  if (preset === undefined || preset.draftRevision !== undefined)
@@ -1,3 +1,6 @@
1
+ import { isResolutionReceipt } from "../../protocol/resolution.js";
2
+ import { legacyModelBindingFailure, projectLegacyModelBindingFailure, } from "./LegacyModelBindingFailure.js";
3
+ import { isPlayFailureKind, } from "../../protocol/playFailure.js";
1
4
  import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
2
5
  import { encodePlayPromptRun, decodePlayPromptRun, } from "./PlayPromptRun.js";
3
6
  import { createHash, randomUUID } from "node:crypto";
@@ -71,7 +74,8 @@ export class FileNativePlayTimelineStore {
71
74
  const eventCount = prefixLength(input.source.events, input.target.events);
72
75
  const completedToolCount = prefixLength(input.source.completedTools, input.target.completedTools);
73
76
  const authorizationCheckpointCount = prefixLength(input.source.documentAuthorizationCheckpoints ?? [], input.target.documentAuthorizationCheckpoints ?? []);
74
- if (input.target.events.some((event) => event.kind === "assistant" && event.status === "completed")) {
77
+ if (input.retainCompleteContext ||
78
+ input.target.events.some((event) => event.kind === "assistant" && event.status === "completed")) {
75
79
  const encoding = await this.readInitialEncoding(input.sourceWorldId, input.source.chainId).catch(() => null);
76
80
  if (encoding !== null)
77
81
  await publishImmutableJson(join(targetRoot, "initial-request-encoding.json"), encoding);
@@ -207,6 +211,9 @@ export class FileNativePlayTimelineStore {
207
211
  continuationDigest,
208
212
  lastRequestAttempt: value.lastRequestAttempt,
209
213
  lastFailure: value.lastFailure,
214
+ ...(value.lastFailureKind === undefined
215
+ ? {}
216
+ : { lastFailureKind: value.lastFailureKind }),
210
217
  updatedAt: value.updatedAt,
211
218
  };
212
219
  await publishJson(join(root, "state.json"), state);
@@ -235,6 +242,20 @@ export class FileNativePlayTimelineStore {
235
242
  throw new Error("Play timeline head generation is inconsistent");
236
243
  return loaded;
237
244
  }
245
+ /** Read only the small active context index, never its transcript or bootstrap. */
246
+ async currentContextRequiresFreshContext(worldId) {
247
+ assertIdentity(worldId, "World ID");
248
+ const head = await this.#readTimelineHead(worldId);
249
+ if (head === null)
250
+ return false;
251
+ const index = await readJson(join(this.#contextRoot(worldId, head.chainId), "index.json"));
252
+ assertContextIndex(index);
253
+ if (index.worldId !== worldId ||
254
+ index.chainId !== head.chainId ||
255
+ index.timelineGeneration !== head.generation)
256
+ throw new Error("Play context durable identity is inconsistent");
257
+ return index.importedFromArchive === true;
258
+ }
238
259
  async readContext(worldId, chainId) {
239
260
  assertIdentity(worldId, "World ID");
240
261
  assertIdentity(chainId, "Call-chain ID");
@@ -277,6 +298,7 @@ export class FileNativePlayTimelineStore {
277
298
  chainId,
278
299
  previousChainId: indexValue.previousChainId,
279
300
  timelineGeneration: indexValue.timelineGeneration,
301
+ ...(indexValue.importedFromArchive ? { importedFromArchive: true } : {}),
280
302
  ...(baseValue.continuityContextId === undefined
281
303
  ? {}
282
304
  : { continuityContextId: baseValue.continuityContextId }),
@@ -327,6 +349,9 @@ export class FileNativePlayTimelineStore {
327
349
  lastRequest,
328
350
  lastRequestAttempt: stateValue.lastRequestAttempt,
329
351
  lastFailure: stateValue.lastFailure,
352
+ ...(stateValue.lastFailureKind === undefined
353
+ ? {}
354
+ : { lastFailureKind: stateValue.lastFailureKind }),
330
355
  updatedAt: stateValue.updatedAt,
331
356
  };
332
357
  return {
@@ -445,7 +470,10 @@ export class FileNativePlayTimelineStore {
445
470
  kind: "event",
446
471
  chainId: pointer.chainId,
447
472
  current: pointer.chainId === head?.chainId,
448
- event: summary,
473
+ ...(context.index.importedFromArchive
474
+ ? { requiresFreshContext: true }
475
+ : {}),
476
+ event: projectLegacyModelBindingFailure(summary),
449
477
  });
450
478
  pointer = { ...pointer, eventId: pointer.eventId - 1 };
451
479
  continue;
@@ -498,6 +526,9 @@ export class FileNativePlayTimelineStore {
498
526
  activeStatus: active?.state.status ?? null,
499
527
  activeCanRetry: active?.state.canRetry ?? false,
500
528
  activeLastFailure: active?.state.lastFailure ?? null,
529
+ ...(active?.state.lastFailureKind === undefined
530
+ ? {}
531
+ : { activeLastFailureKind: active.state.lastFailureKind }),
501
532
  items: reverse.reverse(),
502
533
  nextCursor: pointer.chainId === null && pointer.genesisIndex < 0
503
534
  ? null
@@ -516,7 +547,7 @@ export class FileNativePlayTimelineStore {
516
547
  assertPlayEvent(event);
517
548
  if (event.id !== eventId)
518
549
  throw new Error("Play timeline event identity is inconsistent");
519
- return normalizePlayEvent(event);
550
+ return projectLegacyModelBindingFailure(normalizePlayEvent(event));
520
551
  }
521
552
  newGeneration() {
522
553
  return `timeline:${randomUUID()}`;
@@ -597,11 +628,48 @@ export class FileNativePlayTimelineStore {
597
628
  indexValue.chainId !== chainId ||
598
629
  stateValue.chainId !== chainId)
599
630
  throw new Error("Play context durable identity is inconsistent");
631
+ // Old versions persisted a configuration preflight failure as a terminal
632
+ // context failure. Recover only the disposition proven by its event prefix.
633
+ // Archive contexts and genuine provider failures retain their restrictions.
634
+ if (!indexValue.importedFromArchive &&
635
+ stateValue.status === "interrupted" &&
636
+ stateValue.lastFailure === legacyModelBindingFailure &&
637
+ (stateValue.lastFailureKind === undefined ||
638
+ stateValue.lastFailureKind === "context_unavailable")) {
639
+ for (let id = stateValue.eventCount; id > 0; id -= 1) {
640
+ const event = await readJson(join(root, "events", numbered(id)));
641
+ assertPlayEvent(event);
642
+ if (event.kind === "followup" ||
643
+ (event.kind === "failure" &&
644
+ event.message === legacyModelBindingFailure))
645
+ continue;
646
+ if (event.kind === "assistant" &&
647
+ event.status === "completed" &&
648
+ event.responseKind === "narrative" &&
649
+ event.continuation === "available" &&
650
+ event.committedHead === stateValue.parentHead) {
651
+ stateValue.status = "ready";
652
+ stateValue.canRetry = false;
653
+ stateValue.lastFailure = null;
654
+ delete stateValue.lastFailureKind;
655
+ }
656
+ else if (event.kind === "failure") {
657
+ stateValue.canRetry = event.failureKind === "retryable";
658
+ stateValue.lastFailure = event.message;
659
+ if (event.failureKind === undefined)
660
+ delete stateValue.lastFailureKind;
661
+ else
662
+ stateValue.lastFailureKind = event.failureKind;
663
+ }
664
+ break;
665
+ }
666
+ }
600
667
  return { root, index: indexValue, state: stateValue };
601
668
  }
602
669
  }
603
670
  function contextIndex(value) {
604
671
  return {
672
+ ...(value.importedFromArchive ? { importedFromArchive: true } : {}),
605
673
  schemaVersion: 3,
606
674
  kind: "play_context_index",
607
675
  worldId: value.worldId,
@@ -724,6 +792,8 @@ function assertContextIndex(value) {
724
792
  if (!isRecord(value) ||
725
793
  value.schemaVersion !== 3 ||
726
794
  value.kind !== "play_context_index" ||
795
+ (value.importedFromArchive !== undefined &&
796
+ value.importedFromArchive !== true) ||
727
797
  typeof value.worldId !== "string" ||
728
798
  typeof value.chainId !== "string" ||
729
799
  (value.previousChainId !== null &&
@@ -769,6 +839,8 @@ function assertContextState(value) {
769
839
  !/^[a-f0-9]{64}$/u.test(value.continuationDigest) ||
770
840
  !validCount(value.lastRequestAttempt) ||
771
841
  (value.lastFailure !== null && typeof value.lastFailure !== "string") ||
842
+ (value.lastFailureKind !== undefined &&
843
+ !isPlayFailureKind(value.lastFailureKind)) ||
772
844
  typeof value.updatedAt !== "number")
773
845
  throw new Error("Play context state has an invalid shape");
774
846
  }
@@ -839,6 +911,8 @@ function isReleasedPlayContext(value) {
839
911
  (value.lastRequest !== null && !isRecord(value.lastRequest)) ||
840
912
  !validCount(value.lastRequestAttempt) ||
841
913
  (value.lastFailure !== null && typeof value.lastFailure !== "string") ||
914
+ (value.lastFailureKind !== undefined &&
915
+ !isPlayFailureKind(value.lastFailureKind)) ||
842
916
  typeof value.updatedAt !== "number")
843
917
  return false;
844
918
  try {
@@ -955,6 +1029,9 @@ function assertPlayEvent(value) {
955
1029
  return;
956
1030
  }
957
1031
  if (value.kind === "tool_result") {
1032
+ if (value.resolution !== undefined &&
1033
+ !isResolutionReceipt(value.resolution))
1034
+ throw new Error("Invalid resolution receipt");
958
1035
  if (typeof value.callId !== "string" ||
959
1036
  typeof value.name !== "string" ||
960
1037
  typeof value.ok !== "boolean" ||
@@ -976,7 +1053,10 @@ function assertPlayEvent(value) {
976
1053
  return;
977
1054
  }
978
1055
  if ((value.kind !== "failure" && value.kind !== "cancellation") ||
979
- typeof value.message !== "string")
1056
+ typeof value.message !== "string" ||
1057
+ (value.kind === "failure" &&
1058
+ value.failureKind !== undefined &&
1059
+ !isPlayFailureKind(value.failureKind)))
980
1060
  throw new Error("Play timeline event has an invalid kind");
981
1061
  }
982
1062
  function assertTimelineEventSummary(value) {
@@ -1015,6 +1095,9 @@ function assertTimelineEventSummary(value) {
1015
1095
  return;
1016
1096
  }
1017
1097
  if (value.kind === "tool_result") {
1098
+ if (value.resolution !== undefined &&
1099
+ !isResolutionReceipt(value.resolution))
1100
+ throw new Error("Invalid resolution receipt");
1018
1101
  if (typeof value.callId !== "string" ||
1019
1102
  typeof value.name !== "string" ||
1020
1103
  typeof value.ok !== "boolean" ||
@@ -0,0 +1,10 @@
1
+ export const legacyModelBindingFailure = "The selected model connection does not match the frozen model binding; start a fresh context.";
2
+ /** Presentation only: immutable historical events remain unchanged on disk. */
3
+ export function projectLegacyModelBindingFailure(event) {
4
+ if (event.kind === "failure" &&
5
+ event.message === legacyModelBindingFailure &&
6
+ (event.failureKind === undefined ||
7
+ event.failureKind === "context_unavailable"))
8
+ return { ...event, failureKind: "configuration_mismatch" };
9
+ return event;
10
+ }