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
@@ -1,3 +1,7 @@
1
+ import { projectLegacyModelBindingFailure } from "./LegacyModelBindingFailure.js";
2
+ import { classifyPlayFailure } from "./PlayFailure.js";
3
+ import { captureNarrativeOrigin, snapshotBackground, narrativeBackground, } from "../history/HistoryBackground.js";
4
+ import { isDeepStrictEqual } from "node:util";
1
5
  import { hasLegacyHistoryTools } from "../prompt/FileNativeToolRegistry.js";
2
6
  import { PlayCallChainError } from "./PlayCallChainError.js";
3
7
  import { preparePlayerRevisionTrace, prepareForkTrace, restorePlayDocuments, independentContextCopy, } from "./PlayTraceRetention.js";
@@ -8,7 +12,7 @@ import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepN
8
12
  import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
9
13
  import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
10
14
  import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
11
- import { equalModelHostBinding, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
15
+ import { equalModelHostBinding, ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
12
16
  import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
13
17
  import { parsePlayPresetFiles, } from "./FileNativePlayPresetStore.js";
14
18
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
@@ -88,6 +92,7 @@ export class PlayCallChain {
88
92
  documentSnapshot: documents.snapshot,
89
93
  additionalMaterials: structuredClone(binding.additionalMaterials),
90
94
  history: structuredClone(binding.history),
95
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
91
96
  narrativeCheckpoint: binding.narrativeCheckpoint,
92
97
  documentMaintenance,
93
98
  documentMaintenanceUnavailableReason: documents.maintenanceWarning,
@@ -144,7 +149,7 @@ export class PlayCallChain {
144
149
  lastFailure: null,
145
150
  updatedAt: now,
146
151
  documents,
147
- history: historyEntries(binding.history),
152
+ history: historyEntries(binding.history, binding.historyBackgrounds),
148
153
  narrativeCheckpoint: binding.narrativeCheckpoint,
149
154
  completedToolMap: new Map(),
150
155
  persistenceCursor: {
@@ -159,6 +164,7 @@ export class PlayCallChain {
159
164
  this.#active.set(session.chainId, session);
160
165
  this.#worldChains.set(session.worldId, session.chainId);
161
166
  await this.#persist(session);
167
+ await this.#worlds.completeArchiveFreshContext(session.worldId);
162
168
  return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, signal, input.observer);
163
169
  }
164
170
  async append(input) {
@@ -166,6 +172,8 @@ export class PlayCallChain {
166
172
  }
167
173
  async #append(input, signal) {
168
174
  validateIdentity(input.exchangeId, "exchange ID");
175
+ if (await this.#worlds.archiveNeedsFreshContext(input.worldId))
176
+ throw new PlayCallChainError("This world was imported from a backup; start a fresh context before continuing.");
169
177
  const session = await this.#requireChain(input.worldId, input.chainId);
170
178
  await this.#assertCurrentHead(session);
171
179
  assertSessionModelHostBinding(session, input.modelHost);
@@ -175,6 +183,11 @@ export class PlayCallChain {
175
183
  if (session.status === "interrupted") {
176
184
  if (!session.canRetry || session.lastRequest === null)
177
185
  throw new PlayCallChainError("The current model context cannot continue; use a fresh context.");
186
+ // Pre-snapshot requests have only a binding fingerprint. Do not invent
187
+ // their original encoding settings from a newly selected connection.
188
+ if (session.lastRequest.modelConfig === undefined &&
189
+ !equalModelHostBinding(session.modelBinding, input.modelHost.binding()))
190
+ throw new PlayCallChainError("This older saved request has no model configuration snapshot. Restore its original settings to retry it, or start a fresh context.");
178
191
  return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, signal, input.observer);
179
192
  }
180
193
  await this.#refreshPrompt(session, input);
@@ -182,8 +195,10 @@ export class PlayCallChain {
182
195
  const last = session.transcript.at(-1);
183
196
  if (last?.kind === "assistant" &&
184
197
  last.toolCalls.length === 0 &&
185
- last.text.trim().length > 0)
198
+ last.text.trim().length > 0) {
199
+ session.narrativeOrigin = await this.#captureOrigin(session);
186
200
  session.transcript.push(continuationNotice(this.#compiler.locale));
201
+ }
187
202
  return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, signal, input.observer);
188
203
  }
189
204
  if (duplicatePlayerExchange(session, input.exchangeId, input.playerText, "append"))
@@ -213,6 +228,7 @@ export class PlayCallChain {
213
228
  documentSnapshot: session.documents.snapshot,
214
229
  additionalMaterials: structuredClone(binding.additionalMaterials),
215
230
  history: structuredClone(binding.history),
231
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
216
232
  narrativeCheckpoint: binding.narrativeCheckpoint,
217
233
  },
218
234
  playerInputPlacement: "append",
@@ -221,7 +237,7 @@ export class PlayCallChain {
221
237
  }, playPreset);
222
238
  // The native conversation already contains successful writes and exact reads.
223
239
  // Rebinding initial coverage here would incorrectly reauthorize stale scopes.
224
- session.history = historyEntries(binding.history);
240
+ session.history = historyEntries(binding.history, binding.historyBackgrounds);
225
241
  session.narrativeCheckpoint = binding.narrativeCheckpoint;
226
242
  session.nextMaterials = structuredClone(binding.additionalMaterials);
227
243
  (session.promptRuns ??= []).push({
@@ -458,7 +474,8 @@ export class PlayCallChain {
458
474
  chainId: session.chainId,
459
475
  baselineHead: session.baselineHead,
460
476
  parentHead: session.parentHead,
461
- stale: session.parentHead !== worldHead,
477
+ stale: session.parentHead !== worldHead ||
478
+ (await this.#worlds.archiveNeedsFreshContext(worldId)),
462
479
  playPreset: structuredClone(recordedPlayPrompt(session).playPreset),
463
480
  updatedAt: session.updatedAt,
464
481
  bootstrap: {
@@ -471,17 +488,25 @@ export class PlayCallChain {
471
488
  async deriveWorld(input) {
472
489
  const result = await this.#worlds.deriveWorld({
473
490
  ...input,
474
- stageTarget: async ({ targetWorldRoot, targetWorldId, binding }) => {
491
+ stageTarget: async ({ targetWorldRoot, targetWorldId, binding, sourceHead, }) => {
475
492
  await this.stageForkToDerivedWorld({
476
493
  sourceWorldId: input.sourceWorldId,
477
- sourceHead: input.sourceHead,
494
+ sourceHead,
478
495
  targetWorldId,
479
496
  targetWorldRoot,
480
497
  targetBinding: binding,
498
+ ...(input.retainCompleteTimeline
499
+ ? { retainCompleteTimeline: true }
500
+ : {}),
501
+ ...(input.identityPrefix === "import"
502
+ ? { markImportedContexts: true }
503
+ : {}),
481
504
  });
482
505
  },
483
506
  });
484
- if ((await this.inspectWorld(result.world.worldId)) === null)
507
+ if (input.targetDataRoot === undefined &&
508
+ input.publish !== false &&
509
+ (await this.inspectWorld(result.world.worldId)) === null)
485
510
  await this.forkToDerivedWorld({
486
511
  sourceWorldId: input.sourceWorldId,
487
512
  sourceHead: input.sourceHead,
@@ -517,10 +542,14 @@ export class PlayCallChain {
517
542
  if (source === null)
518
543
  return null;
519
544
  const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.sourceWorldId);
520
- const selectedContextIndex = sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
545
+ const selectedContextIndex = input.retainCompleteTimeline
546
+ ? sourceContexts.length - 1
547
+ : sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
521
548
  if (selectedContextIndex < 0)
522
549
  return null;
523
- const sourceEvents = eventsThroughHead(sourceContexts[selectedContextIndex].events, input.sourceHead);
550
+ const sourceEvents = input.retainCompleteTimeline
551
+ ? sourceContexts[selectedContextIndex].events
552
+ : eventsThroughHead(sourceContexts[selectedContextIndex].events, input.sourceHead);
524
553
  return this.#forkSelectionToDerivedWorld({
525
554
  sourceWorldId: input.sourceWorldId,
526
555
  sourceContexts,
@@ -531,6 +560,8 @@ export class PlayCallChain {
531
560
  branchIdentity: input.sourceHead,
532
561
  targetWorldRoot: input.targetWorldRoot,
533
562
  targetBinding: input.targetBinding,
563
+ ...(input.retainCompleteTimeline ? { retainCompleteTimeline: true } : {}),
564
+ ...(input.markImportedContexts ? { markImportedContexts: true } : {}),
534
565
  });
535
566
  }
536
567
  /**
@@ -598,6 +629,9 @@ export class PlayCallChain {
598
629
  if (selectedContextIndex < 0)
599
630
  throw new PlayCallChainError("The model context to revise does not exist.");
600
631
  const sourceContext = sourceContexts[selectedContextIndex];
632
+ if (input.continuation === "continue_context" &&
633
+ sourceContext.importedFromArchive)
634
+ throw new PlayCallChainError("This model context was imported from a backup; start a fresh context before continuing.");
601
635
  const selectedEventIndex = sourceContext.events.findIndex((event) => event.id === input.eventId);
602
636
  const selectedEvent = sourceContext.events[selectedEventIndex];
603
637
  if (selectedEventIndex < 0 ||
@@ -640,11 +674,10 @@ export class PlayCallChain {
640
674
  this.#worlds.recoverEndpoint(request.worldId, restoresHead),
641
675
  this.#worlds.bindPlayCallChain(request.worldId),
642
676
  ]);
643
- const continuedTrace = traceRetention.withReplacement(selected, {
677
+ const continuedTrace = traceRetention.withReplacement({
644
678
  head: outcome.head,
645
679
  exchangeId: request.replacementExchangeId,
646
680
  text: request.replacementText,
647
- locale: this.#compiler.locale,
648
681
  });
649
682
  if (binding.parentHead !== outcome.head)
650
683
  throw new PlayCallChainError("The timeline revision was committed, but current-world materialization has not reached the new endpoint.");
@@ -666,6 +699,7 @@ export class PlayCallChain {
666
699
  freshPreparation,
667
700
  });
668
701
  await this.#worlds.playTimeline.persist(structuredClone(revised));
702
+ await this.#worlds.completeArchiveFreshContext(request.worldId);
669
703
  const activeId = this.#worldChains.get(request.worldId);
670
704
  if (activeId !== undefined)
671
705
  this.#active.delete(activeId);
@@ -719,6 +753,7 @@ export class PlayCallChain {
719
753
  documentSnapshot: documents.snapshot,
720
754
  additionalMaterials: structuredClone(binding.additionalMaterials),
721
755
  history: structuredClone(binding.history),
756
+ historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
722
757
  narrativeCheckpoint: binding.narrativeCheckpoint,
723
758
  documentMaintenance,
724
759
  documentMaintenanceUnavailableReason: documents.maintenanceWarning,
@@ -788,13 +823,11 @@ export class PlayCallChain {
788
823
  canRetry: false,
789
824
  bootstrap: structuredClone(fresh.bootstrap),
790
825
  tools: structuredClone(fresh.tools),
791
- transcript: playerInputAppend({
792
- history: fresh.binding.history,
793
- checkpoint: fresh.binding.narrativeCheckpoint,
794
- text: request.replacementText,
795
- locale: this.#compiler.locale,
796
- checkpointAvailable: fresh.tools.some(({ name }) => name === "world_checkpoint"),
797
- }),
826
+ narrativeOrigin: captureNarrativeOrigin(WorldDocumentStore.open({
827
+ layout: "world_state",
828
+ files: Object.entries(input.binding.files).map(([path, contents]) => ({ path, contents })),
829
+ }), 1),
830
+ transcript: playerInputAppend(request.replacementText),
798
831
  events: [
799
832
  {
800
833
  id: 1,
@@ -832,22 +865,30 @@ export class PlayCallChain {
832
865
  if (input.targetWorldRoot === undefined &&
833
866
  (await this.#worlds.currentHead(input.targetWorldId)) !== input.sourceHead)
834
867
  throw new PlayCallChainError("The forked world's Authority does not match the selected history endpoint, so the call chain cannot be written.");
835
- const [baseline, selected] = await Promise.all([
836
- this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
837
- ? input.targetWorldId
838
- : input.sourceWorldId, sourceContext.baselineHead),
839
- this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
840
- ? input.targetWorldId
841
- : input.sourceWorldId, input.sourceHead),
842
- ]);
843
- const traceRetention = prepareForkTrace(sourceContext, input.sourceEvents);
844
- const derivedBinding = input.targetBinding ??
845
- (await this.#worlds.bindPlayCallChain(input.targetWorldId));
846
- const retainedTrace = traceRetention.atEndpoint({
847
- baseline,
848
- selected,
849
- binding: derivedBinding,
850
- });
868
+ let retainedTrace;
869
+ if (input.retainCompleteTimeline) {
870
+ // A backup retains the current page closure, even when its last context
871
+ // precedes a correction or contains an uncommitted response diagnostic.
872
+ retainedTrace = independentContextCopy(sourceContext);
873
+ }
874
+ else {
875
+ const [baseline, selected] = await Promise.all([
876
+ this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
877
+ ? input.targetWorldId
878
+ : input.sourceWorldId, sourceContext.baselineHead),
879
+ this.#worlds.recoverEndpoint(input.targetWorldRoot === undefined
880
+ ? input.targetWorldId
881
+ : input.sourceWorldId, input.sourceHead),
882
+ ]);
883
+ const traceRetention = prepareForkTrace(sourceContext, input.sourceEvents);
884
+ const derivedBinding = input.targetBinding ??
885
+ (await this.#worlds.bindPlayCallChain(input.targetWorldId));
886
+ retainedTrace = traceRetention.atEndpoint({
887
+ baseline,
888
+ selected,
889
+ binding: derivedBinding,
890
+ });
891
+ }
851
892
  const now = Date.now();
852
893
  const timelineGeneration = this.#worlds.playTimeline.newGeneration();
853
894
  let previousChainId = null;
@@ -857,6 +898,7 @@ export class PlayCallChain {
857
898
  const chainId = derivedChainId(context.chainId, `prefix:${input.branchIdentity}:${index}`, input.targetWorldId);
858
899
  const previousContext = {
859
900
  ...independentContextCopy(context),
901
+ ...(input.markImportedContexts ? { importedFromArchive: true } : {}),
860
902
  schemaVersion: 3,
861
903
  kind: "play_call_chain",
862
904
  worldId: input.targetWorldId,
@@ -873,11 +915,15 @@ export class PlayCallChain {
873
915
  targetWorldRoot: input.targetWorldRoot,
874
916
  source: context,
875
917
  target: previousContext,
918
+ ...(input.retainCompleteTimeline
919
+ ? { retainCompleteContext: true }
920
+ : {}),
876
921
  });
877
922
  previousChainId = chainId;
878
923
  }
879
924
  const derived = {
880
925
  ...retainedTrace,
926
+ ...(input.markImportedContexts ? { importedFromArchive: true } : {}),
881
927
  schemaVersion: 3,
882
928
  kind: "play_call_chain",
883
929
  chainId: derivedChainId(sourceContext.chainId, input.branchIdentity, input.targetWorldId),
@@ -885,7 +931,9 @@ export class PlayCallChain {
885
931
  previousContexts: [],
886
932
  previousChainId,
887
933
  timelineGeneration,
888
- parentHead: input.sourceHead,
934
+ parentHead: input.retainCompleteTimeline
935
+ ? sourceContext.parentHead
936
+ : input.sourceHead,
889
937
  updatedAt: now,
890
938
  };
891
939
  if (input.targetWorldRoot === undefined)
@@ -896,6 +944,9 @@ export class PlayCallChain {
896
944
  targetWorldRoot: input.targetWorldRoot,
897
945
  source: sourceContext,
898
946
  target: structuredClone(derived),
947
+ ...(input.retainCompleteTimeline
948
+ ? { retainCompleteContext: true }
949
+ : {}),
899
950
  });
900
951
  if (input.targetWorldRoot !== undefined) {
901
952
  const retainedContexts = [
@@ -928,21 +979,28 @@ export class PlayCallChain {
928
979
  this.#active.delete(activeId);
929
980
  this.#worldChains.delete(worldId);
930
981
  }
982
+ async #captureOrigin(session) {
983
+ const binding = await this.#worlds.bindPlayCallChain(session.worldId);
984
+ if (binding.parentHead !== session.parentHead)
985
+ throw new PlayCallChainError("World advanced before background capture; use a fresh context.");
986
+ return captureNarrativeOrigin(WorldDocumentStore.open({
987
+ layout: "world_state",
988
+ files: Object.entries(binding.files).map(([path, contents]) => ({
989
+ path,
990
+ contents,
991
+ })),
992
+ }), session.nextEventId);
993
+ }
931
994
  async #submitPlayer(session, exchangeId, playerText, context, modelHost, signal, observer) {
932
995
  if (duplicatePlayerExchange(session, exchangeId, playerText, context))
933
996
  return projectView(session);
934
997
  if (session.status === "running")
935
998
  throw new PlayCallChainError("The call chain is waiting for the model to return.");
999
+ session.narrativeOrigin = await this.#captureOrigin(session);
936
1000
  const exchange = session.exchange + 1;
937
1001
  const nextRequest = createRequest(session, modelHost, exchange, [
938
1002
  ...session.transcript,
939
- ...playerInputAppend({
940
- history: Object.fromEntries(session.history.map(({ path, contents }) => [path, contents])),
941
- checkpoint: session.narrativeCheckpoint,
942
- text: playerText,
943
- locale: this.#compiler.locale,
944
- checkpointAvailable: currentPlayPrompt(session).tools.some(({ name }) => name === "world_checkpoint"),
945
- }),
1003
+ ...playerInputAppend(playerText),
946
1004
  ]);
947
1005
  const operationKey = `player:${exchangeId}`;
948
1006
  const playerOperationId = operationId(session.chainId, operationKey);
@@ -950,6 +1008,7 @@ export class PlayCallChain {
950
1008
  schemaVersion: 1,
951
1009
  kind: "play_advance",
952
1010
  advanceKind: "player",
1011
+ narrativeOrigin: structuredClone(session.narrativeOrigin),
953
1012
  playContext: session.continuityContextId ?? session.chainId,
954
1013
  worldId: session.worldId,
955
1014
  chainId: session.chainId,
@@ -981,14 +1040,17 @@ export class PlayCallChain {
981
1040
  const message = error instanceof Error
982
1041
  ? error.message
983
1042
  : "Failed to write the original player text.";
1043
+ const failureKind = classifyPlayFailure(error, false);
984
1044
  session.events.push({
985
1045
  id: session.nextEventId++,
986
1046
  kind: "failure",
987
1047
  message,
1048
+ failureKind,
988
1049
  });
989
1050
  session.status = "interrupted";
990
1051
  session.canRetry = false;
991
1052
  session.lastFailure = message;
1053
+ session.lastFailureKind = failureKind;
992
1054
  await this.#persist(session);
993
1055
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
994
1056
  throw new PlayCallChainError(message);
@@ -1003,7 +1065,17 @@ export class PlayCallChain {
1003
1065
  operationId: advance.operationId,
1004
1066
  worldId: session.worldId,
1005
1067
  parentHead: advance.parentHead,
1006
- historyAppend: [{ role: "player", exactText: advance.playerText }],
1068
+ historyAppend: [
1069
+ {
1070
+ role: "player",
1071
+ exactText: advance.playerText,
1072
+ ...(advance.narrativeOrigin?.value == null
1073
+ ? {}
1074
+ : {
1075
+ background: snapshotBackground(advance.narrativeOrigin.value),
1076
+ }),
1077
+ },
1078
+ ],
1007
1079
  nextMaterials: structuredClone(session.nextMaterials),
1008
1080
  stateChanges: [],
1009
1081
  });
@@ -1031,6 +1103,10 @@ export class PlayCallChain {
1031
1103
  session.nextEventId = advance.eventId + 1;
1032
1104
  session.parentHead = outcome.head;
1033
1105
  appendCommittedHistory(session, outcome.head, outcome.historyAppend);
1106
+ if (advance.narrativeOrigin === undefined)
1107
+ delete session.narrativeOrigin;
1108
+ else
1109
+ session.narrativeOrigin = structuredClone(advance.narrativeOrigin);
1034
1110
  session.exchange = advance.exchange;
1035
1111
  session.lastRequest = structuredClone(advance.nextRequest);
1036
1112
  session.lastRequestAttempt = 0;
@@ -1038,6 +1114,7 @@ export class PlayCallChain {
1038
1114
  session.canRetry = true;
1039
1115
  session.lastFailure =
1040
1116
  "The player message is committed; the frozen model request is ready to continue.";
1117
+ session.lastFailureKind = "retryable";
1041
1118
  await this.#persist(session);
1042
1119
  crashAtPlayAdvanceEdge("after_timeline_settled");
1043
1120
  await this.#worlds.playAdvances.markSettled(advance, outcome.head);
@@ -1167,7 +1244,11 @@ export class PlayCallChain {
1167
1244
  : { providerState: response.providerState }),
1168
1245
  toolCalls: structuredClone(calls),
1169
1246
  };
1247
+ const background = visibleText
1248
+ ? narrativeBackground(advance.narrativeOrigin, session.documents.snapshot)
1249
+ : undefined;
1170
1250
  return {
1251
+ ...(background === undefined ? {} : { background }),
1171
1252
  assistantEvent,
1172
1253
  playContext: session.continuityContextId ?? session.chainId,
1173
1254
  ...(worldClock === undefined || stateChanges.length === 0
@@ -1226,6 +1307,9 @@ export class PlayCallChain {
1226
1307
  {
1227
1308
  role: "narrator",
1228
1309
  exactText: settlement.assistantEvent.text,
1310
+ ...(settlement.background === undefined
1311
+ ? {}
1312
+ : { background: settlement.background }),
1229
1313
  },
1230
1314
  ]
1231
1315
  : [],
@@ -1287,7 +1371,7 @@ export class PlayCallChain {
1287
1371
  const binding = await this.#worlds.bindPlayCallChain(session.worldId);
1288
1372
  session.documents = new FileNativePlayDocuments(binding.files);
1289
1373
  session.documents.restoreAuthorizationCheckpoint(settlement.authorizationCheckpoint);
1290
- session.history = historyEntries(binding.history);
1374
+ session.history = historyEntries(binding.history, binding.historyBackgrounds);
1291
1375
  session.nextMaterials = structuredClone(binding.additionalMaterials);
1292
1376
  }
1293
1377
  mergeChangedDocuments(session, settlement.stateChanges);
@@ -1297,6 +1381,7 @@ export class PlayCallChain {
1297
1381
  session.exchange = Math.max(session.exchange, advance.exchange);
1298
1382
  session.canRetry = false;
1299
1383
  session.lastFailure = null;
1384
+ delete session.lastFailureKind;
1300
1385
  let result;
1301
1386
  if (settlement.assistantEvent.continuation !== "available") {
1302
1387
  const message = "The complete model response has no provider-native continuation payload; use a fresh context.";
@@ -1304,20 +1389,25 @@ export class PlayCallChain {
1304
1389
  id: session.nextEventId++,
1305
1390
  kind: "failure",
1306
1391
  message,
1392
+ failureKind: "context_unavailable",
1307
1393
  });
1308
1394
  session.status = "interrupted";
1309
1395
  session.lastFailure = message;
1396
+ session.lastFailureKind = "context_unavailable";
1310
1397
  result = { kind: "terminal", status: "interrupted" };
1311
1398
  }
1312
1399
  else if (hasToolCalls) {
1313
1400
  session.exchange += 1;
1314
1401
  const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange);
1402
+ if (session.lastRequest?.modelConfig !== undefined)
1403
+ nextRequest.modelConfig = structuredClone(session.lastRequest.modelConfig);
1315
1404
  session.lastRequest = structuredClone(nextRequest);
1316
1405
  session.lastRequestAttempt = 0;
1317
1406
  session.status = "interrupted";
1318
1407
  session.canRetry = true;
1319
1408
  session.lastFailure =
1320
1409
  "The completed tool response is settled; its frozen continuation request is ready.";
1410
+ session.lastFailureKind = "retryable";
1321
1411
  result = { kind: "continue", nextRequest };
1322
1412
  }
1323
1413
  else {
@@ -1328,8 +1418,10 @@ export class PlayCallChain {
1328
1418
  id: session.nextEventId++,
1329
1419
  kind: "failure",
1330
1420
  message,
1421
+ failureKind: "other",
1331
1422
  });
1332
1423
  session.lastFailure = message;
1424
+ session.lastFailureKind = "other";
1333
1425
  }
1334
1426
  result = { kind: "terminal", status: session.status };
1335
1427
  }
@@ -1353,7 +1445,7 @@ export class PlayCallChain {
1353
1445
  return this.#settleResponseAdvance(session, advance, current.settlementPrepared.settlement);
1354
1446
  }
1355
1447
  async #dispatch(session, modelHost, firstRequest, firstAttempt, signal, observer) {
1356
- let request = structuredClone(firstRequest);
1448
+ let request = modelHost.freezeRequest?.(firstRequest) ?? structuredClone(firstRequest);
1357
1449
  let attempt = firstAttempt;
1358
1450
  for (;;) {
1359
1451
  this.#setInvocationAbortable(session.chainId, true);
@@ -1363,6 +1455,7 @@ export class PlayCallChain {
1363
1455
  session.status = "running";
1364
1456
  session.canRetry = false;
1365
1457
  session.lastFailure = null;
1458
+ delete session.lastFailureKind;
1366
1459
  session.lastRequest = structuredClone(request);
1367
1460
  session.lastRequestAttempt = attempt;
1368
1461
  const responseExchange = request.exchange ?? session.exchange;
@@ -1382,6 +1475,9 @@ export class PlayCallChain {
1382
1475
  schemaVersion: 1,
1383
1476
  kind: "play_advance",
1384
1477
  advanceKind: "response",
1478
+ ...(session.narrativeOrigin === undefined
1479
+ ? {}
1480
+ : { narrativeOrigin: structuredClone(session.narrativeOrigin) }),
1385
1481
  worldId: session.worldId,
1386
1482
  chainId: session.chainId,
1387
1483
  advanceId: responseOperationId,
@@ -1451,20 +1547,31 @@ export class PlayCallChain {
1451
1547
  const message = error instanceof Error
1452
1548
  ? error.message
1453
1549
  : "The model request was interrupted.";
1454
- session.events.push({
1455
- id: session.nextEventId++,
1456
- kind: error instanceof ModelHostCancelledError
1457
- ? "cancellation"
1458
- : "failure",
1459
- message,
1460
- });
1461
1550
  session.status = "interrupted";
1462
- const requiresFreshContext = error instanceof ModelHostContinuationError ||
1551
+ const requiresFreshContext = error instanceof ModelHostBindingMismatchError ||
1552
+ error instanceof ModelHostContinuationError ||
1463
1553
  error instanceof ModelHostOutcomeUnknownError ||
1464
1554
  modelHostFailureRequiresFreshContext(error);
1465
1555
  session.canRetry =
1466
1556
  !requiresFreshContext && error instanceof ModelHostFailureError;
1467
1557
  session.lastFailure = message;
1558
+ if (error instanceof ModelHostCancelledError) {
1559
+ delete session.lastFailureKind;
1560
+ session.events.push({
1561
+ id: session.nextEventId++,
1562
+ kind: "cancellation",
1563
+ message,
1564
+ });
1565
+ }
1566
+ else {
1567
+ session.lastFailureKind = classifyPlayFailure(error, session.canRetry);
1568
+ session.events.push({
1569
+ id: session.nextEventId++,
1570
+ kind: "failure",
1571
+ message,
1572
+ failureKind: session.lastFailureKind,
1573
+ });
1574
+ }
1468
1575
  await this.#persist(session);
1469
1576
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1470
1577
  this.#notifySnapshot(observer, session);
@@ -1582,14 +1689,17 @@ export class PlayCallChain {
1582
1689
  },
1583
1690
  ],
1584
1691
  });
1692
+ const failureKind = classifyPlayFailure(error, false);
1585
1693
  session.events.push({
1586
1694
  id: session.nextEventId++,
1587
1695
  kind: "failure",
1588
1696
  message,
1697
+ failureKind,
1589
1698
  });
1590
1699
  session.status = "interrupted";
1591
1700
  session.canRetry = false;
1592
1701
  session.lastFailure = message;
1702
+ session.lastFailureKind = failureKind;
1593
1703
  await this.#persist(session);
1594
1704
  this.#notifySnapshot(observer, session);
1595
1705
  return projectView(session);
@@ -1613,6 +1723,7 @@ export class PlayCallChain {
1613
1723
  session.lastRequest = structuredClone(request);
1614
1724
  session.lastRequestAttempt = Math.max(0, attempt - 1);
1615
1725
  session.lastFailure = message;
1726
+ delete session.lastFailureKind;
1616
1727
  await this.#persist(session);
1617
1728
  if (advance !== undefined)
1618
1729
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
@@ -1784,7 +1895,7 @@ export class PlayCallChain {
1784
1895
  documentAuthorizationCheckpoints: persisted.documentAuthorizationCheckpoints?.map((checkpoint) => structuredClone(checkpoint)) ?? [],
1785
1896
  nextMaterials: structuredClone(binding.additionalMaterials),
1786
1897
  documents,
1787
- history: historyEntries(binding.history),
1898
+ history: historyEntries(binding.history, binding.historyBackgrounds),
1788
1899
  narrativeCheckpoint: binding.narrativeCheckpoint,
1789
1900
  completedToolMap: new Map(persisted.completedTools.map((item) => [
1790
1901
  item.key,
@@ -1832,14 +1943,21 @@ export class PlayCallChain {
1832
1943
  : canRetry
1833
1944
  ? "The service stopped before model dispatch; the frozen request can be retried."
1834
1945
  : "The service stopped before preparing a model request; use a fresh context.";
1946
+ const failureKind = dispatchMayHaveStarted
1947
+ ? "outcome_unknown"
1948
+ : canRetry
1949
+ ? "retryable"
1950
+ : "other";
1835
1951
  session.events.push({
1836
1952
  id: session.nextEventId++,
1837
1953
  kind: "failure",
1838
1954
  message,
1955
+ failureKind,
1839
1956
  });
1840
1957
  session.status = "interrupted";
1841
1958
  session.canRetry = canRetry;
1842
1959
  session.lastFailure = message;
1960
+ session.lastFailureKind = failureKind;
1843
1961
  await this.#persist(session);
1844
1962
  }
1845
1963
  async #assertCurrentHead(session) {
@@ -1874,7 +1992,8 @@ function followupOperationId(session) {
1874
1992
  return `${session.chainId}:followup:${session.parentHead}`;
1875
1993
  }
1876
1994
  function createRequest(session, modelHost, exchange, appended = session.transcript) {
1877
- return createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
1995
+ const request = createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
1996
+ return modelHost.freezeRequest?.(request) ?? request;
1878
1997
  }
1879
1998
  function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, appended = session.transcript) {
1880
1999
  return {
@@ -1935,11 +2054,18 @@ function appendCommittedHistory(session, head, messages) {
1935
2054
  const path = `message.${sequence}.${index + 1}.${message.role}`;
1936
2055
  const existing = session.history.find((item) => item.path === path);
1937
2056
  if (existing !== undefined) {
1938
- if (existing.contents !== message.exactText)
2057
+ if (existing.contents !== message.exactText ||
2058
+ !isDeepStrictEqual(existing.background, message.background))
1939
2059
  throw new PlayCallChainError("Recovered Authority history conflicts with the call-chain history.");
1940
2060
  continue;
1941
2061
  }
1942
- session.history.push({ path, contents: message.exactText });
2062
+ session.history.push({
2063
+ path,
2064
+ contents: message.exactText,
2065
+ ...(message.background === undefined
2066
+ ? {}
2067
+ : { background: structuredClone(message.background) }),
2068
+ });
1943
2069
  }
1944
2070
  }
1945
2071
  function prepareTool(session, working, exchange, call, locale) {
@@ -2052,7 +2178,9 @@ function mergeChangedDocuments(session, changes) {
2052
2178
  }
2053
2179
  }
2054
2180
  function projectContext(context) {
2181
+ const response = context.events.findLast((event) => event.kind === "assistant" && event.status === "completed");
2055
2182
  return {
2183
+ ...(context.importedFromArchive ? { requiresFreshContext: true } : {}),
2056
2184
  chainId: context.chainId,
2057
2185
  baselineHead: context.baselineHead,
2058
2186
  ...(context.baselineHistoryLength === undefined
@@ -2060,6 +2188,22 @@ function projectContext(context) {
2060
2188
  : { baselineHistoryLength: context.baselineHistoryLength }),
2061
2189
  parentHead: context.parentHead,
2062
2190
  playPreset: structuredClone(recordedPlayPrompt(context).playPreset),
2191
+ ...(context.modelBinding === undefined
2192
+ ? {}
2193
+ : {
2194
+ contextWindowTokens: context.lastRequest?.modelConfig?.contextWindowTokens ??
2195
+ context.modelBinding.contextWindowTokens,
2196
+ }),
2197
+ ...(response?.kind !== "assistant"
2198
+ ? {}
2199
+ : {
2200
+ latestCompletedResponse: {
2201
+ id: response.id,
2202
+ ...(response.usage === undefined
2203
+ ? {}
2204
+ : { usage: structuredClone(response.usage) }),
2205
+ },
2206
+ }),
2063
2207
  status: context.status,
2064
2208
  canRetry: context.canRetry,
2065
2209
  events: context.events
@@ -2067,6 +2211,9 @@ function projectContext(context) {
2067
2211
  .map((event) => projectActivityEvent(event)),
2068
2212
  changedDocuments: structuredClone(context.changedDocuments),
2069
2213
  lastFailure: context.lastFailure,
2214
+ ...(context.lastFailureKind === undefined
2215
+ ? {}
2216
+ : { lastFailureKind: context.lastFailureKind }),
2070
2217
  updatedAt: context.updatedAt,
2071
2218
  };
2072
2219
  }
@@ -2074,7 +2221,7 @@ function projectActivityEvent(event) {
2074
2221
  if (event.kind === "player" ||
2075
2222
  event.kind === "failure" ||
2076
2223
  event.kind === "cancellation")
2077
- return structuredClone(event);
2224
+ return structuredClone(projectLegacyModelBindingFailure(event));
2078
2225
  if (event.kind === "assistant") {
2079
2226
  const { reasoning, toolFragment, usage, ...summary } = event;
2080
2227
  void reasoning;
@@ -2219,10 +2366,13 @@ function playerRevisionTimelineGeneration(sourceGeneration, operationId, worldId
2219
2366
  .digest("hex")
2220
2367
  .slice(0, 40)}`;
2221
2368
  }
2222
- function historyEntries(history) {
2369
+ function historyEntries(history, backgrounds = {}) {
2223
2370
  return Object.entries(history).map(([path, contents]) => ({
2224
2371
  path,
2225
2372
  contents,
2373
+ ...(backgrounds[path] === undefined
2374
+ ? {}
2375
+ : { background: structuredClone(backgrounds[path]) }),
2226
2376
  }));
2227
2377
  }
2228
2378
  function operationId(chainId, key) {
@@ -2237,7 +2387,8 @@ function assertModelHostBinding(frozen, modelHost) {
2237
2387
  function assertSessionModelHostBinding(session, modelHost) {
2238
2388
  if (session.modelBinding === undefined)
2239
2389
  throw new PlayCallChainError("This model context predates durable provider continuation bindings; start a fresh context.");
2240
- assertModelHostBinding(session.modelBinding, modelHost);
2390
+ if (session.modelBinding.provider !== modelHost.binding().provider)
2391
+ 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");
2241
2392
  }
2242
2393
  function validateIdentity(value, label) {
2243
2394
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u.test(value))