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.
@@ -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
- const existing = await this.#readPersisted(input.worldId);
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: existing?.chainId ?? null,
140
- timelineGeneration: existing?.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.#active.set(session.chainId, session);
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
- (session.promptRuns ??= []).push({
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
- return active;
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.#active.set(hydrated.chainId, hydrated);
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
- const source = await this.#readPersisted(input.sourceWorldId);
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
- const source = await this.#readPersisted(input.sourceWorldId);
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
- const source = await this.#readPersisted(input.worldId);
635
- if (source === null)
636
- throw new PlayCallChainError("The current world has no model call chain to revise.");
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
- 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
- };
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
- const current = await this.#readPersisted(worldId);
718
- if (current?.chainId === resultChainId) {
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 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)
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(current?.timelineGeneration ?? "none", intent.operationId, worldId, intent.continuation);
744
- const previousChainId = sourceIndex === 0 ? null : contexts[sourceIndex - 1].chainId;
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(structuredClone(revised));
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(structuredClone(derived));
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: structuredClone(derived),
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
- return active;
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
- ...structuredClone(persisted),
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
- return structuredClone(loaded.value);
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(structuredClone(persisted), session.persistenceCursor);
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 structuredClone([
15
- ...new Map(runs.map((run) => [run.firstEventId, run])).values(),
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 = structuredClone(source);
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);