narraeon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +164 -0
  3. package/README.zh-CN.md +164 -0
  4. package/dist/node/cli/NarraeonCli.js +163 -0
  5. package/dist/node/cli/main.js +38 -0
  6. package/dist/node/protocol/appPreferences.js +5 -0
  7. package/dist/node/protocol/contentTree.js +14 -0
  8. package/dist/node/protocol/modelConnections.js +110 -0
  9. package/dist/node/protocol/playerViews.js +1 -0
  10. package/dist/node/protocol/v1.js +278 -0
  11. package/dist/node/runtime/V1Runtime.js +625 -0
  12. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +1914 -0
  13. package/dist/node/runtime/config/AppPreferencesStore.js +92 -0
  14. package/dist/node/runtime/config/appPaths.js +19 -0
  15. package/dist/node/runtime/content/ContentLimits.js +1 -0
  16. package/dist/node/runtime/content/ContentTreeFile.js +1 -0
  17. package/dist/node/runtime/content/ContentWorkspace.js +601 -0
  18. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +721 -0
  19. package/dist/node/runtime/content/FileNativeContentTree.js +512 -0
  20. package/dist/node/runtime/extension/FrontendExtensionBundle.js +229 -0
  21. package/dist/node/runtime/extension/PlayerViewPanelProjector.js +121 -0
  22. package/dist/node/runtime/model/AiFailureLog.js +332 -0
  23. package/dist/node/runtime/model/FileNativeModelAdapters.js +1246 -0
  24. package/dist/node/runtime/model/ModelConnectionStore.js +378 -0
  25. package/dist/node/runtime/model/ModelHost.js +89 -0
  26. package/dist/node/runtime/model/ModelHostStream.js +367 -0
  27. package/dist/node/runtime/model/ProviderExchangeState.js +1 -0
  28. package/dist/node/runtime/model/ProviderStream.js +90 -0
  29. package/dist/node/runtime/model/SettingAuthorStream.js +118 -0
  30. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +304 -0
  31. package/dist/node/runtime/play/FileNativePlayPresetStore.js +1856 -0
  32. package/dist/node/runtime/play/PlayCallChain.js +1280 -0
  33. package/dist/node/runtime/play/PlayDocumentTools.js +1121 -0
  34. package/dist/node/runtime/play/PlayFollowupRequests.js +183 -0
  35. package/dist/node/runtime/play/PlayPresetWorkbench.js +185 -0
  36. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +1425 -0
  37. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +192 -0
  38. package/dist/node/runtime/prompt/MaterialSelection.js +95 -0
  39. package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +1726 -0
  40. package/dist/node/runtime/world/FileNativeWorldStore.js +2028 -0
  41. package/dist/node/runtime/world/PlayerViewRenderer.js +322 -0
  42. package/dist/node/runtime/world/WorldDocumentStore.js +2808 -0
  43. package/dist/node/runtime/world/WorldOperationCoordinator.js +490 -0
  44. package/dist/node/runtime/world/WorldStateRevision.js +75 -0
  45. package/dist/node/server/createRuntime.js +6 -0
  46. package/dist/node/server/createServer.js +218 -0
  47. package/dist/node/server/main.js +25 -0
  48. package/dist/node/server/startWebServer.js +28 -0
  49. package/dist/node/shared/default-play-prompts.js +85 -0
  50. package/dist/node/shared/default-preset-host.js +538 -0
  51. package/dist/node/shared/default-setting-improvement-prompt.js +77 -0
  52. package/dist/node/shared/first-party-action-choices.js +164 -0
  53. package/dist/node/shared/first-party-generic-panels.js +105 -0
  54. package/dist/node/shared/first-party-play-preset-templates.js +41 -0
  55. package/dist/node/shared/first-party-player-view.js +179 -0
  56. package/dist/node/shared/play-preset-prompt-roles.js +4 -0
  57. package/dist/web/assets/index-CRGTZ9Ak.css +1 -0
  58. package/dist/web/assets/index-DDx1itzT.js +1192 -0
  59. package/dist/web/index.html +14 -0
  60. package/package.json +86 -0
@@ -0,0 +1,1280 @@
1
+ import { createHash } from "node:crypto";
2
+ import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
3
+ import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
4
+ import { FileNativePlayDocuments, fingerprintControl, isPlayDocumentAuthorizationCheckpoint, } from "./PlayDocumentTools.js";
5
+ const callChainToolNames = new Set([
6
+ "context_list",
7
+ "context_search",
8
+ "context_read",
9
+ "world_patch",
10
+ "world_create",
11
+ ]);
12
+ export class PlayCallChainError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "PlayCallChainError";
16
+ }
17
+ }
18
+ /**
19
+ * The production play loop: one durable logical context, no Runtime-authored
20
+ * adjudication/narration phases, and immediate Authority commits after every
21
+ * completed model response that contains visible text or world changes.
22
+ */
23
+ export class PlayCallChain {
24
+ #worlds;
25
+ #compiler;
26
+ #artifacts;
27
+ #failureLog;
28
+ #active = new Map();
29
+ #worldChains = new Map();
30
+ constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog) {
31
+ this.#worlds = worlds;
32
+ this.#compiler = compiler;
33
+ this.#artifacts = artifacts;
34
+ this.#failureLog = failureLog;
35
+ }
36
+ async start(input) {
37
+ validateIdentity(input.chainId, "call-chain ID");
38
+ validateIdentity(input.exchangeId, "exchange ID");
39
+ validatePlayerText(input.playerText);
40
+ const existing = await this.#readPersisted(input.worldId);
41
+ if (existing?.chainId === input.chainId) {
42
+ const duplicate = duplicatePlayerExchange(existing, input.exchangeId, input.playerText, "fresh");
43
+ if (duplicate)
44
+ return projectView(existing);
45
+ }
46
+ const activeId = this.#worldChains.get(input.worldId);
47
+ const active = activeId === undefined ? undefined : this.#active.get(activeId);
48
+ if (active?.status === "running")
49
+ throw new PlayCallChainError("A model request is still running for this world.");
50
+ const binding = await this.#worlds.bindPlayCallChain(input.worldId);
51
+ const documents = new FileNativePlayDocuments(binding.files);
52
+ const compilation = this.#compiler.compilePlayCallChain({
53
+ endpoint: {
54
+ id: `${input.worldId}:${binding.parentHead}`,
55
+ commit: binding.parentHead,
56
+ },
57
+ hostBinding: structuredClone(input.hostBinding),
58
+ world: {
59
+ controlFingerprint: fingerprintControl(binding.files),
60
+ documentSnapshot: documents.snapshot,
61
+ additionalMaterials: structuredClone(binding.additionalMaterials),
62
+ history: structuredClone(binding.history),
63
+ },
64
+ playerInputPlacement: "append",
65
+ playerInput: input.playerText,
66
+ modelBinding: structuredClone(input.modelBinding),
67
+ }, input.playPreset);
68
+ const tools = structuredClone(compilation.toolUniverse);
69
+ const bootstrap = structuredClone(compilation.bootstrap);
70
+ documents.bindBootstrap(bootstrap);
71
+ const now = Date.now();
72
+ const session = {
73
+ schemaVersion: 1,
74
+ kind: "play_call_chain",
75
+ chainId: input.chainId,
76
+ worldId: input.worldId,
77
+ previousContexts: existing === null
78
+ ? []
79
+ : [
80
+ ...(existing.previousContexts ?? []).map((context) => structuredClone(context)),
81
+ snapshotCurrentContext(existing),
82
+ ],
83
+ baselineHead: binding.parentHead,
84
+ baselineHistoryLength: Object.keys(binding.history).length,
85
+ parentHead: binding.parentHead,
86
+ playPreset: {
87
+ id: input.playPreset.id,
88
+ name: input.playPreset.name,
89
+ revision: input.playPreset.revision,
90
+ },
91
+ followups: structuredClone(compilation.followups ?? []),
92
+ playPresetScriptsEnabled: input.playPreset.scriptsEnabled,
93
+ status: "ready",
94
+ canRetry: false,
95
+ bootstrap,
96
+ tools: structuredClone(tools),
97
+ transcript: [],
98
+ events: [],
99
+ completedTools: [],
100
+ documentAuthorizationCheckpoints: [
101
+ {
102
+ afterEventId: 0,
103
+ authorization: documents.authorizationCheckpoint(),
104
+ },
105
+ ],
106
+ changedDocuments: [],
107
+ nextMaterials: structuredClone(binding.additionalMaterials),
108
+ nextEventId: 1,
109
+ exchange: 0,
110
+ lastRequest: null,
111
+ lastRequestAttempt: 0,
112
+ lastFailure: null,
113
+ updatedAt: now,
114
+ documents,
115
+ history: historyEntries(binding.history),
116
+ completedToolMap: new Map(),
117
+ };
118
+ if (activeId !== undefined)
119
+ this.#active.delete(activeId);
120
+ this.#active.set(session.chainId, session);
121
+ this.#worldChains.set(session.worldId, session.chainId);
122
+ await this.#persist(session);
123
+ return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, input.observer);
124
+ }
125
+ async append(input) {
126
+ validateIdentity(input.exchangeId, "exchange ID");
127
+ const session = await this.#requireChain(input.worldId, input.chainId);
128
+ await this.#assertCurrentHead(session);
129
+ if (input.playerText.trim().length === 0) {
130
+ if (session.status === "running")
131
+ throw new PlayCallChainError("A model request is still running.");
132
+ if (session.status === "interrupted") {
133
+ if (!session.canRetry || session.lastRequest === null)
134
+ throw new PlayCallChainError("Call-chain processing failed, and there is no model context to continue.");
135
+ return this.#dispatch(session, input.modelHost, structuredClone(session.lastRequest), session.lastRequestAttempt + 1, input.observer);
136
+ }
137
+ session.exchange += 1;
138
+ return this.#dispatch(session, input.modelHost, createRequest(session, input.modelHost, session.exchange), 1, input.observer);
139
+ }
140
+ if (duplicatePlayerExchange(session, input.exchangeId, input.playerText, "append"))
141
+ return projectView(session);
142
+ if (session.status !== "ready")
143
+ throw new PlayCallChainError(session.canRetry
144
+ ? "The call chain has an incomplete model response; clear the input and append a continuation, or use a fresh context."
145
+ : "The call chain was interrupted; use a fresh context.");
146
+ return this.#submitPlayer(session, input.exchangeId, input.playerText, "append", input.modelHost, input.observer);
147
+ }
148
+ async inspectWorld(worldId) {
149
+ const activeId = this.#worldChains.get(worldId);
150
+ const active = activeId === undefined ? undefined : this.#active.get(activeId);
151
+ if (active !== undefined)
152
+ return projectView(active);
153
+ const persisted = await this.#readPersisted(worldId);
154
+ if (persisted === null)
155
+ return null;
156
+ if (persisted.status === "running") {
157
+ const streaming = persisted.events.findLast((event) => event.kind === "assistant" && event.status === "streaming");
158
+ if (streaming !== undefined)
159
+ streaming.status = "interrupted";
160
+ const canRetry = persisted.lastRequest !== null;
161
+ const message = canRetry
162
+ ? "The service stopped before the model request completed; the same request can be retried."
163
+ : "The service stopped before dispatching the model request; use a fresh context.";
164
+ persisted.events.push({
165
+ id: persisted.nextEventId++,
166
+ kind: "failure",
167
+ message,
168
+ });
169
+ persisted.status = "interrupted";
170
+ persisted.canRetry = canRetry;
171
+ persisted.lastFailure = message;
172
+ persisted.updatedAt = Date.now();
173
+ await this.#worlds.writePlayCallChain(worldId, persisted);
174
+ }
175
+ return projectView(persisted);
176
+ }
177
+ async forkToDerivedWorld(input) {
178
+ const existing = await this.#readPersisted(input.targetWorldId);
179
+ if (existing !== null)
180
+ return projectView(existing);
181
+ const source = await this.#readPersisted(input.sourceWorldId);
182
+ if (source === null)
183
+ return null;
184
+ const sourceContexts = persistedContexts(source);
185
+ const selectedContextIndex = sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
186
+ if (selectedContextIndex < 0)
187
+ return null;
188
+ const sourceContext = sourceContexts[selectedContextIndex];
189
+ const sourceEvents = eventsThroughHead(sourceContext.events, input.sourceHead);
190
+ return this.#forkSelectionToDerivedWorld({
191
+ sourceContexts,
192
+ selectedContextIndex,
193
+ sourceEvents,
194
+ sourceHead: input.sourceHead,
195
+ targetWorldId: input.targetWorldId,
196
+ branchIdentity: input.sourceHead,
197
+ });
198
+ }
199
+ /**
200
+ * Revise one committed player message in place. Authority remains
201
+ * append-only: one timeline-revision commit restores the player's logical
202
+ * parent snapshot and appends the replacement text atomically.
203
+ */
204
+ async revisePlayer(input) {
205
+ validateIdentity(input.operationId, "timeline-revision operation ID");
206
+ validateIdentity(input.worldId, "world ID");
207
+ validateIdentity(input.chainId, "call-chain ID");
208
+ validateIdentity(input.replacementExchangeId, "replacement exchange ID");
209
+ validatePlayerText(input.replacementText);
210
+ if (!Number.isSafeInteger(input.eventId) || input.eventId < 1)
211
+ throw new PlayCallChainError("The player-event ID is invalid.");
212
+ const activeId = this.#worldChains.get(input.worldId);
213
+ const active = activeId === undefined ? undefined : this.#active.get(activeId);
214
+ if (active?.status === "running")
215
+ throw new PlayCallChainError("A model request is still running; wait for it to return before revising committed history.");
216
+ const source = await this.#readPersisted(input.worldId);
217
+ if (source === null)
218
+ throw new PlayCallChainError("The current world has no model call chain to revise.");
219
+ const sourceContexts = persistedContexts(source);
220
+ const appliedEvent = sourceContexts
221
+ .flatMap(({ events }) => events)
222
+ .find((event) => event.kind === "player" &&
223
+ event.exchangeId === input.replacementExchangeId &&
224
+ event.committedHead !== undefined);
225
+ if (appliedEvent !== undefined) {
226
+ if (appliedEvent.text !== input.replacementText)
227
+ throw new PlayCallChainError("The same replacement exchange ID is already bound to different player text.");
228
+ const authority = await this.#worlds.readAuthorityHistory(input.worldId);
229
+ const commit = authority.commits.find(({ head }) => head === appliedEvent.committedHead);
230
+ if (commit?.operationId !== input.operationId ||
231
+ commit.mode !== "timeline_revision")
232
+ throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
233
+ return {
234
+ outcome: "revised",
235
+ worldId: input.worldId,
236
+ playCallChain: projectView(source),
237
+ };
238
+ }
239
+ const selectedContextIndex = sourceContexts.findIndex(({ chainId }) => chainId === input.chainId);
240
+ if (selectedContextIndex < 0)
241
+ throw new PlayCallChainError("The model context to revise does not exist.");
242
+ const sourceContext = sourceContexts[selectedContextIndex];
243
+ const selectedEventIndex = sourceContext.events.findIndex((event) => event.id === input.eventId);
244
+ const selectedEvent = sourceContext.events[selectedEventIndex];
245
+ if (selectedEventIndex < 0 ||
246
+ selectedEvent?.kind !== "player" ||
247
+ selectedEvent.committedHead === undefined)
248
+ throw new PlayCallChainError("The selected event is not a committed player message.");
249
+ return this.#applyPlayerRevision({
250
+ request: input,
251
+ source,
252
+ sourceContexts,
253
+ selectedContextIndex,
254
+ selectedEventIndex,
255
+ selectedEvent: {
256
+ ...selectedEvent,
257
+ committedHead: selectedEvent.committedHead,
258
+ },
259
+ });
260
+ }
261
+ async #applyPlayerRevision(input) {
262
+ const request = input.request;
263
+ const sourceContext = input.sourceContexts[input.selectedContextIndex];
264
+ let restoresHead = sourceContext.baselineHead;
265
+ for (const event of sourceContext.events.slice(0, input.selectedEventIndex))
266
+ if ((event.kind === "player" || event.kind === "assistant") &&
267
+ event.committedHead !== undefined)
268
+ restoresHead = event.committedHead;
269
+ const requestFingerprint = "sha256:" +
270
+ createHash("sha256")
271
+ .update(JSON.stringify({
272
+ schema: "narraeon.timeline-revision-request/v1",
273
+ chainId: request.chainId,
274
+ eventId: request.eventId,
275
+ selectedHead: input.selectedEvent.committedHead,
276
+ restoresHead,
277
+ replacementExchangeId: request.replacementExchangeId,
278
+ replacementText: request.replacementText,
279
+ }))
280
+ .digest("hex");
281
+ const outcome = await this.#worlds.reviseTimeline({
282
+ operationId: request.operationId,
283
+ worldId: request.worldId,
284
+ expectedCurrentHead: input.source.parentHead,
285
+ restoresHead,
286
+ replacesHead: input.selectedEvent.committedHead,
287
+ replacementText: request.replacementText,
288
+ requestFingerprint,
289
+ });
290
+ const prefixEvents = structuredClone(sourceContext.events.slice(0, input.selectedEventIndex));
291
+ const transcript = transcriptThroughEvents(sourceContext.transcript, prefixEvents);
292
+ transcript.push({ kind: "player", text: request.replacementText });
293
+ const events = [
294
+ ...prefixEvents,
295
+ {
296
+ id: input.selectedEvent.id,
297
+ kind: "player",
298
+ exchangeId: request.replacementExchangeId,
299
+ text: request.replacementText,
300
+ context: input.selectedEvent.context,
301
+ committedHead: outcome.head,
302
+ },
303
+ ];
304
+ const completedKeys = completedToolKeys(prefixEvents);
305
+ const [baseline, selected, binding] = await Promise.all([
306
+ this.#worlds.recoverEndpoint(request.worldId, sourceContext.baselineHead),
307
+ this.#worlds.recoverEndpoint(request.worldId, restoresHead),
308
+ this.#worlds.bindPlayCallChain(request.worldId),
309
+ ]);
310
+ if (binding.parentHead !== outcome.head)
311
+ throw new PlayCallChainError("The timeline revision was committed, but current-world materialization has not reached the new endpoint.");
312
+ const documents = restorePlayDocuments(binding.files, sourceContext, prefixEvents);
313
+ const revised = {
314
+ schemaVersion: 1,
315
+ kind: "play_call_chain",
316
+ chainId: derivedChainId(sourceContext.chainId, "timeline-revision:" + request.operationId, request.worldId),
317
+ worldId: request.worldId,
318
+ previousContexts: input.sourceContexts
319
+ .slice(0, input.selectedContextIndex)
320
+ .map((context) => independentContextCopy(context)),
321
+ baselineHead: sourceContext.baselineHead,
322
+ baselineHistoryLength: sourceContext.baselineHistoryLength ?? baseline.history.length,
323
+ parentHead: outcome.head,
324
+ playPreset: structuredClone(sourceContext.playPreset),
325
+ ...(sourceContext.followups === undefined
326
+ ? {}
327
+ : { followups: structuredClone(sourceContext.followups) }),
328
+ ...(sourceContext.playPresetScriptsEnabled === undefined
329
+ ? {}
330
+ : {
331
+ playPresetScriptsEnabled: sourceContext.playPresetScriptsEnabled,
332
+ }),
333
+ status: "ready",
334
+ canRetry: false,
335
+ bootstrap: structuredClone(sourceContext.bootstrap),
336
+ tools: structuredClone(sourceContext.tools),
337
+ transcript,
338
+ events,
339
+ completedTools: sourceContext.completedTools
340
+ .filter(({ key }) => completedKeys.has(key))
341
+ .map((item) => structuredClone(item)),
342
+ documentAuthorizationCheckpoints: authorizationCheckpointsThroughEvents(sourceContext, events, documents.authorizationCheckpoint()),
343
+ changedDocuments: changedDocumentsAtHead(sourceContext.changedDocuments, baseline.state, selected.state),
344
+ nextMaterials: structuredClone(binding.additionalMaterials),
345
+ nextEventId: Math.max(0, ...events.map(({ id }) => id)) + 1,
346
+ exchange: Math.max(0, ...prefixEvents
347
+ .filter((event) => event.kind === "assistant" && event.status === "completed")
348
+ .map(({ exchange }) => exchange)),
349
+ lastRequest: null,
350
+ lastRequestAttempt: 0,
351
+ lastFailure: null,
352
+ updatedAt: Date.now(),
353
+ };
354
+ await this.#worlds.writePlayCallChain(request.worldId, structuredClone(revised));
355
+ const activeId = this.#worldChains.get(request.worldId);
356
+ if (activeId !== undefined)
357
+ this.#active.delete(activeId);
358
+ this.#active.delete(sourceContext.chainId);
359
+ this.#worldChains.delete(request.worldId);
360
+ return {
361
+ outcome: "revised",
362
+ worldId: request.worldId,
363
+ playCallChain: projectView(revised),
364
+ };
365
+ }
366
+ async #forkSelectionToDerivedWorld(input) {
367
+ const existing = await this.#readPersisted(input.targetWorldId);
368
+ if (existing !== null)
369
+ return projectView(existing);
370
+ const sourceContext = input.sourceContexts[input.selectedContextIndex];
371
+ if ((await this.#worlds.currentHead(input.targetWorldId)) !== input.sourceHead)
372
+ throw new PlayCallChainError("The forked world's Authority does not match the selected history endpoint, so the call chain cannot be written.");
373
+ const [baseline, selected] = await Promise.all([
374
+ this.#worlds.recoverEndpoint(input.targetWorldId, sourceContext.baselineHead),
375
+ this.#worlds.recoverEndpoint(input.targetWorldId, input.sourceHead),
376
+ ]);
377
+ const transcript = transcriptThroughEvents(sourceContext.transcript, input.sourceEvents);
378
+ const completedKeys = completedToolKeys(input.sourceEvents);
379
+ const events = structuredClone(input.sourceEvents);
380
+ const derivedBinding = await this.#worlds.bindPlayCallChain(input.targetWorldId);
381
+ const derivedDocuments = restorePlayDocuments(derivedBinding.files, sourceContext, input.sourceEvents);
382
+ const now = Date.now();
383
+ const derived = {
384
+ schemaVersion: 1,
385
+ kind: "play_call_chain",
386
+ chainId: derivedChainId(sourceContext.chainId, input.branchIdentity, input.targetWorldId),
387
+ worldId: input.targetWorldId,
388
+ previousContexts: input.sourceContexts
389
+ .slice(0, input.selectedContextIndex)
390
+ .map((context) => independentContextCopy(context)),
391
+ baselineHead: sourceContext.baselineHead,
392
+ baselineHistoryLength: sourceContext.baselineHistoryLength ?? baseline.history.length,
393
+ parentHead: input.sourceHead,
394
+ playPreset: structuredClone(sourceContext.playPreset),
395
+ ...(sourceContext.followups === undefined
396
+ ? {}
397
+ : { followups: structuredClone(sourceContext.followups) }),
398
+ ...(sourceContext.playPresetScriptsEnabled === undefined
399
+ ? {}
400
+ : {
401
+ playPresetScriptsEnabled: sourceContext.playPresetScriptsEnabled,
402
+ }),
403
+ status: "ready",
404
+ canRetry: false,
405
+ bootstrap: structuredClone(sourceContext.bootstrap),
406
+ tools: structuredClone(sourceContext.tools),
407
+ transcript,
408
+ events,
409
+ completedTools: sourceContext.completedTools
410
+ .filter(({ key }) => completedKeys.has(key))
411
+ .map((item) => structuredClone(item)),
412
+ documentAuthorizationCheckpoints: authorizationCheckpointsThroughEvents(sourceContext, events, derivedDocuments.authorizationCheckpoint()),
413
+ changedDocuments: changedDocumentsAtHead(sourceContext.changedDocuments, baseline.state, selected.state),
414
+ nextMaterials: structuredClone(derivedBinding.additionalMaterials),
415
+ nextEventId: Math.max(0, ...events.map(({ id }) => id)) + 1,
416
+ exchange: Math.max(0, ...events
417
+ .filter((event) => event.kind === "assistant" && event.status === "completed")
418
+ .map(({ exchange }) => exchange)),
419
+ lastRequest: null,
420
+ lastRequestAttempt: 0,
421
+ lastFailure: null,
422
+ updatedAt: now,
423
+ };
424
+ await this.#worlds.writePlayCallChain(input.targetWorldId, structuredClone(derived));
425
+ return projectView(derived);
426
+ }
427
+ forgetWorld(worldId) {
428
+ const activeId = this.#worldChains.get(worldId);
429
+ const active = activeId === undefined ? undefined : this.#active.get(activeId);
430
+ if (active?.status === "running")
431
+ throw new PlayCallChainError("A model request is still running; wait for it to return before deleting the world.");
432
+ if (activeId !== undefined)
433
+ this.#active.delete(activeId);
434
+ this.#worldChains.delete(worldId);
435
+ }
436
+ async #submitPlayer(session, exchangeId, playerText, context, modelHost, observer) {
437
+ if (duplicatePlayerExchange(session, exchangeId, playerText, context))
438
+ return projectView(session);
439
+ if (session.status === "running")
440
+ throw new PlayCallChainError("The call chain is waiting for the model to return.");
441
+ session.status = "running";
442
+ session.lastFailure = null;
443
+ let committed;
444
+ try {
445
+ committed = await this.#commitStep(session, {
446
+ operationKey: `player:${exchangeId}`,
447
+ historyAppend: [{ role: "player", exactText: playerText }],
448
+ stateChanges: [],
449
+ });
450
+ }
451
+ catch (error) {
452
+ const message = error instanceof Error
453
+ ? error.message
454
+ : "Failed to write the original player text.";
455
+ session.events.push({
456
+ id: session.nextEventId++,
457
+ kind: "failure",
458
+ message,
459
+ });
460
+ session.status = "interrupted";
461
+ session.canRetry = false;
462
+ session.lastFailure = message;
463
+ await this.#persist(session);
464
+ throw new PlayCallChainError(message);
465
+ }
466
+ session.transcript.push({ kind: "player", text: playerText });
467
+ session.events.push({
468
+ id: session.nextEventId++,
469
+ kind: "player",
470
+ exchangeId,
471
+ text: playerText,
472
+ context,
473
+ committedHead: committed.head,
474
+ });
475
+ await this.#persist(session);
476
+ notifySnapshot(observer, session);
477
+ session.exchange += 1;
478
+ const request = createRequest(session, modelHost, session.exchange);
479
+ return this.#dispatch(session, modelHost, request, 1, observer);
480
+ }
481
+ async #dispatch(session, modelHost, firstRequest, firstAttempt, observer) {
482
+ let request = structuredClone(firstRequest);
483
+ let attempt = firstAttempt;
484
+ for (;;) {
485
+ session.status = "running";
486
+ session.canRetry = false;
487
+ session.lastFailure = null;
488
+ session.lastRequest = structuredClone(request);
489
+ session.lastRequestAttempt = attempt;
490
+ const responseExchange = request.exchange ?? session.exchange;
491
+ const event = {
492
+ id: session.nextEventId++,
493
+ kind: "assistant",
494
+ text: "",
495
+ status: "streaming",
496
+ exchange: responseExchange,
497
+ attempt,
498
+ };
499
+ session.events.push(event);
500
+ await this.#persist(session);
501
+ notifySnapshot(observer, session);
502
+ let response;
503
+ try {
504
+ response = await modelHost.exchange(request, {
505
+ onDelta: (delta) => {
506
+ if (delta.kind === "text")
507
+ event.text += delta.text;
508
+ else if (delta.kind === "reasoning")
509
+ event.reasoning = `${event.reasoning ?? ""}${delta.text}`;
510
+ else if (delta.kind === "tool")
511
+ event.toolFragment = `${event.toolFragment ?? ""}${delta.text}`;
512
+ touch(session);
513
+ notifyAssistantDelta(observer, {
514
+ eventId: event.id,
515
+ kind: delta.kind,
516
+ text: delta.text,
517
+ updatedAt: session.updatedAt,
518
+ });
519
+ },
520
+ });
521
+ if (response.diagnostics !== undefined)
522
+ await this.#failureLog?.recordExchangeIfActive(response.diagnostics);
523
+ }
524
+ catch (error) {
525
+ event.status = "interrupted";
526
+ const message = error instanceof Error
527
+ ? error.message
528
+ : "The model request was interrupted.";
529
+ session.events.push({
530
+ id: session.nextEventId++,
531
+ kind: "failure",
532
+ message,
533
+ });
534
+ session.status = "interrupted";
535
+ session.canRetry = true;
536
+ session.lastFailure = message;
537
+ await this.#persist(session);
538
+ notifySnapshot(observer, session);
539
+ return projectView(session);
540
+ }
541
+ event.status = "completed";
542
+ session.canRetry = false;
543
+ event.text = response.text ?? event.text;
544
+ if (response.reasoningContent !== undefined)
545
+ event.reasoning = response.reasoningContent;
546
+ delete event.toolFragment;
547
+ if (response.usage !== undefined)
548
+ event.usage = {
549
+ inputTokens: response.usage.inputTokens,
550
+ outputTokens: response.usage.outputTokens,
551
+ };
552
+ const calls = response.toolCalls ?? [];
553
+ const assistantItem = {
554
+ kind: "assistant",
555
+ text: response.text ?? "",
556
+ ...(response.reasoningContent === undefined
557
+ ? {}
558
+ : { reasoningContent: response.reasoningContent }),
559
+ ...(response.providerState === undefined
560
+ ? {}
561
+ : { providerState: response.providerState }),
562
+ toolCalls: structuredClone(calls),
563
+ };
564
+ try {
565
+ const workingTools = new Map(session.completedToolMap);
566
+ const prepared = [];
567
+ for (const call of calls) {
568
+ const item = prepareTool(session, workingTools, responseExchange, call);
569
+ prepared.push(item);
570
+ session.events.push({
571
+ id: session.nextEventId++,
572
+ kind: "tool_call",
573
+ callId: call.id,
574
+ name: call.name,
575
+ arguments: structuredClone(call.arguments),
576
+ replayed: item.replayed,
577
+ });
578
+ item.event.id = session.nextEventId++;
579
+ session.events.push(item.event);
580
+ }
581
+ const failedTools = prepared.filter(({ result }) => !result.ok);
582
+ if (failedTools.length > 0 && response.diagnostics !== undefined)
583
+ await this.#failureLog?.recordFailure({
584
+ exchange: response.diagnostics,
585
+ failures: [
586
+ {
587
+ kind: "tool_execution",
588
+ message: "A Runtime tool call from the model was rejected.",
589
+ details: {
590
+ calls: failedTools.map(({ call, result, replayed }) => ({
591
+ id: call.id,
592
+ name: call.name,
593
+ arguments: structuredClone(call.arguments),
594
+ ok: result.ok,
595
+ failureKind: result.failureKind,
596
+ markdown: result.markdown,
597
+ replayed,
598
+ })),
599
+ },
600
+ },
601
+ ],
602
+ });
603
+ const stateChanges = session.documents.stateChanges();
604
+ const visibleText = (response.text ?? "").trim().length > 0;
605
+ let committedHead;
606
+ if (visibleText || stateChanges.length > 0) {
607
+ const committed = await this.#commitStep(session, {
608
+ operationKey: `response:${responseExchange}:attempt:${attempt}`,
609
+ historyAppend: visibleText
610
+ ? [{ role: "narrator", exactText: response.text ?? "" }]
611
+ : [],
612
+ stateChanges,
613
+ });
614
+ committedHead = committed.head;
615
+ event.committedHead = committed.head;
616
+ if (stateChanges.length > 0) {
617
+ session.documents.acceptCommittedState();
618
+ mergeChangedDocuments(session, stateChanges);
619
+ }
620
+ }
621
+ const committedWriteRefs = new Set(stateChanges.map(({ stableShortRef }) => stableShortRef));
622
+ for (const item of prepared) {
623
+ const candidateWrite = item.result.candidateWrite;
624
+ if (!item.result.ok || candidateWrite === undefined)
625
+ continue;
626
+ const markdown = candidateWrite.changed &&
627
+ committedWriteRefs.has(candidateWrite.shortRef)
628
+ ? `@${candidateWrite.shortRef} write succeeded`
629
+ : `@${candidateWrite.shortRef} unchanged`;
630
+ item.result.markdown = markdown;
631
+ delete item.result.candidateWrite;
632
+ item.event.markdown = markdown;
633
+ item.transcript.markdown = markdown;
634
+ const completed = workingTools.get(item.key);
635
+ if (completed !== undefined)
636
+ completed.result = structuredClone(item.result);
637
+ }
638
+ if (calls.length > 0 || visibleText)
639
+ session.transcript.push(assistantItem);
640
+ session.transcript.push(...prepared.map(({ transcript }) => transcript));
641
+ session.completedToolMap = workingTools;
642
+ session.completedTools = [...workingTools.values()].map((item) => structuredClone(item));
643
+ const lastToolEventId = prepared.at(-1)?.event.id;
644
+ if (lastToolEventId !== undefined)
645
+ recordDocumentAuthorizationCheckpoint(session, lastToolEventId);
646
+ if (committedHead !== undefined)
647
+ session.parentHead = committedHead;
648
+ if (calls.length === 0) {
649
+ if (!visibleText) {
650
+ const message = "The model returned neither text nor a complete tool call.";
651
+ if (response.diagnostics !== undefined)
652
+ await this.#failureLog?.recordFailure({
653
+ exchange: response.diagnostics,
654
+ failures: [
655
+ {
656
+ kind: "format_validation",
657
+ message,
658
+ },
659
+ ],
660
+ });
661
+ session.events.push({
662
+ id: session.nextEventId++,
663
+ kind: "failure",
664
+ message,
665
+ });
666
+ session.status = "interrupted";
667
+ session.canRetry = false;
668
+ session.lastFailure = message;
669
+ await this.#persist(session);
670
+ notifySnapshot(observer, session);
671
+ return projectView(session);
672
+ }
673
+ // The exchange has settled: no tool call is pending and the narrative
674
+ // is committed. Followups run here, while the chain still reads as
675
+ // running, so the player only regains the composer once the panels
676
+ // they are about to act on are in place.
677
+ if (response.diagnostics !== undefined)
678
+ await this.#failureLog?.resolve({
679
+ exchange: response.diagnostics,
680
+ message: "The play call chain recovered during a later model exchange and completed.",
681
+ details: { parentHead: session.parentHead },
682
+ });
683
+ await this.#runFollowups(session, modelHost, observer);
684
+ session.status = "ready";
685
+ await this.#persist(session);
686
+ notifySnapshot(observer, session);
687
+ return projectView(session);
688
+ }
689
+ await this.#persist(session);
690
+ notifySnapshot(observer, session);
691
+ session.exchange = Math.max(session.exchange, responseExchange) + 1;
692
+ request = createRequest(session, modelHost, session.exchange);
693
+ attempt = 1;
694
+ }
695
+ catch (error) {
696
+ const message = error instanceof Error
697
+ ? error.message
698
+ : "Call-chain processing failed.";
699
+ if (response.diagnostics !== undefined)
700
+ await this.#failureLog?.recordFailure({
701
+ exchange: response.diagnostics,
702
+ failures: [
703
+ {
704
+ kind: "runtime_post_processing",
705
+ message,
706
+ },
707
+ ],
708
+ });
709
+ session.events.push({
710
+ id: session.nextEventId++,
711
+ kind: "failure",
712
+ message,
713
+ });
714
+ session.status = "interrupted";
715
+ session.canRetry = false;
716
+ session.lastFailure = message;
717
+ await this.#persist(session);
718
+ notifySnapshot(observer, session);
719
+ return projectView(session);
720
+ }
721
+ }
722
+ }
723
+ /**
724
+ * Dispatch the frozen follow-ups once against the settled exchange.
725
+ *
726
+ * Everything here is best-effort by construction: Authority already accepted
727
+ * the narrative and the world changes, so a failing panel must cost the
728
+ * player nothing more than a missing panel. Failures are recorded in the
729
+ * trace and the chain still returns to `ready`.
730
+ */
731
+ async #runFollowups(session, modelHost, observer) {
732
+ const followups = session.followups ?? [];
733
+ if (this.#artifacts === undefined || followups.length === 0)
734
+ return;
735
+ const record = (outcome) => {
736
+ session.events.push({
737
+ id: session.nextEventId++,
738
+ kind: "followup",
739
+ followupId: outcome.id,
740
+ displayName: outcome.displayName,
741
+ text: outcome.text,
742
+ ...(outcome.reasoning === undefined
743
+ ? {}
744
+ : { reasoning: outcome.reasoning }),
745
+ ...(outcome.usage === undefined ? {} : { usage: outcome.usage }),
746
+ toolCalls: structuredClone(outcome.toolCalls),
747
+ ...(outcome.failure === undefined ? {} : { failure: outcome.failure }),
748
+ });
749
+ touch(session);
750
+ notifySnapshot(observer, session);
751
+ };
752
+ try {
753
+ await runPlayFollowupRequests({
754
+ artifacts: this.#artifacts,
755
+ modelHost,
756
+ followups,
757
+ bootstrap: session.bootstrap,
758
+ // Captured once: every followup is dispatched against this exact
759
+ // prefix, so none of them observes another one's prompt or output.
760
+ prefix: structuredClone(session.transcript),
761
+ toolStrategy: session.bootstrap.toolStrategy,
762
+ context: {
763
+ // A follow-up produces no world endpoint of its own, so it starts and
764
+ // ends on the settled exchange's head. Reconciliation keeps panels
765
+ // bound to this endpoint and retires ones bound to the previous head.
766
+ worldId: session.worldId,
767
+ parentHead: session.parentHead,
768
+ operationId: followupOperationId(session),
769
+ playPresetId: session.playPreset.id,
770
+ playPresetRevision: session.playPreset.revision,
771
+ playPresetScriptsEnabled: session.playPresetScriptsEnabled ?? true,
772
+ },
773
+ head: session.parentHead,
774
+ maxOutputTokens: modelHost.binding().maxOutputTokens,
775
+ ...(this.#failureLog === undefined
776
+ ? {}
777
+ : { failureLog: this.#failureLog }),
778
+ observer: { onOutcome: record },
779
+ });
780
+ }
781
+ catch (error) {
782
+ session.events.push({
783
+ id: session.nextEventId++,
784
+ kind: "failure",
785
+ message: error instanceof Error
786
+ ? `Follow-up request failed: ${error.message}`
787
+ : "The follow-up request failed.",
788
+ });
789
+ touch(session);
790
+ }
791
+ }
792
+ async #commitStep(session, input) {
793
+ const parentHead = session.parentHead;
794
+ const outcome = await this.#worlds.commitPlayStep({
795
+ operationId: operationId(session.chainId, input.operationKey),
796
+ worldId: session.worldId,
797
+ parentHead,
798
+ historyAppend: structuredClone(input.historyAppend),
799
+ nextMaterials: structuredClone(session.nextMaterials),
800
+ stateChanges: structuredClone(input.stateChanges),
801
+ });
802
+ session.parentHead = outcome.head;
803
+ const sequence = Number(outcome.head.slice("commit:".length));
804
+ for (const [index, message] of input.historyAppend.entries())
805
+ session.history.push({
806
+ path: `${session.worldId}.message.${sequence}.${index + 1}.${message.role}`,
807
+ contents: message.exactText,
808
+ });
809
+ return outcome;
810
+ }
811
+ async #requireChain(worldId, chainId) {
812
+ validateIdentity(chainId, "call-chain ID");
813
+ const active = this.#active.get(chainId);
814
+ if (active !== undefined) {
815
+ if (active.worldId !== worldId)
816
+ throw new PlayCallChainError("The call chain does not belong to this world.");
817
+ return active;
818
+ }
819
+ const persisted = await this.#readPersisted(worldId);
820
+ if (persisted?.chainId !== chainId)
821
+ throw new PlayCallChainError("The call chain does not exist; start from the latest world state.");
822
+ if (persisted.status === "running") {
823
+ persisted.status = "interrupted";
824
+ persisted.canRetry = persisted.lastRequest !== null;
825
+ persisted.lastFailure = persisted.canRetry
826
+ ? "The service stopped before the model request completed; the same request can be retried."
827
+ : "The service stopped before dispatching the model request; use a fresh context.";
828
+ const streaming = persisted.events.findLast((event) => event.kind === "assistant" && event.status === "streaming");
829
+ if (streaming !== undefined)
830
+ streaming.status = "interrupted";
831
+ persisted.events.push({
832
+ id: persisted.nextEventId++,
833
+ kind: "failure",
834
+ message: persisted.lastFailure,
835
+ });
836
+ }
837
+ const binding = await this.#worlds.bindPlayCallChain(worldId);
838
+ if (binding.parentHead !== persisted.parentHead)
839
+ throw new PlayCallChainError("The call-chain record differs from the current world endpoint; use a fresh context.");
840
+ const documents = restorePlayDocuments(binding.files, persisted, persisted.events);
841
+ const session = {
842
+ ...structuredClone(persisted),
843
+ documentAuthorizationCheckpoints: persisted.documentAuthorizationCheckpoints?.map((checkpoint) => structuredClone(checkpoint)) ?? [],
844
+ nextMaterials: structuredClone(binding.additionalMaterials),
845
+ documents,
846
+ history: historyEntries(binding.history),
847
+ completedToolMap: new Map(persisted.completedTools.map((item) => [
848
+ item.key,
849
+ structuredClone(item),
850
+ ])),
851
+ };
852
+ this.#active.set(chainId, session);
853
+ this.#worldChains.set(worldId, chainId);
854
+ if (persisted.status === "interrupted")
855
+ await this.#persist(session);
856
+ return session;
857
+ }
858
+ async #assertCurrentHead(session) {
859
+ if ((await this.#worlds.currentHead(session.worldId)) !== session.parentHead)
860
+ throw new PlayCallChainError("Another operation has advanced the current world; use a fresh context.");
861
+ }
862
+ async #readPersisted(worldId) {
863
+ const value = await this.#worlds.readPlayCallChain(worldId);
864
+ if (value === null)
865
+ return null;
866
+ assertPersistedPlayCallChain(value, worldId);
867
+ return { ...structuredClone(value), schemaVersion: 1 };
868
+ }
869
+ async #persist(session) {
870
+ touch(session);
871
+ session.completedTools = [...session.completedToolMap.values()].map((item) => structuredClone(item));
872
+ const { documents: _documents, history: _history, completedToolMap: _completedToolMap, ...persisted } = session;
873
+ void _documents;
874
+ void _history;
875
+ void _completedToolMap;
876
+ await this.#worlds.writePlayCallChain(session.worldId, structuredClone(persisted));
877
+ }
878
+ }
879
+ /**
880
+ * One operation per settled exchange. A fresh id makes the artifact store
881
+ * supersede the previous exchange's `new_operation` panels, so it advances with
882
+ * the committed endpoint rather than staying stable for the chain.
883
+ */
884
+ function followupOperationId(session) {
885
+ return `${session.chainId}:followup:${session.parentHead}`;
886
+ }
887
+ function createRequest(session, modelHost, exchange) {
888
+ return {
889
+ bootstrap: structuredClone(session.bootstrap),
890
+ tools: structuredClone(session.tools),
891
+ toolUniverse: structuredClone(session.tools),
892
+ allowedTools: session.tools.map(({ name }) => name),
893
+ toolStrategy: session.bootstrap.toolStrategy,
894
+ appended: structuredClone(session.transcript),
895
+ requestId: "play_call_chain",
896
+ operationId: session.chainId,
897
+ requestAttempt: 1,
898
+ exchange,
899
+ maxOutputTokens: modelHost.binding().maxOutputTokens,
900
+ };
901
+ }
902
+ function prepareTool(session, working, exchange, call) {
903
+ const key = `${exchange}:${call.id}`;
904
+ const signature = JSON.stringify(call.arguments);
905
+ const completed = working.get(key);
906
+ let result;
907
+ let replayed = false;
908
+ if (completed !== undefined) {
909
+ replayed = true;
910
+ result =
911
+ completed.name === call.name && completed.signature === signature
912
+ ? structuredClone(completed.result)
913
+ : {
914
+ ok: false,
915
+ failureKind: "protocol",
916
+ markdown: "# Runtime tool rejected\n\nThe same tool-call ID is already bound to another tool name or argument set.",
917
+ };
918
+ }
919
+ else if (!callChainToolNames.has(call.name)) {
920
+ result = {
921
+ ok: false,
922
+ failureKind: "protocol",
923
+ markdown: `# Runtime tool rejected\n\nThe current call chain does not provide ${call.name}.`,
924
+ };
925
+ }
926
+ else {
927
+ try {
928
+ result = session.documents.execute(call, session.history);
929
+ }
930
+ catch (error) {
931
+ result = {
932
+ ok: false,
933
+ failureKind: "protocol",
934
+ markdown: `# Runtime tool failed\n\n${error instanceof Error ? error.message : "Tool execution failed."}`,
935
+ };
936
+ }
937
+ working.set(key, {
938
+ key,
939
+ name: call.name,
940
+ signature,
941
+ result: structuredClone(result),
942
+ });
943
+ }
944
+ const event = {
945
+ id: 0,
946
+ kind: "tool_result",
947
+ callId: call.id,
948
+ name: call.name,
949
+ ok: result.ok,
950
+ markdown: result.markdown,
951
+ replayed,
952
+ };
953
+ return {
954
+ call,
955
+ key,
956
+ result,
957
+ replayed,
958
+ event,
959
+ transcript: {
960
+ kind: "tool",
961
+ toolCallId: call.id,
962
+ markdown: result.markdown,
963
+ },
964
+ };
965
+ }
966
+ function duplicatePlayerExchange(session, exchangeId, playerText, context) {
967
+ const existing = session.events.find((event) => event.kind === "player" && event.exchangeId === exchangeId);
968
+ if (existing === undefined)
969
+ return false;
970
+ if (existing.text !== playerText || existing.context !== context)
971
+ throw new PlayCallChainError("The same exchange ID is already bound to different player text or a different context mode.");
972
+ return true;
973
+ }
974
+ function mergeChangedDocuments(session, changes) {
975
+ for (const change of changes) {
976
+ const projected = {
977
+ kind: change.kind,
978
+ ref: `@${change.stableShortRef}`,
979
+ path: change.relativePath,
980
+ };
981
+ const index = session.changedDocuments.findIndex(({ path }) => path === projected.path);
982
+ if (index < 0)
983
+ session.changedDocuments.push(projected);
984
+ else
985
+ session.changedDocuments[index] = projected;
986
+ }
987
+ }
988
+ function restorePlayDocuments(files, context, events) {
989
+ const documents = new FileNativePlayDocuments(files);
990
+ try {
991
+ const checkpoint = documentAuthorizationThroughEvents(context, events);
992
+ if (checkpoint === undefined)
993
+ documents.bindBootstrap(context.bootstrap);
994
+ else
995
+ documents.restoreAuthorizationCheckpoint(checkpoint.authorization);
996
+ }
997
+ catch (error) {
998
+ throw new PlayCallChainError(`Call-chain document authorization could not be restored: ${error instanceof Error ? error.message : "invalid checkpoint"}。`);
999
+ }
1000
+ return documents;
1001
+ }
1002
+ function recordDocumentAuthorizationCheckpoint(session, afterEventId) {
1003
+ const authorization = session.documents.authorizationCheckpoint();
1004
+ const previous = session.documentAuthorizationCheckpoints.at(-1);
1005
+ if (previous !== undefined &&
1006
+ JSON.stringify(previous.authorization) === JSON.stringify(authorization))
1007
+ return;
1008
+ if (previous !== undefined && afterEventId <= previous.afterEventId)
1009
+ throw new PlayCallChainError("Call-chain document authorization checkpoints are out of order.");
1010
+ session.documentAuthorizationCheckpoints.push({
1011
+ afterEventId,
1012
+ authorization,
1013
+ });
1014
+ }
1015
+ function documentAuthorizationThroughEvents(context, events) {
1016
+ const checkpoints = context.documentAuthorizationCheckpoints;
1017
+ if (checkpoints === undefined || checkpoints.length === 0)
1018
+ return undefined;
1019
+ const selectedEventIds = new Set(events.map(({ id }) => id));
1020
+ const selected = checkpoints.findLast(({ afterEventId }) => afterEventId === 0 || selectedEventIds.has(afterEventId));
1021
+ // Legacy V1 records had no durable dynamic authorization. If the selected
1022
+ // prefix predates their first lazily-written checkpoint, retain the exact
1023
+ // old recovery behavior and rebuild only bootstrap authorization.
1024
+ return selected === undefined ? undefined : structuredClone(selected);
1025
+ }
1026
+ function snapshotCurrentContext(session) {
1027
+ const { schemaVersion: _schemaVersion, kind: _kind, worldId: _worldId, previousContexts: _previousContexts, ...context } = session;
1028
+ void _schemaVersion;
1029
+ void _kind;
1030
+ void _worldId;
1031
+ void _previousContexts;
1032
+ return structuredClone(context);
1033
+ }
1034
+ function persistedContexts(session) {
1035
+ return [
1036
+ ...(session.previousContexts ?? []).map((context) => structuredClone(context)),
1037
+ snapshotCurrentContext(session),
1038
+ ];
1039
+ }
1040
+ function independentContextCopy(source) {
1041
+ const context = structuredClone(source);
1042
+ delete context.derivedFrom;
1043
+ delete context.branchedBeforePlayer;
1044
+ return context;
1045
+ }
1046
+ function authorizationCheckpointsThroughEvents(context, events, fallback) {
1047
+ const eventIds = new Set(events.map(({ id }) => id));
1048
+ const selected = (context.documentAuthorizationCheckpoints ?? [])
1049
+ .filter(({ afterEventId }) => afterEventId === 0 || eventIds.has(afterEventId))
1050
+ .map((checkpoint) => structuredClone(checkpoint));
1051
+ if (selected.length > 0)
1052
+ return selected;
1053
+ return [
1054
+ {
1055
+ afterEventId: Math.max(0, ...events.map(({ id }) => id)),
1056
+ authorization: structuredClone(fallback),
1057
+ },
1058
+ ];
1059
+ }
1060
+ function projectContext(context) {
1061
+ return {
1062
+ chainId: context.chainId,
1063
+ baselineHead: context.baselineHead,
1064
+ ...(context.baselineHistoryLength === undefined
1065
+ ? {}
1066
+ : { baselineHistoryLength: context.baselineHistoryLength }),
1067
+ parentHead: context.parentHead,
1068
+ playPreset: structuredClone(context.playPreset),
1069
+ status: context.status,
1070
+ canRetry: context.canRetry,
1071
+ events: structuredClone(context.events),
1072
+ changedDocuments: structuredClone(context.changedDocuments),
1073
+ lastFailure: context.lastFailure,
1074
+ updatedAt: context.updatedAt,
1075
+ };
1076
+ }
1077
+ function projectView(session) {
1078
+ return {
1079
+ ...projectContext(session),
1080
+ worldId: session.worldId,
1081
+ previousContexts: (session.previousContexts ?? []).map((context) => projectContext(context)),
1082
+ };
1083
+ }
1084
+ function eventsThroughHead(events, head) {
1085
+ const selectedIndex = events.findIndex((event) => (event.kind === "player" || event.kind === "assistant") &&
1086
+ event.committedHead === head);
1087
+ if (selectedIndex < 0)
1088
+ return null;
1089
+ let end = selectedIndex + 1;
1090
+ if (events[selectedIndex]?.kind === "assistant")
1091
+ while (events[end]?.kind === "tool_call" ||
1092
+ events[end]?.kind === "tool_result")
1093
+ end += 1;
1094
+ return structuredClone(events.slice(0, end));
1095
+ }
1096
+ function transcriptThroughEvents(transcript, events) {
1097
+ const result = [];
1098
+ let cursor = 0;
1099
+ for (const [index, event] of events.entries()) {
1100
+ if (event.kind === "player") {
1101
+ const item = transcript[cursor];
1102
+ if (item?.kind !== "player" || item.text !== event.text)
1103
+ throw new PlayCallChainError("Source call-chain events do not match the model transcript, so a fork cannot be created safely.");
1104
+ result.push(structuredClone(item));
1105
+ cursor += 1;
1106
+ continue;
1107
+ }
1108
+ if (event.kind === "assistant" && event.status === "completed") {
1109
+ const hasToolCall = events
1110
+ .slice(index + 1)
1111
+ .find((candidate) => candidate.kind === "player" ||
1112
+ candidate.kind === "assistant" ||
1113
+ candidate.kind === "tool_call");
1114
+ const recorded = event.text.trim().length > 0 || hasToolCall?.kind === "tool_call";
1115
+ if (!recorded)
1116
+ continue;
1117
+ const item = transcript[cursor];
1118
+ if (item?.kind !== "assistant" || item.text !== event.text)
1119
+ throw new PlayCallChainError("Source call-chain responses do not match the model transcript, so a fork cannot be created safely.");
1120
+ result.push(structuredClone(item));
1121
+ cursor += 1;
1122
+ continue;
1123
+ }
1124
+ if (event.kind === "tool_result") {
1125
+ const item = transcript[cursor];
1126
+ if (item?.kind !== "tool" || item.toolCallId !== event.callId)
1127
+ throw new PlayCallChainError("Source call-chain tool results do not match the model transcript, so a fork cannot be created safely.");
1128
+ result.push(structuredClone(item));
1129
+ cursor += 1;
1130
+ }
1131
+ }
1132
+ return result;
1133
+ }
1134
+ function completedToolKeys(events) {
1135
+ const keys = new Set();
1136
+ let exchange = null;
1137
+ for (const event of events) {
1138
+ if (event.kind === "player")
1139
+ exchange = null;
1140
+ else if (event.kind === "assistant")
1141
+ exchange = event.exchange;
1142
+ else if (event.kind === "tool_call" && exchange !== null)
1143
+ keys.add(`${exchange}:${event.callId}`);
1144
+ }
1145
+ return keys;
1146
+ }
1147
+ function changedDocumentsAtHead(changes, baseline, selected) {
1148
+ const before = new Map(baseline.map((file) => [file.path, file.contents]));
1149
+ const after = new Map(selected.map((file) => [file.path, file.contents]));
1150
+ return changes
1151
+ .filter(({ path }) => after.has(path) && before.get(path) !== after.get(path))
1152
+ .map((change) => structuredClone(change));
1153
+ }
1154
+ function derivedChainId(sourceChainId, branchIdentity, targetWorldId) {
1155
+ return `play-branch-${createHash("sha256")
1156
+ .update(`${sourceChainId}\0${branchIdentity}\0${targetWorldId}`)
1157
+ .digest("hex")
1158
+ .slice(0, 40)}`;
1159
+ }
1160
+ function historyEntries(history) {
1161
+ return Object.entries(history).map(([path, contents]) => ({
1162
+ path,
1163
+ contents,
1164
+ }));
1165
+ }
1166
+ function operationId(chainId, key) {
1167
+ return `play-${createHash("sha256")
1168
+ .update(`${chainId}\0${key}`)
1169
+ .digest("hex")}`;
1170
+ }
1171
+ function assertPersistedPlayCallChain(value, worldId) {
1172
+ if (!isRecord(value) ||
1173
+ (value.schemaVersion !== 1 && value.schemaVersion !== 2) ||
1174
+ value.kind !== "play_call_chain" ||
1175
+ value.worldId !== worldId ||
1176
+ !isPersistedPlayCallChainContext(value) ||
1177
+ (value.previousContexts !== undefined &&
1178
+ (!Array.isArray(value.previousContexts) ||
1179
+ value.previousContexts.some((context) => !isPersistedPlayCallChainContext(context)))))
1180
+ throw new PlayCallChainError("The persisted call-chain record does not match the current format and cannot be read.");
1181
+ }
1182
+ function isPersistedPlayCallChainContext(value) {
1183
+ if (!isRecord(value))
1184
+ return false;
1185
+ return (typeof value.chainId === "string" &&
1186
+ typeof value.baselineHead === "string" &&
1187
+ (value.baselineHistoryLength === undefined ||
1188
+ (typeof value.baselineHistoryLength === "number" &&
1189
+ Number.isSafeInteger(value.baselineHistoryLength) &&
1190
+ value.baselineHistoryLength >= 0)) &&
1191
+ typeof value.parentHead === "string" &&
1192
+ (value.derivedFrom === undefined ||
1193
+ (isRecord(value.derivedFrom) &&
1194
+ typeof value.derivedFrom.worldId === "string" &&
1195
+ typeof value.derivedFrom.chainId === "string" &&
1196
+ typeof value.derivedFrom.head === "string")) &&
1197
+ (value.branchedBeforePlayer === undefined ||
1198
+ (isRecord(value.branchedBeforePlayer) &&
1199
+ typeof value.branchedBeforePlayer.worldId === "string" &&
1200
+ typeof value.branchedBeforePlayer.chainId === "string" &&
1201
+ Number.isSafeInteger(value.branchedBeforePlayer.eventId) &&
1202
+ value.branchedBeforePlayer.eventId > 0)) &&
1203
+ isRecord(value.playPreset) &&
1204
+ (value.followups === undefined || Array.isArray(value.followups)) &&
1205
+ (value.playPresetScriptsEnabled === undefined ||
1206
+ typeof value.playPresetScriptsEnabled === "boolean") &&
1207
+ (value.status === "ready" ||
1208
+ value.status === "running" ||
1209
+ value.status === "interrupted") &&
1210
+ typeof value.canRetry === "boolean" &&
1211
+ isRecord(value.bootstrap) &&
1212
+ Array.isArray(value.tools) &&
1213
+ Array.isArray(value.transcript) &&
1214
+ Array.isArray(value.events) &&
1215
+ Array.isArray(value.completedTools) &&
1216
+ (value.documentAuthorizationCheckpoints === undefined ||
1217
+ isPersistedDocumentAuthorizationHistory(value.documentAuthorizationCheckpoints, value.events)) &&
1218
+ Array.isArray(value.changedDocuments) &&
1219
+ Array.isArray(value.nextMaterials) &&
1220
+ Number.isSafeInteger(value.nextEventId) &&
1221
+ Number.isSafeInteger(value.exchange) &&
1222
+ (value.lastRequest === null || isRecord(value.lastRequest)) &&
1223
+ Number.isSafeInteger(value.lastRequestAttempt) &&
1224
+ (value.lastFailure === null || typeof value.lastFailure === "string") &&
1225
+ typeof value.updatedAt === "number");
1226
+ }
1227
+ function isPersistedDocumentAuthorizationHistory(value, events) {
1228
+ if (!Array.isArray(value) || value.length > 100_000)
1229
+ return false;
1230
+ const eventIds = new Set(events.flatMap((event) => isRecord(event) && Number.isSafeInteger(event.id) && Number(event.id) > 0
1231
+ ? [Number(event.id)]
1232
+ : []));
1233
+ let previousEventId = -1;
1234
+ for (const checkpoint of value) {
1235
+ if (!isRecord(checkpoint) ||
1236
+ Object.keys(checkpoint).length !== 2 ||
1237
+ !Object.hasOwn(checkpoint, "afterEventId") ||
1238
+ !Object.hasOwn(checkpoint, "authorization") ||
1239
+ !Number.isSafeInteger(checkpoint.afterEventId) ||
1240
+ Number(checkpoint.afterEventId) < 0 ||
1241
+ Number(checkpoint.afterEventId) <= previousEventId ||
1242
+ (Number(checkpoint.afterEventId) !== 0 &&
1243
+ !eventIds.has(Number(checkpoint.afterEventId))) ||
1244
+ !isPlayDocumentAuthorizationCheckpoint(checkpoint.authorization))
1245
+ return false;
1246
+ previousEventId = Number(checkpoint.afterEventId);
1247
+ }
1248
+ return true;
1249
+ }
1250
+ function validateIdentity(value, label) {
1251
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u.test(value))
1252
+ throw new PlayCallChainError(`${label} is invalid.`);
1253
+ }
1254
+ function validatePlayerText(value) {
1255
+ if (value.trim().length === 0)
1256
+ throw new PlayCallChainError("Player text must not be empty.");
1257
+ }
1258
+ function touch(session) {
1259
+ session.updatedAt = Date.now();
1260
+ }
1261
+ function notifySnapshot(observer, session) {
1262
+ try {
1263
+ observer?.onSnapshot?.(projectView(session));
1264
+ }
1265
+ catch {
1266
+ // A browser stream may disconnect while the durable model request keeps
1267
+ // running. Presentation failure never cancels or changes play Authority.
1268
+ }
1269
+ }
1270
+ function notifyAssistantDelta(observer, delta) {
1271
+ try {
1272
+ observer?.onAssistantDelta?.(structuredClone(delta));
1273
+ }
1274
+ catch {
1275
+ // See notifySnapshot: the Provider exchange outlives its browser stream.
1276
+ }
1277
+ }
1278
+ function isRecord(value) {
1279
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1280
+ }