narraeon 0.8.0 → 0.8.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.
@@ -1,3 +1,5 @@
1
+ import { completedAuthoringEdit, planAuthoringEdit, revertAuthoringChanges, } from "../authoring/AuthoringEditPlan.js";
2
+ import { canRetryAuthoringDispatch, checkpointNextAuthoringDispatch, recoverAuthoringDispatch, } from "../authoring/AuthoringRetry.js";
1
3
  import { authoringRequestPreviews } from "../authoring/AuthoringRequestSnapshot.js";
2
4
  import { createHash } from "node:crypto";
3
5
  import { isDeepStrictEqual } from "node:util";
@@ -8,9 +10,28 @@ import { equalModelHostBinding } from "../model/ModelHost.js";
8
10
  import { settingImprovementPromptForBinding, } from "../play/FileNativePlayPresetStore.js";
9
11
  import { authoringPreloadAuthorization, buildAuthoringPreload, worldRevisionAuthoringSurface, } from "../setting/SettingAuthoringTransaction.js";
10
12
  import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
11
- import { WorldRevisionAuthoringTransaction, worldRevisionRuntimeContract, worldRevisionToolDefinitions, } from "./WorldRevisionAuthoringTransaction.js";
13
+ import { WorldRevisionAuthoringTransaction, inspectWorldRevisionFiles, worldRevisionRuntimeContract, worldRevisionToolDefinitions, } from "./WorldRevisionAuthoringTransaction.js";
12
14
  import { WorldRevisionWorkspaceError } from "./WorldRevisionWorkspace.js";
13
15
  import { historyEntries } from "../history/HistoryTools.js";
16
+ export class WorldRevisionMessageEditError extends Error {
17
+ reason;
18
+ paths;
19
+ constructor(reason, paths = []) {
20
+ super(reason === "sealed"
21
+ ? "Changes in a sealed revision cannot be reverted; keep current files to continue"
22
+ : reason === "conflict"
23
+ ? `Cannot revert changed files: ${paths.join(", ")}`
24
+ : `Reverting would make the revision invalid: ${paths.join(", ")}`);
25
+ this.name = "WorldRevisionMessageEditError";
26
+ this.reason = reason;
27
+ this.paths = paths;
28
+ }
29
+ }
30
+ const liveScopes = new Map();
31
+ const releaseScope = new FinalizationRegistry(({ scope, reference }) => {
32
+ if (liveScopes.get(scope) === reference)
33
+ liveScopes.delete(scope);
34
+ });
14
35
  /** Conversation orchestration over the shared locked revision worktree. */
15
36
  export class WorldRevisionSession {
16
37
  #store;
@@ -22,9 +43,9 @@ export class WorldRevisionSession {
22
43
  #bindExistingModelHost;
23
44
  #bindPlayPreset;
24
45
  #preview;
25
- #conversation = new ModelToolConversation();
46
+ #live;
47
+ #conversation;
26
48
  #changed;
27
- #mutationTail = Promise.resolve();
28
49
  constructor(input) {
29
50
  this.#changed =
30
51
  input.changed ??
@@ -32,6 +53,21 @@ export class WorldRevisionSession {
32
53
  /* No observers attached. */
33
54
  });
34
55
  this.#store = input.store;
56
+ let live = liveScopes.get(input.store.sessionScope)?.deref();
57
+ if (live === undefined) {
58
+ live = {
59
+ conversation: new ModelToolConversation(),
60
+ mutationTail: Promise.resolve(),
61
+ };
62
+ const reference = new WeakRef(live);
63
+ liveScopes.set(input.store.sessionScope, reference);
64
+ releaseScope.register(live, {
65
+ scope: input.store.sessionScope,
66
+ reference,
67
+ });
68
+ }
69
+ this.#live = live;
70
+ this.#conversation = live.conversation;
35
71
  this.#workspace = input.workspace;
36
72
  this.#worlds = input.worlds;
37
73
  this.#compiler = input.compiler;
@@ -56,9 +92,9 @@ export class WorldRevisionSession {
56
92
  }
57
93
  async overview(worldId) {
58
94
  return this.#mutate(async () => {
59
- const activeEpoch = await this.#workspace.active(worldId);
60
- const [sessions, sealedEpochs] = await Promise.all([
61
- this.#recoverWorldSessions(worldId),
95
+ const sessions = await this.#recoverWorldSessions(worldId);
96
+ const [activeEpoch, sealedEpochs] = await Promise.all([
97
+ this.#workspace.active(worldId),
62
98
  this.#workspace.sealed(worldId),
63
99
  ]);
64
100
  const latest = sessions.find((session) => !session.damagedContinuation) ?? null;
@@ -68,7 +104,9 @@ export class WorldRevisionSession {
68
104
  ? null
69
105
  : this.#workspace.view(shownEpoch, activeEpoch?.epochId === shownEpoch.epochId),
70
106
  sealedEpochs,
71
- latest: latest === null ? null : this.#view(latest),
107
+ latest: latest === null
108
+ ? null
109
+ : this.#view(latest, activeEpoch?.epochId ?? null),
72
110
  history: sessions.map(historyItem),
73
111
  };
74
112
  });
@@ -90,6 +128,7 @@ export class WorldRevisionSession {
90
128
  }
91
129
  async status(worldId, sessionId) {
92
130
  return this.#mutate(async () => {
131
+ await this.#workspace.active(worldId);
93
132
  const sessions = await this.#store.listSessions(worldId);
94
133
  const selectedIndex = sessionId === undefined
95
134
  ? sessions.length === 0
@@ -116,7 +155,8 @@ export class WorldRevisionSession {
116
155
  return this.#mutate(async () => {
117
156
  const session = await this.#store.readSession(sessionId);
118
157
  assertWorld(session, worldId);
119
- return this.#view(await this.#recoverIfNeeded(session));
158
+ const recovered = await this.#recoverIfNeeded(session);
159
+ return this.#view(recovered, (await this.#workspace.active(worldId))?.epochId ?? null);
120
160
  });
121
161
  }
122
162
  async deleteSession(worldId, sessionId) {
@@ -211,7 +251,8 @@ export class WorldRevisionSession {
211
251
  if (!equalModelHostBinding(session.modelBinding, host.binding()))
212
252
  throw new Error("No saved model connection matches this world-revision conversation");
213
253
  const { playPreset, bootstrap, preload } = await this.#compileCurrent(input.worldId, session.modelBinding);
214
- session.schemaVersion = 2;
254
+ session.schemaVersion = 3;
255
+ delete session.retryDispatch;
215
256
  session.requests ??= [];
216
257
  session.requests.push({
217
258
  requestId,
@@ -225,6 +266,7 @@ export class WorldRevisionSession {
225
266
  if (preload !== null)
226
267
  session.authorization = authoringPreloadAuthorization(session.authorization, preload.record);
227
268
  appendAuthoringUserMessage(session, message, requestId);
269
+ checkpointNextAuthoringDispatch(session, "dispatching");
228
270
  if (session.modelBinding.provider === "google_interactions")
229
271
  session.googleOutcomeUnknown = false;
230
272
  const boundaries = host.binding().provider === "google_interactions"
@@ -239,7 +281,112 @@ export class WorldRevisionSession {
239
281
  run: this.#startRun(session, host, requestId, boundaries),
240
282
  };
241
283
  });
242
- return prepared.run ?? this.#view(prepared.session);
284
+ return (prepared.run ??
285
+ this.#view(prepared.session, (await this.#workspace.active(input.worldId))?.epochId ?? null));
286
+ }
287
+ async editMessage(input) {
288
+ const message = requiredMessage(input.message);
289
+ const requestId = requiredRequestId(input.requestId);
290
+ if (input.filePolicy !== "keep" && input.filePolicy !== "revert")
291
+ throw new Error("Invalid file policy");
292
+ const prepared = await this.#mutate(async () => {
293
+ // Recover the epoch intent before reading the conversation it may replace.
294
+ let epoch = await this.#workspace.active(input.worldId);
295
+ let session = await this.#store.readSession(input.sessionId);
296
+ assertWorld(session, input.worldId);
297
+ session = await this.#recoverIfNeeded(session);
298
+ const edit = {
299
+ requestId,
300
+ messageId: input.messageId,
301
+ message,
302
+ filePolicy: input.filePolicy,
303
+ };
304
+ if (completedAuthoringEdit(session, edit))
305
+ return { session, run: null };
306
+ const running = this.#conversation.running(session.sessionId);
307
+ if (running !== null) {
308
+ if (running.requestId !== requestId)
309
+ throw new Error("Another message in this world-revision conversation is still running");
310
+ return { session, run: running.promise };
311
+ }
312
+ if (session.googleOutcomeUnknown)
313
+ throw new Error("The Google request outcome is unknown; start a fresh model context.");
314
+ await this.#assertNoRunningConversation(input.worldId, session.sessionId);
315
+ const host = await this.#bindExistingModelHost(session.modelBinding);
316
+ if (!equalModelHostBinding(session.modelBinding, host.binding()))
317
+ throw new Error("No saved model connection matches this world-revision conversation");
318
+ const { tail, next } = planAuthoringEdit(session, edit);
319
+ if (input.filePolicy === "revert") {
320
+ const activeChangeIds = new Set(epoch?.changes.map(({ changeSetId }) => changeSetId) ?? []);
321
+ if (tail.some((item) => item.kind === "tool" &&
322
+ !item.isError &&
323
+ item.changes.length > 0 &&
324
+ (item.changeSetId === null ||
325
+ !activeChangeIds.has(item.changeSetId))))
326
+ throw new WorldRevisionMessageEditError("sealed");
327
+ }
328
+ epoch ??= await this.#workspace.open(input.worldId);
329
+ const reverted = input.filePolicy === "revert"
330
+ ? revertAuthoringChanges(epoch.files, tail)
331
+ : { files: epoch.files, conflicts: [] };
332
+ if (reverted.conflicts.length > 0)
333
+ throw new WorldRevisionMessageEditError("conflict", reverted.conflicts);
334
+ const afterFiles = reverted.files;
335
+ if (input.filePolicy === "revert") {
336
+ const inspection = inspectWorldRevisionFiles(afterFiles, epoch.baseFiles);
337
+ if (inspection.status !== "usable")
338
+ throw new WorldRevisionMessageEditError("invalid_tree", [...new Set(inspection.diagnostics.map(({ path }) => path))].sort());
339
+ }
340
+ const candidate = {
341
+ ...epoch,
342
+ files: afterFiles,
343
+ revision: contentTreeFingerprint(afterFiles),
344
+ };
345
+ const { playPreset, bootstrap, preload } = await this.#compileCurrent(input.worldId, session.modelBinding, candidate);
346
+ next.schemaVersion = 3;
347
+ if (session.epochId !== epoch.epochId ||
348
+ tail.some((item) => item.kind === "prompt_delta" &&
349
+ item.logicalMessages.some((entry) => entry.blocks.some((block) => block.source.startsWith("runtime:world-revision-epoch:")))))
350
+ retargetSession(next, epoch, this.#locale());
351
+ next.epochId = epoch.epochId;
352
+ next.requests.push({
353
+ requestId,
354
+ modelItemStart: next.modelItems.length,
355
+ bootstrap,
356
+ playPreset: structuredClone(playPreset),
357
+ ...(preload === null
358
+ ? {}
359
+ : { preload: structuredClone(preload.record) }),
360
+ });
361
+ next.authorization =
362
+ preload === null
363
+ ? null
364
+ : authoringPreloadAuthorization(null, preload.record);
365
+ appendAuthoringUserMessage(next, message, requestId);
366
+ checkpointNextAuthoringDispatch(next, "dispatching");
367
+ if (next.modelBinding.provider === "google_interactions")
368
+ next.googleOutcomeUnknown = false;
369
+ const boundaries = host.binding().provider === "google_interactions"
370
+ ? await this.#validatedEpochBoundaries(next)
371
+ : undefined;
372
+ if (boundaries !== undefined &&
373
+ host.previewAuthoringRequest !== undefined)
374
+ next.requests.at(-1).wireRequest = host.previewAuthoringRequest(authoringExchange(next, requestId, boundaries));
375
+ await this.#workspace.publishEdit({
376
+ worldId: input.worldId,
377
+ epochId: epoch.epochId,
378
+ expectedRevision: epoch.revision,
379
+ files: afterFiles,
380
+ session: next,
381
+ });
382
+ this.#changed(input.worldId, true);
383
+ return {
384
+ session: next,
385
+ run: this.#startRun(next, host, requestId, boundaries),
386
+ };
387
+ });
388
+ return (prepared.run ??
389
+ this.#view(prepared.session, (await this.#workspace.active(input.worldId))?.epochId ?? null));
243
390
  }
244
391
  /** Continues a paused conversation within the revision it paused in. */
245
392
  async resume(input) {
@@ -263,7 +410,14 @@ export class WorldRevisionSession {
263
410
  const host = await this.#bindExistingModelHost(session.modelBinding);
264
411
  if (!equalModelHostBinding(session.modelBinding, host.binding()))
265
412
  throw new Error("No saved model connection matches this world-revision conversation");
266
- resumePausedAuthoringConversation(session);
413
+ if (canRetryAuthoringDispatch(session)) {
414
+ session.runStatus = "running";
415
+ session.activeRequestId = session.requests?.at(-1)?.requestId ?? null;
416
+ session.lastFailure = null;
417
+ session.updatedAt = Date.now();
418
+ }
419
+ else
420
+ resumePausedAuthoringConversation(session);
267
421
  const boundaries = host.binding().provider === "google_interactions"
268
422
  ? await this.#validatedEpochBoundaries(session)
269
423
  : undefined;
@@ -273,7 +427,8 @@ export class WorldRevisionSession {
273
427
  run: this.#startRun(session, host, requestId, boundaries),
274
428
  };
275
429
  });
276
- return prepared.run ?? this.#view(prepared.session);
430
+ return (prepared.run ??
431
+ this.#view(prepared.session, (await this.#workspace.active(input.worldId))?.epochId ?? null));
277
432
  }
278
433
  async preview(worldId, sessionId) {
279
434
  const session = sessionId === undefined ? null : await this.#store.readSession(sessionId);
@@ -288,8 +443,8 @@ export class WorldRevisionSession {
288
443
  const { bootstrap } = await this.#compileCurrent(worldId, host.binding());
289
444
  return bootstrap;
290
445
  }
291
- async #compileCurrent(worldId, modelBinding) {
292
- const epoch = await this.#workspace.active(worldId);
446
+ async #compileCurrent(worldId, modelBinding, overrideEpoch) {
447
+ const epoch = overrideEpoch ?? (await this.#workspace.active(worldId));
293
448
  const worlds = await this.#worlds.listWorlds();
294
449
  const playPreset = await this.#bindPlayPreset();
295
450
  const locale = this.#locale();
@@ -386,7 +541,8 @@ export class WorldRevisionSession {
386
541
  });
387
542
  const now = Date.now();
388
543
  const session = {
389
- schemaVersion: 1,
544
+ schemaVersion: 3,
545
+ requests: [],
390
546
  sessionId: this.#store.createSessionId(),
391
547
  worldId,
392
548
  worldTitle,
@@ -410,7 +566,6 @@ export class WorldRevisionSession {
410
566
  authorization: null,
411
567
  pendingSettlement: null,
412
568
  };
413
- await this.#store.saveSession(session);
414
569
  return session;
415
570
  }
416
571
  #startRun(session, host, requestId, worldRevisionBoundaries) {
@@ -420,6 +575,7 @@ export class WorldRevisionSession {
420
575
  session,
421
576
  host,
422
577
  requestId,
578
+ exchangeRequestId: session.activeRequestId ?? requestId,
423
579
  ...(worldRevisionBoundaries === undefined
424
580
  ? {}
425
581
  : { worldRevisionBoundaries }),
@@ -429,6 +585,7 @@ export class WorldRevisionSession {
429
585
  activeTransaction = await this.#settleToolResponse(next, assistantItemIndex, calls, activeTransaction);
430
586
  },
431
587
  view: (next) => this.#view(next),
588
+ retrySupported: session.schemaVersion === 3,
432
589
  failure: {
433
590
  cancelled: "The world-revision response was cancelled. Settled worktree changes were retained.",
434
591
  interrupted: "The world-revision response was interrupted",
@@ -510,6 +667,8 @@ export class WorldRevisionSession {
510
667
  toolResults,
511
668
  });
512
669
  finalizeSettlement(session, prepared);
670
+ if (session.schemaVersion === 3)
671
+ checkpointNextAuthoringDispatch(session, "dispatching");
513
672
  session.updatedAt = Date.now();
514
673
  await this.#store.saveSession(session);
515
674
  return { revision: afterRevision, transaction };
@@ -571,6 +730,8 @@ export class WorldRevisionSession {
571
730
  return session;
572
731
  if (this.#conversation.isRunning(session.sessionId))
573
732
  return session;
733
+ await this.#workspace.active(session.worldId);
734
+ session = await this.#store.readSession(session.sessionId);
574
735
  const lastItem = session.modelItems.at(-1);
575
736
  if (session.pendingSettlement === null &&
576
737
  session.modelBinding.provider === "google_interactions" &&
@@ -607,6 +768,7 @@ export class WorldRevisionSession {
607
768
  session.googleOutcomeUnknown !== false)
608
769
  session.googleOutcomeUnknown = true;
609
770
  const recoveringSettlement = session.pendingSettlement !== null;
771
+ let settlementPublished = false;
610
772
  if (session.pendingSettlement !== null) {
611
773
  if (session.modelBinding.provider === "google_interactions")
612
774
  session.googleOutcomeUnknown = false;
@@ -614,20 +776,16 @@ export class WorldRevisionSession {
614
776
  if (pending.phase === "publication_prepared") {
615
777
  const active = await this.#workspace.active(session.worldId);
616
778
  if (active?.epochId === pending.epochId) {
617
- try {
618
- await this.#workspace.publishAi({
619
- worldId: session.worldId,
620
- epochId: pending.epochId,
621
- expectedRevision: pending.beforeRevision,
622
- afterRevision: pending.afterRevision,
623
- files: pending.afterFiles,
624
- toolResults: pending.toolResults,
625
- });
626
- finalizeSettlement(session, pending);
627
- }
628
- catch (error) {
629
- finalizeUnconfirmed(session, pending.assistantItemIndex, error);
630
- }
779
+ await this.#workspace.publishAi({
780
+ worldId: session.worldId,
781
+ epochId: pending.epochId,
782
+ expectedRevision: pending.beforeRevision,
783
+ afterRevision: pending.afterRevision,
784
+ files: pending.afterFiles,
785
+ toolResults: pending.toolResults,
786
+ });
787
+ finalizeSettlement(session, pending);
788
+ settlementPublished = true;
631
789
  }
632
790
  else
633
791
  finalizeUnconfirmed(session, pending.assistantItemIndex, new Error("The revision epoch ended before tool publication recovered"));
@@ -638,6 +796,13 @@ export class WorldRevisionSession {
638
796
  if (session.runStatus !== "running" && !recoveringSettlement)
639
797
  return session;
640
798
  const requestId = session.activeRequestId;
799
+ if (settlementPublished &&
800
+ session.modelBinding.provider !== "google_interactions" &&
801
+ session.requests?.at(-1) !== undefined) {
802
+ session.schemaVersion = 3;
803
+ checkpointNextAuthoringDispatch(session, "retryable");
804
+ }
805
+ recoverAuthoringDispatch(session);
641
806
  session.runStatus =
642
807
  session.modelBinding.provider === "google_interactions" &&
643
808
  session.googleOutcomeUnknown === false
@@ -671,7 +836,7 @@ export class WorldRevisionSession {
671
836
  this.#conversation.isRunning(sessionId)))
672
837
  throw new WorldRevisionWorkspaceError("Wait for or cancel the running AI response before changing the revision");
673
838
  }
674
- #view(session) {
839
+ #view(session, activeEpochId = session.epochId) {
675
840
  const streaming = session.runStatus === "running"
676
841
  ? this.#conversation.streaming(session.sessionId)
677
842
  : null;
@@ -691,6 +856,7 @@ export class WorldRevisionSession {
691
856
  updatedAt: session.updatedAt,
692
857
  },
693
858
  lastFailure: session.lastFailure,
859
+ canRetry: activeEpochId === session.epochId && canRetryAuthoringDispatch(session),
694
860
  };
695
861
  }
696
862
  #statusView(session) {
@@ -709,9 +875,9 @@ export class WorldRevisionSession {
709
875
  };
710
876
  }
711
877
  async #mutate(operation) {
712
- const previous = this.#mutationTail;
878
+ const previous = this.#live.mutationTail;
713
879
  let release = () => undefined;
714
- this.#mutationTail = new Promise((resolveMutation) => {
880
+ this.#live.mutationTail = new Promise((resolveMutation) => {
715
881
  release = resolveMutation;
716
882
  });
717
883
  await previous;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
3
4
  import { readDeclaredWorldClock } from "../prompt/WorldMaintenanceReport.js";
4
5
  import { WorldOperationClaimLostError } from "../world/WorldOperationCoordinator.js";
@@ -125,6 +126,47 @@ export class WorldRevisionWorkspace {
125
126
  return epoch;
126
127
  });
127
128
  }
129
+ async publishEdit(input) {
130
+ const handle = revisionHandle(input);
131
+ await this.#withMutableEpoch(handle, async (epoch) => {
132
+ assertExpectedRevision(epoch, input.expectedRevision);
133
+ const files = cloneFiles(input.files);
134
+ assertUsableWorldRevisionFiles(files, epoch.baseFiles);
135
+ const changes = fileDiff(epoch.files, files);
136
+ if (changes.length > 0)
137
+ epoch.changes.push({
138
+ changeSetId: this.#store.createChangeSetId(),
139
+ source: "rollback",
140
+ createdAt: Date.now(),
141
+ changes,
142
+ });
143
+ epoch.files = files;
144
+ epoch.revision = contentTreeFingerprint(files);
145
+ epoch.updatedAt = Date.now();
146
+ await this.#store.prepareEdit({
147
+ beforeRevision: input.expectedRevision,
148
+ epoch,
149
+ session: input.session,
150
+ });
151
+ await this.#finishEdit(handle);
152
+ });
153
+ }
154
+ async #finishEdit(handle) {
155
+ const pending = await this.#store.readEdit(handle.epochId);
156
+ if (pending === null)
157
+ return;
158
+ if (pending.epoch.worldId !== handle.worldId)
159
+ throw new WorldRevisionWorkspaceError("Pending edit belongs to another world");
160
+ const current = await this.#store.readEpoch(handle.epochId);
161
+ if (!isDeepStrictEqual(current, pending.epoch)) {
162
+ if (current.lifecycle !== "active" ||
163
+ current.revision !== pending.beforeRevision)
164
+ throw new WorldRevisionWorkspaceError("Pending world-revision edit conflicts with the worktree");
165
+ await this.#store.saveEpoch(pending.epoch);
166
+ }
167
+ await this.#store.saveSession(pending.session);
168
+ await this.#store.removeEdit(handle.epochId);
169
+ }
128
170
  async rollback(input) {
129
171
  const handle = revisionHandle(input);
130
172
  return this.#withMutableEpoch(handle, async (epoch) => {
@@ -227,6 +269,7 @@ export class WorldRevisionWorkspace {
227
269
  };
228
270
  }
229
271
  async #resumeLocked(handle) {
272
+ await this.#withRevisionOwnership(handle, () => Promise.resolve());
230
273
  let epoch = await this.#store.readEpochIfPresent(handle.epochId);
231
274
  if (epoch === null) {
232
275
  const binding = await this.#worlds.bindWorldRevision(handle);
@@ -336,7 +379,10 @@ export class WorldRevisionWorkspace {
336
379
  }
337
380
  async #withRevisionOwnership(handle, action) {
338
381
  try {
339
- return await this.#worlds.operations.withWorldRevisionLock(handle, action);
382
+ return await this.#worlds.operations.withWorldRevisionLock(handle, async () => {
383
+ await this.#finishEdit(handle);
384
+ return action();
385
+ });
340
386
  }
341
387
  catch (error) {
342
388
  if (error instanceof WorldOperationClaimLostError)