narraeon 0.7.1 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/node/protocol/v1.js +8 -2
  2. package/dist/node/runtime/V1Runtime.js +104 -71
  3. package/dist/node/runtime/archive/WorldArchives.js +17 -5
  4. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +59 -12
  5. package/dist/node/runtime/config/dataRootCleanup.js +52 -0
  6. package/dist/node/runtime/content/ContentWorkspace.js +2 -2
  7. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +34 -2
  8. package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
  9. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +70 -3
  10. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +236 -26
  11. package/dist/node/runtime/play/PlayCallChain.js +172 -54
  12. package/dist/node/runtime/play/PlayPromptRun.js +157 -3
  13. package/dist/node/runtime/play/PlayTraceRetention.js +2 -2
  14. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +10 -0
  15. package/dist/node/runtime/world/FileNativeWorldStore.js +11 -4
  16. package/dist/node/runtime/world/WorldLedgerAuthority.js +13 -10
  17. package/dist/node/runtime/world/WorldOperationCoordinator.js +4 -0
  18. package/dist/node/runtime/world/WorldQueue.js +14 -1
  19. package/dist/node/runtime/world/WorldTurnLedger.js +48 -20
  20. package/dist/node/server/createServer.js +44 -15
  21. package/dist/node/server/startWebServer.js +2 -5
  22. package/dist/web/assets/index-9N9kO8kY.js +1408 -0
  23. package/dist/web/assets/index-BjxBYYQi.css +1 -0
  24. package/dist/web/index.html +2 -2
  25. package/package.json +1 -1
  26. package/dist/node/runtime/config/dataRootLock.js +0 -108
  27. package/dist/web/assets/index-BUCslLEK.css +0 -1
  28. package/dist/web/assets/index-DkamBH6c.js +0 -1408
@@ -2,10 +2,10 @@ import { isResolutionReceipt } from "../../protocol/resolution.js";
2
2
  import { legacyModelBindingFailure, projectLegacyModelBindingFailure, } from "./LegacyModelBindingFailure.js";
3
3
  import { isPlayFailureKind, } from "../../protocol/playFailure.js";
4
4
  import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
5
- import { encodePlayPromptRun, decodePlayPromptRun, } from "./PlayPromptRun.js";
5
+ import { contextPromptBasis, decodePlayPromptRun, encodePlayPromptRunRecord, isPlayPromptRunReference, promptRunCompiled, promptRunFromReference, sharePromptCompiled, sharePromptRun, } from "./PlayPromptRun.js";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { constants } from "node:fs";
8
- import { chmod, copyFile, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
8
+ import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
9
9
  import { linkExclusive } from "../FileNativeExclusiveLink.js";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import { isDeepStrictEqual } from "node:util";
@@ -43,16 +43,10 @@ export class FileNativePlayTimelineStore {
43
43
  const transcriptCount = prefixLength(input.source.transcript, input.target.transcript);
44
44
  const eventCount = prefixLength(input.source.events, input.target.events);
45
45
  const completedToolCount = prefixLength(input.source.completedTools, input.target.completedTools);
46
- if (input.retainCompleteContext ||
47
- input.target.events.some((event) => event.kind === "assistant" && event.status === "completed")) {
48
- const encoding = await this.readInitialEncoding(input.sourceWorldId, input.source.chainId).catch(() => null);
46
+ for (const exchange of (await recordedEncodingExchanges(sourceRoot)).slice(0, 2)) {
47
+ const encoding = await this.readInitialEncoding(input.sourceWorldId, input.source.chainId, exchange).catch(() => null);
49
48
  if (encoding !== null)
50
- await publishImmutableJson(join(targetRoot, "initial-request-encoding.json"), encoding);
51
- }
52
- for (const run of input.target.promptRuns ?? []) {
53
- const encoding = await this.readInitialEncoding(input.sourceWorldId, input.source.chainId, run.exchange).catch(() => null);
54
- if (encoding !== null)
55
- await publishImmutableJson(join(targetRoot, `request-encoding-${run.exchange}.json`), encoding);
49
+ await publishImmutableJson(join(targetRoot, encodingFileName(exchange)), encoding);
56
50
  }
57
51
  await Promise.all([
58
52
  cloneNumberedPrefix({
@@ -120,7 +114,7 @@ export class FileNativePlayTimelineStore {
120
114
  };
121
115
  const lastRequestDigest = value.lastRequest === null
122
116
  ? null
123
- : await this.#publishRequest(runtimeRoot, value.lastRequest);
117
+ : await this.#publishRequest(root, value.lastRequest);
124
118
  const continuation = {
125
119
  schemaVersion: 3,
126
120
  kind: "play_context_continuation",
@@ -148,8 +142,9 @@ export class FileNativePlayTimelineStore {
148
142
  for (let index = previous.completedToolCount; index < value.completedTools.length; index += 1)
149
143
  await publishImmutableJson(join(root, "completed-tools", numbered(index + 1)), value.completedTools[index]);
150
144
  const promptRuns = value.promptRuns ?? [];
145
+ const promptBase = contextPromptBasis(value);
151
146
  for (let index = supplied.promptRunCount ?? 0; index < promptRuns.length; index += 1) {
152
- await publishImmutableJson(join(root, "prompt-runs", numbered(index + 1)), encodePlayPromptRun(promptRuns[index]));
147
+ await publishImmutableJson(join(root, "prompt-runs", numbered(index + 1)), encodePlayPromptRunRecord(promptRuns[index], promptRunBasisAt(promptRuns, index, promptBase)));
153
148
  }
154
149
  const state = {
155
150
  schemaVersion: 3,
@@ -173,6 +168,17 @@ export class FileNativePlayTimelineStore {
173
168
  updatedAt: value.updatedAt,
174
169
  };
175
170
  await publishJson(join(root, "state.json"), state);
171
+ // Only after state.json names the new continuation and request do the
172
+ // superseded ones go; a reader holding the old state retries (readContext).
173
+ await pruneSupersededContextFiles(root, {
174
+ continuationDigest,
175
+ lastRequestDigest,
176
+ });
177
+ // Every write path upgrades the world first (ensureCurrentStorage), so a
178
+ // context written here is already in the current layout.
179
+ if ((await readOptionalJson(join(runtimeRoot, LAYOUT_MARKER))) ===
180
+ null)
181
+ await publishJson(join(runtimeRoot, LAYOUT_MARKER), layoutMarker());
176
182
  if (publishHead)
177
183
  await publishJson(join(runtimeRoot, "play-timeline-head.json"), {
178
184
  schemaVersion: 3,
@@ -214,20 +220,33 @@ export class FileNativePlayTimelineStore {
214
220
  async readContext(worldId, chainId) {
215
221
  assertIdentity(worldId, "World ID");
216
222
  assertIdentity(chainId, "Call-chain ID");
223
+ // Reads take no lock. A writer prunes the superseded continuation and request
224
+ // only after publishing the state that names their replacements, so a reader
225
+ // that loaded the older state retries from the current one.
226
+ for (let attempt = 1;; attempt += 1)
227
+ try {
228
+ return await this.#readContextOnce(worldId, chainId);
229
+ }
230
+ catch (error) {
231
+ if (attempt === 3 || !isNodeError(error) || error.code !== "ENOENT")
232
+ throw error;
233
+ }
234
+ }
235
+ async #readContextOnce(worldId, chainId) {
217
236
  const metadata = await this.#readContextMetadata(worldId, chainId);
218
237
  if (metadata === null)
219
238
  return null;
220
239
  const { index: indexValue, state: stateValue, root } = metadata;
221
- const [baseValue, continuationValue, transcript, events, completedTools, promptRuns,] = await Promise.all([
240
+ const [baseValue, continuationValue, transcript, events, completedTools] = await Promise.all([
222
241
  readJson(join(root, "base.json")),
223
242
  readJson(join(root, "continuations", `${stateValue.continuationDigest}.json`)),
224
243
  readNumbered(join(root, "transcript"), stateValue.transcriptCount),
225
244
  readNumberedValidated(join(root, "events"), stateValue.eventCount, assertPlayEvent),
226
245
  readNumbered(join(root, "completed-tools"), stateValue.completedToolCount),
227
- readNumbered(join(root, "prompt-runs"), stateValue.promptRunCount ?? 0).then((records) => records.map(decodePlayPromptRun)),
228
246
  ]);
229
247
  assertContextBase(baseValue);
230
248
  assertContextContinuation(continuationValue);
249
+ const promptRuns = await readPromptRuns(join(root, "prompt-runs"), stateValue.promptRunCount ?? 0, contextPromptBasis({ ...baseValue, playPreset: indexValue.playPreset }));
231
250
  if (baseValue.worldId !== worldId ||
232
251
  baseValue.chainId !== chainId ||
233
252
  continuationValue.chainId !== chainId ||
@@ -237,7 +256,7 @@ export class FileNativePlayTimelineStore {
237
256
  throw new Error("Play context durable identity is inconsistent");
238
257
  const lastRequest = continuationValue.lastRequestDigest === null
239
258
  ? null
240
- : await readJson(join(this.#worldRuntimeRoot(worldId), "play-requests", `${continuationValue.lastRequestDigest}.json`));
259
+ : await readJson(join(root, "requests", `${continuationValue.lastRequestDigest}.json`));
241
260
  if (lastRequest !== null &&
242
261
  (!isRecord(lastRequest) ||
243
262
  createHash("sha256")
@@ -316,17 +335,18 @@ export class FileNativePlayTimelineStore {
316
335
  },
317
336
  };
318
337
  }
319
- /** The first dispatched request encoding is diagnostic evidence, not Authority. */
338
+ /**
339
+ * The first dispatched request encoding is diagnostic evidence, not Authority.
340
+ * A context keeps only the encodings of its two latest exchanges, which are
341
+ * what the AI-reading diagnostics compare.
342
+ */
320
343
  async recordInitialEncoding(worldId, chainId, encoding, exchange = 1) {
321
- const path = join(this.#contextRoot(worldId, chainId), exchange === 1
322
- ? "initial-request-encoding.json"
323
- : `request-encoding-${exchange}.json`);
324
- await publishImmutableJson(path, encoding);
344
+ const root = this.#contextRoot(worldId, chainId);
345
+ await publishImmutableJson(join(root, encodingFileName(exchange)), encoding);
346
+ await pruneEncodings(root);
325
347
  }
326
348
  async readInitialEncoding(worldId, chainId, exchange = 1) {
327
- const value = await readOptionalJson(join(this.#contextRoot(worldId, chainId), exchange === 1
328
- ? "initial-request-encoding.json"
329
- : `request-encoding-${exchange}.json`));
349
+ const value = await readOptionalJson(join(this.#contextRoot(worldId, chainId), encodingFileName(exchange)));
330
350
  if (value === null)
331
351
  return null;
332
352
  if (!isRecord(value) ||
@@ -354,6 +374,26 @@ export class FileNativePlayTimelineStore {
354
374
  }
355
375
  return reverse.reverse();
356
376
  }
377
+ /**
378
+ * The timeline's context IDs, oldest first, and its generation, read from the
379
+ * small context indexes only, never their transcripts or prompt runs.
380
+ */
381
+ async readTimelineOutline(worldId) {
382
+ assertIdentity(worldId, "World ID");
383
+ const head = await this.#readTimelineHead(worldId);
384
+ if (head === null)
385
+ return null;
386
+ const reverse = [];
387
+ let chainId = head.chainId;
388
+ while (chainId !== null) {
389
+ const metadata = await this.#readContextMetadata(worldId, chainId);
390
+ if (metadata === null)
391
+ throw new Error("Play timeline points to a missing context");
392
+ reverse.push(chainId);
393
+ chainId = metadata.index.previousChainId;
394
+ }
395
+ return { generation: head.generation, chainIds: reverse.reverse() };
396
+ }
357
397
  /** Resolve only requested reply summaries; decoration reads never load Provider transcripts. */
358
398
  async resolveReplies(worldId, targets) {
359
399
  if (targets.length === 0)
@@ -503,6 +543,47 @@ export class FileNativePlayTimelineStore {
503
543
  newGeneration() {
504
544
  return `timeline:${randomUUID()}`;
505
545
  }
546
+ /**
547
+ * Whether the world's play timeline is in the current layout (ADR-0034). A world
548
+ * with no play timeline has nothing to upgrade, so reads never write its marker.
549
+ */
550
+ async layoutCurrent(worldId) {
551
+ assertIdentity(worldId, "World ID");
552
+ const runtimeRoot = this.#worldRuntimeRoot(worldId);
553
+ if ((await readOptionalJson(this.#layoutMarkerPath(worldId))) !==
554
+ null)
555
+ return true;
556
+ return ((await pathExists(join(runtimeRoot, "play-contexts"))) === false &&
557
+ (await pathExists(join(runtimeRoot, "play-requests"))) === false);
558
+ }
559
+ /**
560
+ * Upgrade a released play timeline in place: each context takes its own copy
561
+ * of its frozen request, snapshots that repeat their basis become references,
562
+ * and superseded encodings and records go. The released request directory is
563
+ * removed only after every context is complete, and the marker is written
564
+ * last, so an interrupted run starts again from the released files.
565
+ */
566
+ async migrateLayout(worldId) {
567
+ assertIdentity(worldId, "World ID");
568
+ if (await this.layoutCurrent(worldId))
569
+ return;
570
+ const runtimeRoot = this.#worldRuntimeRoot(worldId);
571
+ const contextsRoot = join(runtimeRoot, "play-contexts");
572
+ for (const entry of await readDirectoryNames(contextsRoot)) {
573
+ const root = join(contextsRoot, entry);
574
+ if ((await readOptionalJson(join(root, "state.json"))) === null)
575
+ continue;
576
+ await migrateContextLayout(runtimeRoot, root);
577
+ }
578
+ await rm(join(runtimeRoot, "play-requests"), {
579
+ recursive: true,
580
+ force: true,
581
+ });
582
+ await publishJson(this.#layoutMarkerPath(worldId), layoutMarker());
583
+ }
584
+ #layoutMarkerPath(worldId) {
585
+ return join(this.#worldRuntimeRoot(worldId), LAYOUT_MARKER);
586
+ }
506
587
  #worldRuntimeRoot(worldId) {
507
588
  return join(this.#worldsRoot, worldId, "runtime");
508
589
  }
@@ -522,11 +603,12 @@ export class FileNativePlayTimelineStore {
522
603
  assertIdentity(value.chainId, "Call-chain ID");
523
604
  return value;
524
605
  }
525
- async #publishRequest(runtimeRoot, request) {
606
+ /** The context's frozen request lives in the context: forks copy the one they keep. */
607
+ async #publishRequest(contextRoot, request) {
526
608
  const digest = createHash("sha256")
527
609
  .update(JSON.stringify(request))
528
610
  .digest("hex");
529
- await publishImmutableJson(join(runtimeRoot, "play-requests", `${digest}.json`), request);
611
+ await publishImmutableJson(join(contextRoot, "requests", `${digest}.json`), request);
530
612
  return digest;
531
613
  }
532
614
  async #initialCursor(worldId, generation, chainId) {
@@ -1062,6 +1144,134 @@ async function assertSameFileBytes(source, target) {
1062
1144
  async function readNumbered(root, count) {
1063
1145
  return Promise.all(Array.from({ length: count }, (_, index) => readJson(join(root, numbered(index + 1)))));
1064
1146
  }
1147
+ /**
1148
+ * Runs are read one at a time and share their strings as they are decoded, so
1149
+ * a long context never holds every run's raw text and parsed copy at once. A
1150
+ * reference repeats an earlier compiled prompt: the context base (0) or a run
1151
+ * decoded before it.
1152
+ */
1153
+ async function readPromptRuns(root, count, base) {
1154
+ const strings = new Map();
1155
+ const sharedBase = base === null ? null : sharePromptCompiled(base, strings);
1156
+ const runs = [];
1157
+ for (let index = 0; index < count; index += 1) {
1158
+ const record = await readJson(join(root, numbered(index + 1)));
1159
+ runs.push(isPlayPromptRunReference(record)
1160
+ ? promptRunFromReference(record, (basisRun) => basisRun === 0
1161
+ ? sharedBase
1162
+ : basisRun <= runs.length
1163
+ ? runs[basisRun - 1]
1164
+ : null)
1165
+ : sharePromptRun(decodePlayPromptRun(record), strings));
1166
+ }
1167
+ return runs;
1168
+ }
1169
+ /** The basis a run repeats: the context base for the first run, else the run before it. */
1170
+ function promptRunBasisAt(runs, index, base) {
1171
+ if (index === 0)
1172
+ return base === null ? null : { index: 0, compiled: base };
1173
+ return { index, compiled: promptRunCompiled(runs[index - 1]) };
1174
+ }
1175
+ const LAYOUT_MARKER = "play-timeline-layout.json";
1176
+ function layoutMarker() {
1177
+ return { schemaVersion: 1, kind: "play_timeline_layout" };
1178
+ }
1179
+ async function pathExists(path) {
1180
+ return stat(path).then(() => true, (error) => {
1181
+ if (isNodeError(error) && error.code === "ENOENT")
1182
+ return false;
1183
+ throw error;
1184
+ });
1185
+ }
1186
+ function encodingFileName(exchange) {
1187
+ return exchange === 1
1188
+ ? "initial-request-encoding.json"
1189
+ : `request-encoding-${exchange}.json`;
1190
+ }
1191
+ /** The exchanges with a recorded encoding in a context, latest first. */
1192
+ async function recordedEncodingExchanges(contextRoot) {
1193
+ return (await readDirectoryNames(contextRoot))
1194
+ .map((name) => {
1195
+ if (name === "initial-request-encoding.json")
1196
+ return 1;
1197
+ const match = /^request-encoding-([1-9][0-9]*)\.json$/u.exec(name);
1198
+ return match === null ? null : Number(match[1]);
1199
+ })
1200
+ .filter((exchange) => exchange !== null)
1201
+ .sort((left, right) => right - left);
1202
+ }
1203
+ /**
1204
+ * Keep the encodings of the two latest exchanges, the pair the AI-reading
1205
+ * diagnostics compare. A context without runs is read at exchange 1.
1206
+ */
1207
+ async function pruneEncodings(contextRoot) {
1208
+ for (const exchange of (await recordedEncodingExchanges(contextRoot)).slice(2))
1209
+ await rm(join(contextRoot, encodingFileName(exchange)), { force: true });
1210
+ }
1211
+ /**
1212
+ * Bring one released context to the current layout. Every step is idempotent:
1213
+ * an existing request copy is kept, and a record already in its current form is
1214
+ * left alone, so a rerun after an interruption converges on the same files.
1215
+ */
1216
+ async function migrateContextLayout(runtimeRoot, root) {
1217
+ const indexValue = await readJson(join(root, "index.json"));
1218
+ const stateValue = await readJson(join(root, "state.json"));
1219
+ assertContextIndex(indexValue);
1220
+ assertContextState(stateValue);
1221
+ const baseValue = await readJson(join(root, "base.json"));
1222
+ assertContextBase(baseValue);
1223
+ const continuationValue = await readJson(join(root, "continuations", `${stateValue.continuationDigest}.json`));
1224
+ assertContextContinuation(continuationValue);
1225
+ const digest = continuationValue.lastRequestDigest;
1226
+ if (digest !== null) {
1227
+ const target = join(root, "requests", `${digest}.json`);
1228
+ if ((await readOptionalJson(target)) === null) {
1229
+ const request = await readJson(join(runtimeRoot, "play-requests", `${digest}.json`));
1230
+ if (!isRecord(request) ||
1231
+ createHash("sha256").update(JSON.stringify(request)).digest("hex") !==
1232
+ digest)
1233
+ throw new Error("Frozen play request does not match its durable identity");
1234
+ await publishImmutableJson(target, request);
1235
+ }
1236
+ }
1237
+ const basis = contextPromptBasis({
1238
+ ...baseValue,
1239
+ playPreset: indexValue.playPreset,
1240
+ });
1241
+ const promptRuns = await readPromptRuns(join(root, "prompt-runs"), stateValue.promptRunCount ?? 0, basis);
1242
+ for (let index = 0; index < promptRuns.length; index += 1) {
1243
+ const path = join(root, "prompt-runs", numbered(index + 1));
1244
+ const record = encodePlayPromptRunRecord(promptRuns[index], promptRunBasisAt(promptRuns, index, basis));
1245
+ if (!isDeepStrictEqual(await readJson(path), record))
1246
+ await publishJson(path, record);
1247
+ }
1248
+ await pruneEncodings(root);
1249
+ await pruneSupersededContextFiles(root, {
1250
+ continuationDigest: stateValue.continuationDigest,
1251
+ lastRequestDigest: digest,
1252
+ });
1253
+ }
1254
+ /** Remove continuations and requests that the current state no longer names. */
1255
+ async function pruneSupersededContextFiles(root, current) {
1256
+ for (const [directory, keep] of [
1257
+ ["continuations", current.continuationDigest],
1258
+ ["requests", current.lastRequestDigest],
1259
+ ]) {
1260
+ for (const name of await readDirectoryNames(join(root, directory)))
1261
+ if (name !== `${keep}.json`)
1262
+ await rm(join(root, directory, name), { force: true });
1263
+ }
1264
+ }
1265
+ async function readDirectoryNames(directory) {
1266
+ try {
1267
+ return await readdir(directory);
1268
+ }
1269
+ catch (error) {
1270
+ if (isNodeError(error) && error.code === "ENOENT")
1271
+ return [];
1272
+ throw error;
1273
+ }
1274
+ }
1065
1275
  async function readNumberedValidated(root, count, assertValue) {
1066
1276
  const values = await readNumbered(root, count);
1067
1277
  const validated = [];