narraeon 0.4.0 → 0.5.1

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 (76) hide show
  1. package/dist/node/protocol/appPreferences.js +25 -0
  2. package/dist/node/protocol/conversationObservation.js +67 -0
  3. package/dist/node/protocol/v1.js +186 -34
  4. package/dist/node/protocol/worldExtensions.js +16 -0
  5. package/dist/node/protocol/worldMaintenance.js +77 -0
  6. package/dist/node/protocol/worldPromptDiagnostics.js +1 -0
  7. package/dist/node/runtime/ConversationChanges.js +24 -0
  8. package/dist/node/runtime/V1Runtime.js +445 -121
  9. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +326 -12
  10. package/dist/node/runtime/authoring/AuthoringRequestSnapshot.js +63 -0
  11. package/dist/node/runtime/authoring/ModelToolConversation.js +218 -0
  12. package/dist/node/runtime/config/AppPreferencesStore.js +46 -17
  13. package/dist/node/runtime/content/ContentTreeFingerprint.js +13 -0
  14. package/dist/node/runtime/content/ContentWorkspace.js +43 -13
  15. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +128 -46
  16. package/dist/node/runtime/content/FileNativeContentTree.js +28 -10
  17. package/dist/node/runtime/content/PackageFollowups.js +80 -0
  18. package/dist/node/runtime/extension/FrontendExtensionBundle.js +19 -14
  19. package/dist/node/runtime/extension/PackageScriptPermissions.js +70 -0
  20. package/dist/node/runtime/extension/WorldExtensionControls.js +205 -0
  21. package/dist/node/runtime/extension/WorldExtensionRequests.js +64 -0
  22. package/dist/node/runtime/model/FileNativeModelAdapters.js +21 -466
  23. package/dist/node/runtime/model/ModelConnectionStore.js +13 -0
  24. package/dist/node/runtime/model/ModelHost.js +31 -1
  25. package/dist/node/runtime/model/ModelHostStream.js +51 -5
  26. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +55 -45
  27. package/dist/node/runtime/play/FileNativePlayPresetStore.js +256 -238
  28. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +118 -9
  29. package/dist/node/runtime/play/OrderedFollowups.js +71 -0
  30. package/dist/node/runtime/play/OrderedPlayPrompts.js +180 -0
  31. package/dist/node/runtime/play/PlayCallChain.js +814 -61
  32. package/dist/node/runtime/play/PlayContinuity.js +88 -0
  33. package/dist/node/runtime/play/PlayDocumentTools.js +205 -287
  34. package/dist/node/runtime/play/PlayFollowupRequests.js +34 -3
  35. package/dist/node/runtime/play/PlayPresetWorkbench.js +45 -54
  36. package/dist/node/runtime/play/PlayPromptRun.js +86 -0
  37. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +670 -93
  38. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -21
  39. package/dist/node/runtime/prompt/PromptCompilationCodec.js +322 -0
  40. package/dist/node/runtime/prompt/WorldMaintenanceReport.js +251 -0
  41. package/dist/node/runtime/prompt/WorldMaterialCoverage.js +83 -0
  42. package/dist/node/runtime/prompt/WorldPromptDiagnostics.js +185 -0
  43. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +1027 -0
  44. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +1339 -0
  45. package/dist/node/runtime/setting/SettingImprovementSession.js +714 -0
  46. package/dist/node/runtime/world/FileNativeAuthorityV3.js +88 -1
  47. package/dist/node/runtime/world/FileNativeWorldStore.js +292 -27
  48. package/dist/node/runtime/world/PlayerViewBindings.js +30 -0
  49. package/dist/node/runtime/world/PlayerViewRenderer.js +27 -34
  50. package/dist/node/runtime/world/WorldDocumentMaintenance.js +174 -0
  51. package/dist/node/runtime/world/WorldDocumentStore.js +166 -96
  52. package/dist/node/runtime/world/WorldOperationCoordinator.js +103 -2
  53. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +696 -0
  54. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +380 -0
  55. package/dist/node/runtime/world-revision/WorldRevisionSession.js +709 -0
  56. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +494 -0
  57. package/dist/node/server/ConversationStream.js +109 -0
  58. package/dist/node/server/createServer.js +5 -1
  59. package/dist/node/shared/artifact-payload-contract.js +123 -0
  60. package/dist/node/shared/default-play-prompts.js +25 -45
  61. package/dist/node/shared/default-preset-host.js +114 -138
  62. package/dist/node/shared/default-setting-improvement-prompt.js +52 -38
  63. package/dist/node/shared/display-regex.js +114 -0
  64. package/dist/node/shared/first-party-action-choices.js +1 -6
  65. package/dist/node/shared/first-party-player-view.js +1 -6
  66. package/dist/node/shared/ordered-author-prompts.js +64 -0
  67. package/dist/node/shared/ordered-followups.js +42 -0
  68. package/dist/node/shared/ordered-play-prompts.js +73 -0
  69. package/dist/web/assets/index-DG85hpWC.css +1 -0
  70. package/dist/web/assets/index-DfxvCjb6.js +1207 -0
  71. package/dist/web/index.html +2 -2
  72. package/package.json +1 -1
  73. package/dist/node/runtime/model/SettingAuthorStream.js +0 -21
  74. package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +0 -1731
  75. package/dist/web/assets/index-D8YKfmNG.js +0 -1192
  76. package/dist/web/assets/index-Xa0s35CJ.css +0 -1
@@ -1,6 +1,13 @@
1
+ import { currentPlayPrompt, recordedPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
2
+ import { comparePromptPrefixes } from "../prompt/WorldPromptDiagnostics.js";
1
3
  import { createHash } from "node:crypto";
2
- import { equalModelHostBinding, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "../model/ModelHost.js";
4
+ import { completedPlayerRounds, continuationNotice, isPlayerRoundMarker, playerInputAppend, toolStepNotice, } from "./PlayContinuity.js";
5
+ import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
6
+ import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
7
+ import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
8
+ import { equalModelHostBinding, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, modelHostFailureRequiresFreshContext, } from "../model/ModelHost.js";
3
9
  import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
10
+ import { parsePlayPresetFiles, } from "./FileNativePlayPresetStore.js";
4
11
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
5
12
  import { FileNativePlayDocuments, fingerprintControl, } from "./PlayDocumentTools.js";
6
13
  const callChainToolNames = new Set([
@@ -12,6 +19,8 @@ const callChainToolNames = new Set([
12
19
  "context_read",
13
20
  "world_patch",
14
21
  "world_create",
22
+ "world_retire",
23
+ "world_checkpoint",
15
24
  ]);
16
25
  const projectedEventTail = 40;
17
26
  export class PlayCallChainError extends Error {
@@ -28,6 +37,7 @@ export class PlayCallChainError extends Error {
28
37
  * response shape whose non-empty text can become committed narrative.
29
38
  */
30
39
  export class PlayCallChain {
40
+ changed;
31
41
  #worlds;
32
42
  #compiler;
33
43
  #artifacts;
@@ -36,7 +46,10 @@ export class PlayCallChain {
36
46
  #worldChains = new Map();
37
47
  #loadedCursors = new Map();
38
48
  #activeInvocations = new Map();
39
- constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog) {
49
+ constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog, changed = () => {
50
+ /* No observers attached. */
51
+ }) {
52
+ this.changed = changed;
40
53
  this.#worlds = worlds;
41
54
  this.#compiler = compiler;
42
55
  this.#artifacts = artifacts;
@@ -62,7 +75,9 @@ export class PlayCallChain {
62
75
  throw new PlayCallChainError("A model request is still running for this world.");
63
76
  const binding = await this.#worlds.bindPlayCallChain(input.worldId);
64
77
  const documents = new FileNativePlayDocuments(binding.files);
78
+ const documentMaintenance = await this.#bindDocumentMaintenance(documents, input.worldId, binding.parentHead);
65
79
  const compilation = this.#compiler.compilePlayCallChain({
80
+ extensionControls: await this.#worlds.extensionControls(input.worldId, input.playPreset, this.#compiler.locale),
66
81
  endpoint: {
67
82
  id: `${input.worldId}:${binding.parentHead}`,
68
83
  commit: binding.parentHead,
@@ -73,6 +88,9 @@ export class PlayCallChain {
73
88
  documentSnapshot: documents.snapshot,
74
89
  additionalMaterials: structuredClone(binding.additionalMaterials),
75
90
  history: structuredClone(binding.history),
91
+ narrativeCheckpoint: binding.narrativeCheckpoint,
92
+ documentMaintenance,
93
+ documentMaintenanceUnavailableReason: documents.maintenanceWarning,
76
94
  },
77
95
  playerInputPlacement: "append",
78
96
  playerInput: input.playerText,
@@ -86,6 +104,7 @@ export class PlayCallChain {
86
104
  schemaVersion: 3,
87
105
  kind: "play_call_chain",
88
106
  chainId: input.chainId,
107
+ continuityContextId: input.chainId,
89
108
  worldId: input.worldId,
90
109
  previousContexts: [],
91
110
  previousChainId: existing?.chainId ?? null,
@@ -101,6 +120,7 @@ export class PlayCallChain {
101
120
  },
102
121
  followups: structuredClone(compilation.followups ?? []),
103
122
  playPresetScriptsEnabled: input.playPreset.scriptsEnabled,
123
+ presetFiles: structuredClone(input.playPreset.files),
104
124
  modelBinding: structuredClone(input.modelBinding),
105
125
  status: "ready",
106
126
  canRetry: false,
@@ -125,6 +145,7 @@ export class PlayCallChain {
125
145
  updatedAt: now,
126
146
  documents,
127
147
  history: historyEntries(binding.history),
148
+ narrativeCheckpoint: binding.narrativeCheckpoint,
128
149
  completedToolMap: new Map(),
129
150
  persistenceCursor: {
130
151
  eventCount: 0,
@@ -153,10 +174,16 @@ export class PlayCallChain {
153
174
  throw new PlayCallChainError("A model request is still running.");
154
175
  if (session.status === "interrupted") {
155
176
  if (!session.canRetry || session.lastRequest === null)
156
- throw new PlayCallChainError("Call-chain processing failed, and there is no model context to continue.");
177
+ throw new PlayCallChainError("The current model context cannot continue; use a fresh context.");
157
178
  return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, signal, input.observer);
158
179
  }
180
+ await this.#refreshPrompt(session, input);
159
181
  session.exchange += 1;
182
+ const last = session.transcript.at(-1);
183
+ if (last?.kind === "assistant" &&
184
+ last.toolCalls.length === 0 &&
185
+ last.text.trim().length > 0)
186
+ session.transcript.push(continuationNotice(this.#compiler.locale));
160
187
  return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, signal, input.observer);
161
188
  }
162
189
  if (duplicatePlayerExchange(session, input.exchangeId, input.playerText, "append"))
@@ -165,8 +192,67 @@ export class PlayCallChain {
165
192
  throw new PlayCallChainError(session.canRetry
166
193
  ? "The call chain has an incomplete model response; clear the input and append a continuation, or use a fresh context."
167
194
  : "The call chain was interrupted; use a fresh context.");
195
+ await this.#refreshPrompt(session, input);
168
196
  return this.#submitPlayer(session, input.exchangeId, input.playerText, "append", input.modelHost, signal, input.observer);
169
197
  }
198
+ async #refreshPrompt(session, input) {
199
+ if (input.resolvePrompt === undefined)
200
+ return;
201
+ const { hostBinding, playPreset } = await input.resolvePrompt();
202
+ const binding = await this.#worlds.bindPlayCallChain(input.worldId);
203
+ const baseline = await this.#worlds.bindPlayCallChainAt(input.worldId, session.baselineHead);
204
+ const documents = new FileNativePlayDocuments(binding.files);
205
+ const documentMaintenance = await this.#bindDocumentMaintenance(documents, input.worldId, binding.parentHead);
206
+ const compilation = this.#compiler.compilePlayCallChain({
207
+ extensionControls: await this.#worlds.extensionControls(input.worldId, playPreset, this.#compiler.locale),
208
+ endpoint: {
209
+ id: `${input.worldId}:${binding.parentHead}`,
210
+ commit: binding.parentHead,
211
+ },
212
+ hostBinding,
213
+ world: {
214
+ controlFingerprint: fingerprintControl(binding.files),
215
+ documentSnapshot: documents.snapshot,
216
+ additionalMaterials: structuredClone(binding.additionalMaterials),
217
+ // Only pre-context history belongs in the prefix. Native conversation stays appended.
218
+ history: structuredClone(binding.history),
219
+ historyAlreadyAppended: Object.keys(binding.history).filter((key) => !(key in baseline.history)),
220
+ narrativeCheckpoint: baseline.narrativeCheckpoint,
221
+ documentMaintenance,
222
+ documentMaintenanceUnavailableReason: documents.maintenanceWarning,
223
+ },
224
+ playerInputPlacement: "append",
225
+ playerInput: input.playerText,
226
+ modelBinding: input.modelHost.binding(),
227
+ }, playPreset);
228
+ documents.bindBootstrap(compilation.bootstrap, session.documents.authorizationCheckpoint());
229
+ session.documents = documents;
230
+ session.history = historyEntries(binding.history);
231
+ session.narrativeCheckpoint = binding.narrativeCheckpoint;
232
+ session.nextMaterials = structuredClone(binding.additionalMaterials);
233
+ (session.promptRuns ??= []).push({
234
+ schemaVersion: 1,
235
+ firstEventId: session.nextEventId,
236
+ head: binding.parentHead,
237
+ exchange: session.exchange + 1,
238
+ playPreset: {
239
+ id: playPreset.id,
240
+ name: playPreset.name,
241
+ revision: playPreset.revision,
242
+ },
243
+ bootstrap: structuredClone(compilation.bootstrap),
244
+ tools: structuredClone(compilation.toolUniverse),
245
+ followups: structuredClone(compilation.followups ?? []),
246
+ playPresetScriptsEnabled: playPreset.scriptsEnabled,
247
+ presetFiles: structuredClone(playPreset.files),
248
+ });
249
+ session.documentAuthorizationCheckpoints.push({
250
+ afterEventId: session.nextEventId,
251
+ authorization: documents.authorizationCheckpoint(),
252
+ });
253
+ await this.#persist(session);
254
+ crashAtPlayAdvanceEdge("after_prompt_run_prepared");
255
+ }
170
256
  cancel(input) {
171
257
  validateIdentity(input.worldId, "world ID");
172
258
  validateIdentity(input.chainId, "call-chain ID");
@@ -206,13 +292,19 @@ export class PlayCallChain {
206
292
  controller: new AbortController(),
207
293
  abortable: true,
208
294
  };
295
+ const controlUseId = operationId(input.chainId, `control-use:${input.exchangeId}`);
296
+ this.#worlds.freezeControl(input.worldId, controlUseId);
209
297
  this.#activeInvocations.set(input.chainId, invocation);
298
+ this.changed(input.worldId);
210
299
  try {
300
+ await this.#worlds.assertWorldRevisionUnlocked(input.worldId);
211
301
  return await run(invocation.controller.signal);
212
302
  }
213
303
  finally {
214
304
  if (this.#activeInvocations.get(input.chainId) === invocation)
215
305
  this.#activeInvocations.delete(input.chainId);
306
+ this.#worlds.releaseControl(input.worldId, controlUseId);
307
+ this.changed(input.worldId);
216
308
  }
217
309
  }
218
310
  #observeInvocation(chainId, phase, increments = {}) {
@@ -226,6 +318,7 @@ export class PlayCallChain {
226
318
  active.toolChars += increments.toolChars ?? 0;
227
319
  active.toolCalls += increments.toolCalls ?? 0;
228
320
  active.dispatches += increments.dispatches ?? 0;
321
+ this.changed(active.worldId);
229
322
  }
230
323
  #setInvocationAbortable(chainId, abortable) {
231
324
  const active = this.#activeInvocations.get(chainId);
@@ -245,6 +338,7 @@ export class PlayCallChain {
245
338
  }
246
339
  #notifySnapshot(observer, session) {
247
340
  try {
341
+ this.changed(session.worldId);
248
342
  observer?.onSnapshot?.(this.#projectActiveView(session));
249
343
  }
250
344
  catch {
@@ -253,12 +347,31 @@ export class PlayCallChain {
253
347
  }
254
348
  }
255
349
  async inspectWorld(worldId) {
350
+ const session = await this.#worldSession(worldId);
351
+ return session === null ? null : this.#projectActiveView(session);
352
+ }
353
+ /** Browser observation never traverses old contexts. History and completed
354
+ * diagnostics remain behind the paged timeline/detail Interface. */
355
+ async observeWorld(worldId) {
356
+ const session = await this.#worldSession(worldId);
357
+ if (session === null)
358
+ return null;
359
+ const view = this.#projectActiveView({ ...session, previousContexts: [] });
360
+ const live = new Map(session.events
361
+ .slice(-projectedEventTail)
362
+ .map((event) => [event.id, event]));
363
+ view.events = view.events.map((event) => event.kind === "assistant" && event.status === "streaming"
364
+ ? structuredClone(live.get(event.id))
365
+ : event);
366
+ return view;
367
+ }
368
+ async #worldSession(worldId) {
256
369
  const activeId = this.#worldChains.get(worldId);
257
370
  const active = activeId === undefined ? undefined : this.#active.get(activeId);
258
371
  if (active !== undefined) {
259
372
  if (this.#activeInvocations.get(active.chainId)?.worldId !== active.worldId)
260
373
  await this.#reconcileSessionAdvance(active);
261
- return this.#projectActiveView(active);
374
+ return active;
262
375
  }
263
376
  const persisted = await this.#readPersisted(worldId);
264
377
  if (persisted === null)
@@ -270,7 +383,96 @@ export class PlayCallChain {
270
383
  await this.#reconcileSessionAdvance(session, advance);
271
384
  if (session.status === "running")
272
385
  await this.#interruptAbandonedDispatch(session);
273
- return this.#projectActiveView(session);
386
+ return session;
387
+ }
388
+ async inspectReading(worldId, worldHead) {
389
+ const view = await this.inspectWorld(worldId);
390
+ if (view === null)
391
+ return null;
392
+ const session = this.#active.get(view.chainId);
393
+ if (session === undefined)
394
+ throw new PlayCallChainError("The current play context could not be inspected.");
395
+ const runs = playPromptRunsThroughEvents(session, session.events);
396
+ const latestRun = runs.at(-1);
397
+ const priorRun = runs.at(-2);
398
+ const previousContext = latestRun !== undefined || session.previousChainId === null
399
+ ? null
400
+ : await this.#worlds.playTimeline.readContext(worldId, session.previousChainId);
401
+ const previousContextRun = previousContext === null
402
+ ? undefined
403
+ : playPromptRunsThroughEvents(previousContext.value, previousContext.value.events).at(-1);
404
+ const previous = latestRun !== undefined
405
+ ? (priorRun ?? session)
406
+ : (previousContextRun ?? previousContext?.value ?? null);
407
+ const currentExchange = latestRun?.exchange ?? 1;
408
+ const previousExchange = priorRun?.exchange ?? previousContextRun?.exchange ?? 1;
409
+ const [currentEncoding, previousEncoding] = await Promise.all([
410
+ this.#worlds.playTimeline
411
+ .readInitialEncoding(worldId, session.chainId, currentExchange)
412
+ .catch(() => null),
413
+ previous === null
414
+ ? null
415
+ : this.#worlds.playTimeline
416
+ .readInitialEncoding(worldId, latestRun !== undefined
417
+ ? session.chainId
418
+ : previousContext.value.chainId, previousExchange)
419
+ .catch(() => null),
420
+ ]);
421
+ const usageAt = (events, exchange) => events.findLast((event) => event.kind === "assistant" &&
422
+ event.status === "completed" &&
423
+ event.exchange === exchange);
424
+ const currentResponse = usageAt(session.events, currentExchange);
425
+ const previousResponse = usageAt(previousContext?.value.events ?? session.events, previousExchange);
426
+ const prefixDiagnostics = comparePromptPrefixes(previous === null
427
+ ? null
428
+ : {
429
+ bootstrap: previous.bootstrap,
430
+ encoding: previousEncoding,
431
+ ...(previousResponse?.kind === "assistant" &&
432
+ previousResponse.usage !== undefined
433
+ ? { usage: previousResponse.usage }
434
+ : {}),
435
+ }, {
436
+ bootstrap: recordedPlayPrompt(session).bootstrap,
437
+ encoding: currentEncoding,
438
+ ...(currentResponse?.kind === "assistant" &&
439
+ currentResponse.usage !== undefined
440
+ ? { usage: currentResponse.usage }
441
+ : {}),
442
+ });
443
+ return {
444
+ prefixDiagnostics,
445
+ promptHistory: [
446
+ {
447
+ exchange: 1,
448
+ head: session.baselineHead,
449
+ playPreset: session.playPreset,
450
+ bootstrap: session.bootstrap,
451
+ },
452
+ ...runs,
453
+ ].map(({ exchange, head, playPreset, bootstrap }) => ({
454
+ exchange,
455
+ head,
456
+ playPreset: structuredClone(playPreset),
457
+ bootstrap: {
458
+ logicalMessages: structuredClone(bootstrap.logicalMessages),
459
+ coverage: structuredClone(bootstrap.coverage),
460
+ },
461
+ })),
462
+ requestExchange: currentExchange,
463
+ requestHead: latestRun?.head ?? session.baselineHead,
464
+ chainId: session.chainId,
465
+ baselineHead: session.baselineHead,
466
+ parentHead: session.parentHead,
467
+ stale: session.parentHead !== worldHead,
468
+ playPreset: structuredClone(recordedPlayPrompt(session).playPreset),
469
+ updatedAt: session.updatedAt,
470
+ bootstrap: {
471
+ logicalMessages: structuredClone(recordedPlayPrompt(session).bootstrap.logicalMessages),
472
+ coverage: structuredClone(recordedPlayPrompt(session).bootstrap.coverage),
473
+ },
474
+ reads: projectContextReads(session),
475
+ };
274
476
  }
275
477
  async deriveWorld(input) {
276
478
  const result = await this.#worlds.deriveWorld({
@@ -358,14 +560,27 @@ export class PlayCallChain {
358
560
  if (source === null)
359
561
  throw new PlayCallChainError("The current world has no model call chain to revise.");
360
562
  const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.worldId);
361
- const appliedEvent = sourceContexts
362
- .flatMap(({ events }) => events)
363
- .find((event) => event.kind === "player" &&
563
+ const appliedContext = sourceContexts.find(({ events }) => events.some((event) => event.kind === "player" &&
564
+ event.exchangeId === input.replacementExchangeId &&
565
+ event.committedHead !== undefined));
566
+ const appliedEvent = appliedContext?.events.find((event) => event.kind === "player" &&
364
567
  event.exchangeId === input.replacementExchangeId &&
365
568
  event.committedHead !== undefined);
366
569
  if (appliedEvent !== undefined) {
367
570
  if (appliedEvent.text !== input.replacementText)
368
571
  throw new PlayCallChainError("The same replacement exchange ID is already bound to different player text.");
572
+ const requestedSource = await this.#worlds.playTimeline.readContext(input.worldId, input.chainId);
573
+ const requestedEventIndex = requestedSource?.value.events.findIndex(({ id }) => id === input.eventId);
574
+ const requestedEvent = requestedEventIndex === undefined || requestedEventIndex < 0
575
+ ? undefined
576
+ : requestedSource?.value.events[requestedEventIndex];
577
+ if (requestedSource === null ||
578
+ requestedEventIndex === undefined ||
579
+ requestedEventIndex < 0 ||
580
+ requestedEvent?.kind !== "player" ||
581
+ requestedEvent.committedHead === undefined)
582
+ throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
583
+ const requestedRestoresHead = playerRevisionRestoresHead(requestedSource.value, requestedEventIndex);
369
584
  const authority = await this.#worlds.readAuthorityHistory(input.worldId);
370
585
  const commit = authority.commits.find(({ head }) => head === appliedEvent.committedHead);
371
586
  const operation = await this.#worlds.getOperationOutcome(input.operationId);
@@ -373,7 +588,11 @@ export class PlayCallChain {
373
588
  (operation.outcome !== "committed" &&
374
589
  operation.outcome !== "committed_materialization_pending") ||
375
590
  operation.worldId !== input.worldId ||
376
- operation.head !== appliedEvent.committedHead)
591
+ operation.head !== appliedEvent.committedHead ||
592
+ commit.timelineRevision?.restoresHead !== requestedRestoresHead ||
593
+ commit.timelineRevision.replacesHead !== requestedEvent.committedHead ||
594
+ commit.timelineRevision.requestFingerprint !==
595
+ playerRevisionRequestFingerprint(input, requestedEvent.committedHead, requestedRestoresHead))
377
596
  throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
378
597
  return {
379
598
  outcome: "revised",
@@ -406,23 +625,14 @@ export class PlayCallChain {
406
625
  async #applyPlayerRevision(input) {
407
626
  const request = input.request;
408
627
  const sourceContext = input.sourceContexts[input.selectedContextIndex];
409
- let restoresHead = sourceContext.baselineHead;
410
- for (const event of sourceContext.events.slice(0, input.selectedEventIndex))
411
- if ((event.kind === "player" || event.kind === "assistant") &&
412
- event.committedHead !== undefined)
413
- restoresHead = event.committedHead;
414
- const requestFingerprint = "sha256:" +
415
- createHash("sha256")
416
- .update(JSON.stringify({
417
- schema: "narraeon.timeline-revision-request/v1",
418
- chainId: request.chainId,
419
- eventId: request.eventId,
420
- selectedHead: input.selectedEvent.committedHead,
421
- restoresHead,
422
- replacementExchangeId: request.replacementExchangeId,
423
- replacementText: request.replacementText,
424
- }))
425
- .digest("hex");
628
+ const restoresHead = playerRevisionRestoresHead(sourceContext, input.selectedEventIndex);
629
+ const freshPreparation = request.continuation === "fresh_context"
630
+ ? await this.#prepareFreshPlayerRevision(request, restoresHead)
631
+ : null;
632
+ const timelineGeneration = playerRevisionTimelineGeneration(input.source.timelineGeneration, request.operationId, request.worldId, request.continuation);
633
+ const requestFingerprint = playerRevisionRequestFingerprint(request, input.selectedEvent.committedHead, restoresHead);
634
+ const prefixEvents = structuredClone(sourceContext.events.slice(0, input.selectedEventIndex));
635
+ const transcript = transcriptThroughEvents(sourceContext.transcript, prefixEvents);
426
636
  const outcome = await this.#worlds.reviseTimeline({
427
637
  operationId: request.operationId,
428
638
  worldId: request.worldId,
@@ -432,9 +642,6 @@ export class PlayCallChain {
432
642
  replacementText: request.replacementText,
433
643
  requestFingerprint,
434
644
  });
435
- const prefixEvents = structuredClone(sourceContext.events.slice(0, input.selectedEventIndex));
436
- const transcript = transcriptThroughEvents(sourceContext.transcript, prefixEvents);
437
- transcript.push({ kind: "player", text: request.replacementText });
438
645
  const events = [
439
646
  ...prefixEvents,
440
647
  {
@@ -452,22 +659,66 @@ export class PlayCallChain {
452
659
  this.#worlds.recoverEndpoint(request.worldId, restoresHead),
453
660
  this.#worlds.bindPlayCallChain(request.worldId),
454
661
  ]);
662
+ transcript.push(...playerInputAppend({
663
+ history: Object.fromEntries(selected.history.map(({ messageId, exactText }) => [
664
+ messageId,
665
+ exactText,
666
+ ])),
667
+ checkpoint: selected.narrativeCheckpoint,
668
+ text: request.replacementText,
669
+ locale: this.#compiler.locale,
670
+ checkpointAvailable: sourceContext.tools.some(({ name }) => name === "world_checkpoint"),
671
+ }));
455
672
  if (binding.parentHead !== outcome.head)
456
673
  throw new PlayCallChainError("The timeline revision was committed, but current-world materialization has not reached the new endpoint.");
674
+ if (request.continuation === "fresh_context") {
675
+ if (freshPreparation === null)
676
+ throw new PlayCallChainError("The fresh model context was not prepared before committing the timeline revision.");
677
+ const revised = await this.#buildFreshPlayerRevision({
678
+ request,
679
+ sourceContext,
680
+ sourceContexts: input.sourceContexts,
681
+ selectedContextIndex: input.selectedContextIndex,
682
+ prefixEvents,
683
+ baseline,
684
+ selected,
685
+ binding,
686
+ restoresHead,
687
+ outcomeHead: outcome.head,
688
+ timelineGeneration,
689
+ freshPreparation,
690
+ });
691
+ await this.#worlds.playTimeline.persist(structuredClone(revised));
692
+ const activeId = this.#worldChains.get(request.worldId);
693
+ if (activeId !== undefined)
694
+ this.#active.delete(activeId);
695
+ this.#active.delete(sourceContext.chainId);
696
+ this.#worldChains.delete(request.worldId);
697
+ return {
698
+ outcome: "revised",
699
+ worldId: request.worldId,
700
+ playCallChain: projectView(revised),
701
+ };
702
+ }
457
703
  const documents = restorePlayDocuments(binding.files, sourceContext, prefixEvents);
458
704
  const revised = {
705
+ continuityContextId: sourceContext.continuityContextId ?? sourceContext.chainId,
459
706
  schemaVersion: 3,
460
707
  kind: "play_call_chain",
461
- chainId: derivedChainId(sourceContext.chainId, "timeline-revision:" + request.operationId, request.worldId),
708
+ chainId: playerRevisionChainId(sourceContext.chainId, request.operationId, request.worldId, request.continuation),
462
709
  worldId: request.worldId,
463
710
  previousContexts: [],
464
711
  previousChainId: input.selectedContextIndex === 0
465
712
  ? null
466
713
  : input.sourceContexts[input.selectedContextIndex - 1].chainId,
467
- timelineGeneration: this.#worlds.playTimeline.newGeneration(),
714
+ timelineGeneration,
468
715
  baselineHead: sourceContext.baselineHead,
469
716
  baselineHistoryLength: sourceContext.baselineHistoryLength ?? baseline.history.length,
470
717
  parentHead: outcome.head,
718
+ promptRuns: playPromptRunsThroughEvents(sourceContext, events),
719
+ ...(sourceContext.presetFiles === undefined
720
+ ? {}
721
+ : { presetFiles: structuredClone(sourceContext.presetFiles) }),
471
722
  playPreset: structuredClone(sourceContext.playPreset),
472
723
  ...(sourceContext.followups === undefined
473
724
  ? {}
@@ -513,6 +764,158 @@ export class PlayCallChain {
513
764
  playCallChain: projectView(revised),
514
765
  };
515
766
  }
767
+ async #prepareFreshPlayerRevision(request, restoresHead) {
768
+ const binding = await this.#worlds.bindPlayCallChainAt(request.worldId, restoresHead);
769
+ const documents = new FileNativePlayDocuments(binding.files);
770
+ const documentMaintenance = await this.#bindDocumentMaintenance(documents, request.worldId, binding.parentHead);
771
+ const compilation = this.#compiler.compilePlayCallChain({
772
+ extensionControls: await this.#worlds.extensionControls(request.worldId, request.freshContext.playPreset, this.#compiler.locale),
773
+ endpoint: {
774
+ id: `${request.worldId}:${binding.parentHead}`,
775
+ commit: binding.parentHead,
776
+ },
777
+ hostBinding: structuredClone(request.freshContext.hostBinding),
778
+ world: {
779
+ controlFingerprint: fingerprintControl(binding.files),
780
+ documentSnapshot: documents.snapshot,
781
+ additionalMaterials: structuredClone(binding.additionalMaterials),
782
+ history: structuredClone(binding.history),
783
+ narrativeCheckpoint: binding.narrativeCheckpoint,
784
+ documentMaintenance,
785
+ documentMaintenanceUnavailableReason: documents.maintenanceWarning,
786
+ },
787
+ playerInputPlacement: "append",
788
+ playerInput: request.replacementText,
789
+ modelBinding: structuredClone(request.freshContext.modelBinding),
790
+ }, request.freshContext.playPreset);
791
+ const bootstrap = structuredClone(compilation.bootstrap);
792
+ documents.bindBootstrap(bootstrap);
793
+ return {
794
+ binding,
795
+ bootstrap,
796
+ tools: structuredClone(compilation.toolUniverse),
797
+ followups: structuredClone(compilation.followups ?? []),
798
+ authorization: documents.authorizationCheckpoint(),
799
+ };
800
+ }
801
+ async #buildFreshPlayerRevision(input) {
802
+ const { request, sourceContext } = input;
803
+ let previousChainId = input.selectedContextIndex === 0
804
+ ? null
805
+ : input.sourceContexts[input.selectedContextIndex - 1].chainId;
806
+ if (input.prefixEvents.length > 0) {
807
+ const prefixTranscript = transcriptThroughEvents(sourceContext.transcript, input.prefixEvents);
808
+ const completedKeys = completedToolKeys(input.prefixEvents);
809
+ const documents = restorePlayDocuments(input.binding.files, sourceContext, input.prefixEvents);
810
+ const prefix = {
811
+ continuityContextId: sourceContext.continuityContextId ?? sourceContext.chainId,
812
+ schemaVersion: 3,
813
+ kind: "play_call_chain",
814
+ chainId: derivedChainId(sourceContext.chainId, "timeline-revision-prefix:" + request.operationId, request.worldId),
815
+ worldId: request.worldId,
816
+ previousContexts: [],
817
+ previousChainId,
818
+ timelineGeneration: input.timelineGeneration,
819
+ baselineHead: sourceContext.baselineHead,
820
+ baselineHistoryLength: sourceContext.baselineHistoryLength ?? input.baseline.history.length,
821
+ parentHead: input.restoresHead,
822
+ promptRuns: playPromptRunsThroughEvents(sourceContext, input.prefixEvents),
823
+ ...(sourceContext.presetFiles === undefined
824
+ ? {}
825
+ : { presetFiles: structuredClone(sourceContext.presetFiles) }),
826
+ playPreset: structuredClone(sourceContext.playPreset),
827
+ ...(sourceContext.followups === undefined
828
+ ? {}
829
+ : { followups: structuredClone(sourceContext.followups) }),
830
+ ...(sourceContext.playPresetScriptsEnabled === undefined
831
+ ? {}
832
+ : {
833
+ playPresetScriptsEnabled: sourceContext.playPresetScriptsEnabled,
834
+ }),
835
+ ...(sourceContext.modelBinding === undefined
836
+ ? {}
837
+ : { modelBinding: structuredClone(sourceContext.modelBinding) }),
838
+ status: "ready",
839
+ canRetry: false,
840
+ bootstrap: structuredClone(sourceContext.bootstrap),
841
+ tools: structuredClone(sourceContext.tools),
842
+ transcript: prefixTranscript,
843
+ events: structuredClone(input.prefixEvents),
844
+ completedTools: sourceContext.completedTools
845
+ .filter(({ key }) => completedKeys.has(key))
846
+ .map((item) => structuredClone(item)),
847
+ documentAuthorizationCheckpoints: authorizationCheckpointsThroughEvents(sourceContext, input.prefixEvents, documents.authorizationCheckpoint()),
848
+ changedDocuments: changedDocumentsAtHead(sourceContext.changedDocuments, input.baseline.state, input.selected.state),
849
+ nextMaterials: structuredClone(input.selected.additionalMaterials),
850
+ nextEventId: Math.max(0, ...input.prefixEvents.map(({ id }) => id)) + 1,
851
+ exchange: completedAssistantExchange(input.prefixEvents),
852
+ lastRequest: null,
853
+ lastRequestAttempt: 0,
854
+ lastFailure: null,
855
+ updatedAt: Date.now(),
856
+ };
857
+ await this.#worlds.playTimeline.persistDetached(structuredClone(prefix));
858
+ previousChainId = prefix.chainId;
859
+ }
860
+ const fresh = input.freshPreparation;
861
+ return {
862
+ schemaVersion: 3,
863
+ kind: "play_call_chain",
864
+ chainId: playerRevisionChainId(sourceContext.chainId, request.operationId, request.worldId, request.continuation),
865
+ worldId: request.worldId,
866
+ previousContexts: [],
867
+ previousChainId,
868
+ timelineGeneration: input.timelineGeneration,
869
+ baselineHead: input.restoresHead,
870
+ baselineHistoryLength: Object.keys(fresh.binding.history).length,
871
+ parentHead: input.outcomeHead,
872
+ playPreset: {
873
+ id: request.freshContext.playPreset.id,
874
+ name: request.freshContext.playPreset.name,
875
+ revision: request.freshContext.playPreset.revision,
876
+ },
877
+ followups: structuredClone(fresh.followups),
878
+ playPresetScriptsEnabled: request.freshContext.playPreset.scriptsEnabled,
879
+ presetFiles: structuredClone(request.freshContext.playPreset.files),
880
+ modelBinding: structuredClone(request.freshContext.modelBinding),
881
+ status: "ready",
882
+ canRetry: false,
883
+ bootstrap: structuredClone(fresh.bootstrap),
884
+ tools: structuredClone(fresh.tools),
885
+ transcript: playerInputAppend({
886
+ history: fresh.binding.history,
887
+ checkpoint: fresh.binding.narrativeCheckpoint,
888
+ text: request.replacementText,
889
+ locale: this.#compiler.locale,
890
+ checkpointAvailable: fresh.tools.some(({ name }) => name === "world_checkpoint"),
891
+ }),
892
+ events: [
893
+ {
894
+ id: 1,
895
+ kind: "player",
896
+ exchangeId: request.replacementExchangeId,
897
+ text: request.replacementText,
898
+ context: "fresh",
899
+ committedHead: input.outcomeHead,
900
+ },
901
+ ],
902
+ completedTools: [],
903
+ documentAuthorizationCheckpoints: [
904
+ {
905
+ afterEventId: 0,
906
+ authorization: structuredClone(fresh.authorization),
907
+ },
908
+ ],
909
+ changedDocuments: [],
910
+ nextMaterials: structuredClone(input.binding.additionalMaterials),
911
+ nextEventId: 2,
912
+ exchange: 0,
913
+ lastRequest: null,
914
+ lastRequestAttempt: 0,
915
+ lastFailure: null,
916
+ updatedAt: Date.now(),
917
+ };
918
+ }
516
919
  async #forkSelectionToDerivedWorld(input) {
517
920
  if (input.targetWorldRoot === undefined) {
518
921
  const existing = await this.#readPersisted(input.targetWorldId);
@@ -574,6 +977,7 @@ export class PlayCallChain {
574
977
  previousChainId = chainId;
575
978
  }
576
979
  const derived = {
980
+ continuityContextId: sourceContext.continuityContextId ?? sourceContext.chainId,
577
981
  schemaVersion: 3,
578
982
  kind: "play_call_chain",
579
983
  chainId: derivedChainId(sourceContext.chainId, input.branchIdentity, input.targetWorldId),
@@ -584,6 +988,10 @@ export class PlayCallChain {
584
988
  baselineHead: sourceContext.baselineHead,
585
989
  baselineHistoryLength: sourceContext.baselineHistoryLength ?? baseline.history.length,
586
990
  parentHead: input.sourceHead,
991
+ promptRuns: playPromptRunsThroughEvents(sourceContext, events),
992
+ ...(sourceContext.presetFiles === undefined
993
+ ? {}
994
+ : { presetFiles: structuredClone(sourceContext.presetFiles) }),
587
995
  playPreset: structuredClone(sourceContext.playPreset),
588
996
  ...(sourceContext.followups === undefined
589
997
  ? {}
@@ -626,6 +1034,26 @@ export class PlayCallChain {
626
1034
  source: sourceContext,
627
1035
  target: structuredClone(derived),
628
1036
  });
1037
+ if (input.targetWorldRoot !== undefined) {
1038
+ const retainedContexts = [
1039
+ ...input.sourceContexts.slice(0, input.selectedContextIndex),
1040
+ { ...sourceContext, events: input.sourceEvents },
1041
+ ];
1042
+ await this.#artifacts?.stageFork?.({
1043
+ sourceWorldId: input.sourceWorldId,
1044
+ targetWorldId: input.targetWorldId,
1045
+ targetWorldRoot: input.targetWorldRoot,
1046
+ timeline: retainedContexts.flatMap((context) => context.events.flatMap((event) => event.kind === "assistant" && event.committedHead !== undefined
1047
+ ? [
1048
+ {
1049
+ contextId: context.continuityContextId ?? context.chainId,
1050
+ eventId: event.id,
1051
+ head: event.committedHead,
1052
+ },
1053
+ ]
1054
+ : [])),
1055
+ });
1056
+ }
629
1057
  return projectView(derived);
630
1058
  }
631
1059
  forgetWorld(worldId) {
@@ -645,7 +1073,13 @@ export class PlayCallChain {
645
1073
  const exchange = session.exchange + 1;
646
1074
  const nextRequest = createRequest(session, modelHost, exchange, [
647
1075
  ...session.transcript,
648
- { kind: "player", text: playerText },
1076
+ ...playerInputAppend({
1077
+ history: Object.fromEntries(session.history.map(({ path, contents }) => [path, contents])),
1078
+ checkpoint: session.narrativeCheckpoint,
1079
+ text: playerText,
1080
+ locale: this.#compiler.locale,
1081
+ checkpointAvailable: currentPlayPrompt(session).tools.some(({ name }) => name === "world_checkpoint"),
1082
+ }),
649
1083
  ]);
650
1084
  const operationKey = `player:${exchangeId}`;
651
1085
  const playerOperationId = operationId(session.chainId, operationKey);
@@ -653,6 +1087,7 @@ export class PlayCallChain {
653
1087
  schemaVersion: 1,
654
1088
  kind: "play_advance",
655
1089
  advanceKind: "player",
1090
+ playContext: session.continuityContextId ?? session.chainId,
656
1091
  worldId: session.worldId,
657
1092
  chainId: session.chainId,
658
1093
  advanceId: playerOperationId,
@@ -699,6 +1134,9 @@ export class PlayCallChain {
699
1134
  }
700
1135
  async #settlePlayerAdvance(session, advance) {
701
1136
  const outcome = await this.#worlds.commitPlayStep({
1137
+ ...(advance.playContext === undefined
1138
+ ? {}
1139
+ : { playContext: advance.playContext }),
702
1140
  operationId: advance.operationId,
703
1141
  worldId: session.worldId,
704
1142
  parentHead: advance.parentHead,
@@ -725,7 +1163,7 @@ export class PlayCallChain {
725
1163
  throw new PlayCallChainError("The recovered call-chain transcript is shorter than its player prefix.");
726
1164
  session.transcript = [
727
1165
  ...session.transcript.slice(0, advance.transcriptStart),
728
- { kind: "player", text: advance.playerText },
1166
+ ...structuredClone(advance.nextRequest.appended.slice(advance.transcriptStart)),
729
1167
  ];
730
1168
  session.nextEventId = advance.eventId + 1;
731
1169
  session.parentHead = outcome.head;
@@ -741,7 +1179,7 @@ export class PlayCallChain {
741
1179
  crashAtPlayAdvanceEdge("after_timeline_settled");
742
1180
  await this.#worlds.playAdvances.markSettled(advance, outcome.head);
743
1181
  }
744
- #prepareResponseSettlement(session, advance, response) {
1182
+ async #prepareResponseSettlement(session, advance, response) {
745
1183
  const existing = session.events.find(({ id }) => id === advance.eventId);
746
1184
  if (existing?.kind !== "assistant")
747
1185
  throw new PlayCallChainError("The durable Provider result no longer matches its assistant event.");
@@ -775,7 +1213,7 @@ export class PlayCallChain {
775
1213
  const trailingEvents = [];
776
1214
  let nextEventId = advance.eventId + 1;
777
1215
  for (const call of calls) {
778
- const item = prepareTool(session, workingTools, advance.exchange, call);
1216
+ const item = prepareTool(session, workingTools, advance.exchange, call, this.#compiler.locale);
779
1217
  prepared.push(item);
780
1218
  trailingEvents.push({
781
1219
  id: nextEventId++,
@@ -789,7 +1227,72 @@ export class PlayCallChain {
789
1227
  trailingEvents.push(structuredClone(item.event));
790
1228
  }
791
1229
  const stateChanges = session.documents.stateChanges();
1230
+ // Resolve once after the entire tool batch. Store the prediction with the
1231
+ // prepared fact so crash recovery publishes exactly the same receipt.
1232
+ const writes = [...workingTools.values()].filter(({ result }) => result.candidateWrite !== undefined);
1233
+ let worldClock;
1234
+ if (writes.length > 0) {
1235
+ try {
1236
+ // Author controls may change while an existing Provider context stays
1237
+ // frozen. Predictions and clock capture use the controls active now.
1238
+ const binding = await this.#worlds.bindPlayCallChain(session.worldId);
1239
+ const snapshot = WorldDocumentStore.open({
1240
+ layout: "world_state",
1241
+ files: [
1242
+ ...session.documents.snapshot.files.filter(({ path }) => path.startsWith("state/")),
1243
+ ...Object.entries(binding.files)
1244
+ .filter(([path]) => path.startsWith("control/"))
1245
+ .map(([path, contents]) => ({ path, contents })),
1246
+ ],
1247
+ });
1248
+ worldClock = readDeclaredWorldClock(snapshot);
1249
+ const { coverage, maintenance } = this.#compiler.inspectWorldMaterials({
1250
+ controlFingerprint: "receipt",
1251
+ documentSnapshot: snapshot,
1252
+ history: Object.fromEntries(session.history.map(({ path, contents }) => [path, contents])),
1253
+ additionalMaterials: session.nextMaterials,
1254
+ });
1255
+ for (const { result } of writes) {
1256
+ const write = result.candidateWrite;
1257
+ write.freshContextCoverage = renderFreshContextCoverage(snapshot, coverage, write.shortRef, this.#compiler.locale);
1258
+ const previous = session.documents.committedSnapshot.query({
1259
+ kind: "read_document",
1260
+ document: { shortRef: write.shortRef },
1261
+ });
1262
+ const advice = renderWriteSizeAdvice(maintenance, write.shortRef, previous.kind === "read_document"
1263
+ ? Buffer.byteLength(previous.body, "utf8")
1264
+ : null, this.#compiler.locale);
1265
+ if (advice.length > 0)
1266
+ write.freshContextCoverage += `\n${advice}`;
1267
+ }
1268
+ }
1269
+ catch (error) {
1270
+ // A coverage prediction must never reject an otherwise valid write.
1271
+ const reason = error instanceof Error ? error.message : "Material selection failed";
1272
+ for (const { result } of writes)
1273
+ result.candidateWrite.freshContextCoverage = `${this.#compiler.locale === "zh-CN" ? "暂时无法判定下次注入;写入结果不受影响。原因" : "Fresh-context coverage unavailable; the write result is unaffected. Reason"}: ${reason}`;
1274
+ }
1275
+ }
792
1276
  const visibleText = responseKind === "narrative";
1277
+ const previousNarrative = session.events.findLast((event) => event.kind === "assistant" &&
1278
+ event.committedHead !== undefined &&
1279
+ event.responseKind === "narrative");
1280
+ const previousPlayer = session.events.findLast((event) => event.kind === "player");
1281
+ const checkpointRequested = visibleText &&
1282
+ session.events.some((event) => event.id >
1283
+ Math.max(previousNarrative?.id ?? 0, previousPlayer?.id ?? 0) &&
1284
+ event.kind === "tool_result" &&
1285
+ event.name === "world_checkpoint" &&
1286
+ event.ok);
1287
+ const narrativeCheckpoint = checkpointRequested
1288
+ ? {
1289
+ contextId: session.chainId,
1290
+ completedPlayerRounds: completedPlayerRounds({
1291
+ ...Object.fromEntries(session.history.map(({ path, contents }) => [path, contents])),
1292
+ "message.pending.narrator": text,
1293
+ }),
1294
+ }
1295
+ : undefined;
793
1296
  const assistantItem = {
794
1297
  kind: "assistant",
795
1298
  text,
@@ -803,6 +1306,10 @@ export class PlayCallChain {
803
1306
  };
804
1307
  return {
805
1308
  assistantEvent,
1309
+ playContext: session.continuityContextId ?? session.chainId,
1310
+ ...(worldClock === undefined || stateChanges.length === 0
1311
+ ? {}
1312
+ : { worldClock }),
806
1313
  trailingEvents,
807
1314
  transcriptStart: session.transcript.length,
808
1315
  transcriptAppend: [
@@ -812,11 +1319,15 @@ export class PlayCallChain {
812
1319
  ? [assistantItem]
813
1320
  : []),
814
1321
  ...prepared.map(({ transcript }) => structuredClone(transcript)),
1322
+ ...(calls.length > 0 && hasText
1323
+ ? [toolStepNotice(this.#compiler.locale)]
1324
+ : []),
815
1325
  ],
816
1326
  completedTools: [...workingTools.values()].map((item) => structuredClone(item)),
817
1327
  authorizationCheckpoint: session.documents.authorizationCheckpoint(),
818
1328
  stateChanges: structuredClone(stateChanges),
819
1329
  visibleText,
1330
+ ...(narrativeCheckpoint === undefined ? {} : { narrativeCheckpoint }),
820
1331
  };
821
1332
  }
822
1333
  async #settleResponseAdvance(session, advance, prepared) {
@@ -838,6 +1349,12 @@ export class PlayCallChain {
838
1349
  let committedHead;
839
1350
  if (settlement.visibleText || settlement.stateChanges.length > 0) {
840
1351
  const outcome = await this.#worlds.commitPlayStep({
1352
+ ...(settlement.playContext === undefined
1353
+ ? {}
1354
+ : { playContext: settlement.playContext }),
1355
+ ...(settlement.worldClock === undefined
1356
+ ? {}
1357
+ : { worldClock: settlement.worldClock }),
841
1358
  operationId: advance.operationId,
842
1359
  worldId: session.worldId,
843
1360
  parentHead: advance.parentHead,
@@ -851,9 +1368,20 @@ export class PlayCallChain {
851
1368
  : [],
852
1369
  nextMaterials: structuredClone(session.nextMaterials),
853
1370
  stateChanges: structuredClone(settlement.stateChanges),
1371
+ ...(settlement.narrativeCheckpoint === undefined
1372
+ ? {}
1373
+ : { narrativeCheckpoint: settlement.narrativeCheckpoint }),
854
1374
  });
855
1375
  committedHead = outcome.head;
856
1376
  settlement.assistantEvent.committedHead = outcome.head;
1377
+ if (settlement.narrativeCheckpoint !== undefined) {
1378
+ settlement.assistantEvent.checkpoint = true;
1379
+ session.narrativeCheckpoint = {
1380
+ ...settlement.narrativeCheckpoint,
1381
+ head: outcome.head,
1382
+ historyMessageId: `message.${outcome.head.slice("commit:".length)}.1.narrator`,
1383
+ };
1384
+ }
857
1385
  crashAtPlayAdvanceEdge("after_authority_accepted");
858
1386
  appendCommittedHistory(session, outcome.head, outcome.historyAppend);
859
1387
  }
@@ -901,6 +1429,8 @@ export class PlayCallChain {
901
1429
  }
902
1430
  mergeChangedDocuments(session, settlement.stateChanges);
903
1431
  }
1432
+ if (settlement.stateChanges.length > 0)
1433
+ await this.#bindDocumentMaintenance(session.documents, session.worldId, session.parentHead);
904
1434
  session.exchange = Math.max(session.exchange, advance.exchange);
905
1435
  session.canRetry = false;
906
1436
  session.lastFailure = null;
@@ -1007,6 +1537,16 @@ export class PlayCallChain {
1007
1537
  let response;
1008
1538
  try {
1009
1539
  this.#observeInvocation(session.chainId, "waiting", { dispatches: 1 });
1540
+ if (modelHost.previewRequest !== undefined &&
1541
+ (request.exchange === 1 ||
1542
+ request.exchange === session.promptRuns?.at(-1)?.exchange)) {
1543
+ try {
1544
+ await this.#worlds.playTimeline.recordInitialEncoding(session.worldId, session.chainId, modelHost.previewRequest(request), request.exchange);
1545
+ }
1546
+ catch {
1547
+ // Optional diagnostics never interrupt a model request or alter its cache key.
1548
+ }
1549
+ }
1010
1550
  response = await modelHost.exchange(request, {
1011
1551
  signal,
1012
1552
  onDelta: (delta) => {
@@ -1024,6 +1564,7 @@ export class PlayCallChain {
1024
1564
  else if (delta.kind === "tool")
1025
1565
  event.toolFragment = `${event.toolFragment ?? ""}${delta.text}`;
1026
1566
  touch(session);
1567
+ this.changed(session.worldId);
1027
1568
  notifyAssistantDelta(observer, {
1028
1569
  eventId: event.id,
1029
1570
  kind: delta.kind,
@@ -1056,7 +1597,8 @@ export class PlayCallChain {
1056
1597
  });
1057
1598
  session.status = "interrupted";
1058
1599
  const requiresFreshContext = error instanceof ModelHostContinuationError ||
1059
- error instanceof ModelHostOutcomeUnknownError;
1600
+ error instanceof ModelHostOutcomeUnknownError ||
1601
+ modelHostFailureRequiresFreshContext(error);
1060
1602
  session.canRetry =
1061
1603
  !requiresFreshContext && error instanceof ModelHostFailureError;
1062
1604
  session.lastFailure = message;
@@ -1069,7 +1611,7 @@ export class PlayCallChain {
1069
1611
  try {
1070
1612
  await this.#worlds.playAdvances.recordProviderCompleted(advance, durableResponse);
1071
1613
  crashAtPlayAdvanceEdge("after_provider_completed");
1072
- const settlement = this.#prepareResponseSettlement(session, advance, durableResponse);
1614
+ const settlement = await this.#prepareResponseSettlement(session, advance, durableResponse);
1073
1615
  await this.#worlds.playAdvances.recordSettlementPrepared(advance, settlement);
1074
1616
  crashAtPlayAdvanceEdge("after_settlement_prepared");
1075
1617
  const failedTools = settlement.trailingEvents.filter((candidate) => candidate.kind === "tool_result" && !candidate.ok);
@@ -1123,7 +1665,7 @@ export class PlayCallChain {
1123
1665
  message: "The play call chain recovered during a later model exchange and completed.",
1124
1666
  details: { parentHead: session.parentHead },
1125
1667
  });
1126
- if ((session.followups?.length ?? 0) > 0) {
1668
+ if ((currentPlayPrompt(session).followups?.length ?? 0) > 0) {
1127
1669
  // Follow-up artifacts are part of this player-visible invocation.
1128
1670
  // Keep `running` ephemeral: the settled main response remains the
1129
1671
  // durable recovery point if this Runtime process stops here.
@@ -1141,6 +1683,7 @@ export class PlayCallChain {
1141
1683
  message: "The player cancelled the remaining follow-up generation after the narrative was committed.",
1142
1684
  });
1143
1685
  touch(session);
1686
+ this.changed(session.worldId);
1144
1687
  }
1145
1688
  session.status = "ready";
1146
1689
  await this.#persist(session);
@@ -1222,9 +1765,13 @@ export class PlayCallChain {
1222
1765
  * trace and the chain still returns to `ready`.
1223
1766
  */
1224
1767
  async #runFollowups(session, modelHost, signal, observer) {
1225
- const followups = session.followups ?? [];
1768
+ const followups = currentPlayPrompt(session).followups ?? [];
1226
1769
  if (this.#artifacts === undefined || followups.length === 0)
1227
1770
  return;
1771
+ const reply = session.events.findLast((event) => event.kind === "assistant" &&
1772
+ event.committedHead === session.parentHead);
1773
+ if (reply === undefined)
1774
+ throw new PlayCallChainError("Follow-up reply is not committed");
1228
1775
  const observeFollowupDelta = (delta) => {
1229
1776
  this.#observeInvocation(session.chainId, "followup", {
1230
1777
  ...(delta.kind === "reasoning"
@@ -1258,13 +1805,15 @@ export class PlayCallChain {
1258
1805
  try {
1259
1806
  await runPlayFollowupRequests({
1260
1807
  artifacts: this.#artifacts,
1808
+ controls: this.#worlds.extensionRequests,
1809
+ presentations: frozenFollowupPresentations(session),
1261
1810
  modelHost,
1262
1811
  followups,
1263
- bootstrap: session.bootstrap,
1812
+ bootstrap: currentPlayPrompt(session).bootstrap,
1264
1813
  // Captured once: every followup is dispatched against this exact
1265
1814
  // prefix, so none of them observes another one's prompt or output.
1266
1815
  prefix: structuredClone(session.transcript),
1267
- toolStrategy: session.bootstrap.toolStrategy,
1816
+ toolStrategy: currentPlayPrompt(session).bootstrap.toolStrategy,
1268
1817
  context: {
1269
1818
  // A follow-up produces no world endpoint of its own, so it starts and
1270
1819
  // ends on the settled exchange's head. Reconciliation keeps panels
@@ -1272,9 +1821,15 @@ export class PlayCallChain {
1272
1821
  worldId: session.worldId,
1273
1822
  parentHead: session.parentHead,
1274
1823
  operationId: followupOperationId(session),
1275
- playPresetId: session.playPreset.id,
1276
- playPresetRevision: session.playPreset.revision,
1277
- playPresetScriptsEnabled: session.playPresetScriptsEnabled ?? true,
1824
+ attachment: {
1825
+ contextId: session.continuityContextId ?? session.chainId,
1826
+ eventId: reply.id,
1827
+ head: session.parentHead,
1828
+ runId: `${session.chainId}:send:${session.promptRuns?.at(-1)?.firstEventId ?? reply.id}`,
1829
+ },
1830
+ playPresetId: currentPlayPrompt(session).playPreset.id,
1831
+ playPresetRevision: currentPlayPrompt(session).playPreset.revision,
1832
+ playPresetScriptsEnabled: currentPlayPrompt(session).playPresetScriptsEnabled ?? true,
1278
1833
  },
1279
1834
  head: session.parentHead,
1280
1835
  maxOutputTokens: modelHost.binding().maxOutputTokens,
@@ -1331,8 +1886,24 @@ export class PlayCallChain {
1331
1886
  throw new PlayCallChainError("The call-chain record differs from the current world endpoint; use a fresh context.");
1332
1887
  return session;
1333
1888
  }
1889
+ async #bindDocumentMaintenance(documents, worldId, head) {
1890
+ const inspection = await this.#worlds.inspectDocumentMaintenance(worldId, head);
1891
+ if (inspection.documentMaintenanceUnavailableReason === undefined)
1892
+ documents.bindMaintenance(inspection.documentMaintenance, this.#compiler.locale);
1893
+ else
1894
+ documents.bindMaintenanceUnavailable(inspection.documentMaintenanceUnavailableReason, this.#compiler.locale);
1895
+ return inspection.documentMaintenance;
1896
+ }
1334
1897
  async #hydrateSession(persisted, advance) {
1335
- const binding = await this.#worlds.bindPlayCallChain(persisted.worldId);
1898
+ const currentBinding = await this.#worlds.bindPlayCallChain(persisted.worldId);
1899
+ // A completed chain still describes its own immutable endpoint after a
1900
+ // world revision. Restore its reads against that state, not the revised
1901
+ // current tree. An unsettled advance keeps the existing crash-recovery
1902
+ // path because Authority may already contain its prepared result.
1903
+ const binding = currentBinding.parentHead !== persisted.parentHead &&
1904
+ advance?.settled !== null
1905
+ ? await this.#worlds.bindPlayCallChainAt(persisted.worldId, persisted.parentHead)
1906
+ : currentBinding;
1336
1907
  let documents;
1337
1908
  try {
1338
1909
  documents = restorePlayDocuments(binding.files, persisted, persisted.events);
@@ -1344,12 +1915,14 @@ export class PlayCallChain {
1344
1915
  documents = new FileNativePlayDocuments(binding.files);
1345
1916
  documents.restoreAuthorizationCheckpoint(prepared.authorizationCheckpoint);
1346
1917
  }
1918
+ await this.#bindDocumentMaintenance(documents, persisted.worldId, binding.parentHead);
1347
1919
  return {
1348
1920
  ...structuredClone(persisted),
1349
1921
  documentAuthorizationCheckpoints: persisted.documentAuthorizationCheckpoints?.map((checkpoint) => structuredClone(checkpoint)) ?? [],
1350
1922
  nextMaterials: structuredClone(binding.additionalMaterials),
1351
1923
  documents,
1352
1924
  history: historyEntries(binding.history),
1925
+ narrativeCheckpoint: binding.narrativeCheckpoint,
1353
1926
  completedToolMap: new Map(persisted.completedTools.map((item) => [
1354
1927
  item.key,
1355
1928
  structuredClone(item),
@@ -1378,7 +1951,7 @@ export class PlayCallChain {
1378
1951
  }
1379
1952
  let settlement = current.settlementPrepared?.settlement;
1380
1953
  if (settlement === undefined) {
1381
- settlement = this.#prepareResponseSettlement(session, current.base, current.providerCompleted.response);
1954
+ settlement = await this.#prepareResponseSettlement(session, current.base, current.providerCompleted.response);
1382
1955
  await this.#worlds.playAdvances.recordSettlementPrepared(current.base, settlement);
1383
1956
  }
1384
1957
  await this.#settleResponseAdvance(session, current.base, settlement);
@@ -1442,11 +2015,11 @@ function createRequest(session, modelHost, exchange, appended = session.transcri
1442
2015
  }
1443
2016
  function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, appended = session.transcript) {
1444
2017
  return {
1445
- bootstrap: structuredClone(session.bootstrap),
1446
- tools: structuredClone(session.tools),
1447
- toolUniverse: structuredClone(session.tools),
1448
- allowedTools: session.tools.map(({ name }) => name),
1449
- toolStrategy: session.bootstrap.toolStrategy,
2018
+ bootstrap: structuredClone(currentPlayPrompt(session).bootstrap),
2019
+ tools: structuredClone(currentPlayPrompt(session).tools),
2020
+ toolUniverse: structuredClone(currentPlayPrompt(session).tools),
2021
+ allowedTools: currentPlayPrompt(session).tools.map(({ name }) => name),
2022
+ toolStrategy: currentPlayPrompt(session).bootstrap.toolStrategy,
1450
2023
  appended: structuredClone([...appended]),
1451
2024
  requestId: "play_call_chain",
1452
2025
  operationId: session.chainId,
@@ -1467,9 +2040,12 @@ function finalizePreparedReceipts(settlement) {
1467
2040
  const candidateWrite = completed.result.candidateWrite;
1468
2041
  if (!completed.result.ok || candidateWrite === undefined)
1469
2042
  continue;
1470
- const markdown = candidateWrite.changed && committedWriteRefs.has(candidateWrite.shortRef)
2043
+ const status = candidateWrite.changed && committedWriteRefs.has(candidateWrite.shortRef)
1471
2044
  ? `@${candidateWrite.shortRef} write succeeded`
1472
2045
  : `@${candidateWrite.shortRef} unchanged`;
2046
+ const markdown = [status, candidateWrite.freshContextCoverage]
2047
+ .filter(Boolean)
2048
+ .join("\n");
1473
2049
  completed.result.markdown = markdown;
1474
2050
  delete completed.result.candidateWrite;
1475
2051
  const callId = completed.key.slice(completed.key.indexOf(":") + 1);
@@ -1503,7 +2079,7 @@ function appendCommittedHistory(session, head, messages) {
1503
2079
  session.history.push({ path, contents: message.exactText });
1504
2080
  }
1505
2081
  }
1506
- function prepareTool(session, working, exchange, call) {
2082
+ function prepareTool(session, working, exchange, call, locale) {
1507
2083
  const key = `${exchange}:${call.id}`;
1508
2084
  const signature = JSON.stringify(call.arguments);
1509
2085
  const completed = working.get(key);
@@ -1521,7 +2097,7 @@ function prepareTool(session, working, exchange, call) {
1521
2097
  };
1522
2098
  }
1523
2099
  else if (!callChainToolNames.has(call.name) ||
1524
- !session.tools.some(({ name }) => name === call.name)) {
2100
+ !currentPlayPrompt(session).tools.some(({ name }) => name === call.name)) {
1525
2101
  result = {
1526
2102
  ok: false,
1527
2103
  failureKind: "protocol",
@@ -1530,7 +2106,24 @@ function prepareTool(session, working, exchange, call) {
1530
2106
  }
1531
2107
  else {
1532
2108
  try {
1533
- result = session.documents.execute(call, session.history);
2109
+ result =
2110
+ call.name === "world_checkpoint"
2111
+ ? typeof call.arguments === "object" &&
2112
+ call.arguments !== null &&
2113
+ !Array.isArray(call.arguments) &&
2114
+ Object.keys(call.arguments).length === 0
2115
+ ? {
2116
+ ok: true,
2117
+ markdown: locale === "zh-CN"
2118
+ ? "检查点已登记,将在本轮最终叙事提交后生效。玩家随后可以选择开启全新上下文。"
2119
+ : "Checkpoint registered. It becomes effective when this turn’s final narrative commits. The player can then choose a fresh context.",
2120
+ }
2121
+ : {
2122
+ ok: false,
2123
+ failureKind: "protocol",
2124
+ markdown: "world_checkpoint requires an empty argument object.",
2125
+ }
2126
+ : session.documents.execute(call, session.history);
1534
2127
  }
1535
2128
  catch (error) {
1536
2129
  result = {
@@ -1595,9 +2188,10 @@ function restorePlayDocuments(files, context, events) {
1595
2188
  const documents = new FileNativePlayDocuments(files);
1596
2189
  try {
1597
2190
  const checkpoint = documentAuthorizationThroughEvents(context, events);
1598
- if (checkpoint === undefined)
1599
- documents.bindBootstrap(context.bootstrap);
1600
- else
2191
+ const runs = playPromptRunsThroughEvents(context, events);
2192
+ if (checkpoint === undefined || runs.length > 0)
2193
+ documents.bindBootstrap(currentPlayPrompt({ ...context, promptRuns: runs }).bootstrap);
2194
+ if (checkpoint !== undefined)
1601
2195
  documents.restoreAuthorizationCheckpoint(checkpoint.authorization);
1602
2196
  }
1603
2197
  catch (error) {
@@ -1618,6 +2212,7 @@ function documentAuthorizationThroughEvents(context, events) {
1618
2212
  }
1619
2213
  function independentContextCopy(source) {
1620
2214
  const context = structuredClone(source);
2215
+ context.continuityContextId ??= source.chainId;
1621
2216
  delete context.derivedFrom;
1622
2217
  delete context.branchedBeforePlayer;
1623
2218
  return context;
@@ -1644,7 +2239,7 @@ function projectContext(context) {
1644
2239
  ? {}
1645
2240
  : { baselineHistoryLength: context.baselineHistoryLength }),
1646
2241
  parentHead: context.parentHead,
1647
- playPreset: structuredClone(context.playPreset),
2242
+ playPreset: structuredClone(recordedPlayPrompt(context).playPreset),
1648
2243
  status: context.status,
1649
2244
  canRetry: context.canRetry,
1650
2245
  events: context.events
@@ -1691,6 +2286,57 @@ function projectView(session) {
1691
2286
  previousContexts: (session.previousContexts ?? []).map((context) => projectContext(context)),
1692
2287
  };
1693
2288
  }
2289
+ function projectContextReads(session) {
2290
+ const completed = new Map(session.completedTools.map((tool) => [tool.key, tool.result]));
2291
+ let exchange = 0;
2292
+ const calls = new Map();
2293
+ const reads = [];
2294
+ for (const event of session.events) {
2295
+ if (event.kind === "assistant") {
2296
+ exchange = event.exchange;
2297
+ continue;
2298
+ }
2299
+ if (event.kind === "tool_call" && event.name === "context_read") {
2300
+ const ref = isRecord(event.arguments) && typeof event.arguments.ref === "string"
2301
+ ? event.arguments.ref
2302
+ : "(invalid ref)";
2303
+ calls.set(event.callId, {
2304
+ key: `${exchange}:${event.callId}`,
2305
+ ref,
2306
+ });
2307
+ continue;
2308
+ }
2309
+ if (event.kind !== "tool_result" || event.name !== "context_read")
2310
+ continue;
2311
+ const call = calls.get(event.callId);
2312
+ if (call === undefined)
2313
+ continue;
2314
+ const result = completed.get(call.key);
2315
+ reads.push({
2316
+ eventId: event.id,
2317
+ callId: event.callId,
2318
+ ref: call.ref,
2319
+ ok: event.ok,
2320
+ complete: result === undefined ? null : completedContextReadIsComplete(result),
2321
+ markdown: result?.markdown ?? null,
2322
+ locator: result?.readAuthorization?.locator ?? null,
2323
+ });
2324
+ }
2325
+ return reads;
2326
+ }
2327
+ function completedContextReadIsComplete(result) {
2328
+ if (result.readAuthorization === undefined)
2329
+ return /(?:^|\n)Complete: yes(?:\n|$)/u.test(result.markdown);
2330
+ const legacyAuthorization = result.readAuthorization;
2331
+ if (!isRecord(legacyAuthorization) || !isRecord(legacyAuthorization.page))
2332
+ return true;
2333
+ return (typeof legacyAuthorization.page.end === "number" &&
2334
+ typeof legacyAuthorization.page.total === "number" &&
2335
+ legacyAuthorization.page.end === legacyAuthorization.page.total);
2336
+ }
2337
+ function isRecord(value) {
2338
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2339
+ }
1694
2340
  function eventsThroughHead(events, head) {
1695
2341
  const selectedIndex = events.findIndex((event) => (event.kind === "player" || event.kind === "assistant") &&
1696
2342
  event.committedHead === head);
@@ -1708,6 +2354,11 @@ function transcriptThroughEvents(transcript, events) {
1708
2354
  let cursor = 0;
1709
2355
  for (const [index, event] of events.entries()) {
1710
2356
  if (event.kind === "player") {
2357
+ const marker = transcript[cursor];
2358
+ if (isPlayerRoundMarker(marker)) {
2359
+ result.push(structuredClone(marker));
2360
+ cursor += 1;
2361
+ }
1711
2362
  const item = transcript[cursor];
1712
2363
  if (item?.kind !== "player" || item.text !== event.text)
1713
2364
  throw new PlayCallChainError("Source call-chain events do not match the model transcript, so a fork cannot be created safely.");
@@ -1716,6 +2367,9 @@ function transcriptThroughEvents(transcript, events) {
1716
2367
  continue;
1717
2368
  }
1718
2369
  if (event.kind === "assistant" && event.status === "completed") {
2370
+ const notice = transcript[cursor];
2371
+ const hasContinuationNotice = notice?.kind === "runtime_notice" && notice.notice === "continuation";
2372
+ const item = transcript[cursor + (hasContinuationNotice ? 1 : 0)];
1719
2373
  const hasToolCall = events
1720
2374
  .slice(index + 1)
1721
2375
  .find((candidate) => candidate.kind === "player" ||
@@ -1723,12 +2377,17 @@ function transcriptThroughEvents(transcript, events) {
1723
2377
  candidate.kind === "tool_call");
1724
2378
  const recorded = event.text.trim().length > 0 ||
1725
2379
  hasToolCall?.kind === "tool_call" ||
1726
- transcript[cursor]?.kind === "assistant";
2380
+ item?.kind === "assistant";
1727
2381
  if (!recorded)
1728
2382
  continue;
1729
- const item = transcript[cursor];
1730
2383
  if (item?.kind !== "assistant" || item.text !== event.text)
1731
2384
  throw new PlayCallChainError("Source call-chain responses do not match the model transcript, so a fork cannot be created safely.");
2385
+ // A continuation notice belongs to the generation it precedes, never
2386
+ // to a fork ending at the previously completed narrative.
2387
+ if (hasContinuationNotice) {
2388
+ result.push(structuredClone(notice));
2389
+ cursor += 1;
2390
+ }
1732
2391
  result.push(structuredClone(item));
1733
2392
  cursor += 1;
1734
2393
  continue;
@@ -1739,6 +2398,13 @@ function transcriptThroughEvents(transcript, events) {
1739
2398
  throw new PlayCallChainError("Source call-chain tool results do not match the model transcript, so a fork cannot be created safely.");
1740
2399
  result.push(structuredClone(item));
1741
2400
  cursor += 1;
2401
+ // A tool-step notice follows the entire settled batch and remains part
2402
+ // of its closure even when the selected endpoint stops at that batch.
2403
+ const notice = transcript[cursor];
2404
+ if (notice?.kind === "runtime_notice" && notice.notice === "tool_step") {
2405
+ result.push(structuredClone(notice));
2406
+ cursor += 1;
2407
+ }
1742
2408
  }
1743
2409
  }
1744
2410
  return result;
@@ -1769,6 +2435,55 @@ function derivedChainId(sourceChainId, branchIdentity, targetWorldId) {
1769
2435
  .digest("hex")
1770
2436
  .slice(0, 40)}`;
1771
2437
  }
2438
+ function playerRevisionRestoresHead(source, selectedEventIndex) {
2439
+ let restoresHead = source.baselineHead;
2440
+ for (const event of source.events.slice(0, selectedEventIndex))
2441
+ if ((event.kind === "player" || event.kind === "assistant") &&
2442
+ event.committedHead !== undefined)
2443
+ restoresHead = event.committedHead;
2444
+ return restoresHead;
2445
+ }
2446
+ function playerRevisionRequestFingerprint(request, selectedHead, restoresHead) {
2447
+ const base = {
2448
+ schema: "narraeon.timeline-revision-request/v1",
2449
+ chainId: request.chainId,
2450
+ eventId: request.eventId,
2451
+ selectedHead,
2452
+ restoresHead,
2453
+ replacementExchangeId: request.replacementExchangeId,
2454
+ replacementText: request.replacementText,
2455
+ };
2456
+ // Keep the released continuation fingerprint byte-for-byte recoverable;
2457
+ // the fresh strategy owns a new request shape with an explicit mode.
2458
+ const value = request.continuation === "continue_context"
2459
+ ? base
2460
+ : {
2461
+ ...base,
2462
+ schema: "narraeon.timeline-revision-request/v2",
2463
+ continuation: request.continuation,
2464
+ };
2465
+ return ("sha256:" + createHash("sha256").update(JSON.stringify(value)).digest("hex"));
2466
+ }
2467
+ function playerRevisionChainId(sourceChainId, operationId, worldId, continuation) {
2468
+ return derivedChainId(sourceChainId, `${continuation === "fresh_context" ? "timeline-revision-fresh" : "timeline-revision"}:${operationId}`, worldId);
2469
+ }
2470
+ function playerRevisionTimelineGeneration(sourceGeneration, operationId, worldId, continuation) {
2471
+ return `timeline:${createHash("sha256")
2472
+ .update(JSON.stringify({
2473
+ schema: "narraeon.player-revision-timeline-generation/v1",
2474
+ sourceGeneration,
2475
+ operationId,
2476
+ worldId,
2477
+ continuation,
2478
+ }))
2479
+ .digest("hex")
2480
+ .slice(0, 40)}`;
2481
+ }
2482
+ function completedAssistantExchange(events) {
2483
+ return Math.max(0, ...events
2484
+ .filter((event) => event.kind === "assistant" && event.status === "completed")
2485
+ .map(({ exchange }) => exchange));
2486
+ }
1772
2487
  function historyEntries(history) {
1773
2488
  return Object.entries(history).map(([path, contents]) => ({
1774
2489
  path,
@@ -1813,3 +2528,41 @@ function crashAtPlayAdvanceEdge(edge) {
1813
2528
  process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_PLAY_ADVANCE_EDGE === edge)
1814
2529
  throw new Error(`Simulated process exit at play advance edge: ${edge}`);
1815
2530
  }
2531
+ function frozenFollowupPresentations(session) {
2532
+ const files = session.promptRuns?.at(-1)?.presetFiles ?? session.presetFiles;
2533
+ if (files === undefined)
2534
+ return {};
2535
+ const parsed = parsePlayPresetFiles(files);
2536
+ if (parsed.kind === "invalid")
2537
+ throw parsed.error;
2538
+ const definition = parsed.definition;
2539
+ return Object.fromEntries((currentPlayPrompt(session).followups ?? []).map((followup) => [
2540
+ followup.id,
2541
+ Object.fromEntries(followup.artifacts.flatMap((declaration) => {
2542
+ const resources = followup.frozenResources;
2543
+ const mount = resources?.mount ??
2544
+ definition.mounts.find((item) => item.channel === declaration.channel)?.mount;
2545
+ return mount === undefined
2546
+ ? []
2547
+ : [
2548
+ [
2549
+ declaration.name,
2550
+ {
2551
+ ...resources,
2552
+ declaration: structuredClone(declaration),
2553
+ files: resources?.files ??
2554
+ Object.fromEntries([
2555
+ declaration.renderer,
2556
+ declaration.regex,
2557
+ ...(declaration.scripts ?? []),
2558
+ ...(declaration.assets ?? []),
2559
+ ].flatMap((path) => path === undefined
2560
+ ? []
2561
+ : [[path, (resources?.files ?? files)[path]]])),
2562
+ mount,
2563
+ },
2564
+ ],
2565
+ ];
2566
+ })),
2567
+ ]));
2568
+ }