narraeon 0.5.6 → 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 (39) 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 +46 -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/StoryExport.js +33 -0
  15. package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
  16. package/dist/node/runtime/model/ModelHost.js +12 -1
  17. package/dist/node/runtime/model/ModelHostStream.js +11 -3
  18. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +80 -4
  19. package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
  20. package/dist/node/runtime/play/PlayCallChain.js +144 -52
  21. package/dist/node/runtime/play/PlayCallChainError.js +3 -1
  22. package/dist/node/runtime/play/PlayContinuity.js +3 -20
  23. package/dist/node/runtime/play/PlayFailure.js +20 -0
  24. package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
  25. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +4 -22
  26. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +2 -2
  27. package/dist/node/runtime/world/FileNativeWorldStore.js +172 -39
  28. package/dist/node/runtime/world/WorldActivity.js +143 -0
  29. package/dist/node/server/createServer.js +48 -0
  30. package/dist/node/shared/default-preset-host.js +10 -6
  31. package/dist/node/shared/ordered-play-prompts.js +2 -2
  32. package/dist/node/shared/sample-packages/index.js +10 -0
  33. package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
  34. package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
  35. package/dist/web/assets/index-DxNtxzLR.js +1255 -0
  36. package/dist/web/assets/{index-CVN48xrw.css → index-DzOnu9uv.css} +1 -1
  37. package/dist/web/index.html +2 -2
  38. package/package.json +6 -2
  39. package/dist/web/assets/index-BH8NyuE4.js +0 -1233
@@ -1,3 +1,5 @@
1
+ import { projectLegacyModelBindingFailure } from "./LegacyModelBindingFailure.js";
2
+ import { classifyPlayFailure } from "./PlayFailure.js";
1
3
  import { captureNarrativeOrigin, snapshotBackground, narrativeBackground, } from "../history/HistoryBackground.js";
2
4
  import { isDeepStrictEqual } from "node:util";
3
5
  import { hasLegacyHistoryTools } from "../prompt/FileNativeToolRegistry.js";
@@ -10,7 +12,7 @@ import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepN
10
12
  import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
11
13
  import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
12
14
  import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
13
- import { equalModelHostBinding, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
15
+ import { equalModelHostBinding, ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
14
16
  import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
15
17
  import { parsePlayPresetFiles, } from "./FileNativePlayPresetStore.js";
16
18
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
@@ -162,6 +164,7 @@ export class PlayCallChain {
162
164
  this.#active.set(session.chainId, session);
163
165
  this.#worldChains.set(session.worldId, session.chainId);
164
166
  await this.#persist(session);
167
+ await this.#worlds.completeArchiveFreshContext(session.worldId);
165
168
  return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, signal, input.observer);
166
169
  }
167
170
  async append(input) {
@@ -169,6 +172,8 @@ export class PlayCallChain {
169
172
  }
170
173
  async #append(input, signal) {
171
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.");
172
177
  const session = await this.#requireChain(input.worldId, input.chainId);
173
178
  await this.#assertCurrentHead(session);
174
179
  assertSessionModelHostBinding(session, input.modelHost);
@@ -178,6 +183,11 @@ export class PlayCallChain {
178
183
  if (session.status === "interrupted") {
179
184
  if (!session.canRetry || session.lastRequest === null)
180
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.");
181
191
  return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, signal, input.observer);
182
192
  }
183
193
  await this.#refreshPrompt(session, input);
@@ -464,7 +474,8 @@ export class PlayCallChain {
464
474
  chainId: session.chainId,
465
475
  baselineHead: session.baselineHead,
466
476
  parentHead: session.parentHead,
467
- stale: session.parentHead !== worldHead,
477
+ stale: session.parentHead !== worldHead ||
478
+ (await this.#worlds.archiveNeedsFreshContext(worldId)),
468
479
  playPreset: structuredClone(recordedPlayPrompt(session).playPreset),
469
480
  updatedAt: session.updatedAt,
470
481
  bootstrap: {
@@ -477,17 +488,25 @@ export class PlayCallChain {
477
488
  async deriveWorld(input) {
478
489
  const result = await this.#worlds.deriveWorld({
479
490
  ...input,
480
- stageTarget: async ({ targetWorldRoot, targetWorldId, binding }) => {
491
+ stageTarget: async ({ targetWorldRoot, targetWorldId, binding, sourceHead, }) => {
481
492
  await this.stageForkToDerivedWorld({
482
493
  sourceWorldId: input.sourceWorldId,
483
- sourceHead: input.sourceHead,
494
+ sourceHead,
484
495
  targetWorldId,
485
496
  targetWorldRoot,
486
497
  targetBinding: binding,
498
+ ...(input.retainCompleteTimeline
499
+ ? { retainCompleteTimeline: true }
500
+ : {}),
501
+ ...(input.identityPrefix === "import"
502
+ ? { markImportedContexts: true }
503
+ : {}),
487
504
  });
488
505
  },
489
506
  });
490
- 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)
491
510
  await this.forkToDerivedWorld({
492
511
  sourceWorldId: input.sourceWorldId,
493
512
  sourceHead: input.sourceHead,
@@ -523,10 +542,14 @@ export class PlayCallChain {
523
542
  if (source === null)
524
543
  return null;
525
544
  const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.sourceWorldId);
526
- 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);
527
548
  if (selectedContextIndex < 0)
528
549
  return null;
529
- const sourceEvents = eventsThroughHead(sourceContexts[selectedContextIndex].events, input.sourceHead);
550
+ const sourceEvents = input.retainCompleteTimeline
551
+ ? sourceContexts[selectedContextIndex].events
552
+ : eventsThroughHead(sourceContexts[selectedContextIndex].events, input.sourceHead);
530
553
  return this.#forkSelectionToDerivedWorld({
531
554
  sourceWorldId: input.sourceWorldId,
532
555
  sourceContexts,
@@ -537,6 +560,8 @@ export class PlayCallChain {
537
560
  branchIdentity: input.sourceHead,
538
561
  targetWorldRoot: input.targetWorldRoot,
539
562
  targetBinding: input.targetBinding,
563
+ ...(input.retainCompleteTimeline ? { retainCompleteTimeline: true } : {}),
564
+ ...(input.markImportedContexts ? { markImportedContexts: true } : {}),
540
565
  });
541
566
  }
542
567
  /**
@@ -604,6 +629,9 @@ export class PlayCallChain {
604
629
  if (selectedContextIndex < 0)
605
630
  throw new PlayCallChainError("The model context to revise does not exist.");
606
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.");
607
635
  const selectedEventIndex = sourceContext.events.findIndex((event) => event.id === input.eventId);
608
636
  const selectedEvent = sourceContext.events[selectedEventIndex];
609
637
  if (selectedEventIndex < 0 ||
@@ -646,11 +674,10 @@ export class PlayCallChain {
646
674
  this.#worlds.recoverEndpoint(request.worldId, restoresHead),
647
675
  this.#worlds.bindPlayCallChain(request.worldId),
648
676
  ]);
649
- const continuedTrace = traceRetention.withReplacement(selected, {
677
+ const continuedTrace = traceRetention.withReplacement({
650
678
  head: outcome.head,
651
679
  exchangeId: request.replacementExchangeId,
652
680
  text: request.replacementText,
653
- locale: this.#compiler.locale,
654
681
  });
655
682
  if (binding.parentHead !== outcome.head)
656
683
  throw new PlayCallChainError("The timeline revision was committed, but current-world materialization has not reached the new endpoint.");
@@ -672,6 +699,7 @@ export class PlayCallChain {
672
699
  freshPreparation,
673
700
  });
674
701
  await this.#worlds.playTimeline.persist(structuredClone(revised));
702
+ await this.#worlds.completeArchiveFreshContext(request.worldId);
675
703
  const activeId = this.#worldChains.get(request.worldId);
676
704
  if (activeId !== undefined)
677
705
  this.#active.delete(activeId);
@@ -799,13 +827,7 @@ export class PlayCallChain {
799
827
  layout: "world_state",
800
828
  files: Object.entries(input.binding.files).map(([path, contents]) => ({ path, contents })),
801
829
  }), 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
- }),
830
+ transcript: playerInputAppend(request.replacementText),
809
831
  events: [
810
832
  {
811
833
  id: 1,
@@ -843,22 +865,30 @@ export class PlayCallChain {
843
865
  if (input.targetWorldRoot === undefined &&
844
866
  (await this.#worlds.currentHead(input.targetWorldId)) !== input.sourceHead)
845
867
  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
- });
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
+ }
862
892
  const now = Date.now();
863
893
  const timelineGeneration = this.#worlds.playTimeline.newGeneration();
864
894
  let previousChainId = null;
@@ -868,6 +898,7 @@ export class PlayCallChain {
868
898
  const chainId = derivedChainId(context.chainId, `prefix:${input.branchIdentity}:${index}`, input.targetWorldId);
869
899
  const previousContext = {
870
900
  ...independentContextCopy(context),
901
+ ...(input.markImportedContexts ? { importedFromArchive: true } : {}),
871
902
  schemaVersion: 3,
872
903
  kind: "play_call_chain",
873
904
  worldId: input.targetWorldId,
@@ -884,11 +915,15 @@ export class PlayCallChain {
884
915
  targetWorldRoot: input.targetWorldRoot,
885
916
  source: context,
886
917
  target: previousContext,
918
+ ...(input.retainCompleteTimeline
919
+ ? { retainCompleteContext: true }
920
+ : {}),
887
921
  });
888
922
  previousChainId = chainId;
889
923
  }
890
924
  const derived = {
891
925
  ...retainedTrace,
926
+ ...(input.markImportedContexts ? { importedFromArchive: true } : {}),
892
927
  schemaVersion: 3,
893
928
  kind: "play_call_chain",
894
929
  chainId: derivedChainId(sourceContext.chainId, input.branchIdentity, input.targetWorldId),
@@ -896,7 +931,9 @@ export class PlayCallChain {
896
931
  previousContexts: [],
897
932
  previousChainId,
898
933
  timelineGeneration,
899
- parentHead: input.sourceHead,
934
+ parentHead: input.retainCompleteTimeline
935
+ ? sourceContext.parentHead
936
+ : input.sourceHead,
900
937
  updatedAt: now,
901
938
  };
902
939
  if (input.targetWorldRoot === undefined)
@@ -907,6 +944,9 @@ export class PlayCallChain {
907
944
  targetWorldRoot: input.targetWorldRoot,
908
945
  source: sourceContext,
909
946
  target: structuredClone(derived),
947
+ ...(input.retainCompleteTimeline
948
+ ? { retainCompleteContext: true }
949
+ : {}),
910
950
  });
911
951
  if (input.targetWorldRoot !== undefined) {
912
952
  const retainedContexts = [
@@ -960,13 +1000,7 @@ export class PlayCallChain {
960
1000
  const exchange = session.exchange + 1;
961
1001
  const nextRequest = createRequest(session, modelHost, exchange, [
962
1002
  ...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
- }),
1003
+ ...playerInputAppend(playerText),
970
1004
  ]);
971
1005
  const operationKey = `player:${exchangeId}`;
972
1006
  const playerOperationId = operationId(session.chainId, operationKey);
@@ -1006,14 +1040,17 @@ export class PlayCallChain {
1006
1040
  const message = error instanceof Error
1007
1041
  ? error.message
1008
1042
  : "Failed to write the original player text.";
1043
+ const failureKind = classifyPlayFailure(error, false);
1009
1044
  session.events.push({
1010
1045
  id: session.nextEventId++,
1011
1046
  kind: "failure",
1012
1047
  message,
1048
+ failureKind,
1013
1049
  });
1014
1050
  session.status = "interrupted";
1015
1051
  session.canRetry = false;
1016
1052
  session.lastFailure = message;
1053
+ session.lastFailureKind = failureKind;
1017
1054
  await this.#persist(session);
1018
1055
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1019
1056
  throw new PlayCallChainError(message);
@@ -1077,6 +1114,7 @@ export class PlayCallChain {
1077
1114
  session.canRetry = true;
1078
1115
  session.lastFailure =
1079
1116
  "The player message is committed; the frozen model request is ready to continue.";
1117
+ session.lastFailureKind = "retryable";
1080
1118
  await this.#persist(session);
1081
1119
  crashAtPlayAdvanceEdge("after_timeline_settled");
1082
1120
  await this.#worlds.playAdvances.markSettled(advance, outcome.head);
@@ -1343,6 +1381,7 @@ export class PlayCallChain {
1343
1381
  session.exchange = Math.max(session.exchange, advance.exchange);
1344
1382
  session.canRetry = false;
1345
1383
  session.lastFailure = null;
1384
+ delete session.lastFailureKind;
1346
1385
  let result;
1347
1386
  if (settlement.assistantEvent.continuation !== "available") {
1348
1387
  const message = "The complete model response has no provider-native continuation payload; use a fresh context.";
@@ -1350,20 +1389,25 @@ export class PlayCallChain {
1350
1389
  id: session.nextEventId++,
1351
1390
  kind: "failure",
1352
1391
  message,
1392
+ failureKind: "context_unavailable",
1353
1393
  });
1354
1394
  session.status = "interrupted";
1355
1395
  session.lastFailure = message;
1396
+ session.lastFailureKind = "context_unavailable";
1356
1397
  result = { kind: "terminal", status: "interrupted" };
1357
1398
  }
1358
1399
  else if (hasToolCalls) {
1359
1400
  session.exchange += 1;
1360
1401
  const nextRequest = createRequestWithMaxOutputTokens(session, session.lastRequest?.maxOutputTokens ?? 1, session.exchange);
1402
+ if (session.lastRequest?.modelConfig !== undefined)
1403
+ nextRequest.modelConfig = structuredClone(session.lastRequest.modelConfig);
1361
1404
  session.lastRequest = structuredClone(nextRequest);
1362
1405
  session.lastRequestAttempt = 0;
1363
1406
  session.status = "interrupted";
1364
1407
  session.canRetry = true;
1365
1408
  session.lastFailure =
1366
1409
  "The completed tool response is settled; its frozen continuation request is ready.";
1410
+ session.lastFailureKind = "retryable";
1367
1411
  result = { kind: "continue", nextRequest };
1368
1412
  }
1369
1413
  else {
@@ -1374,8 +1418,10 @@ export class PlayCallChain {
1374
1418
  id: session.nextEventId++,
1375
1419
  kind: "failure",
1376
1420
  message,
1421
+ failureKind: "other",
1377
1422
  });
1378
1423
  session.lastFailure = message;
1424
+ session.lastFailureKind = "other";
1379
1425
  }
1380
1426
  result = { kind: "terminal", status: session.status };
1381
1427
  }
@@ -1399,7 +1445,7 @@ export class PlayCallChain {
1399
1445
  return this.#settleResponseAdvance(session, advance, current.settlementPrepared.settlement);
1400
1446
  }
1401
1447
  async #dispatch(session, modelHost, firstRequest, firstAttempt, signal, observer) {
1402
- let request = structuredClone(firstRequest);
1448
+ let request = modelHost.freezeRequest?.(firstRequest) ?? structuredClone(firstRequest);
1403
1449
  let attempt = firstAttempt;
1404
1450
  for (;;) {
1405
1451
  this.#setInvocationAbortable(session.chainId, true);
@@ -1409,6 +1455,7 @@ export class PlayCallChain {
1409
1455
  session.status = "running";
1410
1456
  session.canRetry = false;
1411
1457
  session.lastFailure = null;
1458
+ delete session.lastFailureKind;
1412
1459
  session.lastRequest = structuredClone(request);
1413
1460
  session.lastRequestAttempt = attempt;
1414
1461
  const responseExchange = request.exchange ?? session.exchange;
@@ -1500,20 +1547,31 @@ export class PlayCallChain {
1500
1547
  const message = error instanceof Error
1501
1548
  ? error.message
1502
1549
  : "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
1550
  session.status = "interrupted";
1511
- const requiresFreshContext = error instanceof ModelHostContinuationError ||
1551
+ const requiresFreshContext = error instanceof ModelHostBindingMismatchError ||
1552
+ error instanceof ModelHostContinuationError ||
1512
1553
  error instanceof ModelHostOutcomeUnknownError ||
1513
1554
  modelHostFailureRequiresFreshContext(error);
1514
1555
  session.canRetry =
1515
1556
  !requiresFreshContext && error instanceof ModelHostFailureError;
1516
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
+ }
1517
1575
  await this.#persist(session);
1518
1576
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1519
1577
  this.#notifySnapshot(observer, session);
@@ -1631,14 +1689,17 @@ export class PlayCallChain {
1631
1689
  },
1632
1690
  ],
1633
1691
  });
1692
+ const failureKind = classifyPlayFailure(error, false);
1634
1693
  session.events.push({
1635
1694
  id: session.nextEventId++,
1636
1695
  kind: "failure",
1637
1696
  message,
1697
+ failureKind,
1638
1698
  });
1639
1699
  session.status = "interrupted";
1640
1700
  session.canRetry = false;
1641
1701
  session.lastFailure = message;
1702
+ session.lastFailureKind = failureKind;
1642
1703
  await this.#persist(session);
1643
1704
  this.#notifySnapshot(observer, session);
1644
1705
  return projectView(session);
@@ -1662,6 +1723,7 @@ export class PlayCallChain {
1662
1723
  session.lastRequest = structuredClone(request);
1663
1724
  session.lastRequestAttempt = Math.max(0, attempt - 1);
1664
1725
  session.lastFailure = message;
1726
+ delete session.lastFailureKind;
1665
1727
  await this.#persist(session);
1666
1728
  if (advance !== undefined)
1667
1729
  await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
@@ -1881,14 +1943,21 @@ export class PlayCallChain {
1881
1943
  : canRetry
1882
1944
  ? "The service stopped before model dispatch; the frozen request can be retried."
1883
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";
1884
1951
  session.events.push({
1885
1952
  id: session.nextEventId++,
1886
1953
  kind: "failure",
1887
1954
  message,
1955
+ failureKind,
1888
1956
  });
1889
1957
  session.status = "interrupted";
1890
1958
  session.canRetry = canRetry;
1891
1959
  session.lastFailure = message;
1960
+ session.lastFailureKind = failureKind;
1892
1961
  await this.#persist(session);
1893
1962
  }
1894
1963
  async #assertCurrentHead(session) {
@@ -1923,7 +1992,8 @@ function followupOperationId(session) {
1923
1992
  return `${session.chainId}:followup:${session.parentHead}`;
1924
1993
  }
1925
1994
  function createRequest(session, modelHost, exchange, appended = session.transcript) {
1926
- return createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
1995
+ const request = createRequestWithMaxOutputTokens(session, modelHost.binding().maxOutputTokens, exchange, appended);
1996
+ return modelHost.freezeRequest?.(request) ?? request;
1927
1997
  }
1928
1998
  function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, appended = session.transcript) {
1929
1999
  return {
@@ -2108,7 +2178,9 @@ function mergeChangedDocuments(session, changes) {
2108
2178
  }
2109
2179
  }
2110
2180
  function projectContext(context) {
2181
+ const response = context.events.findLast((event) => event.kind === "assistant" && event.status === "completed");
2111
2182
  return {
2183
+ ...(context.importedFromArchive ? { requiresFreshContext: true } : {}),
2112
2184
  chainId: context.chainId,
2113
2185
  baselineHead: context.baselineHead,
2114
2186
  ...(context.baselineHistoryLength === undefined
@@ -2116,6 +2188,22 @@ function projectContext(context) {
2116
2188
  : { baselineHistoryLength: context.baselineHistoryLength }),
2117
2189
  parentHead: context.parentHead,
2118
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
+ }),
2119
2207
  status: context.status,
2120
2208
  canRetry: context.canRetry,
2121
2209
  events: context.events
@@ -2123,6 +2211,9 @@ function projectContext(context) {
2123
2211
  .map((event) => projectActivityEvent(event)),
2124
2212
  changedDocuments: structuredClone(context.changedDocuments),
2125
2213
  lastFailure: context.lastFailure,
2214
+ ...(context.lastFailureKind === undefined
2215
+ ? {}
2216
+ : { lastFailureKind: context.lastFailureKind }),
2126
2217
  updatedAt: context.updatedAt,
2127
2218
  };
2128
2219
  }
@@ -2130,7 +2221,7 @@ function projectActivityEvent(event) {
2130
2221
  if (event.kind === "player" ||
2131
2222
  event.kind === "failure" ||
2132
2223
  event.kind === "cancellation")
2133
- return structuredClone(event);
2224
+ return structuredClone(projectLegacyModelBindingFailure(event));
2134
2225
  if (event.kind === "assistant") {
2135
2226
  const { reasoning, toolFragment, usage, ...summary } = event;
2136
2227
  void reasoning;
@@ -2296,7 +2387,8 @@ function assertModelHostBinding(frozen, modelHost) {
2296
2387
  function assertSessionModelHostBinding(session, modelHost) {
2297
2388
  if (session.modelBinding === undefined)
2298
2389
  throw new PlayCallChainError("This model context predates durable provider continuation bindings; start a fresh context.");
2299
- 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");
2300
2392
  }
2301
2393
  function validateIdentity(value, label) {
2302
2394
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u.test(value))
@@ -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
+ }
@@ -13,7 +13,7 @@ export function preparePlayerRevisionTrace(source, eventIndex) {
13
13
  const player = source.events[eventIndex];
14
14
  return {
15
15
  // This append precedes the operation's materialization check, as before.
16
- withReplacement(selected, replacement) {
16
+ withReplacement(replacement) {
17
17
  const events = [
18
18
  ...prefix,
19
19
  {
@@ -27,16 +27,7 @@ export function preparePlayerRevisionTrace(source, eventIndex) {
27
27
  ];
28
28
  const continuedTranscript = [
29
29
  ...transcript,
30
- ...playerInputAppend({
31
- history: Object.fromEntries(selected.history.map(({ messageId, exactText }) => [
32
- messageId,
33
- exactText,
34
- ])),
35
- checkpoint: selected.narrativeCheckpoint,
36
- text: replacement.text,
37
- locale: replacement.locale,
38
- checkpointAvailable: source.tools.some(({ name }) => name === "world_checkpoint"),
39
- }),
30
+ ...playerInputAppend(replacement.text),
40
31
  ];
41
32
  return {
42
33
  atEndpoint(endpoint) {
@@ -96,6 +87,7 @@ function assembleRetainedTrace(source, endpoint, selection) {
96
87
  const documents = restorePlayDocuments(endpoint.binding.files, source, selection.authorizationEvents);
97
88
  const lastEvent = selection.events.at(-1);
98
89
  return {
90
+ ...(source.importedFromArchive ? { importedFromArchive: true } : {}),
99
91
  continuityContextId: source.continuityContextId ?? source.chainId,
100
92
  baselineHead: source.baselineHead,
101
93
  baselineHistoryLength: source.baselineHistoryLength ?? endpoint.baseline.history.length,
@@ -13,7 +13,7 @@ import { renderWorldYamlSource } from "../world/WorldYamlRendering.js";
13
13
  export { renderWorldYamlSource } from "../world/WorldYamlRendering.js";
14
14
  import { measureWorldMaterials, refreshMaintenanceTotals, } from "./WorldMaintenanceReport.js";
15
15
  import { renderPlayerViewBindings } from "../world/PlayerViewBindings.js";
16
- import { checkpointHistory, checkpointReplayBlocks, playerInputAppend, isPlayerRoundMarker, } from "../play/PlayContinuity.js";
16
+ import { checkpointHistory, checkpointReplayBlocks, } from "../play/PlayContinuity.js";
17
17
  import { defaultAppLocale, } from "../../protocol/appPreferences.js";
18
18
  import { WorldDocumentStore, } from "../world/WorldDocumentStore.js";
19
19
  import { defaultRuntimeToolDefinitionStrategy, registeredRuntimeToolNames, runtimeToolsForNames, } from "./FileNativeToolRegistry.js";
@@ -373,11 +373,11 @@ export class FileNativePromptCompiler {
373
373
  const playCompilation = playPreset === undefined
374
374
  ? undefined
375
375
  : this.compilePlayCallChain(input, playPreset);
376
- return promptPreview(input, playPreset, playCompilation?.bootstrap ?? this.compileBootstrap(input), playCompilation, this.#locale);
376
+ return promptPreview(input, playPreset, playCompilation?.bootstrap ?? this.compileBootstrap(input), playCompilation);
377
377
  }
378
378
  previewPlayPreset(input, playPreset) {
379
379
  const playCompilation = this.compilePlayPreset(input, playPreset);
380
- return promptPreview(input, playPreset, playCompilation.bootstrap, playCompilation, this.#locale);
380
+ return promptPreview(input, playPreset, playCompilation.bootstrap, playCompilation);
381
381
  }
382
382
  /**
383
383
  * Compile the production play surface. The selected preset contributes its
@@ -909,14 +909,7 @@ function withoutAppendedContextGenesis(input) {
909
909
  },
910
910
  };
911
911
  }
912
- function promptPreview(input, playPreset, compilation, playCompilation, locale = defaultAppLocale) {
913
- const marker = playerInputAppend({
914
- history: input.world.history ?? {},
915
- checkpoint: input.world.narrativeCheckpoint,
916
- text: input.playerInput,
917
- locale,
918
- checkpointAvailable: playCompilation?.toolUniverse.some(({ name }) => name === "world_checkpoint") ?? false,
919
- })[0];
912
+ function promptPreview(input, playPreset, compilation, playCompilation) {
920
913
  const result = {
921
914
  diagnosticBinding: {
922
915
  endpoint: input.endpoint.id,
@@ -937,17 +930,6 @@ function promptPreview(input, playPreset, compilation, playCompilation, locale =
937
930
  initialAppend: {
938
931
  logical: { kind: "player", text: input.playerInput },
939
932
  provider: { role: "user", content: input.playerInput },
940
- ...(isPlayerRoundMarker(marker)
941
- ? {
942
- beforePlayer: {
943
- logical: marker,
944
- provider: {
945
- role: "user",
946
- content: marker.text,
947
- },
948
- },
949
- }
950
- : {}),
951
933
  },
952
934
  }
953
935
  : {}),