narraeon 0.5.7 → 0.6.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.
- package/README.md +13 -0
- package/README.zh-CN.md +10 -0
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +15 -2
- package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +58 -7
- package/dist/node/runtime/archive/WorldArchives.js +14 -46
- package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
- package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
- package/dist/node/runtime/config/dataRootLock.js +108 -0
- package/dist/node/runtime/content/ContentWorkspace.js +10 -12
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +50 -46
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
- package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
- package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +31 -170
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +36 -239
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +359 -359
- package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
- package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
- package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
- package/dist/node/runtime/play/TabletopResolution.js +372 -0
- package/dist/node/runtime/play/TabletopRules.js +141 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
- package/dist/node/runtime/world/FileNativeWorldStore.js +266 -783
- package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
- package/dist/node/runtime/world/WorldActivity.js +14 -22
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
- package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
- package/dist/node/runtime/world/WorldQueue.js +46 -0
- package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -12
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
- package/dist/node/server/ConversationStream.js +2 -0
- package/dist/node/server/mobile.js +20 -0
- package/dist/node/server/startWebServer.js +15 -8
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +23 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-7GzLSUpU.js +1323 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
- package/dist/web/assets/index-DxNtxzLR.js +0 -1255
- package/dist/web/assets/index-DzOnu9uv.css +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { projectLegacyModelBindingFailure } from "./LegacyModelBindingFailure.js";
|
|
2
2
|
import { classifyPlayFailure } from "./PlayFailure.js";
|
|
3
|
+
import { isResolutionTool, resolutionMarkdown, resolutionToolNames, resolveTabletop, seededRandom, } from "./TabletopResolution.js";
|
|
3
4
|
import { captureNarrativeOrigin, snapshotBackground, narrativeBackground, } from "../history/HistoryBackground.js";
|
|
4
5
|
import { isDeepStrictEqual } from "node:util";
|
|
5
6
|
import { hasLegacyHistoryTools } from "../prompt/FileNativeToolRegistry.js";
|
|
@@ -7,7 +8,7 @@ import { PlayCallChainError } from "./PlayCallChainError.js";
|
|
|
7
8
|
import { preparePlayerRevisionTrace, prepareForkTrace, restorePlayDocuments, independentContextCopy, } from "./PlayTraceRetention.js";
|
|
8
9
|
import { currentPlayPrompt, recordedPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
|
|
9
10
|
import { comparePromptPrefixes } from "../prompt/WorldPromptDiagnostics.js";
|
|
10
|
-
import { createHash } from "node:crypto";
|
|
11
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
12
|
import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepNotice, } from "./PlayContinuity.js";
|
|
12
13
|
import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
|
|
13
14
|
import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
|
|
@@ -18,6 +19,7 @@ import { parsePlayPresetFiles, } from "./FileNativePlayPresetStore.js";
|
|
|
18
19
|
import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
|
|
19
20
|
import { FileNativePlayDocuments, fingerprintControl, } from "./PlayDocumentTools.js";
|
|
20
21
|
const callChainToolNames = new Set([
|
|
22
|
+
...resolutionToolNames,
|
|
21
23
|
"state_list",
|
|
22
24
|
"history_list",
|
|
23
25
|
"history_search",
|
|
@@ -32,6 +34,24 @@ const callChainToolNames = new Set([
|
|
|
32
34
|
"world_checkpoint",
|
|
33
35
|
]);
|
|
34
36
|
const projectedEventTail = 40;
|
|
37
|
+
function isPlayerRevisionIntent(value) {
|
|
38
|
+
if (typeof value !== "object" || value === null)
|
|
39
|
+
return false;
|
|
40
|
+
const record = value;
|
|
41
|
+
return (record.schemaVersion === 1 &&
|
|
42
|
+
record.kind === "player_revision_intent" &&
|
|
43
|
+
typeof record.operationId === "string" &&
|
|
44
|
+
typeof record.worldId === "string" &&
|
|
45
|
+
typeof record.chainId === "string" &&
|
|
46
|
+
Number.isSafeInteger(record.eventId) &&
|
|
47
|
+
typeof record.replacementExchangeId === "string" &&
|
|
48
|
+
typeof record.replacementText === "string" &&
|
|
49
|
+
(record.continuation === "continue_context" ||
|
|
50
|
+
record.continuation === "fresh_context") &&
|
|
51
|
+
typeof record.restoresHead === "string" &&
|
|
52
|
+
(record.continuation === "continue_context" ||
|
|
53
|
+
typeof record.freshContext === "object"));
|
|
54
|
+
}
|
|
35
55
|
export { PlayCallChainError } from "./PlayCallChainError.js";
|
|
36
56
|
/**
|
|
37
57
|
* The production play loop: one durable logical context, no Runtime-authored
|
|
@@ -48,8 +68,12 @@ export class PlayCallChain {
|
|
|
48
68
|
#failureLog;
|
|
49
69
|
#active = new Map();
|
|
50
70
|
#worldChains = new Map();
|
|
71
|
+
/** One in-flight disk hydration per world; concurrent callers share it. */
|
|
72
|
+
#hydrating = new Map();
|
|
51
73
|
#loadedCursors = new Map();
|
|
52
74
|
#activeInvocations = new Map();
|
|
75
|
+
/** Resolves when the matching invocation has left the active table. */
|
|
76
|
+
#invocationSettled = new Map();
|
|
53
77
|
constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog, changed = () => {
|
|
54
78
|
/* No observers attached. */
|
|
55
79
|
}) {
|
|
@@ -134,12 +158,6 @@ export class PlayCallChain {
|
|
|
134
158
|
transcript: [],
|
|
135
159
|
events: [],
|
|
136
160
|
completedTools: [],
|
|
137
|
-
documentAuthorizationCheckpoints: [
|
|
138
|
-
{
|
|
139
|
-
afterEventId: 0,
|
|
140
|
-
authorization: documents.authorizationCheckpoint(),
|
|
141
|
-
},
|
|
142
|
-
],
|
|
143
161
|
changedDocuments: [],
|
|
144
162
|
nextMaterials: structuredClone(binding.additionalMaterials),
|
|
145
163
|
nextEventId: 1,
|
|
@@ -156,7 +174,6 @@ export class PlayCallChain {
|
|
|
156
174
|
eventCount: 0,
|
|
157
175
|
transcriptCount: 0,
|
|
158
176
|
completedToolCount: 0,
|
|
159
|
-
authorizationCheckpointCount: 0,
|
|
160
177
|
},
|
|
161
178
|
};
|
|
162
179
|
if (activeId !== undefined)
|
|
@@ -256,12 +273,7 @@ export class PlayCallChain {
|
|
|
256
273
|
playPresetScriptsEnabled: playPreset.scriptsEnabled,
|
|
257
274
|
presetFiles: structuredClone(playPreset.files),
|
|
258
275
|
});
|
|
259
|
-
session.documentAuthorizationCheckpoints.push({
|
|
260
|
-
afterEventId: session.nextEventId,
|
|
261
|
-
authorization: session.documents.authorizationCheckpoint(),
|
|
262
|
-
});
|
|
263
276
|
await this.#persist(session);
|
|
264
|
-
crashAtPlayAdvanceEdge("after_prompt_run_prepared");
|
|
265
277
|
}
|
|
266
278
|
cancel(input) {
|
|
267
279
|
validateIdentity(input.worldId, "world ID");
|
|
@@ -287,6 +299,10 @@ export class PlayCallChain {
|
|
|
287
299
|
[...this.#activeInvocations.values()].some(({ worldId }) => worldId === input.worldId))
|
|
288
300
|
throw new PlayCallChainError("A model request is already running for this world or call chain.");
|
|
289
301
|
const startedAt = Date.now();
|
|
302
|
+
let markSettled;
|
|
303
|
+
const settled = new Promise((resolve) => {
|
|
304
|
+
markSettled = resolve;
|
|
305
|
+
});
|
|
290
306
|
const invocation = {
|
|
291
307
|
worldId: input.worldId,
|
|
292
308
|
chainId: input.chainId,
|
|
@@ -305,6 +321,7 @@ export class PlayCallChain {
|
|
|
305
321
|
const controlUseId = operationId(input.chainId, `control-use:${input.exchangeId}`);
|
|
306
322
|
this.#worlds.freezeControl(input.worldId, controlUseId);
|
|
307
323
|
this.#activeInvocations.set(input.chainId, invocation);
|
|
324
|
+
this.#invocationSettled.set(input.chainId, settled);
|
|
308
325
|
this.changed(input.worldId);
|
|
309
326
|
try {
|
|
310
327
|
await this.#worlds.assertWorldRevisionUnlocked(input.worldId);
|
|
@@ -315,6 +332,8 @@ export class PlayCallChain {
|
|
|
315
332
|
this.#activeInvocations.delete(input.chainId);
|
|
316
333
|
this.#worlds.releaseControl(input.worldId, controlUseId);
|
|
317
334
|
this.changed(input.worldId);
|
|
335
|
+
this.#invocationSettled.delete(input.chainId);
|
|
336
|
+
markSettled();
|
|
318
337
|
}
|
|
319
338
|
}
|
|
320
339
|
#observeInvocation(chainId, phase, increments = {}) {
|
|
@@ -376,6 +395,14 @@ export class PlayCallChain {
|
|
|
376
395
|
return view;
|
|
377
396
|
}
|
|
378
397
|
async #worldSession(worldId) {
|
|
398
|
+
return this.#loadWorldSession(worldId);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Returns the live session for a world, hydrating it from disk at most once
|
|
402
|
+
* at a time. Concurrent callers share one hydration, so a session object is
|
|
403
|
+
* never replaced by a stale copy that finished hydrating later.
|
|
404
|
+
*/
|
|
405
|
+
async #loadWorldSession(worldId) {
|
|
379
406
|
const activeId = this.#worldChains.get(worldId);
|
|
380
407
|
const active = activeId === undefined ? undefined : this.#active.get(activeId);
|
|
381
408
|
if (active !== undefined) {
|
|
@@ -383,17 +410,38 @@ export class PlayCallChain {
|
|
|
383
410
|
await this.#reconcileSessionAdvance(active);
|
|
384
411
|
return active;
|
|
385
412
|
}
|
|
413
|
+
const pending = this.#hydrating.get(worldId);
|
|
414
|
+
if (pending !== undefined)
|
|
415
|
+
return pending.session;
|
|
416
|
+
const token = Symbol(worldId);
|
|
417
|
+
const session = this.#hydrateWorldSession(worldId, token);
|
|
418
|
+
this.#hydrating.set(worldId, { token, session });
|
|
419
|
+
try {
|
|
420
|
+
return await session;
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
if (this.#hydrating.get(worldId)?.token === token)
|
|
424
|
+
this.#hydrating.delete(worldId);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async #hydrateWorldSession(worldId, token) {
|
|
428
|
+
await this.#recoverPlayerRevision(worldId);
|
|
386
429
|
const persisted = await this.#readPersisted(worldId);
|
|
387
430
|
if (persisted === null)
|
|
388
431
|
return null;
|
|
389
432
|
const advance = await this.#worlds.playAdvances.readCurrent(worldId, persisted.chainId);
|
|
390
|
-
const
|
|
391
|
-
this
|
|
392
|
-
this.#
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
433
|
+
const hydrated = await this.#hydrateSession(persisted, advance);
|
|
434
|
+
// A live session registered while this disk read was in flight wins.
|
|
435
|
+
const live = this.#active.get(hydrated.chainId);
|
|
436
|
+
if (live !== undefined)
|
|
437
|
+
return live;
|
|
438
|
+
// forgetWorld ran during hydration; do not resurrect the old session.
|
|
439
|
+
if (this.#hydrating.get(worldId)?.token !== token)
|
|
440
|
+
return null;
|
|
441
|
+
this.#active.set(hydrated.chainId, hydrated);
|
|
442
|
+
this.#worldChains.set(worldId, hydrated.chainId);
|
|
443
|
+
await this.#reconcileSessionAdvance(hydrated, advance);
|
|
444
|
+
return hydrated;
|
|
397
445
|
}
|
|
398
446
|
async inspectReading(worldId, worldHead) {
|
|
399
447
|
const view = await this.inspectWorld(worldId);
|
|
@@ -565,9 +613,10 @@ export class PlayCallChain {
|
|
|
565
613
|
});
|
|
566
614
|
}
|
|
567
615
|
/**
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
616
|
+
* Edit one committed player message as a rewind followed by an ordinary
|
|
617
|
+
* submission (ADR-0044, ADR-0045). The intent is written before the first
|
|
618
|
+
* world change; every later step is repeatable, so an exit at any point is
|
|
619
|
+
* completed by the same path on the next load.
|
|
571
620
|
*/
|
|
572
621
|
async revisePlayer(input) {
|
|
573
622
|
validateIdentity(input.operationId, "timeline-revision operation ID");
|
|
@@ -581,173 +630,229 @@ export class PlayCallChain {
|
|
|
581
630
|
const active = activeId === undefined ? undefined : this.#active.get(activeId);
|
|
582
631
|
if (active?.status === "running")
|
|
583
632
|
throw new PlayCallChainError("A model request is still running; wait for it to return before revising committed history.");
|
|
633
|
+
await this.#recoverPlayerRevision(input.worldId);
|
|
584
634
|
const source = await this.#readPersisted(input.worldId);
|
|
585
635
|
if (source === null)
|
|
586
636
|
throw new PlayCallChainError("The current world has no model call chain to revise.");
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
637
|
+
const contexts = await this.#worlds.playTimeline.readAllContexts(input.worldId);
|
|
638
|
+
// A replacement exchange that already reached the timeline is a repeat of
|
|
639
|
+
// the same request: answer with the current view and do not redo it.
|
|
640
|
+
const applied = contexts
|
|
641
|
+
.flatMap(({ events }) => events)
|
|
642
|
+
.find((event) => event.kind === "player" &&
|
|
592
643
|
event.exchangeId === input.replacementExchangeId &&
|
|
593
644
|
event.committedHead !== undefined);
|
|
594
|
-
if (
|
|
595
|
-
if (
|
|
645
|
+
if (applied !== undefined) {
|
|
646
|
+
if (applied.kind === "player" && applied.text !== input.replacementText)
|
|
596
647
|
throw new PlayCallChainError("The same replacement exchange ID is already bound to different player text.");
|
|
597
|
-
const requestedSource = await this.#worlds.playTimeline.readContext(input.worldId, input.chainId);
|
|
598
|
-
const requestedEventIndex = requestedSource?.value.events.findIndex(({ id }) => id === input.eventId);
|
|
599
|
-
const requestedEvent = requestedEventIndex === undefined || requestedEventIndex < 0
|
|
600
|
-
? undefined
|
|
601
|
-
: requestedSource?.value.events[requestedEventIndex];
|
|
602
|
-
if (requestedSource === null ||
|
|
603
|
-
requestedEventIndex === undefined ||
|
|
604
|
-
requestedEventIndex < 0 ||
|
|
605
|
-
requestedEvent?.kind !== "player" ||
|
|
606
|
-
requestedEvent.committedHead === undefined)
|
|
607
|
-
throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
|
|
608
|
-
const requestedRestoresHead = playerRevisionRestoresHead(requestedSource.value, requestedEventIndex);
|
|
609
|
-
const authority = await this.#worlds.readAuthorityHistory(input.worldId);
|
|
610
|
-
const commit = authority.commits.find(({ head }) => head === appliedEvent.committedHead);
|
|
611
|
-
const operation = await this.#worlds.getOperationOutcome(input.operationId);
|
|
612
|
-
if (commit?.mode !== "timeline_revision" ||
|
|
613
|
-
(operation.outcome !== "committed" &&
|
|
614
|
-
operation.outcome !== "committed_materialization_pending") ||
|
|
615
|
-
operation.worldId !== input.worldId ||
|
|
616
|
-
operation.head !== appliedEvent.committedHead ||
|
|
617
|
-
commit.timelineRevision?.restoresHead !== requestedRestoresHead ||
|
|
618
|
-
commit.timelineRevision.replacesHead !== requestedEvent.committedHead ||
|
|
619
|
-
commit.timelineRevision.requestFingerprint !==
|
|
620
|
-
playerRevisionRequestFingerprint(input, requestedEvent.committedHead, requestedRestoresHead))
|
|
621
|
-
throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
|
|
622
648
|
return {
|
|
623
649
|
outcome: "revised",
|
|
624
650
|
worldId: input.worldId,
|
|
625
651
|
playCallChain: projectView(source),
|
|
626
652
|
};
|
|
627
653
|
}
|
|
628
|
-
const
|
|
629
|
-
if (
|
|
654
|
+
const contextIndex = contexts.findIndex(({ chainId }) => chainId === input.chainId);
|
|
655
|
+
if (contextIndex < 0)
|
|
630
656
|
throw new PlayCallChainError("The model context to revise does not exist.");
|
|
631
|
-
const sourceContext =
|
|
657
|
+
const sourceContext = contexts[contextIndex];
|
|
632
658
|
if (input.continuation === "continue_context" &&
|
|
633
659
|
sourceContext.importedFromArchive)
|
|
634
660
|
throw new PlayCallChainError("This model context was imported from a backup; start a fresh context before continuing.");
|
|
635
|
-
const
|
|
636
|
-
const selectedEvent = sourceContext.events[
|
|
637
|
-
if (
|
|
661
|
+
const eventIndex = sourceContext.events.findIndex((event) => event.id === input.eventId);
|
|
662
|
+
const selectedEvent = sourceContext.events[eventIndex];
|
|
663
|
+
if (eventIndex < 0 ||
|
|
638
664
|
selectedEvent?.kind !== "player" ||
|
|
639
665
|
selectedEvent.committedHead === undefined)
|
|
640
666
|
throw new PlayCallChainError("The selected event is not a committed player message.");
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
667
|
+
const intent = {
|
|
668
|
+
schemaVersion: 1,
|
|
669
|
+
kind: "player_revision_intent",
|
|
670
|
+
operationId: input.operationId,
|
|
671
|
+
worldId: input.worldId,
|
|
672
|
+
chainId: input.chainId,
|
|
673
|
+
eventId: input.eventId,
|
|
674
|
+
replacementExchangeId: input.replacementExchangeId,
|
|
675
|
+
replacementText: input.replacementText,
|
|
676
|
+
continuation: input.continuation,
|
|
677
|
+
restoresHead: playerRevisionRestoresHead(sourceContext, eventIndex),
|
|
678
|
+
discardChainIds: contexts
|
|
679
|
+
.slice(contextIndex)
|
|
680
|
+
.map(({ chainId }) => chainId),
|
|
681
|
+
...(input.continuation === "fresh_context"
|
|
682
|
+
? { freshContext: structuredClone(input.freshContext) }
|
|
683
|
+
: {}),
|
|
684
|
+
};
|
|
685
|
+
await this.#worlds.writePlayerRevisionIntent(input.worldId, intent);
|
|
686
|
+
const revised = await this.#completePlayerRevision(intent);
|
|
687
|
+
return {
|
|
688
|
+
outcome: "revised",
|
|
689
|
+
worldId: input.worldId,
|
|
690
|
+
playCallChain: projectView(revised),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
/** Complete an interrupted revision recorded by a previous process. */
|
|
694
|
+
async #recoverPlayerRevision(worldId) {
|
|
695
|
+
await this.#worlds.operations.withExclusiveWorldStateMutation(worldId, async () => {
|
|
696
|
+
const intent = await this.#worlds.readPlayerRevisionIntent(worldId);
|
|
697
|
+
if (intent === null)
|
|
698
|
+
return;
|
|
699
|
+
if (!isPlayerRevisionIntent(intent) || intent.worldId !== worldId)
|
|
700
|
+
throw new PlayCallChainError("The interrupted player revision record is invalid.");
|
|
701
|
+
await this.#completePlayerRevision(intent);
|
|
651
702
|
});
|
|
652
703
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
704
|
+
/**
|
|
705
|
+
* Run the revision steps in order. Each step either checks whether it is
|
|
706
|
+
* already done or repeats harmlessly: rewinding to the restored endpoint is a
|
|
707
|
+
* no-op at that endpoint, the commit uses the intent's operation ID, and the
|
|
708
|
+
* context is published last, so it is the only step that changes what the
|
|
709
|
+
* next load sees.
|
|
710
|
+
*/
|
|
711
|
+
async #completePlayerRevision(intent) {
|
|
712
|
+
return this.#worlds.operations.withExclusiveWorldStateMutation(intent.worldId, () => this.#completePlayerRevisionQueued(intent));
|
|
713
|
+
}
|
|
714
|
+
async #completePlayerRevisionQueued(intent) {
|
|
715
|
+
const worldId = intent.worldId;
|
|
716
|
+
const resultChainId = revisionChainId(intent.chainId, intent.operationId, worldId, intent.continuation);
|
|
717
|
+
const current = await this.#readPersisted(worldId);
|
|
718
|
+
if (current?.chainId === resultChainId) {
|
|
719
|
+
await this.#discardRevisedContexts(intent, resultChainId);
|
|
720
|
+
await this.#worlds.clearPlayerRevisionIntent(worldId);
|
|
721
|
+
return current;
|
|
722
|
+
}
|
|
723
|
+
const contexts = await this.#worlds.playTimeline.readAllContexts(worldId);
|
|
724
|
+
const sourceIndex = contexts.findIndex(({ chainId }) => chainId === intent.chainId);
|
|
725
|
+
const sourceContext = contexts[sourceIndex];
|
|
726
|
+
if (sourceContext === undefined)
|
|
727
|
+
throw new PlayCallChainError("The model context to revise does not exist.");
|
|
728
|
+
const eventIndex = sourceContext.events.findIndex(({ id }) => id === intent.eventId);
|
|
729
|
+
if (eventIndex < 0)
|
|
730
|
+
throw new PlayCallChainError("The selected event is no longer in its model context.");
|
|
731
|
+
// Fresh-context bootstrap is compiled before any world change.
|
|
732
|
+
const fresh = intent.continuation === "fresh_context" && intent.freshContext
|
|
733
|
+
? await this.#prepareFreshPlayerRevision(worldId, intent.freshContext, intent.restoresHead, intent.replacementText)
|
|
659
734
|
: null;
|
|
660
|
-
const
|
|
661
|
-
const requestFingerprint = playerRevisionRequestFingerprint(request, input.selectedEvent.committedHead, restoresHead);
|
|
662
|
-
const traceRetention = preparePlayerRevisionTrace(sourceContext, input.selectedEventIndex);
|
|
663
|
-
const outcome = await this.#worlds.reviseTimeline({
|
|
664
|
-
operationId: request.operationId,
|
|
665
|
-
worldId: request.worldId,
|
|
666
|
-
expectedCurrentHead: input.source.parentHead,
|
|
667
|
-
restoresHead,
|
|
668
|
-
replacesHead: input.selectedEvent.committedHead,
|
|
669
|
-
replacementText: request.replacementText,
|
|
670
|
-
requestFingerprint,
|
|
671
|
-
});
|
|
735
|
+
const head = await this.#commitPlayerRevision(intent);
|
|
672
736
|
const [baseline, selected, binding] = await Promise.all([
|
|
673
|
-
this.#worlds.recoverEndpoint(
|
|
674
|
-
this.#worlds.recoverEndpoint(
|
|
675
|
-
this.#worlds.bindPlayCallChain(
|
|
737
|
+
this.#worlds.recoverEndpoint(worldId, sourceContext.baselineHead),
|
|
738
|
+
this.#worlds.recoverEndpoint(worldId, intent.restoresHead),
|
|
739
|
+
this.#worlds.bindPlayCallChain(worldId),
|
|
676
740
|
]);
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
741
|
+
if (binding.parentHead !== head)
|
|
742
|
+
throw new PlayCallChainError("The player revision was committed, but current-world materialization has not reached the new endpoint.");
|
|
743
|
+
const timelineGeneration = revisionTimelineGeneration(current?.timelineGeneration ?? "none", intent.operationId, worldId, intent.continuation);
|
|
744
|
+
const previousChainId = sourceIndex === 0 ? null : contexts[sourceIndex - 1].chainId;
|
|
745
|
+
const traceRetention = preparePlayerRevisionTrace(sourceContext, eventIndex);
|
|
746
|
+
let revised;
|
|
747
|
+
if (intent.continuation === "fresh_context") {
|
|
748
|
+
if (fresh === null || intent.freshContext === undefined)
|
|
749
|
+
throw new PlayCallChainError("The fresh model context was not prepared before committing the player revision.");
|
|
750
|
+
revised = await this.#buildFreshPlayerRevision({
|
|
751
|
+
intent,
|
|
752
|
+
freshContext: intent.freshContext,
|
|
689
753
|
sourceContext,
|
|
690
|
-
sourceContexts: input.sourceContexts,
|
|
691
|
-
selectedContextIndex: input.selectedContextIndex,
|
|
692
754
|
traceRetention,
|
|
693
755
|
baseline,
|
|
694
756
|
selected,
|
|
695
757
|
binding,
|
|
696
|
-
|
|
697
|
-
|
|
758
|
+
head,
|
|
759
|
+
chainId: resultChainId,
|
|
760
|
+
previousChainId,
|
|
698
761
|
timelineGeneration,
|
|
699
|
-
|
|
762
|
+
fresh,
|
|
700
763
|
});
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
revised = {
|
|
767
|
+
...traceRetention
|
|
768
|
+
.withReplacement({
|
|
769
|
+
head,
|
|
770
|
+
exchangeId: intent.replacementExchangeId,
|
|
771
|
+
text: intent.replacementText,
|
|
772
|
+
})
|
|
773
|
+
.atEndpoint({ baseline, selected, binding }),
|
|
774
|
+
schemaVersion: 3,
|
|
775
|
+
kind: "play_call_chain",
|
|
776
|
+
chainId: resultChainId,
|
|
777
|
+
worldId,
|
|
778
|
+
previousContexts: [],
|
|
779
|
+
previousChainId,
|
|
780
|
+
timelineGeneration,
|
|
781
|
+
parentHead: head,
|
|
782
|
+
updatedAt: Date.now(),
|
|
712
783
|
};
|
|
713
784
|
}
|
|
714
|
-
const revised = {
|
|
715
|
-
...continuedTrace.atEndpoint({ baseline, selected, binding }),
|
|
716
|
-
schemaVersion: 3,
|
|
717
|
-
kind: "play_call_chain",
|
|
718
|
-
chainId: playerRevisionChainId(sourceContext.chainId, request.operationId, request.worldId, request.continuation),
|
|
719
|
-
worldId: request.worldId,
|
|
720
|
-
previousContexts: [],
|
|
721
|
-
previousChainId: input.selectedContextIndex === 0
|
|
722
|
-
? null
|
|
723
|
-
: input.sourceContexts[input.selectedContextIndex - 1].chainId,
|
|
724
|
-
timelineGeneration,
|
|
725
|
-
parentHead: outcome.head,
|
|
726
|
-
updatedAt: Date.now(),
|
|
727
|
-
};
|
|
728
785
|
await this.#worlds.playTimeline.persist(structuredClone(revised));
|
|
729
|
-
|
|
786
|
+
if (intent.continuation === "fresh_context")
|
|
787
|
+
await this.#worlds.completeArchiveFreshContext(worldId);
|
|
788
|
+
await this.#discardRevisedContexts(intent, resultChainId);
|
|
789
|
+
const activeId = this.#worldChains.get(worldId);
|
|
730
790
|
if (activeId !== undefined)
|
|
731
791
|
this.#active.delete(activeId);
|
|
732
792
|
this.#active.delete(sourceContext.chainId);
|
|
733
|
-
this.#worldChains.delete(
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
793
|
+
this.#worldChains.delete(worldId);
|
|
794
|
+
await this.#worlds.clearPlayerRevisionIntent(worldId);
|
|
795
|
+
return revised;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Delete the segments of the edited context and every later one. Their
|
|
799
|
+
* surviving content has been copied into the revised context, so only the
|
|
800
|
+
* discarded rounds and their in-progress markers are removed from disk.
|
|
801
|
+
*/
|
|
802
|
+
async #discardRevisedContexts(intent, keepChainId) {
|
|
803
|
+
const discarded = intent.discardChainIds.filter((chainId) => chainId !== keepChainId);
|
|
804
|
+
await this.#worlds.playTimeline.discardContexts(intent.worldId, discarded);
|
|
805
|
+
for (const chainId of discarded)
|
|
806
|
+
await this.#worlds.playAdvances.discardChain(intent.worldId, chainId);
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Rewind the world to the message's logical parent and commit the replacement
|
|
810
|
+
* as an ordinary player step. Returns the committed head. A commit already
|
|
811
|
+
* made in this process is reused rather than rewound and repeated.
|
|
812
|
+
*/
|
|
813
|
+
async #commitPlayerRevision(intent) {
|
|
814
|
+
const done = await this.#worlds.getOperationOutcome(intent.operationId);
|
|
815
|
+
if (done.outcome === "committed")
|
|
816
|
+
return done.head;
|
|
817
|
+
await this.#worlds.rewindPlayerRevision(intent.worldId, intent.restoresHead);
|
|
818
|
+
await this.#artifacts?.discardAfterHead(intent.worldId, intent.restoresHead);
|
|
819
|
+
const binding = await this.#worlds.bindPlayCallChainAt(intent.worldId, intent.restoresHead);
|
|
820
|
+
const origin = captureNarrativeOrigin(WorldDocumentStore.open({
|
|
821
|
+
layout: "world_state",
|
|
822
|
+
files: Object.entries(binding.files).map(([path, contents]) => ({
|
|
823
|
+
path,
|
|
824
|
+
contents,
|
|
825
|
+
})),
|
|
826
|
+
}), intent.eventId);
|
|
827
|
+
const outcome = await this.#worlds.commitPlayStep({
|
|
828
|
+
operationId: intent.operationId,
|
|
829
|
+
worldId: intent.worldId,
|
|
830
|
+
parentHead: intent.restoresHead,
|
|
831
|
+
historyAppend: [
|
|
832
|
+
{
|
|
833
|
+
role: "player",
|
|
834
|
+
exactText: intent.replacementText,
|
|
835
|
+
...(origin.value == null
|
|
836
|
+
? {}
|
|
837
|
+
: { background: snapshotBackground(origin.value) }),
|
|
838
|
+
},
|
|
839
|
+
],
|
|
840
|
+
nextMaterials: structuredClone(binding.additionalMaterials),
|
|
841
|
+
stateChanges: [],
|
|
842
|
+
});
|
|
843
|
+
return outcome.head;
|
|
739
844
|
}
|
|
740
|
-
async #prepareFreshPlayerRevision(
|
|
741
|
-
const binding = await this.#worlds.bindPlayCallChainAt(
|
|
845
|
+
async #prepareFreshPlayerRevision(worldId, freshContext, restoresHead, replacementText) {
|
|
846
|
+
const binding = await this.#worlds.bindPlayCallChainAt(worldId, restoresHead);
|
|
742
847
|
const documents = new FileNativePlayDocuments(binding.files);
|
|
743
|
-
const documentMaintenance = await this.#bindDocumentMaintenance(documents,
|
|
848
|
+
const documentMaintenance = await this.#bindDocumentMaintenance(documents, worldId, binding.parentHead);
|
|
744
849
|
const compilation = this.#compiler.compilePlayCallChain({
|
|
745
|
-
extensionControls: await this.#worlds.extensionControls(
|
|
850
|
+
extensionControls: await this.#worlds.extensionControls(worldId, freshContext.playPreset, this.#compiler.locale),
|
|
746
851
|
endpoint: {
|
|
747
|
-
id: `${
|
|
852
|
+
id: `${worldId}:${binding.parentHead}`,
|
|
748
853
|
commit: binding.parentHead,
|
|
749
854
|
},
|
|
750
|
-
hostBinding: structuredClone(
|
|
855
|
+
hostBinding: structuredClone(freshContext.hostBinding),
|
|
751
856
|
world: {
|
|
752
857
|
controlFingerprint: fingerprintControl(binding.files),
|
|
753
858
|
documentSnapshot: documents.snapshot,
|
|
@@ -759,24 +864,20 @@ export class PlayCallChain {
|
|
|
759
864
|
documentMaintenanceUnavailableReason: documents.maintenanceWarning,
|
|
760
865
|
},
|
|
761
866
|
playerInputPlacement: "append",
|
|
762
|
-
playerInput:
|
|
763
|
-
modelBinding: structuredClone(
|
|
764
|
-
},
|
|
765
|
-
|
|
766
|
-
documents.bindBootstrap(bootstrap);
|
|
867
|
+
playerInput: replacementText,
|
|
868
|
+
modelBinding: structuredClone(freshContext.modelBinding),
|
|
869
|
+
}, freshContext.playPreset);
|
|
870
|
+
documents.bindBootstrap(compilation.bootstrap);
|
|
767
871
|
return {
|
|
768
872
|
binding,
|
|
769
|
-
bootstrap,
|
|
873
|
+
bootstrap: structuredClone(compilation.bootstrap),
|
|
770
874
|
tools: structuredClone(compilation.toolUniverse),
|
|
771
875
|
followups: structuredClone(compilation.followups ?? []),
|
|
772
|
-
authorization: documents.authorizationCheckpoint(),
|
|
773
876
|
};
|
|
774
877
|
}
|
|
775
878
|
async #buildFreshPlayerRevision(input) {
|
|
776
|
-
const {
|
|
777
|
-
let previousChainId = input.
|
|
778
|
-
? null
|
|
779
|
-
: input.sourceContexts[input.selectedContextIndex - 1].chainId;
|
|
879
|
+
const { intent, sourceContext, fresh } = input;
|
|
880
|
+
let previousChainId = input.previousChainId;
|
|
780
881
|
const retainedPrefix = input.traceRetention.pagePrefixAtEndpoint({
|
|
781
882
|
baseline: input.baseline,
|
|
782
883
|
selected: input.selected,
|
|
@@ -787,38 +888,37 @@ export class PlayCallChain {
|
|
|
787
888
|
...retainedPrefix,
|
|
788
889
|
schemaVersion: 3,
|
|
789
890
|
kind: "play_call_chain",
|
|
790
|
-
chainId: derivedChainId(sourceContext.chainId, "timeline-revision-prefix:" +
|
|
791
|
-
worldId:
|
|
891
|
+
chainId: derivedChainId(sourceContext.chainId, "timeline-revision-prefix:" + intent.operationId, intent.worldId),
|
|
892
|
+
worldId: intent.worldId,
|
|
792
893
|
previousContexts: [],
|
|
793
894
|
previousChainId,
|
|
794
895
|
timelineGeneration: input.timelineGeneration,
|
|
795
|
-
parentHead:
|
|
896
|
+
parentHead: intent.restoresHead,
|
|
796
897
|
updatedAt: Date.now(),
|
|
797
898
|
};
|
|
798
899
|
await this.#worlds.playTimeline.persistDetached(structuredClone(prefix));
|
|
799
900
|
previousChainId = prefix.chainId;
|
|
800
901
|
}
|
|
801
|
-
const fresh = input.freshPreparation;
|
|
802
902
|
return {
|
|
803
903
|
schemaVersion: 3,
|
|
804
904
|
kind: "play_call_chain",
|
|
805
|
-
chainId:
|
|
806
|
-
worldId:
|
|
905
|
+
chainId: input.chainId,
|
|
906
|
+
worldId: intent.worldId,
|
|
807
907
|
previousContexts: [],
|
|
808
908
|
previousChainId,
|
|
809
909
|
timelineGeneration: input.timelineGeneration,
|
|
810
|
-
baselineHead:
|
|
910
|
+
baselineHead: intent.restoresHead,
|
|
811
911
|
baselineHistoryLength: Object.keys(fresh.binding.history).length,
|
|
812
|
-
parentHead: input.
|
|
912
|
+
parentHead: input.head,
|
|
813
913
|
playPreset: {
|
|
814
|
-
id:
|
|
815
|
-
name:
|
|
816
|
-
revision:
|
|
914
|
+
id: input.freshContext.playPreset.id,
|
|
915
|
+
name: input.freshContext.playPreset.name,
|
|
916
|
+
revision: input.freshContext.playPreset.revision,
|
|
817
917
|
},
|
|
818
918
|
followups: structuredClone(fresh.followups),
|
|
819
|
-
playPresetScriptsEnabled:
|
|
820
|
-
presetFiles: structuredClone(
|
|
821
|
-
modelBinding: structuredClone(
|
|
919
|
+
playPresetScriptsEnabled: input.freshContext.playPreset.scriptsEnabled,
|
|
920
|
+
presetFiles: structuredClone(input.freshContext.playPreset.files),
|
|
921
|
+
modelBinding: structuredClone(input.freshContext.modelBinding),
|
|
822
922
|
status: "ready",
|
|
823
923
|
canRetry: false,
|
|
824
924
|
bootstrap: structuredClone(fresh.bootstrap),
|
|
@@ -827,24 +927,18 @@ export class PlayCallChain {
|
|
|
827
927
|
layout: "world_state",
|
|
828
928
|
files: Object.entries(input.binding.files).map(([path, contents]) => ({ path, contents })),
|
|
829
929
|
}), 1),
|
|
830
|
-
transcript: playerInputAppend(
|
|
930
|
+
transcript: playerInputAppend(intent.replacementText),
|
|
831
931
|
events: [
|
|
832
932
|
{
|
|
833
933
|
id: 1,
|
|
834
934
|
kind: "player",
|
|
835
|
-
exchangeId:
|
|
836
|
-
text:
|
|
935
|
+
exchangeId: intent.replacementExchangeId,
|
|
936
|
+
text: intent.replacementText,
|
|
837
937
|
context: "fresh",
|
|
838
|
-
committedHead: input.
|
|
938
|
+
committedHead: input.head,
|
|
839
939
|
},
|
|
840
940
|
],
|
|
841
941
|
completedTools: [],
|
|
842
|
-
documentAuthorizationCheckpoints: [
|
|
843
|
-
{
|
|
844
|
-
afterEventId: 0,
|
|
845
|
-
authorization: structuredClone(fresh.authorization),
|
|
846
|
-
},
|
|
847
|
-
],
|
|
848
942
|
changedDocuments: [],
|
|
849
943
|
nextMaterials: structuredClone(input.binding.additionalMaterials),
|
|
850
944
|
nextEventId: 2,
|
|
@@ -948,28 +1042,24 @@ export class PlayCallChain {
|
|
|
948
1042
|
? { retainCompleteContext: true }
|
|
949
1043
|
: {}),
|
|
950
1044
|
});
|
|
951
|
-
if (input.targetWorldRoot !== undefined) {
|
|
952
|
-
const retainedContexts = [
|
|
953
|
-
...input.sourceContexts.slice(0, input.selectedContextIndex),
|
|
954
|
-
{ ...sourceContext, events: input.sourceEvents },
|
|
955
|
-
];
|
|
956
|
-
await this.#artifacts?.stageFork?.({
|
|
957
|
-
sourceWorldId: input.sourceWorldId,
|
|
958
|
-
targetWorldId: input.targetWorldId,
|
|
959
|
-
targetWorldRoot: input.targetWorldRoot,
|
|
960
|
-
timeline: retainedContexts.flatMap((context) => context.events.flatMap((event) => event.kind === "assistant" && event.committedHead !== undefined
|
|
961
|
-
? [
|
|
962
|
-
{
|
|
963
|
-
contextId: context.continuityContextId ?? context.chainId,
|
|
964
|
-
eventId: event.id,
|
|
965
|
-
head: event.committedHead,
|
|
966
|
-
},
|
|
967
|
-
]
|
|
968
|
-
: [])),
|
|
969
|
-
});
|
|
970
|
-
}
|
|
971
1045
|
return projectView(derived);
|
|
972
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Stops every in-flight model request for a world and waits until each has
|
|
1049
|
+
* left the active table, so deletion never races a play write.
|
|
1050
|
+
*/
|
|
1051
|
+
async abortWorld(worldId) {
|
|
1052
|
+
validateIdentity(worldId, "world ID");
|
|
1053
|
+
const invocations = [...this.#activeInvocations.values()].filter((invocation) => invocation.worldId === worldId);
|
|
1054
|
+
for (const invocation of invocations) {
|
|
1055
|
+
if (invocation.abortable && !invocation.controller.signal.aborted) {
|
|
1056
|
+
invocation.phase = "cancelling";
|
|
1057
|
+
invocation.lastActivityAt = Date.now();
|
|
1058
|
+
invocation.controller.abort();
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
await Promise.all(invocations.map((invocation) => this.#invocationSettled.get(invocation.chainId) ?? Promise.resolve()));
|
|
1062
|
+
}
|
|
973
1063
|
forgetWorld(worldId) {
|
|
974
1064
|
const activeId = this.#worldChains.get(worldId);
|
|
975
1065
|
const active = activeId === undefined ? undefined : this.#active.get(activeId);
|
|
@@ -978,6 +1068,7 @@ export class PlayCallChain {
|
|
|
978
1068
|
if (activeId !== undefined)
|
|
979
1069
|
this.#active.delete(activeId);
|
|
980
1070
|
this.#worldChains.delete(worldId);
|
|
1071
|
+
this.#hydrating.delete(worldId);
|
|
981
1072
|
}
|
|
982
1073
|
async #captureOrigin(session) {
|
|
983
1074
|
const binding = await this.#worlds.bindPlayCallChain(session.worldId);
|
|
@@ -1029,14 +1120,6 @@ export class PlayCallChain {
|
|
|
1029
1120
|
await this.#settlePlayerAdvance(session, advance);
|
|
1030
1121
|
}
|
|
1031
1122
|
catch (error) {
|
|
1032
|
-
const accepted = await this.#worlds
|
|
1033
|
-
.getOperationOutcome(advance.operationId)
|
|
1034
|
-
.catch(() => ({ outcome: "not_started" }));
|
|
1035
|
-
if (accepted.outcome === "committed" ||
|
|
1036
|
-
accepted.outcome === "committed_materialization_pending") {
|
|
1037
|
-
await this.#settlePlayerAdvance(session, advance);
|
|
1038
|
-
return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
|
|
1039
|
-
}
|
|
1040
1123
|
const message = error instanceof Error
|
|
1041
1124
|
? error.message
|
|
1042
1125
|
: "Failed to write the original player text.";
|
|
@@ -1052,7 +1135,7 @@ export class PlayCallChain {
|
|
|
1052
1135
|
session.lastFailure = message;
|
|
1053
1136
|
session.lastFailureKind = failureKind;
|
|
1054
1137
|
await this.#persist(session);
|
|
1055
|
-
await this.#worlds.playAdvances.markSettled(advance
|
|
1138
|
+
await this.#worlds.playAdvances.markSettled(advance);
|
|
1056
1139
|
throw new PlayCallChainError(message);
|
|
1057
1140
|
}
|
|
1058
1141
|
return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
|
|
@@ -1116,8 +1199,7 @@ export class PlayCallChain {
|
|
|
1116
1199
|
"The player message is committed; the frozen model request is ready to continue.";
|
|
1117
1200
|
session.lastFailureKind = "retryable";
|
|
1118
1201
|
await this.#persist(session);
|
|
1119
|
-
|
|
1120
|
-
await this.#worlds.playAdvances.markSettled(advance, outcome.head);
|
|
1202
|
+
await this.#worlds.playAdvances.markSettled(advance);
|
|
1121
1203
|
}
|
|
1122
1204
|
async #prepareResponseSettlement(session, advance, response) {
|
|
1123
1205
|
const existing = session.events.find(({ id }) => id === advance.eventId);
|
|
@@ -1152,8 +1234,13 @@ export class PlayCallChain {
|
|
|
1152
1234
|
const prepared = [];
|
|
1153
1235
|
const trailingEvents = [];
|
|
1154
1236
|
let nextEventId = advance.eventId + 1;
|
|
1237
|
+
// Fresh in-memory seed per settlement. It is never persisted: a crashed
|
|
1238
|
+
// exchange is retried by the player and re-rolls (ADR-0044).
|
|
1239
|
+
const resolutionSeed = calls.some(({ name }) => isResolutionTool(name))
|
|
1240
|
+
? randomBytes(32).toString("hex")
|
|
1241
|
+
: undefined;
|
|
1155
1242
|
for (const call of calls) {
|
|
1156
|
-
const item = prepareTool(session, workingTools, advance.exchange, call, this.#compiler.locale);
|
|
1243
|
+
const item = prepareTool(session, workingTools, advance.exchange, call, this.#compiler.locale, resolutionSeed);
|
|
1157
1244
|
prepared.push(item);
|
|
1158
1245
|
trailingEvents.push({
|
|
1159
1246
|
id: nextEventId++,
|
|
@@ -1268,7 +1355,6 @@ export class PlayCallChain {
|
|
|
1268
1355
|
: []),
|
|
1269
1356
|
],
|
|
1270
1357
|
completedTools: [...workingTools.values()].map((item) => structuredClone(item)),
|
|
1271
|
-
authorizationCheckpoint: session.documents.authorizationCheckpoint(),
|
|
1272
1358
|
stateChanges: structuredClone(stateChanges),
|
|
1273
1359
|
visibleText,
|
|
1274
1360
|
...(narrativeCheckpoint === undefined ? {} : { narrativeCheckpoint }),
|
|
@@ -1329,7 +1415,6 @@ export class PlayCallChain {
|
|
|
1329
1415
|
historyMessageId: `message.${outcome.head.slice("commit:".length)}.1.narrator`,
|
|
1330
1416
|
};
|
|
1331
1417
|
}
|
|
1332
|
-
crashAtPlayAdvanceEdge("after_authority_accepted");
|
|
1333
1418
|
appendCommittedHistory(session, outcome.head, outcome.historyAppend);
|
|
1334
1419
|
}
|
|
1335
1420
|
finalizePreparedReceipts(settlement);
|
|
@@ -1351,29 +1436,10 @@ export class PlayCallChain {
|
|
|
1351
1436
|
item.key,
|
|
1352
1437
|
structuredClone(item),
|
|
1353
1438
|
]));
|
|
1354
|
-
const lastToolEventId = settlement.trailingEvents.findLast(({ kind }) => kind === "tool_result")?.id;
|
|
1355
|
-
if (lastToolEventId !== undefined) {
|
|
1356
|
-
session.documentAuthorizationCheckpoints =
|
|
1357
|
-
session.documentAuthorizationCheckpoints.filter(({ afterEventId }) => afterEventId < advance.eventId);
|
|
1358
|
-
session.documentAuthorizationCheckpoints.push({
|
|
1359
|
-
afterEventId: lastToolEventId,
|
|
1360
|
-
authorization: structuredClone(settlement.authorizationCheckpoint),
|
|
1361
|
-
});
|
|
1362
|
-
}
|
|
1363
1439
|
if (committedHead !== undefined)
|
|
1364
1440
|
session.parentHead = committedHead;
|
|
1365
1441
|
if (settlement.stateChanges.length > 0) {
|
|
1366
|
-
|
|
1367
|
-
if (currentAuthorization.stateFingerprint ===
|
|
1368
|
-
settlement.authorizationCheckpoint.stateFingerprint)
|
|
1369
|
-
session.documents.acceptCommittedState();
|
|
1370
|
-
else {
|
|
1371
|
-
const binding = await this.#worlds.bindPlayCallChain(session.worldId);
|
|
1372
|
-
session.documents = new FileNativePlayDocuments(binding.files);
|
|
1373
|
-
session.documents.restoreAuthorizationCheckpoint(settlement.authorizationCheckpoint);
|
|
1374
|
-
session.history = historyEntries(binding.history, binding.historyBackgrounds);
|
|
1375
|
-
session.nextMaterials = structuredClone(binding.additionalMaterials);
|
|
1376
|
-
}
|
|
1442
|
+
session.documents.acceptCommittedState();
|
|
1377
1443
|
mergeChangedDocuments(session, settlement.stateChanges);
|
|
1378
1444
|
}
|
|
1379
1445
|
if (settlement.stateChanges.length > 0)
|
|
@@ -1426,24 +1492,9 @@ export class PlayCallChain {
|
|
|
1426
1492
|
result = { kind: "terminal", status: session.status };
|
|
1427
1493
|
}
|
|
1428
1494
|
await this.#persist(session);
|
|
1429
|
-
|
|
1430
|
-
await this.#worlds.playAdvances.markSettled(advance, committedHead ?? session.parentHead);
|
|
1495
|
+
await this.#worlds.playAdvances.markSettled(advance);
|
|
1431
1496
|
return result;
|
|
1432
1497
|
}
|
|
1433
|
-
async #recoverAcceptedResponseAdvance(session, advance) {
|
|
1434
|
-
const outcome = await this.#worlds
|
|
1435
|
-
.getOperationOutcome(advance.operationId)
|
|
1436
|
-
.catch(() => ({ outcome: "not_started" }));
|
|
1437
|
-
if (outcome.outcome !== "committed" &&
|
|
1438
|
-
outcome.outcome !== "committed_materialization_pending")
|
|
1439
|
-
return null;
|
|
1440
|
-
const current = await this.#worlds.playAdvances.readCurrent(advance.worldId, advance.chainId);
|
|
1441
|
-
if (current?.base.advanceKind !== "response" ||
|
|
1442
|
-
current.base.advanceId !== advance.advanceId ||
|
|
1443
|
-
current.settlementPrepared === null)
|
|
1444
|
-
throw new PlayCallChainError("Authority accepted a response whose durable settlement is missing.");
|
|
1445
|
-
return this.#settleResponseAdvance(session, advance, current.settlementPrepared.settlement);
|
|
1446
|
-
}
|
|
1447
1498
|
async #dispatch(session, modelHost, firstRequest, firstAttempt, signal, observer) {
|
|
1448
1499
|
let request = modelHost.freezeRequest?.(firstRequest) ?? structuredClone(firstRequest);
|
|
1449
1500
|
let attempt = firstAttempt;
|
|
@@ -1489,7 +1540,6 @@ export class PlayCallChain {
|
|
|
1489
1540
|
createdAt: Date.now(),
|
|
1490
1541
|
};
|
|
1491
1542
|
await this.#worlds.playAdvances.begin(advance);
|
|
1492
|
-
crashAtPlayAdvanceEdge("after_response_advance_began");
|
|
1493
1543
|
this.#notifySnapshot(observer, session);
|
|
1494
1544
|
if (signal.aborted)
|
|
1495
1545
|
return this.#cancelBeforeProviderDispatch(session, request, attempt, observer, advance);
|
|
@@ -1573,17 +1623,13 @@ export class PlayCallChain {
|
|
|
1573
1623
|
});
|
|
1574
1624
|
}
|
|
1575
1625
|
await this.#persist(session);
|
|
1576
|
-
await this.#worlds.playAdvances.markSettled(advance
|
|
1626
|
+
await this.#worlds.playAdvances.markSettled(advance);
|
|
1577
1627
|
this.#notifySnapshot(observer, session);
|
|
1578
1628
|
return projectView(session);
|
|
1579
1629
|
}
|
|
1580
1630
|
const durableResponse = durableModelResponse(response);
|
|
1581
1631
|
try {
|
|
1582
|
-
await this.#worlds.playAdvances.recordProviderCompleted(advance, durableResponse);
|
|
1583
|
-
crashAtPlayAdvanceEdge("after_provider_completed");
|
|
1584
1632
|
const settlement = await this.#prepareResponseSettlement(session, advance, durableResponse);
|
|
1585
|
-
await this.#worlds.playAdvances.recordSettlementPrepared(advance, settlement);
|
|
1586
|
-
crashAtPlayAdvanceEdge("after_settlement_prepared");
|
|
1587
1633
|
const failedTools = settlement.trailingEvents.filter((candidate) => candidate.kind === "tool_result" && !candidate.ok);
|
|
1588
1634
|
if (failedTools.length > 0 && response.diagnostics !== undefined)
|
|
1589
1635
|
await this.#failureLog?.recordFailure({
|
|
@@ -1665,17 +1711,6 @@ export class PlayCallChain {
|
|
|
1665
1711
|
attempt = 1;
|
|
1666
1712
|
}
|
|
1667
1713
|
catch (error) {
|
|
1668
|
-
const recovered = await this.#recoverAcceptedResponseAdvance(session, advance);
|
|
1669
|
-
if (recovered !== null) {
|
|
1670
|
-
if (recovered.kind === "continue") {
|
|
1671
|
-
request = recovered.nextRequest;
|
|
1672
|
-
attempt = 1;
|
|
1673
|
-
this.#notifySnapshot(observer, session);
|
|
1674
|
-
continue;
|
|
1675
|
-
}
|
|
1676
|
-
this.#notifySnapshot(observer, session);
|
|
1677
|
-
return projectView(session);
|
|
1678
|
-
}
|
|
1679
1714
|
const message = error instanceof Error
|
|
1680
1715
|
? error.message
|
|
1681
1716
|
: "Call-chain processing failed.";
|
|
@@ -1726,7 +1761,7 @@ export class PlayCallChain {
|
|
|
1726
1761
|
delete session.lastFailureKind;
|
|
1727
1762
|
await this.#persist(session);
|
|
1728
1763
|
if (advance !== undefined)
|
|
1729
|
-
await this.#worlds.playAdvances.markSettled(advance
|
|
1764
|
+
await this.#worlds.playAdvances.markSettled(advance);
|
|
1730
1765
|
this.#notifySnapshot(observer, session);
|
|
1731
1766
|
return projectView(session);
|
|
1732
1767
|
}
|
|
@@ -1845,16 +1880,9 @@ export class PlayCallChain {
|
|
|
1845
1880
|
await this.#reconcileSessionAdvance(active);
|
|
1846
1881
|
return active;
|
|
1847
1882
|
}
|
|
1848
|
-
const
|
|
1849
|
-
if (
|
|
1883
|
+
const session = await this.#loadWorldSession(worldId);
|
|
1884
|
+
if (session?.chainId !== chainId)
|
|
1850
1885
|
throw new PlayCallChainError("The call chain does not exist; start from the latest world state.");
|
|
1851
|
-
const advance = await this.#worlds.playAdvances.readCurrent(worldId, chainId);
|
|
1852
|
-
const session = await this.#hydrateSession(persisted, advance);
|
|
1853
|
-
this.#active.set(chainId, session);
|
|
1854
|
-
this.#worldChains.set(worldId, chainId);
|
|
1855
|
-
await this.#reconcileSessionAdvance(session, advance);
|
|
1856
|
-
if (session.status === "running")
|
|
1857
|
-
await this.#interruptAbandonedDispatch(session);
|
|
1858
1886
|
const currentHead = await this.#worlds.currentHead(worldId);
|
|
1859
1887
|
if (currentHead !== session.parentHead)
|
|
1860
1888
|
throw new PlayCallChainError("The call-chain record differs from the current world endpoint; use a fresh context.");
|
|
@@ -1872,27 +1900,15 @@ export class PlayCallChain {
|
|
|
1872
1900
|
const currentBinding = await this.#worlds.bindPlayCallChain(persisted.worldId);
|
|
1873
1901
|
// A completed chain still describes its own immutable endpoint after a
|
|
1874
1902
|
// world revision. Restore its reads against that state, not the revised
|
|
1875
|
-
// current tree. An
|
|
1876
|
-
//
|
|
1877
|
-
const binding = currentBinding.parentHead !== persisted.parentHead &&
|
|
1878
|
-
advance?.settled !== null
|
|
1903
|
+
// current tree. An in-progress advance reads the current tree; it is
|
|
1904
|
+
// interrupted on load and retried against that tree.
|
|
1905
|
+
const binding = currentBinding.parentHead !== persisted.parentHead && advance === null
|
|
1879
1906
|
? await this.#worlds.bindPlayCallChainAt(persisted.worldId, persisted.parentHead)
|
|
1880
1907
|
: currentBinding;
|
|
1881
|
-
|
|
1882
|
-
try {
|
|
1883
|
-
documents = restorePlayDocuments(binding.files, persisted, persisted.events);
|
|
1884
|
-
}
|
|
1885
|
-
catch (error) {
|
|
1886
|
-
const prepared = advance?.settlementPrepared?.settlement;
|
|
1887
|
-
if (prepared === undefined)
|
|
1888
|
-
throw error;
|
|
1889
|
-
documents = new FileNativePlayDocuments(binding.files);
|
|
1890
|
-
documents.restoreAuthorizationCheckpoint(prepared.authorizationCheckpoint);
|
|
1891
|
-
}
|
|
1908
|
+
const documents = restorePlayDocuments(binding.files, persisted, persisted.events);
|
|
1892
1909
|
await this.#bindDocumentMaintenance(documents, persisted.worldId, binding.parentHead);
|
|
1893
1910
|
return {
|
|
1894
1911
|
...structuredClone(persisted),
|
|
1895
|
-
documentAuthorizationCheckpoints: persisted.documentAuthorizationCheckpoints?.map((checkpoint) => structuredClone(checkpoint)) ?? [],
|
|
1896
1912
|
nextMaterials: structuredClone(binding.additionalMaterials),
|
|
1897
1913
|
documents,
|
|
1898
1914
|
history: historyEntries(binding.history, binding.historyBackgrounds),
|
|
@@ -1905,49 +1921,45 @@ export class PlayCallChain {
|
|
|
1905
1921
|
eventCount: 0,
|
|
1906
1922
|
transcriptCount: 0,
|
|
1907
1923
|
completedToolCount: 0,
|
|
1908
|
-
authorizationCheckpointCount: 0,
|
|
1909
1924
|
},
|
|
1910
1925
|
};
|
|
1911
1926
|
}
|
|
1927
|
+
/**
|
|
1928
|
+
* An in-progress advance, or a session still marked running, means a model
|
|
1929
|
+
* dispatch or settlement was cut off by a process exit. Nothing is replayed:
|
|
1930
|
+
* the exchange is interrupted and the player retries (ADR-0044).
|
|
1931
|
+
*/
|
|
1912
1932
|
async #reconcileSessionAdvance(session, loaded) {
|
|
1913
|
-
const
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
if (current.base.advanceKind === "player") {
|
|
1918
|
-
await this.#settlePlayerAdvance(session, current.base);
|
|
1919
|
-
return;
|
|
1920
|
-
}
|
|
1921
|
-
if (current.providerCompleted === null) {
|
|
1922
|
-
await this.#interruptAbandonedDispatch(session, current.base);
|
|
1923
|
-
await this.#worlds.playAdvances.markSettled(current.base, session.parentHead);
|
|
1933
|
+
const advance = loaded === undefined
|
|
1934
|
+
? await this.#worlds.playAdvances.readCurrent(session.worldId, session.chainId)
|
|
1935
|
+
: loaded;
|
|
1936
|
+
if (advance === null && session.status !== "running")
|
|
1924
1937
|
return;
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
settlement = await this.#prepareResponseSettlement(session, current.base, current.providerCompleted.response);
|
|
1929
|
-
await this.#worlds.playAdvances.recordSettlementPrepared(current.base, settlement);
|
|
1930
|
-
}
|
|
1931
|
-
await this.#settleResponseAdvance(session, current.base, settlement);
|
|
1938
|
+
await this.#interruptAbandonedDispatch(session, advance);
|
|
1939
|
+
if (advance !== null)
|
|
1940
|
+
await this.#worlds.playAdvances.markSettled(advance);
|
|
1932
1941
|
}
|
|
1933
1942
|
async #interruptAbandonedDispatch(session, advance) {
|
|
1934
|
-
const
|
|
1935
|
-
|
|
1936
|
-
|
|
1943
|
+
const headMoved = (await this.#worlds.currentHead(session.worldId)) !== session.parentHead;
|
|
1944
|
+
const playerPhase = advance?.advanceKind === "player";
|
|
1945
|
+
const streaming = playerPhase
|
|
1946
|
+
? undefined
|
|
1947
|
+
: session.events.findLast((event) => event.kind === "assistant" &&
|
|
1948
|
+
event.status === "streaming" &&
|
|
1949
|
+
(advance === null || event.id === advance.eventId));
|
|
1937
1950
|
if (streaming !== undefined)
|
|
1938
1951
|
streaming.status = "interrupted";
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
const
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
: "other";
|
|
1952
|
+
// Authority already moved past the persisted parent head: the world
|
|
1953
|
+
// cannot be replayed onto that head, so a fresh context is required.
|
|
1954
|
+
const canRetry = !headMoved && !playerPhase && session.lastRequest !== null;
|
|
1955
|
+
const message = headMoved
|
|
1956
|
+
? "The service stopped after the world was updated; use a fresh context."
|
|
1957
|
+
: playerPhase
|
|
1958
|
+
? "The service stopped while saving the player message; use a fresh context."
|
|
1959
|
+
: canRetry
|
|
1960
|
+
? "The service stopped while the model request was in progress; retry to call the model again."
|
|
1961
|
+
: "The service stopped before preparing a model request; use a fresh context.";
|
|
1962
|
+
const failureKind = canRetry ? "retryable" : "other";
|
|
1951
1963
|
session.events.push({
|
|
1952
1964
|
id: session.nextEventId++,
|
|
1953
1965
|
kind: "failure",
|
|
@@ -2068,7 +2080,7 @@ function appendCommittedHistory(session, head, messages) {
|
|
|
2068
2080
|
});
|
|
2069
2081
|
}
|
|
2070
2082
|
}
|
|
2071
|
-
function prepareTool(session, working, exchange, call, locale) {
|
|
2083
|
+
function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
|
|
2072
2084
|
const key = `${exchange}:${call.id}`;
|
|
2073
2085
|
const signature = JSON.stringify(call.arguments);
|
|
2074
2086
|
const completed = working.get(key);
|
|
@@ -2095,8 +2107,9 @@ function prepareTool(session, working, exchange, call, locale) {
|
|
|
2095
2107
|
}
|
|
2096
2108
|
else {
|
|
2097
2109
|
try {
|
|
2098
|
-
result =
|
|
2099
|
-
call.name
|
|
2110
|
+
result = isResolutionTool(call.name)
|
|
2111
|
+
? resolutionToolResult(call.name, call.arguments, resolutionSeed, key, locale)
|
|
2112
|
+
: call.name === "world_checkpoint"
|
|
2100
2113
|
? typeof call.arguments === "object" &&
|
|
2101
2114
|
call.arguments !== null &&
|
|
2102
2115
|
!Array.isArray(call.arguments) &&
|
|
@@ -2139,6 +2152,9 @@ function prepareTool(session, working, exchange, call, locale) {
|
|
|
2139
2152
|
name: call.name,
|
|
2140
2153
|
ok: result.ok,
|
|
2141
2154
|
markdown: result.markdown,
|
|
2155
|
+
...(result.resolution === undefined
|
|
2156
|
+
? {}
|
|
2157
|
+
: { resolution: structuredClone(result.resolution) }),
|
|
2142
2158
|
replayed,
|
|
2143
2159
|
};
|
|
2144
2160
|
return {
|
|
@@ -2330,31 +2346,10 @@ function playerRevisionRestoresHead(source, selectedEventIndex) {
|
|
|
2330
2346
|
restoresHead = event.committedHead;
|
|
2331
2347
|
return restoresHead;
|
|
2332
2348
|
}
|
|
2333
|
-
function
|
|
2334
|
-
const base = {
|
|
2335
|
-
schema: "narraeon.timeline-revision-request/v1",
|
|
2336
|
-
chainId: request.chainId,
|
|
2337
|
-
eventId: request.eventId,
|
|
2338
|
-
selectedHead,
|
|
2339
|
-
restoresHead,
|
|
2340
|
-
replacementExchangeId: request.replacementExchangeId,
|
|
2341
|
-
replacementText: request.replacementText,
|
|
2342
|
-
};
|
|
2343
|
-
// Keep the released continuation fingerprint byte-for-byte recoverable;
|
|
2344
|
-
// the fresh strategy owns a new request shape with an explicit mode.
|
|
2345
|
-
const value = request.continuation === "continue_context"
|
|
2346
|
-
? base
|
|
2347
|
-
: {
|
|
2348
|
-
...base,
|
|
2349
|
-
schema: "narraeon.timeline-revision-request/v2",
|
|
2350
|
-
continuation: request.continuation,
|
|
2351
|
-
};
|
|
2352
|
-
return ("sha256:" + createHash("sha256").update(JSON.stringify(value)).digest("hex"));
|
|
2353
|
-
}
|
|
2354
|
-
function playerRevisionChainId(sourceChainId, operationId, worldId, continuation) {
|
|
2349
|
+
function revisionChainId(sourceChainId, operationId, worldId, continuation) {
|
|
2355
2350
|
return derivedChainId(sourceChainId, `${continuation === "fresh_context" ? "timeline-revision-fresh" : "timeline-revision"}:${operationId}`, worldId);
|
|
2356
2351
|
}
|
|
2357
|
-
function
|
|
2352
|
+
function revisionTimelineGeneration(sourceGeneration, operationId, worldId, continuation) {
|
|
2358
2353
|
return `timeline:${createHash("sha256")
|
|
2359
2354
|
.update(JSON.stringify({
|
|
2360
2355
|
schema: "narraeon.player-revision-timeline-generation/v1",
|
|
@@ -2409,10 +2404,15 @@ function notifyAssistantDelta(observer, delta) {
|
|
|
2409
2404
|
// Like snapshot projection, the Provider exchange outlives its browser stream.
|
|
2410
2405
|
}
|
|
2411
2406
|
}
|
|
2412
|
-
function
|
|
2413
|
-
if (
|
|
2414
|
-
|
|
2415
|
-
|
|
2407
|
+
function resolutionToolResult(name, args, seed, identity, locale) {
|
|
2408
|
+
if (seed === undefined)
|
|
2409
|
+
throw new Error("The resolution seed for this advance is unavailable.");
|
|
2410
|
+
const resolution = resolveTabletop(name, args, seededRandom(seed, identity));
|
|
2411
|
+
return {
|
|
2412
|
+
ok: true,
|
|
2413
|
+
markdown: resolutionMarkdown(resolution, locale),
|
|
2414
|
+
resolution,
|
|
2415
|
+
};
|
|
2416
2416
|
}
|
|
2417
2417
|
function frozenFollowupPresentations(session) {
|
|
2418
2418
|
const files = session.promptRuns?.at(-1)?.presetFiles ?? session.presetFiles;
|