narraeon 0.7.0 → 0.7.2
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/dist/node/runtime/V1Runtime.js +93 -69
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +59 -12
- package/dist/node/runtime/config/dataRootCleanup.js +52 -0
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +236 -26
- package/dist/node/runtime/play/PlayCallChain.js +172 -54
- package/dist/node/runtime/play/PlayPromptRun.js +157 -3
- package/dist/node/runtime/play/PlayTraceRetention.js +2 -2
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +5 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +10 -4
- package/dist/node/runtime/world/WorldLedgerAuthority.js +13 -10
- package/dist/node/runtime/world/WorldOperationCoordinator.js +4 -0
- package/dist/node/runtime/world/WorldQueue.js +14 -1
- package/dist/node/runtime/world/WorldTurnLedger.js +48 -20
- package/dist/node/server/createServer.js +3 -1
- package/dist/node/server/startWebServer.js +2 -5
- package/dist/web/assets/index-DkamBH6c.js +1408 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/node/runtime/config/dataRootLock.js +0 -108
- package/dist/web/assets/index-Ro7dnSsC.js +0 -1408
|
@@ -6,7 +6,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
6
6
|
import { hasLegacyHistoryTools } from "../prompt/FileNativeToolRegistry.js";
|
|
7
7
|
import { PlayCallChainError } from "./PlayCallChainError.js";
|
|
8
8
|
import { preparePlayerRevisionTrace, prepareForkTrace, restorePlayDocuments, independentContextCopy, } from "./PlayTraceRetention.js";
|
|
9
|
-
import { currentPlayPrompt, recordedPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
|
|
9
|
+
import { cloneSharingPromptRuns, currentPlayPrompt, recordedPlayPrompt, playPromptRunsThroughEvents, promptRunStrings, sharePromptRun, } from "./PlayPromptRun.js";
|
|
10
10
|
import { comparePromptPrefixes } from "../prompt/WorldPromptDiagnostics.js";
|
|
11
11
|
import { createHash, randomBytes } from "node:crypto";
|
|
12
12
|
import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepNotice, turnGuideNotice, } from "./PlayContinuity.js";
|
|
@@ -52,6 +52,12 @@ function isPlayerRevisionIntent(value) {
|
|
|
52
52
|
(record.continuation === "continue_context" ||
|
|
53
53
|
typeof record.freshContext === "object"));
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Play sessions kept in memory at once, one per most recently used world. An
|
|
57
|
+
* idle session beyond this is released and reloaded from disk on next use,
|
|
58
|
+
* which behaves like a process restart (ADR-0044). Not a user setting.
|
|
59
|
+
*/
|
|
60
|
+
const residentWorldLimit = 3;
|
|
55
61
|
export { PlayCallChainError } from "./PlayCallChainError.js";
|
|
56
62
|
/**
|
|
57
63
|
* The production play loop: one durable logical context, no Runtime-authored
|
|
@@ -74,6 +80,8 @@ export class PlayCallChain {
|
|
|
74
80
|
#activeInvocations = new Map();
|
|
75
81
|
/** Resolves when the matching invocation has left the active table. */
|
|
76
82
|
#invocationSettled = new Map();
|
|
83
|
+
/** Worlds whose release is queued in their WorldQueue slot. */
|
|
84
|
+
#releasing = new Set();
|
|
77
85
|
constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog, changed = () => {
|
|
78
86
|
/* No observers attached. */
|
|
79
87
|
}) {
|
|
@@ -91,7 +99,12 @@ export class PlayCallChain {
|
|
|
91
99
|
validateIdentity(input.exchangeId, "exchange ID");
|
|
92
100
|
validatePlayerText(input.playerText);
|
|
93
101
|
assertModelHostBinding(input.modelBinding, input.modelHost);
|
|
94
|
-
|
|
102
|
+
// Only a resubmission to the current context needs that context loaded.
|
|
103
|
+
await this.#worlds.ensureCurrentStorage(input.worldId);
|
|
104
|
+
const outline = await this.#worlds.playTimeline.readTimelineOutline(input.worldId);
|
|
105
|
+
const existing = outline?.chainIds.at(-1) === input.chainId
|
|
106
|
+
? await this.#readPersisted(input.worldId)
|
|
107
|
+
: null;
|
|
95
108
|
if (existing?.chainId === input.chainId) {
|
|
96
109
|
const duplicate = duplicatePlayerExchange(existing, input.exchangeId, input.playerText, "fresh");
|
|
97
110
|
if (duplicate)
|
|
@@ -136,9 +149,8 @@ export class PlayCallChain {
|
|
|
136
149
|
continuityContextId: input.chainId,
|
|
137
150
|
worldId: input.worldId,
|
|
138
151
|
previousContexts: [],
|
|
139
|
-
previousChainId:
|
|
140
|
-
timelineGeneration:
|
|
141
|
-
this.#worlds.playTimeline.newGeneration(),
|
|
152
|
+
previousChainId: outline?.chainIds.at(-1) ?? null,
|
|
153
|
+
timelineGeneration: outline?.generation ?? this.#worlds.playTimeline.newGeneration(),
|
|
142
154
|
baselineHead: binding.parentHead,
|
|
143
155
|
baselineHistoryLength: Object.keys(binding.history).length,
|
|
144
156
|
parentHead: binding.parentHead,
|
|
@@ -178,8 +190,7 @@ export class PlayCallChain {
|
|
|
178
190
|
};
|
|
179
191
|
if (activeId !== undefined)
|
|
180
192
|
this.#active.delete(activeId);
|
|
181
|
-
this.#
|
|
182
|
-
this.#worldChains.set(session.worldId, session.chainId);
|
|
193
|
+
this.#registerResident(session);
|
|
183
194
|
await this.#persist(session);
|
|
184
195
|
await this.#worlds.completeArchiveFreshContext(session.worldId);
|
|
185
196
|
return this.#submitPlayer(session, input.exchangeId, input.playerText, "fresh", input.modelHost, signal, input.observer);
|
|
@@ -257,7 +268,8 @@ export class PlayCallChain {
|
|
|
257
268
|
session.history = historyEntries(binding.history, binding.historyBackgrounds);
|
|
258
269
|
session.narrativeCheckpoint = binding.narrativeCheckpoint;
|
|
259
270
|
session.nextMaterials = structuredClone(binding.additionalMaterials);
|
|
260
|
-
|
|
271
|
+
const previousRun = session.promptRuns?.at(-1);
|
|
272
|
+
(session.promptRuns ??= []).push(sharePromptRun({
|
|
261
273
|
schemaVersion: 1,
|
|
262
274
|
firstEventId: session.nextEventId,
|
|
263
275
|
head: binding.parentHead,
|
|
@@ -272,7 +284,7 @@ export class PlayCallChain {
|
|
|
272
284
|
followups: structuredClone(compilation.followups ?? []),
|
|
273
285
|
playPresetScriptsEnabled: playPreset.scriptsEnabled,
|
|
274
286
|
presetFiles: structuredClone(playPreset.files),
|
|
275
|
-
});
|
|
287
|
+
}, promptRunStrings(previousRun)));
|
|
276
288
|
await this.#persist(session);
|
|
277
289
|
}
|
|
278
290
|
cancel(input) {
|
|
@@ -322,6 +334,7 @@ export class PlayCallChain {
|
|
|
322
334
|
this.#worlds.freezeControl(input.worldId, controlUseId);
|
|
323
335
|
this.#activeInvocations.set(input.chainId, invocation);
|
|
324
336
|
this.#invocationSettled.set(input.chainId, settled);
|
|
337
|
+
this.#touchWorld(input.worldId);
|
|
325
338
|
this.changed(input.worldId);
|
|
326
339
|
try {
|
|
327
340
|
await this.#worlds.assertWorldRevisionUnlocked(input.worldId);
|
|
@@ -330,6 +343,7 @@ export class PlayCallChain {
|
|
|
330
343
|
finally {
|
|
331
344
|
if (this.#activeInvocations.get(input.chainId) === invocation)
|
|
332
345
|
this.#activeInvocations.delete(input.chainId);
|
|
346
|
+
this.#enforceResidentLimit();
|
|
333
347
|
this.#worlds.releaseControl(input.worldId, controlUseId);
|
|
334
348
|
this.changed(input.worldId);
|
|
335
349
|
this.#invocationSettled.delete(input.chainId);
|
|
@@ -375,6 +389,10 @@ export class PlayCallChain {
|
|
|
375
389
|
// running. Presentation failure never cancels or changes play Authority.
|
|
376
390
|
}
|
|
377
391
|
}
|
|
392
|
+
/** Worlds whose play session is held in memory, least recently used first. */
|
|
393
|
+
residentWorldIds() {
|
|
394
|
+
return [...this.#worldChains.keys()];
|
|
395
|
+
}
|
|
378
396
|
async inspectWorld(worldId) {
|
|
379
397
|
const session = await this.#worldSession(worldId);
|
|
380
398
|
return session === null ? null : this.#projectActiveView(session);
|
|
@@ -394,6 +412,16 @@ export class PlayCallChain {
|
|
|
394
412
|
: event);
|
|
395
413
|
return view;
|
|
396
414
|
}
|
|
415
|
+
/** The live context's frozen binding and transcript, without reloading it. */
|
|
416
|
+
async currentContinuation(worldId) {
|
|
417
|
+
const session = await this.#worldSession(worldId);
|
|
418
|
+
return session === null
|
|
419
|
+
? null
|
|
420
|
+
: {
|
|
421
|
+
modelBinding: session.modelBinding,
|
|
422
|
+
transcript: structuredClone(session.transcript),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
397
425
|
async #worldSession(worldId) {
|
|
398
426
|
return this.#loadWorldSession(worldId);
|
|
399
427
|
}
|
|
@@ -406,9 +434,13 @@ export class PlayCallChain {
|
|
|
406
434
|
const activeId = this.#worldChains.get(worldId);
|
|
407
435
|
const active = activeId === undefined ? undefined : this.#active.get(activeId);
|
|
408
436
|
if (active !== undefined) {
|
|
437
|
+
this.#touchWorld(worldId);
|
|
409
438
|
if (this.#activeInvocations.get(active.chainId)?.worldId !== active.worldId)
|
|
410
439
|
await this.#reconcileSessionAdvance(active);
|
|
411
|
-
|
|
440
|
+
// Released while reconciling: load the current session, not this copy.
|
|
441
|
+
if (this.#active.get(active.chainId) === active)
|
|
442
|
+
return active;
|
|
443
|
+
return this.#loadWorldSession(worldId);
|
|
412
444
|
}
|
|
413
445
|
const pending = this.#hydrating.get(worldId);
|
|
414
446
|
if (pending !== undefined)
|
|
@@ -438,8 +470,7 @@ export class PlayCallChain {
|
|
|
438
470
|
// forgetWorld ran during hydration; do not resurrect the old session.
|
|
439
471
|
if (this.#hydrating.get(worldId)?.token !== token)
|
|
440
472
|
return null;
|
|
441
|
-
this.#
|
|
442
|
-
this.#worldChains.set(worldId, hydrated.chainId);
|
|
473
|
+
this.#registerResident(hydrated);
|
|
443
474
|
await this.#reconcileSessionAdvance(hydrated, advance);
|
|
444
475
|
return hydrated;
|
|
445
476
|
}
|
|
@@ -566,10 +597,10 @@ export class PlayCallChain {
|
|
|
566
597
|
const existing = await this.#readPersisted(input.targetWorldId);
|
|
567
598
|
if (existing !== null)
|
|
568
599
|
return projectView(existing);
|
|
569
|
-
|
|
570
|
-
if (source === null)
|
|
571
|
-
return null;
|
|
600
|
+
await this.#worlds.ensureCurrentStorage(input.sourceWorldId);
|
|
572
601
|
const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.sourceWorldId);
|
|
602
|
+
if (sourceContexts.length === 0)
|
|
603
|
+
return null;
|
|
573
604
|
const selectedContextIndex = sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
|
|
574
605
|
if (selectedContextIndex < 0)
|
|
575
606
|
return null;
|
|
@@ -586,10 +617,10 @@ export class PlayCallChain {
|
|
|
586
617
|
});
|
|
587
618
|
}
|
|
588
619
|
async stageForkToDerivedWorld(input) {
|
|
589
|
-
|
|
590
|
-
if (source === null)
|
|
591
|
-
return null;
|
|
620
|
+
await this.#worlds.ensureCurrentStorage(input.sourceWorldId);
|
|
592
621
|
const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.sourceWorldId);
|
|
622
|
+
if (sourceContexts.length === 0)
|
|
623
|
+
return null;
|
|
593
624
|
const selectedContextIndex = input.retainCompleteTimeline
|
|
594
625
|
? sourceContexts.length - 1
|
|
595
626
|
: sourceContexts.findIndex(({ events }) => eventsThroughHead(events, input.sourceHead) !== null);
|
|
@@ -631,10 +662,32 @@ export class PlayCallChain {
|
|
|
631
662
|
if (active?.status === "running")
|
|
632
663
|
throw new PlayCallChainError("A model request is still running; wait for it to return before revising committed history.");
|
|
633
664
|
await this.#recoverPlayerRevision(input.worldId);
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
665
|
+
// The plan's fully loaded contexts are out of scope once it returns, so
|
|
666
|
+
// completion does not hold a second copy of a long timeline.
|
|
667
|
+
const plan = await this.#planPlayerRevision(input);
|
|
668
|
+
if (plan.kind === "applied") {
|
|
669
|
+
const current = await this.#readPersisted(input.worldId);
|
|
670
|
+
if (current === null)
|
|
671
|
+
throw new PlayCallChainError("The current world has no model call chain to revise.");
|
|
672
|
+
return {
|
|
673
|
+
outcome: "revised",
|
|
674
|
+
worldId: input.worldId,
|
|
675
|
+
playCallChain: projectView(current),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
await this.#worlds.writePlayerRevisionIntent(input.worldId, plan.intent);
|
|
679
|
+
const revised = await this.#completePlayerRevision(plan.intent);
|
|
680
|
+
return {
|
|
681
|
+
outcome: "revised",
|
|
682
|
+
worldId: input.worldId,
|
|
683
|
+
playCallChain: projectView(revised),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
async #planPlayerRevision(input) {
|
|
687
|
+
await this.#worlds.ensureCurrentStorage(input.worldId);
|
|
637
688
|
const contexts = await this.#worlds.playTimeline.readAllContexts(input.worldId);
|
|
689
|
+
if (contexts.length === 0)
|
|
690
|
+
throw new PlayCallChainError("The current world has no model call chain to revise.");
|
|
638
691
|
// A replacement exchange that already reached the timeline is a repeat of
|
|
639
692
|
// the same request: answer with the current view and do not redo it.
|
|
640
693
|
const applied = contexts
|
|
@@ -645,11 +698,7 @@ export class PlayCallChain {
|
|
|
645
698
|
if (applied !== undefined) {
|
|
646
699
|
if (applied.kind === "player" && applied.text !== input.replacementText)
|
|
647
700
|
throw new PlayCallChainError("The same replacement exchange ID is already bound to different player text.");
|
|
648
|
-
return {
|
|
649
|
-
outcome: "revised",
|
|
650
|
-
worldId: input.worldId,
|
|
651
|
-
playCallChain: projectView(source),
|
|
652
|
-
};
|
|
701
|
+
return { kind: "applied" };
|
|
653
702
|
}
|
|
654
703
|
const contextIndex = contexts.findIndex(({ chainId }) => chainId === input.chainId);
|
|
655
704
|
if (contextIndex < 0)
|
|
@@ -682,13 +731,7 @@ export class PlayCallChain {
|
|
|
682
731
|
? { freshContext: structuredClone(input.freshContext) }
|
|
683
732
|
: {}),
|
|
684
733
|
};
|
|
685
|
-
|
|
686
|
-
const revised = await this.#completePlayerRevision(intent);
|
|
687
|
-
return {
|
|
688
|
-
outcome: "revised",
|
|
689
|
-
worldId: input.worldId,
|
|
690
|
-
playCallChain: projectView(revised),
|
|
691
|
-
};
|
|
734
|
+
return { kind: "intent", intent };
|
|
692
735
|
}
|
|
693
736
|
/** Complete an interrupted revision recorded by a previous process. */
|
|
694
737
|
async #recoverPlayerRevision(worldId) {
|
|
@@ -714,16 +757,25 @@ export class PlayCallChain {
|
|
|
714
757
|
async #completePlayerRevisionQueued(intent) {
|
|
715
758
|
const worldId = intent.worldId;
|
|
716
759
|
const resultChainId = revisionChainId(intent.chainId, intent.operationId, worldId, intent.continuation);
|
|
717
|
-
|
|
718
|
-
|
|
760
|
+
await this.#worlds.ensureCurrentStorage(worldId);
|
|
761
|
+
// Only the edited context is loaded in full; a long timeline's prompt runs
|
|
762
|
+
// would otherwise be held several times over.
|
|
763
|
+
const outline = await this.#worlds.playTimeline.readTimelineOutline(worldId);
|
|
764
|
+
if (outline?.chainIds.at(-1) === resultChainId) {
|
|
765
|
+
const current = await this.#readPersisted(worldId);
|
|
766
|
+
if (current === null)
|
|
767
|
+
throw new PlayCallChainError("The revised model context could not be read.");
|
|
719
768
|
await this.#discardRevisedContexts(intent, resultChainId);
|
|
720
769
|
await this.#worlds.clearPlayerRevisionIntent(worldId);
|
|
721
770
|
return current;
|
|
722
771
|
}
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
const sourceContext =
|
|
726
|
-
|
|
772
|
+
const sourceIndex = outline?.chainIds.findIndex((chainId) => chainId === intent.chainId) ??
|
|
773
|
+
-1;
|
|
774
|
+
const sourceContext = sourceIndex < 0
|
|
775
|
+
? undefined
|
|
776
|
+
: (await this.#worlds.playTimeline.readContext(worldId, intent.chainId))
|
|
777
|
+
?.value;
|
|
778
|
+
if (outline === null || sourceContext === undefined)
|
|
727
779
|
throw new PlayCallChainError("The model context to revise does not exist.");
|
|
728
780
|
const eventIndex = sourceContext.events.findIndex(({ id }) => id === intent.eventId);
|
|
729
781
|
if (eventIndex < 0)
|
|
@@ -740,8 +792,8 @@ export class PlayCallChain {
|
|
|
740
792
|
]);
|
|
741
793
|
if (binding.parentHead !== head)
|
|
742
794
|
throw new PlayCallChainError("The player revision was committed, but current-world materialization has not reached the new endpoint.");
|
|
743
|
-
const timelineGeneration = revisionTimelineGeneration(
|
|
744
|
-
const previousChainId = sourceIndex === 0 ? null :
|
|
795
|
+
const timelineGeneration = revisionTimelineGeneration(outline.generation, intent.operationId, worldId, intent.continuation);
|
|
796
|
+
const previousChainId = sourceIndex === 0 ? null : outline.chainIds[sourceIndex - 1];
|
|
745
797
|
const traceRetention = preparePlayerRevisionTrace(sourceContext, eventIndex);
|
|
746
798
|
let revised;
|
|
747
799
|
if (intent.continuation === "fresh_context") {
|
|
@@ -782,7 +834,7 @@ export class PlayCallChain {
|
|
|
782
834
|
updatedAt: Date.now(),
|
|
783
835
|
};
|
|
784
836
|
}
|
|
785
|
-
await this.#worlds.playTimeline.persist(
|
|
837
|
+
await this.#worlds.playTimeline.persist(revised);
|
|
786
838
|
if (intent.continuation === "fresh_context")
|
|
787
839
|
await this.#worlds.completeArchiveFreshContext(worldId);
|
|
788
840
|
await this.#discardRevisedContexts(intent, resultChainId);
|
|
@@ -1009,9 +1061,6 @@ export class PlayCallChain {
|
|
|
1009
1061
|
targetWorldRoot: input.targetWorldRoot,
|
|
1010
1062
|
source: context,
|
|
1011
1063
|
target: previousContext,
|
|
1012
|
-
...(input.retainCompleteTimeline
|
|
1013
|
-
? { retainCompleteContext: true }
|
|
1014
|
-
: {}),
|
|
1015
1064
|
});
|
|
1016
1065
|
previousChainId = chainId;
|
|
1017
1066
|
}
|
|
@@ -1031,16 +1080,13 @@ export class PlayCallChain {
|
|
|
1031
1080
|
updatedAt: now,
|
|
1032
1081
|
};
|
|
1033
1082
|
if (input.targetWorldRoot === undefined)
|
|
1034
|
-
await this.#worlds.playTimeline.persist(
|
|
1083
|
+
await this.#worlds.playTimeline.persist(cloneSharingPromptRuns(derived));
|
|
1035
1084
|
else
|
|
1036
1085
|
await this.#worlds.playTimeline.cloneContextPrefixToStaging({
|
|
1037
1086
|
sourceWorldId: input.sourceWorldId,
|
|
1038
1087
|
targetWorldRoot: input.targetWorldRoot,
|
|
1039
1088
|
source: sourceContext,
|
|
1040
|
-
target:
|
|
1041
|
-
...(input.retainCompleteTimeline
|
|
1042
|
-
? { retainCompleteContext: true }
|
|
1043
|
-
: {}),
|
|
1089
|
+
target: cloneSharingPromptRuns(derived),
|
|
1044
1090
|
});
|
|
1045
1091
|
return projectView(derived);
|
|
1046
1092
|
}
|
|
@@ -1878,7 +1924,8 @@ export class PlayCallChain {
|
|
|
1878
1924
|
if (active.worldId !== worldId)
|
|
1879
1925
|
throw new PlayCallChainError("The call chain does not belong to this world.");
|
|
1880
1926
|
await this.#reconcileSessionAdvance(active);
|
|
1881
|
-
|
|
1927
|
+
if (this.#active.get(chainId) === active)
|
|
1928
|
+
return active;
|
|
1882
1929
|
}
|
|
1883
1930
|
const session = await this.#loadWorldSession(worldId);
|
|
1884
1931
|
if (session?.chainId !== chainId)
|
|
@@ -1908,7 +1955,7 @@ export class PlayCallChain {
|
|
|
1908
1955
|
const documents = restorePlayDocuments(binding.files, persisted, persisted.events);
|
|
1909
1956
|
await this.#bindDocumentMaintenance(documents, persisted.worldId, binding.parentHead);
|
|
1910
1957
|
return {
|
|
1911
|
-
...
|
|
1958
|
+
...cloneSharingPromptRuns(persisted),
|
|
1912
1959
|
nextMaterials: structuredClone(binding.additionalMaterials),
|
|
1913
1960
|
documents,
|
|
1914
1961
|
history: historyEntries(binding.history, binding.historyBackgrounds),
|
|
@@ -1982,9 +2029,80 @@ export class PlayCallChain {
|
|
|
1982
2029
|
if (loaded === null)
|
|
1983
2030
|
return null;
|
|
1984
2031
|
this.#loadedCursors.set(loaded.value.chainId, loaded.cursor);
|
|
1985
|
-
|
|
2032
|
+
// A fresh read shares nothing, so it needs no defensive copy.
|
|
2033
|
+
return loaded.value;
|
|
2034
|
+
}
|
|
2035
|
+
/** Makes a session the live one for its world, then trims idle worlds. */
|
|
2036
|
+
#registerResident(session) {
|
|
2037
|
+
this.#active.set(session.chainId, session);
|
|
2038
|
+
this.#worldChains.delete(session.worldId);
|
|
2039
|
+
this.#worldChains.set(session.worldId, session.chainId);
|
|
2040
|
+
this.#enforceResidentLimit();
|
|
2041
|
+
}
|
|
2042
|
+
#touchWorld(worldId) {
|
|
2043
|
+
const chainId = this.#worldChains.get(worldId);
|
|
2044
|
+
if (chainId === undefined)
|
|
2045
|
+
return;
|
|
2046
|
+
this.#worldChains.delete(worldId);
|
|
2047
|
+
this.#worldChains.set(worldId, chainId);
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Schedules release of least recently used idle worlds until at most
|
|
2051
|
+
* residentWorldLimit remain. The most recently used world is never a
|
|
2052
|
+
* candidate. A busy or queued world is skipped, and a later call trims it
|
|
2053
|
+
* once it has gone idle.
|
|
2054
|
+
*/
|
|
2055
|
+
#enforceResidentLimit() {
|
|
2056
|
+
const worldIds = [...this.#worldChains.keys()];
|
|
2057
|
+
let excess = worldIds.length - residentWorldLimit - this.#releasing.size;
|
|
2058
|
+
for (const worldId of worldIds.slice(0, -1)) {
|
|
2059
|
+
if (excess <= 0)
|
|
2060
|
+
return;
|
|
2061
|
+
if (this.#releasing.has(worldId))
|
|
2062
|
+
continue;
|
|
2063
|
+
if (this.#worlds.operations.pendingWorldOperations(worldId) > 0 ||
|
|
2064
|
+
!this.#isIdle(worldId))
|
|
2065
|
+
continue;
|
|
2066
|
+
excess -= 1;
|
|
2067
|
+
this.#releasing.add(worldId);
|
|
2068
|
+
void this.#worlds.operations.withWorldAuthorityLock(worldId, () => {
|
|
2069
|
+
this.#releaseIfIdle(worldId);
|
|
2070
|
+
return Promise.resolve();
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Runs in the world's queue slot, so no world operation can hold this
|
|
2076
|
+
* session while it is released. An operation queued after the release was
|
|
2077
|
+
* scheduled is counted, and the release waits for a later call.
|
|
2078
|
+
*/
|
|
2079
|
+
#releaseIfIdle(worldId) {
|
|
2080
|
+
this.#releasing.delete(worldId);
|
|
2081
|
+
if (this.#worlds.operations.pendingWorldOperations(worldId) > 1 ||
|
|
2082
|
+
!this.#isIdle(worldId))
|
|
2083
|
+
return;
|
|
2084
|
+
const chainId = this.#worldChains.get(worldId);
|
|
2085
|
+
if (chainId === undefined)
|
|
2086
|
+
return;
|
|
2087
|
+
this.#active.delete(chainId);
|
|
2088
|
+
this.#worldChains.delete(worldId);
|
|
2089
|
+
this.#loadedCursors.delete(chainId);
|
|
2090
|
+
}
|
|
2091
|
+
/** No call, hydration, or running request holds this world's session. */
|
|
2092
|
+
#isIdle(worldId) {
|
|
2093
|
+
const chainId = this.#worldChains.get(worldId);
|
|
2094
|
+
const session = chainId === undefined ? undefined : this.#active.get(chainId);
|
|
2095
|
+
if (session === undefined || session.status === "running")
|
|
2096
|
+
return false;
|
|
2097
|
+
if (this.#hydrating.has(worldId))
|
|
2098
|
+
return false;
|
|
2099
|
+
return ![...this.#activeInvocations.values()].some((invocation) => invocation.worldId === worldId);
|
|
1986
2100
|
}
|
|
1987
2101
|
async #persist(session) {
|
|
2102
|
+
// Only the live session may write. A copy released from memory would fork
|
|
2103
|
+
// the world's session; readers that hold one reload on their next step.
|
|
2104
|
+
if (this.#active.get(session.chainId) !== session)
|
|
2105
|
+
throw new PlayCallChainError("The play context was released from memory; reload it before writing.");
|
|
1988
2106
|
touch(session);
|
|
1989
2107
|
session.completedTools = [...session.completedToolMap.values()].map((item) => structuredClone(item));
|
|
1990
2108
|
const { documents: _documents, history: _history, completedToolMap: _completedToolMap, persistenceCursor: _persistenceCursor, ...persisted } = session;
|
|
@@ -1992,7 +2110,7 @@ export class PlayCallChain {
|
|
|
1992
2110
|
void _history;
|
|
1993
2111
|
void _completedToolMap;
|
|
1994
2112
|
void _persistenceCursor;
|
|
1995
|
-
session.persistenceCursor = await this.#worlds.playTimeline.persist(
|
|
2113
|
+
session.persistenceCursor = await this.#worlds.playTimeline.persist(cloneSharingPromptRuns(persisted), session.persistenceCursor);
|
|
1996
2114
|
}
|
|
1997
2115
|
}
|
|
1998
2116
|
/**
|
|
@@ -8,12 +8,74 @@ export function recordedPlayPrompt(source) {
|
|
|
8
8
|
return (source.promptRuns?.findLast((run) => run.firstEventId <= lastEvent) ??
|
|
9
9
|
source);
|
|
10
10
|
}
|
|
11
|
+
/** Shared runs are frozen, so the returned runs are the context's own. */
|
|
11
12
|
export function playPromptRunsThroughEvents(context, events) {
|
|
12
13
|
const lastEvent = events.at(-1)?.id ?? 0;
|
|
13
14
|
const runs = (context.promptRuns ?? []).filter(({ firstEventId }) => firstEventId <= lastEvent);
|
|
14
|
-
return
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
return [...new Map(runs.map((run) => [run.firstEventId, run])).values()];
|
|
16
|
+
}
|
|
17
|
+
/** Strings shorter than this are not worth a pool entry. */
|
|
18
|
+
const sharedStringLength = 128;
|
|
19
|
+
/**
|
|
20
|
+
* Make a freshly built or decoded run share the pool's equal strings, then
|
|
21
|
+
* freeze it: contexts and their copies hold the same run instead of a clone.
|
|
22
|
+
*/
|
|
23
|
+
export function sharePromptRun(run, strings) {
|
|
24
|
+
return deepFreeze(internStrings(run, strings));
|
|
25
|
+
}
|
|
26
|
+
/** A pool seeded with an already shared run's strings. */
|
|
27
|
+
export function promptRunStrings(run) {
|
|
28
|
+
const strings = new Map();
|
|
29
|
+
if (run !== undefined)
|
|
30
|
+
collectStrings(run, strings);
|
|
31
|
+
return strings;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Deep-copy a context or session while sharing its frozen prompt runs, the
|
|
35
|
+
* only part of it that is large.
|
|
36
|
+
*/
|
|
37
|
+
export function cloneSharingPromptRuns(value) {
|
|
38
|
+
const { promptRuns, ...rest } = value;
|
|
39
|
+
const copy = structuredClone(rest);
|
|
40
|
+
if (promptRuns !== undefined)
|
|
41
|
+
copy.promptRuns = [...promptRuns];
|
|
42
|
+
return copy;
|
|
43
|
+
}
|
|
44
|
+
function internStrings(value, strings) {
|
|
45
|
+
if (typeof value === "string")
|
|
46
|
+
return intern(value, strings);
|
|
47
|
+
if (typeof value !== "object" || value === null)
|
|
48
|
+
return value;
|
|
49
|
+
const record = value;
|
|
50
|
+
for (const key of Object.keys(record))
|
|
51
|
+
record[key] = internStrings(record[key], strings);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function intern(value, strings) {
|
|
55
|
+
if (value.length < sharedStringLength)
|
|
56
|
+
return value;
|
|
57
|
+
const shared = strings.get(value);
|
|
58
|
+
if (shared !== undefined)
|
|
59
|
+
return shared;
|
|
60
|
+
strings.set(value, value);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
function collectStrings(value, strings) {
|
|
64
|
+
if (typeof value === "string") {
|
|
65
|
+
intern(value, strings);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (typeof value !== "object" || value === null)
|
|
69
|
+
return;
|
|
70
|
+
for (const item of Object.values(value))
|
|
71
|
+
collectStrings(item, strings);
|
|
72
|
+
}
|
|
73
|
+
function deepFreeze(value) {
|
|
74
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value))
|
|
75
|
+
return value;
|
|
76
|
+
for (const item of Object.values(value))
|
|
77
|
+
deepFreeze(item);
|
|
78
|
+
return Object.freeze(value);
|
|
17
79
|
}
|
|
18
80
|
export function assertPlayPromptRun(value) {
|
|
19
81
|
if (typeof value !== "object" || value === null)
|
|
@@ -58,6 +120,98 @@ export function encodePlayPromptRun(run) {
|
|
|
58
120
|
digest: createHash("sha256").update(JSON.stringify(run)).digest("hex"),
|
|
59
121
|
};
|
|
60
122
|
}
|
|
123
|
+
export function promptRunCompiled(source) {
|
|
124
|
+
return {
|
|
125
|
+
playPreset: source.playPreset,
|
|
126
|
+
bootstrap: source.bootstrap,
|
|
127
|
+
tools: source.tools,
|
|
128
|
+
followups: source.followups,
|
|
129
|
+
playPresetScriptsEnabled: source.playPresetScriptsEnabled,
|
|
130
|
+
presetFiles: source.presetFiles,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The compiled prompt a context base holds. A base written without its follow-up,
|
|
135
|
+
* script or file fields cannot be a basis, so runs are then stored in full.
|
|
136
|
+
*/
|
|
137
|
+
export function contextPromptBasis(source) {
|
|
138
|
+
if (source.followups === undefined ||
|
|
139
|
+
source.playPresetScriptsEnabled === undefined ||
|
|
140
|
+
source.presetFiles === undefined)
|
|
141
|
+
return null;
|
|
142
|
+
return promptRunCompiled(source);
|
|
143
|
+
}
|
|
144
|
+
/** Sorted-key JSON, so equal prompts digest equally whatever their key order. */
|
|
145
|
+
export function compiledPromptDigest(compiled) {
|
|
146
|
+
return createHash("sha256")
|
|
147
|
+
.update(JSON.stringify(promptRunCompiled(compiled), (_key, value) => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
148
|
+
? Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0))
|
|
149
|
+
: value))
|
|
150
|
+
.digest("hex");
|
|
151
|
+
}
|
|
152
|
+
/** A compiled prompt shared into a pool and frozen, as the runs built on it are. */
|
|
153
|
+
export function sharePromptCompiled(compiled, strings) {
|
|
154
|
+
return deepFreeze(internStrings(structuredClone(promptRunCompiled(compiled)), strings));
|
|
155
|
+
}
|
|
156
|
+
/** Full record, unless the compiled prompt repeats the basis. */
|
|
157
|
+
export function encodePlayPromptRunRecord(run, basis) {
|
|
158
|
+
if (basis !== null &&
|
|
159
|
+
compiledPromptDigest(run) === compiledPromptDigest(basis.compiled))
|
|
160
|
+
return {
|
|
161
|
+
kind: "play_prompt_run_reference",
|
|
162
|
+
schemaVersion: 1,
|
|
163
|
+
firstEventId: run.firstEventId,
|
|
164
|
+
head: run.head,
|
|
165
|
+
exchange: run.exchange,
|
|
166
|
+
basisRun: basis.index,
|
|
167
|
+
digest: compiledPromptDigest(basis.compiled),
|
|
168
|
+
};
|
|
169
|
+
return encodePlayPromptRun(run);
|
|
170
|
+
}
|
|
171
|
+
export function isPlayPromptRunReference(value) {
|
|
172
|
+
return isRecordValue(value) && value.kind === "play_prompt_run_reference";
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Rebuild a referenced run from the basis it names. The caller resolves the
|
|
176
|
+
* basis number from runs it has already read; the run shares its frozen parts.
|
|
177
|
+
*/
|
|
178
|
+
export function promptRunFromReference(record, basisOf) {
|
|
179
|
+
if (!isRecordValue(record) ||
|
|
180
|
+
!exactKeys(record, [
|
|
181
|
+
"kind",
|
|
182
|
+
"schemaVersion",
|
|
183
|
+
"firstEventId",
|
|
184
|
+
"head",
|
|
185
|
+
"exchange",
|
|
186
|
+
"basisRun",
|
|
187
|
+
"digest",
|
|
188
|
+
]) ||
|
|
189
|
+
record.schemaVersion !== 1 ||
|
|
190
|
+
!Number.isSafeInteger(record.firstEventId) ||
|
|
191
|
+
Number(record.firstEventId) < 1 ||
|
|
192
|
+
!Number.isSafeInteger(record.exchange) ||
|
|
193
|
+
Number(record.exchange) < 1 ||
|
|
194
|
+
typeof record.head !== "string" ||
|
|
195
|
+
!Number.isSafeInteger(record.basisRun) ||
|
|
196
|
+
Number(record.basisRun) < 0 ||
|
|
197
|
+
typeof record.digest !== "string")
|
|
198
|
+
throw new Error("Invalid play prompt run reference");
|
|
199
|
+
const basis = basisOf(Number(record.basisRun));
|
|
200
|
+
if (basis === null)
|
|
201
|
+
throw new Error("Play prompt run reference has no earlier basis");
|
|
202
|
+
if (compiledPromptDigest(basis) !== record.digest)
|
|
203
|
+
throw new Error("Play prompt run reference does not match its basis");
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
schemaVersion: 1,
|
|
206
|
+
firstEventId: Number(record.firstEventId),
|
|
207
|
+
head: record.head,
|
|
208
|
+
exchange: Number(record.exchange),
|
|
209
|
+
...promptRunCompiled(basis),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function isRecordValue(value) {
|
|
213
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
214
|
+
}
|
|
61
215
|
export function decodePlayPromptRun(value) {
|
|
62
216
|
if (typeof value !== "object" || value === null)
|
|
63
217
|
throw new Error("Invalid play prompt run record");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { captureNarrativeOrigin } from "../history/HistoryBackground.js";
|
|
2
2
|
import { FileNativePlayDocuments } from "./PlayDocumentTools.js";
|
|
3
|
-
import { currentPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
|
|
3
|
+
import { cloneSharingPromptRuns, currentPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
|
|
4
4
|
import { isPlayerRoundMarker, playerInputAppend } from "./PlayContinuity.js";
|
|
5
5
|
import { PlayCallChainError } from "./PlayCallChainError.js";
|
|
6
6
|
/**
|
|
@@ -190,7 +190,7 @@ function isReplayableReadAuthorization(value) {
|
|
|
190
190
|
markdown.every((segment) => typeof segment === "string"));
|
|
191
191
|
}
|
|
192
192
|
export function independentContextCopy(source) {
|
|
193
|
-
const context =
|
|
193
|
+
const context = cloneSharingPromptRuns(source);
|
|
194
194
|
context.continuityContextId ??= source.chainId;
|
|
195
195
|
delete context.derivedFrom;
|
|
196
196
|
delete context.branchedBeforePlayer;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FileNativePlayTimelineStore } from "../play/FileNativePlayTimelineStore.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -19,8 +20,10 @@ export class UnsupportedLegacyStorageError extends Error {
|
|
|
19
20
|
*/
|
|
20
21
|
export class FileNativeWorldStorageMigrator {
|
|
21
22
|
#worldsRoot;
|
|
23
|
+
#playTimeline;
|
|
22
24
|
constructor(dataRoot) {
|
|
23
25
|
this.#worldsRoot = join(resolve(dataRoot), "worlds-file-native");
|
|
26
|
+
this.#playTimeline = new FileNativePlayTimelineStore(dataRoot);
|
|
24
27
|
}
|
|
25
28
|
async migrate(worldId) {
|
|
26
29
|
assertIdentity(worldId, "World ID");
|
|
@@ -35,12 +38,14 @@ export class FileNativeWorldStorageMigrator {
|
|
|
35
38
|
join(runtimeRoot, ".authority-v3-migration"),
|
|
36
39
|
])
|
|
37
40
|
await rm(residue, { recursive: true, force: true });
|
|
41
|
+
await this.#playTimeline.migrateLayout(worldId);
|
|
38
42
|
const head = await ledger.readHead();
|
|
39
43
|
return { outcome: "already_current", authorityCommits: head.sequence };
|
|
40
44
|
}
|
|
41
45
|
const authority = new LegacyAuthorityV3Reader(worldRoot);
|
|
42
46
|
if (await authority.exists()) {
|
|
43
47
|
const { commits } = await convertAuthorityV3ToLedger(worldRoot);
|
|
48
|
+
await this.#playTimeline.migrateLayout(worldId);
|
|
44
49
|
return { outcome: "migrated", authorityCommits: commits };
|
|
45
50
|
}
|
|
46
51
|
throw new UnsupportedLegacyStorageError(worldId);
|