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
@@ -1,3 +1,6 @@
1
+ import { projectLegacyModelBindingFailure } from "./LegacyModelBindingFailure.js";
2
+ import { classifyPlayFailure } from "./PlayFailure.js";
3
+ import { isResolutionTool, resolutionMarkdown, resolutionToolNames, resolveTabletop, seededRandom, } from "./TabletopResolution.js";
1
4
  import { captureNarrativeOrigin, snapshotBackground, narrativeBackground, } from "../history/HistoryBackground.js";
2
5
  import { isDeepStrictEqual } from "node:util";
3
6
  import { hasLegacyHistoryTools } from "../prompt/FileNativeToolRegistry.js";
@@ -10,12 +13,13 @@ import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepN
10
13
  import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
11
14
  import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
12
15
  import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
13
- import { equalModelHostBinding, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
16
+ import { equalModelHostBinding, ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
14
17
  import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
15
18
  import { parsePlayPresetFiles, } from "./FileNativePlayPresetStore.js";
16
19
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
17
20
  import { FileNativePlayDocuments, fingerprintControl, } from "./PlayDocumentTools.js";
18
21
  const callChainToolNames = new Set([
22
+ ...resolutionToolNames,
19
23
  "state_list",
20
24
  "history_list",
21
25
  "history_search",
@@ -162,6 +166,7 @@ export class PlayCallChain {
162
166
  this.#active.set(session.chainId, session);
163
167
  this.#worldChains.set(session.worldId, session.chainId);
164
168
  await this.#persist(session);
169
+ await this.#worlds.completeArchiveFreshContext(session.worldId);
165
170
  return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, signal, input.observer);
166
171
  }
167
172
  async append(input) {
@@ -169,6 +174,8 @@ export class PlayCallChain {
169
174
  }
170
175
  async #append(input, signal) {
171
176
  validateIdentity(input.exchangeId, "exchange ID");
177
+ if (await this.#worlds.archiveNeedsFreshContext(input.worldId))
178
+ throw new PlayCallChainError("This world was imported from a backup; start a fresh context before continuing.");
172
179
  const session = await this.#requireChain(input.worldId, input.chainId);
173
180
  await this.#assertCurrentHead(session);
174
181
  assertSessionModelHostBinding(session, input.modelHost);
@@ -178,6 +185,11 @@ export class PlayCallChain {
178
185
  if (session.status === "interrupted") {
179
186
  if (!session.canRetry || session.lastRequest === null)
180
187
  throw new PlayCallChainError("The current model context cannot continue; use a fresh context.");
188
+ // Pre-snapshot requests have only a binding fingerprint. Do not invent
189
+ // their original encoding settings from a newly selected connection.
190
+ if (session.lastRequest.modelConfig === undefined &&
191
+ !equalModelHostBinding(session.modelBinding, input.modelHost.binding()))
192
+ throw new PlayCallChainError("This older saved request has no model configuration snapshot. Restore its original settings to retry it, or start a fresh context.");
181
193
  return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, signal, input.observer);
182
194
  }
183
195
  await this.#refreshPrompt(session, input);
@@ -464,7 +476,8 @@ export class PlayCallChain {
464
476
  chainId: session.chainId,
465
477
  baselineHead: session.baselineHead,
466
478
  parentHead: session.parentHead,
467
- stale: session.parentHead !== worldHead,
479
+ stale: session.parentHead !== worldHead ||
480
+ (await this.#worlds.archiveNeedsFreshContext(worldId)),
468
481
  playPreset: structuredClone(recordedPlayPrompt(session).playPreset),
469
482
  updatedAt: session.updatedAt,
470
483
  bootstrap: {
@@ -477,17 +490,25 @@ export class PlayCallChain {
477
490
  async deriveWorld(input) {
478
491
  const result = await this.#worlds.deriveWorld({
479
492
  ...input,
480
- stageTarget: async ({ targetWorldRoot, targetWorldId, binding }) => {
493
+ stageTarget: async ({ targetWorldRoot, targetWorldId, binding, sourceHead, }) => {
481
494
  await this.stageForkToDerivedWorld({
482
495
  sourceWorldId: input.sourceWorldId,
483
- sourceHead: input.sourceHead,
496
+ sourceHead,
484
497
  targetWorldId,
485
498
  targetWorldRoot,
486
499
  targetBinding: binding,
500
+ ...(input.retainCompleteTimeline
501
+ ? { retainCompleteTimeline: true }
502
+ : {}),
503
+ ...(input.identityPrefix === "import"
504
+ ? { markImportedContexts: true }
505
+ : {}),
487
506
  });
488
507
  },
489
508
  });
490
- if ((await this.inspectWorld(result.world.worldId)) === null)
509
+ if (input.targetDataRoot === undefined &&
510
+ input.publish !== false &&
511
+ (await this.inspectWorld(result.world.worldId)) === null)
491
512
  await this.forkToDerivedWorld({
492
513
  sourceWorldId: input.sourceWorldId,
493
514
  sourceHead: input.sourceHead,
@@ -523,10 +544,14 @@ export class PlayCallChain {
523
544
  if (source === null)
524
545
  return null;
525
546
  const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.sourceWorldId);
526
- const selectedContextIndex = sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
547
+ const selectedContextIndex = input.retainCompleteTimeline
548
+ ? sourceContexts.length - 1
549
+ : sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
527
550
  if (selectedContextIndex < 0)
528
551
  return null;
529
- const sourceEvents = eventsThroughHead(sourceContexts[selectedContextIndex].events, input.sourceHead);
552
+ const sourceEvents = input.retainCompleteTimeline
553
+ ? sourceContexts[selectedContextIndex].events
554
+ : eventsThroughHead(sourceContexts[selectedContextIndex].events, input.sourceHead);
530
555
  return this.#forkSelectionToDerivedWorld({
531
556
  sourceWorldId: input.sourceWorldId,
532
557
  sourceContexts,
@@ -537,6 +562,8 @@ export class PlayCallChain {
537
562
  branchIdentity: input.sourceHead,
538
563
  targetWorldRoot: input.targetWorldRoot,
539
564
  targetBinding: input.targetBinding,
565
+ ...(input.retainCompleteTimeline ? { retainCompleteTimeline: true } : {}),
566
+ ...(input.markImportedContexts ? { markImportedContexts: true } : {}),
540
567
  });
541
568
  }
542
569
  /**
@@ -604,6 +631,9 @@ export class PlayCallChain {
604
631
  if (selectedContextIndex < 0)
605
632
  throw new PlayCallChainError("The model context to revise does not exist.");
606
633
  const sourceContext = sourceContexts[selectedContextIndex];
634
+ if (input.continuation === "continue_context" &&
635
+ sourceContext.importedFromArchive)
636
+ throw new PlayCallChainError("This model context was imported from a backup; start a fresh context before continuing.");
607
637
  const selectedEventIndex = sourceContext.events.findIndex((event) => event.id === input.eventId);
608
638
  const selectedEvent = sourceContext.events[selectedEventIndex];
609
639
  if (selectedEventIndex < 0 ||
@@ -632,10 +662,15 @@ export class PlayCallChain {
632
662
  const timelineGeneration = playerRevisionTimelineGeneration(input.source.timelineGeneration, request.operationId, request.worldId, request.continuation);
633
663
  const requestFingerprint = playerRevisionRequestFingerprint(request, input.selectedEvent.committedHead, restoresHead);
634
664
  const traceRetention = preparePlayerRevisionTrace(sourceContext, input.selectedEventIndex);
665
+ // A timeline revision appends on the current audit head and restores the
666
+ // selected message's logical parent, so a chain that lags the world (an
667
+ // out-of-story world revision, or an earlier revision whose chain was not
668
+ // published) must not block it. The commit still rechecks this head under
669
+ // the world lock.
635
670
  const outcome = await this.#worlds.reviseTimeline({
636
671
  operationId: request.operationId,
637
672
  worldId: request.worldId,
638
- expectedCurrentHead: input.source.parentHead,
673
+ expectedCurrentHead: await this.#worlds.currentHead(request.worldId),
639
674
  restoresHead,
640
675
  replacesHead: input.selectedEvent.committedHead,
641
676
  replacementText: request.replacementText,
@@ -646,11 +681,10 @@ export class PlayCallChain {
646
681
  this.#worlds.recoverEndpoint(request.worldId, restoresHead),
647
682
  this.#worlds.bindPlayCallChain(request.worldId),
648
683
  ]);
649
- const continuedTrace = traceRetention.withReplacement(selected, {
684
+ const continuedTrace = traceRetention.withReplacement({
650
685
  head: outcome.head,
651
686
  exchangeId: request.replacementExchangeId,
652
687
  text: request.replacementText,
653
- locale: this.#compiler.locale,
654
688
  });
655
689
  if (binding.parentHead !== outcome.head)
656
690
  throw new PlayCallChainError("The timeline revision was committed, but current-world materialization has not reached the new endpoint.");
@@ -672,6 +706,7 @@ export class PlayCallChain {
672
706
  freshPreparation,
673
707
  });
674
708
  await this.#worlds.playTimeline.persist(structuredClone(revised));
709
+ await this.#worlds.completeArchiveFreshContext(request.worldId);
675
710
  const activeId = this.#worldChains.get(request.worldId);
676
711
  if (activeId !== undefined)
677
712
  this.#active.delete(activeId);
@@ -799,13 +834,7 @@ export class PlayCallChain {
799
834
  layout: "world_state",
800
835
  files: Object.entries(input.binding.files).map(([path, contents]) => ({ path, contents })),
801
836
  }), 1),
802
- transcript: playerInputAppend({
803
- history: fresh.binding.history,
804
- checkpoint: fresh.binding.narrativeCheckpoint,
805
- text: request.replacementText,
806
- locale: this.#compiler.locale,
807
- checkpointAvailable: fresh.tools.some(({ name }) => name === "world_checkpoint"),
808
- }),
837
+ transcript: playerInputAppend(request.replacementText),
809
838
  events: [
810
839
  {
811
840
  id: 1,
@@ -843,22 +872,30 @@ export class PlayCallChain {
843
872
  if (input.targetWorldRoot === undefined &&
844
873
  (await this.#worlds.currentHead(input.targetWorldId)) !== input.sourceHead)
845
874
  throw new PlayCallChainError("The forked world's Authority does not match the selected history endpoint, so the call chain cannot be written.");
846
- const [baseline, selected] = await Promise.all([
847
- this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
848
- ? input.targetWorldId
849
- : input.sourceWorldId, sourceContext.baselineHead),
850
- this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
851
- ? input.targetWorldId
852
- : input.sourceWorldId, input.sourceHead),
853
- ]);
854
- const traceRetention = prepareForkTrace(sourceContext, input.sourceEvents);
855
- const derivedBinding = input.targetBinding ??
856
- (await this.#worlds.bindPlayCallChain(input.targetWorldId));
857
- const retainedTrace = traceRetention.atEndpoint({
858
- baseline,
859
- selected,
860
- binding: derivedBinding,
861
- });
875
+ let retainedTrace;
876
+ if (input.retainCompleteTimeline) {
877
+ // A backup retains the current page closure, even when its last context
878
+ // precedes a correction or contains an uncommitted response diagnostic.
879
+ retainedTrace = independentContextCopy(sourceContext);
880
+ }
881
+ else {
882
+ const [baseline, selected] = await Promise.all([
883
+ this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
884
+ ? input.targetWorldId
885
+ : input.sourceWorldId, sourceContext.baselineHead),
886
+ this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
887
+ ? input.targetWorldId
888
+ : input.sourceWorldId, input.sourceHead),
889
+ ]);
890
+ const traceRetention = prepareForkTrace(sourceContext, input.sourceEvents);
891
+ const derivedBinding = input.targetBinding ??
892
+ (await this.#worlds.bindPlayCallChain(input.targetWorldId));
893
+ retainedTrace = traceRetention.atEndpoint({
894
+ baseline,
895
+ selected,
896
+ binding: derivedBinding,
897
+ });
898
+ }
862
899
  const now = Date.now();
863
900
  const timelineGeneration = this.#worlds.playTimeline.newGeneration();
864
901
  let previousChainId = null;
@@ -868,6 +905,7 @@ export class PlayCallChain {
868
905
  const chainId = derivedChainId(context.chainId, `prefix:${input.branchIdentity}:${index}`, input.targetWorldId);
869
906
  const previousContext = {
870
907
  ...independentContextCopy(context),
908
+ ...(input.markImportedContexts ? { importedFromArchive: true } : {}),
871
909
  schemaVersion: 3,
872
910
  kind: "play_call_chain",
873
911
  worldId: input.targetWorldId,
@@ -884,11 +922,15 @@ export class PlayCallChain {
884
922
  targetWorldRoot: input.targetWorldRoot,
885
923
  source: context,
886
924
  target: previousContext,
925
+ ...(input.retainCompleteTimeline
926
+ ? { retainCompleteContext: true }
927
+ : {}),
887
928
  });
888
929
  previousChainId = chainId;
889
930
  }
890
931
  const derived = {
891
932
  ...retainedTrace,
933
+ ...(input.markImportedContexts ? { importedFromArchive: true } : {}),
892
934
  schemaVersion: 3,
893
935
  kind: "play_call_chain",
894
936
  chainId: derivedChainId(sourceContext.chainId, input.branchIdentity, input.targetWorldId),
@@ -896,7 +938,9 @@ export class PlayCallChain {
896
938
  previousContexts: [],
897
939
  previousChainId,
898
940
  timelineGeneration,
899
- parentHead: input.sourceHead,
941
+ parentHead: input.retainCompleteTimeline
942
+ ? sourceContext.parentHead
943
+ : input.sourceHead,
900
944
  updatedAt: now,
901
945
  };
902
946
  if (input.targetWorldRoot === undefined)
@@ -907,6 +951,9 @@ export class PlayCallChain {
907
951
  targetWorldRoot: input.targetWorldRoot,
908
952
  source: sourceContext,
909
953
  target: structuredClone(derived),
954
+ ...(input.retainCompleteTimeline
955
+ ? { retainCompleteContext: true }
956
+ : {}),
910
957
  });
911
958
  if (input.targetWorldRoot !== undefined) {
912
959
  const retainedContexts = [
@@ -960,13 +1007,7 @@ export class PlayCallChain {
960
1007
  const exchange = session.exchange + 1;
961
1008
  const nextRequest = createRequest(session, modelHost, exchange, [
962
1009
  ...session.transcript,
963
- ...playerInputAppend({
964
- history: Object.fromEntries(session.history.map(({ path, contents }) => [path, contents])),
965
- checkpoint: session.narrativeCheckpoint,
966
- text: playerText,
967
- locale: this.#compiler.locale,
968
- checkpointAvailable: currentPlayPrompt(session).tools.some(({ name }) => name === "world_checkpoint"),
969
- }),
1010
+ ...playerInputAppend(playerText),
970
1011
  ]);
971
1012
  const operationKey = `player:${exchangeId}`;
972
1013
  const playerOperationId = operationId(session.chainId, operationKey);
@@ -1006,14 +1047,17 @@ export class PlayCallChain {
1006
1047
  const message = error instanceof Error
1007
1048
  ? error.message
1008
1049
  : "Failed to write the original player text.";
1050
+ const failureKind = classifyPlayFailure(error, false);
1009
1051
  session.events.push({
1010
1052
  id: session.nextEventId++,
1011
1053
  kind: "failure",
1012
1054
  message,
1055
+ failureKind,
1013
1056
  });
1014
1057
  session.status = "interrupted";
1015
1058
  session.canRetry = false;
1016
1059
  session.lastFailure = message;
1060
+ session.lastFailureKind = failureKind;
1017
1061
  await this.#persist(session);
1018
1062
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1019
1063
  throw new PlayCallChainError(message);
@@ -1077,6 +1121,7 @@ export class PlayCallChain {
1077
1121
  session.canRetry = true;
1078
1122
  session.lastFailure =
1079
1123
  "The player message is committed; the frozen model request is ready to continue.";
1124
+ session.lastFailureKind = "retryable";
1080
1125
  await this.#persist(session);
1081
1126
  crashAtPlayAdvanceEdge("after_timeline_settled");
1082
1127
  await this.#worlds.playAdvances.markSettled(advance, outcome.head);
@@ -1114,8 +1159,15 @@ export class PlayCallChain {
1114
1159
  const prepared = [];
1115
1160
  const trailingEvents = [];
1116
1161
  let nextEventId = advance.eventId + 1;
1162
+ // One persisted seed per advance: every resolution call draws from it by
1163
+ // call identity, so recovery reproduces the same receipts.
1164
+ const resolutionSeed = calls.some(({ name }) => isResolutionTool(name))
1165
+ ? await this.#worlds.playAdvances.resolutionSeed(advance)
1166
+ : undefined;
1117
1167
  for (const call of calls) {
1118
- const item = prepareTool(session, workingTools, advance.exchange, call, this.#compiler.locale);
1168
+ const item = prepareTool(session, workingTools, advance.exchange, call, this.#compiler.locale, resolutionSeed);
1169
+ if (item.result.resolution !== undefined)
1170
+ crashAtPlayAdvanceEdge("after_resolution");
1119
1171
  prepared.push(item);
1120
1172
  trailingEvents.push({
1121
1173
  id: nextEventId++,
@@ -1343,6 +1395,7 @@ export class PlayCallChain {
1343
1395
  session.exchange = Math.max(session.exchange, advance.exchange);
1344
1396
  session.canRetry = false;
1345
1397
  session.lastFailure = null;
1398
+ delete session.lastFailureKind;
1346
1399
  let result;
1347
1400
  if (settlement.assistantEvent.continuation !== "available") {
1348
1401
  const message = "The complete model response has no provider-native continuation payload; use a fresh context.";
@@ -1350,20 +1403,25 @@ export class PlayCallChain {
1350
1403
  id: session.nextEventId++,
1351
1404
  kind: "failure",
1352
1405
  message,
1406
+ failureKind: "context_unavailable",
1353
1407
  });
1354
1408
  session.status = "interrupted";
1355
1409
  session.lastFailure = message;
1410
+ session.lastFailureKind = "context_unavailable";
1356
1411
  result = { kind: "terminal", status: "interrupted" };
1357
1412
  }
1358
1413
  else if (hasToolCalls) {
1359
1414
  session.exchange += 1;
1360
1415
  const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange);
1416
+ if (session.lastRequest?.modelConfig !== undefined)
1417
+ nextRequest.modelConfig = structuredClone(session.lastRequest.modelConfig);
1361
1418
  session.lastRequest = structuredClone(nextRequest);
1362
1419
  session.lastRequestAttempt = 0;
1363
1420
  session.status = "interrupted";
1364
1421
  session.canRetry = true;
1365
1422
  session.lastFailure =
1366
1423
  "The completed tool response is settled; its frozen continuation request is ready.";
1424
+ session.lastFailureKind = "retryable";
1367
1425
  result = { kind: "continue", nextRequest };
1368
1426
  }
1369
1427
  else {
@@ -1374,8 +1432,10 @@ export class PlayCallChain {
1374
1432
  id: session.nextEventId++,
1375
1433
  kind: "failure",
1376
1434
  message,
1435
+ failureKind: "other",
1377
1436
  });
1378
1437
  session.lastFailure = message;
1438
+ session.lastFailureKind = "other";
1379
1439
  }
1380
1440
  result = { kind: "terminal", status: session.status };
1381
1441
  }
@@ -1399,7 +1459,7 @@ export class PlayCallChain {
1399
1459
  return this.#settleResponseAdvance(session, advance, current.settlementPrepared.settlement);
1400
1460
  }
1401
1461
  async #dispatch(session, modelHost, firstRequest, firstAttempt, signal, observer) {
1402
- let request = structuredClone(firstRequest);
1462
+ let request = modelHost.freezeRequest?.(firstRequest) ?? structuredClone(firstRequest);
1403
1463
  let attempt = firstAttempt;
1404
1464
  for (;;) {
1405
1465
  this.#setInvocationAbortable(session.chainId, true);
@@ -1409,6 +1469,7 @@ export class PlayCallChain {
1409
1469
  session.status = "running";
1410
1470
  session.canRetry = false;
1411
1471
  session.lastFailure = null;
1472
+ delete session.lastFailureKind;
1412
1473
  session.lastRequest = structuredClone(request);
1413
1474
  session.lastRequestAttempt = attempt;
1414
1475
  const responseExchange = request.exchange ?? session.exchange;
@@ -1500,20 +1561,31 @@ export class PlayCallChain {
1500
1561
  const message = error instanceof Error
1501
1562
  ? error.message
1502
1563
  : "The model request was interrupted.";
1503
- session.events.push({
1504
- id: session.nextEventId++,
1505
- kind: error instanceof ModelHostCancelledError
1506
- ? "cancellation"
1507
- : "failure",
1508
- message,
1509
- });
1510
1564
  session.status = "interrupted";
1511
- const requiresFreshContext = error instanceof ModelHostContinuationError ||
1565
+ const requiresFreshContext = error instanceof ModelHostBindingMismatchError ||
1566
+ error instanceof ModelHostContinuationError ||
1512
1567
  error instanceof ModelHostOutcomeUnknownError ||
1513
1568
  modelHostFailureRequiresFreshContext(error);
1514
1569
  session.canRetry =
1515
1570
  !requiresFreshContext && error instanceof ModelHostFailureError;
1516
1571
  session.lastFailure = message;
1572
+ if (error instanceof ModelHostCancelledError) {
1573
+ delete session.lastFailureKind;
1574
+ session.events.push({
1575
+ id: session.nextEventId++,
1576
+ kind: "cancellation",
1577
+ message,
1578
+ });
1579
+ }
1580
+ else {
1581
+ session.lastFailureKind = classifyPlayFailure(error, session.canRetry);
1582
+ session.events.push({
1583
+ id: session.nextEventId++,
1584
+ kind: "failure",
1585
+ message,
1586
+ failureKind: session.lastFailureKind,
1587
+ });
1588
+ }
1517
1589
  await this.#persist(session);
1518
1590
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1519
1591
  this.#notifySnapshot(observer, session);
@@ -1631,14 +1703,17 @@ export class PlayCallChain {
1631
1703
  },
1632
1704
  ],
1633
1705
  });
1706
+ const failureKind = classifyPlayFailure(error, false);
1634
1707
  session.events.push({
1635
1708
  id: session.nextEventId++,
1636
1709
  kind: "failure",
1637
1710
  message,
1711
+ failureKind,
1638
1712
  });
1639
1713
  session.status = "interrupted";
1640
1714
  session.canRetry = false;
1641
1715
  session.lastFailure = message;
1716
+ session.lastFailureKind = failureKind;
1642
1717
  await this.#persist(session);
1643
1718
  this.#notifySnapshot(observer, session);
1644
1719
  return projectView(session);
@@ -1662,6 +1737,7 @@ export class PlayCallChain {
1662
1737
  session.lastRequest = structuredClone(request);
1663
1738
  session.lastRequestAttempt = Math.max(0, attempt - 1);
1664
1739
  session.lastFailure = message;
1740
+ delete session.lastFailureKind;
1665
1741
  await this.#persist(session);
1666
1742
  if (advance !== undefined)
1667
1743
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
@@ -1881,14 +1957,21 @@ export class PlayCallChain {
1881
1957
  : canRetry
1882
1958
  ? "The service stopped before model dispatch; the frozen request can be retried."
1883
1959
  : "The service stopped before preparing a model request; use a fresh context.";
1960
+ const failureKind = dispatchMayHaveStarted
1961
+ ? "outcome_unknown"
1962
+ : canRetry
1963
+ ? "retryable"
1964
+ : "other";
1884
1965
  session.events.push({
1885
1966
  id: session.nextEventId++,
1886
1967
  kind: "failure",
1887
1968
  message,
1969
+ failureKind,
1888
1970
  });
1889
1971
  session.status = "interrupted";
1890
1972
  session.canRetry = canRetry;
1891
1973
  session.lastFailure = message;
1974
+ session.lastFailureKind = failureKind;
1892
1975
  await this.#persist(session);
1893
1976
  }
1894
1977
  async #assertCurrentHead(session) {
@@ -1923,7 +2006,8 @@ function followupOperationId(session) {
1923
2006
  return `${session.chainId}:followup:${session.parentHead}`;
1924
2007
  }
1925
2008
  function createRequest(session, modelHost, exchange, appended = session.transcript) {
1926
- return createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
2009
+ const request = createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
2010
+ return modelHost.freezeRequest?.(request) ?? request;
1927
2011
  }
1928
2012
  function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, appended = session.transcript) {
1929
2013
  return {
@@ -1998,7 +2082,7 @@ function appendCommittedHistory(session, head, messages) {
1998
2082
  });
1999
2083
  }
2000
2084
  }
2001
- function prepareTool(session, working, exchange, call, locale) {
2085
+ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2002
2086
  const key = `${exchange}:${call.id}`;
2003
2087
  const signature = JSON.stringify(call.arguments);
2004
2088
  const completed = working.get(key);
@@ -2025,8 +2109,9 @@ function prepareTool(session, working, exchange, call, locale) {
2025
2109
  }
2026
2110
  else {
2027
2111
  try {
2028
- result =
2029
- call.name === "world_checkpoint"
2112
+ result = isResolutionTool(call.name)
2113
+ ? resolutionToolResult(call.name, call.arguments, resolutionSeed, key, locale)
2114
+ : call.name === "world_checkpoint"
2030
2115
  ? typeof call.arguments === "object" &&
2031
2116
  call.arguments !== null &&
2032
2117
  !Array.isArray(call.arguments) &&
@@ -2069,6 +2154,9 @@ function prepareTool(session, working, exchange, call, locale) {
2069
2154
  name: call.name,
2070
2155
  ok: result.ok,
2071
2156
  markdown: result.markdown,
2157
+ ...(result.resolution === undefined
2158
+ ? {}
2159
+ : { resolution: structuredClone(result.resolution) }),
2072
2160
  replayed,
2073
2161
  };
2074
2162
  return {
@@ -2108,7 +2196,9 @@ function mergeChangedDocuments(session, changes) {
2108
2196
  }
2109
2197
  }
2110
2198
  function projectContext(context) {
2199
+ const response = context.events.findLast((event) => event.kind === "assistant" && event.status === "completed");
2111
2200
  return {
2201
+ ...(context.importedFromArchive ? { requiresFreshContext: true } : {}),
2112
2202
  chainId: context.chainId,
2113
2203
  baselineHead: context.baselineHead,
2114
2204
  ...(context.baselineHistoryLength === undefined
@@ -2116,6 +2206,22 @@ function projectContext(context) {
2116
2206
  : { baselineHistoryLength: context.baselineHistoryLength }),
2117
2207
  parentHead: context.parentHead,
2118
2208
  playPreset: structuredClone(recordedPlayPrompt(context).playPreset),
2209
+ ...(context.modelBinding === undefined
2210
+ ? {}
2211
+ : {
2212
+ contextWindowTokens: context.lastRequest?.modelConfig?.contextWindowTokens ??
2213
+ context.modelBinding.contextWindowTokens,
2214
+ }),
2215
+ ...(response?.kind !== "assistant"
2216
+ ? {}
2217
+ : {
2218
+ latestCompletedResponse: {
2219
+ id: response.id,
2220
+ ...(response.usage === undefined
2221
+ ? {}
2222
+ : { usage: structuredClone(response.usage) }),
2223
+ },
2224
+ }),
2119
2225
  status: context.status,
2120
2226
  canRetry: context.canRetry,
2121
2227
  events: context.events
@@ -2123,6 +2229,9 @@ function projectContext(context) {
2123
2229
  .map((event) => projectActivityEvent(event)),
2124
2230
  changedDocuments: structuredClone(context.changedDocuments),
2125
2231
  lastFailure: context.lastFailure,
2232
+ ...(context.lastFailureKind === undefined
2233
+ ? {}
2234
+ : { lastFailureKind: context.lastFailureKind }),
2126
2235
  updatedAt: context.updatedAt,
2127
2236
  };
2128
2237
  }
@@ -2130,7 +2239,7 @@ function projectActivityEvent(event) {
2130
2239
  if (event.kind === "player" ||
2131
2240
  event.kind === "failure" ||
2132
2241
  event.kind === "cancellation")
2133
- return structuredClone(event);
2242
+ return structuredClone(projectLegacyModelBindingFailure(event));
2134
2243
  if (event.kind === "assistant") {
2135
2244
  const { reasoning, toolFragment, usage, ...summary } = event;
2136
2245
  void reasoning;
@@ -2296,7 +2405,8 @@ function assertModelHostBinding(frozen, modelHost) {
2296
2405
  function assertSessionModelHostBinding(session, modelHost) {
2297
2406
  if (session.modelBinding === undefined)
2298
2407
  throw new PlayCallChainError("This model context predates durable provider continuation bindings; start a fresh context.");
2299
- assertModelHostBinding(session.modelBinding, modelHost);
2408
+ if (session.modelBinding.provider !== modelHost.binding().provider)
2409
+ throw new PlayCallChainError("The selected model protocol differs from this context. Switch back to its original protocol to continue, or start a fresh context.", "model_protocol_mismatch");
2300
2410
  }
2301
2411
  function validateIdentity(value, label) {
2302
2412
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u.test(value))
@@ -2317,6 +2427,16 @@ function notifyAssistantDelta(observer, delta) {
2317
2427
  // Like snapshot projection, the Provider exchange outlives its browser stream.
2318
2428
  }
2319
2429
  }
2430
+ function resolutionToolResult(name, args, seed, identity, locale) {
2431
+ if (seed === undefined)
2432
+ throw new Error("The resolution seed for this advance is unavailable.");
2433
+ const resolution = resolveTabletop(name, args, seededRandom(seed, identity));
2434
+ return {
2435
+ ok: true,
2436
+ markdown: resolutionMarkdown(resolution, locale),
2437
+ resolution,
2438
+ };
2439
+ }
2320
2440
  function crashAtPlayAdvanceEdge(edge) {
2321
2441
  if (process.env.NODE_ENV === "test" &&
2322
2442
  process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_PLAY_ADVANCE_EDGE === edge)
@@ -1,6 +1,8 @@
1
1
  export class PlayCallChainError extends Error {
2
- constructor(message) {
2
+ code;
3
+ constructor(message, code = "invalid_request") {
3
4
  super(message);
4
5
  this.name = "PlayCallChainError";
6
+ this.code = code;
5
7
  }
6
8
  }
@@ -28,16 +28,7 @@ export function checkpointHistory(history, checkpoint) {
28
28
  .slice(cutoff + 1)
29
29
  .filter(([id]) => !id.includes("message.genesis"));
30
30
  }
31
- export function playerRoundMarker(rounds, hasCheckpoint, locale) {
32
- const markdown = locale === "zh-CN"
33
- ? `距上次检查点已完成 ${rounds} 回合。${hasCheckpoint ? "" : "当前尚无检查点,以世界起点计数。"}`
34
- : `Completed player rounds since the last checkpoint: ${rounds}.${hasCheckpoint ? "" : " No checkpoint yet; counting from the world origin."}`;
35
- return {
36
- kind: "runtime_notice",
37
- notice: "checkpoint_rounds",
38
- text: `${locale === "zh-CN" ? "[Runtime 回合提示]" : "[Runtime round marker]"}\n${markdown}`,
39
- };
40
- }
31
+ /** Retain released markers when reconstructing frozen transcript prefixes. */
41
32
  export function isPlayerRoundMarker(item) {
42
33
  return item?.kind === "runtime_notice" && item.notice === "checkpoint_rounds";
43
34
  }
@@ -59,16 +50,8 @@ export function toolStepNotice(locale) {
59
50
  : "[Runtime settlement]\nThe previous response called tools, so its text has not yet been displayed or committed as final story. The tools have settled as their receipts state. Use those receipts to present this turn’s still-unshown content in nonempty final prose with no tool calls, consistent with committed state. Tool settlement does not require advancing the story beyond that content.",
60
51
  };
61
52
  }
62
- export function playerInputAppend(input) {
63
- return [
64
- ...(input.text.trim().length > 0 && input.checkpointAvailable
65
- ? [
66
- playerRoundMarker(completedPlayerRounds(input.history) -
67
- (input.checkpoint?.completedPlayerRounds ?? 0), input.checkpoint !== undefined, input.locale),
68
- ]
69
- : []),
70
- { kind: "player", text: input.text },
71
- ];
53
+ export function playerInputAppend(text) {
54
+ return [{ kind: "player", text }];
72
55
  }
73
56
  export function checkpointReplayBlocks(history, checkpoint, locale, excluded = new Set(), snapshot = new HistoryQuery("checkpoint-replay", historyInputs(Object.entries(history).map(([path, contents]) => ({ path, contents }))))) {
74
57
  const eligible = checkpointHistory(history, checkpoint);
@@ -0,0 +1,20 @@
1
+ import { ModelHostBindingMismatchError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
2
+ /** Classification does not grant permission to replay a request. */
3
+ export function classifyPlayFailure(error, canRetry) {
4
+ if (error instanceof ModelHostOutcomeUnknownError)
5
+ return "outcome_unknown";
6
+ if (error instanceof ModelHostContinuationError ||
7
+ error instanceof ModelHostBindingMismatchError)
8
+ return "context_unavailable";
9
+ if (error instanceof ModelHostFailureError) {
10
+ if (typeof error.details === "object" &&
11
+ error.details !== null &&
12
+ "code" in error.details &&
13
+ error.details.code === "model_output_limit")
14
+ return "output_limit";
15
+ if (modelHostFailureRequiresFreshContext(error))
16
+ return "context_limit";
17
+ return canRetry ? "retryable" : "provider_error";
18
+ }
19
+ return canRetry ? "retryable" : "other";
20
+ }