narraeon 0.7.10 → 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.
Files changed (49) hide show
  1. package/dist/node/protocol/modelConnections.js +15 -0
  2. package/dist/node/protocol/modelUsage.js +4 -0
  3. package/dist/node/protocol/sillyTavernContent.js +1 -0
  4. package/dist/node/protocol/v1.js +97 -0
  5. package/dist/node/runtime/V1Runtime.js +105 -3
  6. package/dist/node/runtime/authoring/AuthoringEditPlan.js +79 -0
  7. package/dist/node/runtime/authoring/AuthoringRequestSnapshot.js +15 -3
  8. package/dist/node/runtime/authoring/AuthoringRetry.js +59 -0
  9. package/dist/node/runtime/authoring/DamagedGoogleContinuation.js +68 -0
  10. package/dist/node/runtime/authoring/ModelToolConversation.js +66 -25
  11. package/dist/node/runtime/content/ContentWorkspace.js +10 -2
  12. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +14 -8
  13. package/dist/node/runtime/content/SillyTavernContent.js +796 -0
  14. package/dist/node/runtime/model/AiFailureLog.js +8 -1
  15. package/dist/node/runtime/model/DispatchFailure.js +8 -0
  16. package/dist/node/runtime/model/FileNativeModelAdapters.js +627 -18
  17. package/dist/node/runtime/model/GoogleHopJournal.js +90 -0
  18. package/dist/node/runtime/model/GoogleInteractionsStream.js +242 -0
  19. package/dist/node/runtime/model/GoogleRequestPreview.js +184 -0
  20. package/dist/node/runtime/model/ModelConnectionStore.js +4 -1
  21. package/dist/node/runtime/model/ProviderExchangeState.js +44 -1
  22. package/dist/node/runtime/model/ProviderStream.js +4 -2
  23. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +4 -4
  24. package/dist/node/runtime/play/FileNativePlayPresetStore.js +150 -78
  25. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +199 -10
  26. package/dist/node/runtime/play/OrderedPlayPrompts.js +139 -178
  27. package/dist/node/runtime/play/PlayAdvanceLifecycle.js +51 -23
  28. package/dist/node/runtime/play/PlayCallChain.js +92 -8
  29. package/dist/node/runtime/play/PlayFollowupRequests.js +23 -3
  30. package/dist/node/runtime/prompt/PromptCompilationCodec.js +2 -1
  31. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +321 -15
  32. package/dist/node/runtime/setting/SettingImprovementSession.js +285 -21
  33. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +236 -15
  34. package/dist/node/runtime/world-revision/WorldRevisionSession.js +324 -50
  35. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +47 -1
  36. package/dist/node/shared/application-preset-files.js +29 -0
  37. package/dist/node/shared/default-preset-host.js +50 -38
  38. package/dist/node/shared/first-party-action-choices.js +7 -11
  39. package/dist/node/shared/first-party-generic-panels.js +7 -11
  40. package/dist/node/shared/first-party-player-view.js +7 -11
  41. package/dist/node/shared/ordered-author-prompts.js +6 -4
  42. package/dist/node/shared/ordered-play-prompts.js +4 -2
  43. package/dist/node/shared/prompt-arrangement.js +205 -0
  44. package/dist/node/shared/sillytavern-preset.js +454 -0
  45. package/dist/web/assets/index-BthiDIQu.js +1432 -0
  46. package/dist/web/assets/{index-DH2BVsYd.css → index-wO9Y4HT1.css} +1 -1
  47. package/dist/web/index.html +2 -2
  48. package/package.json +1 -1
  49. package/dist/web/assets/index-B2kbaOGe.js +0 -1408
@@ -1,15 +1,37 @@
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";
5
+ import { isDeepStrictEqual } from "node:util";
3
6
  import { emptyAggregatedModelUsage } from "../../protocol/modelUsage.js";
4
- import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringRequest, resumePausedAuthoringConversation, } from "../authoring/ModelToolConversation.js";
7
+ import { appendAuthoringUserMessage, authoringExchange, ModelToolConversation, rememberAuthoringRequest, resumePausedAuthoringConversation, } from "../authoring/ModelToolConversation.js";
5
8
  import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
6
9
  import { equalModelHostBinding } from "../model/ModelHost.js";
7
10
  import { settingImprovementPromptForBinding, } from "../play/FileNativePlayPresetStore.js";
8
11
  import { authoringPreloadAuthorization, buildAuthoringPreload, worldRevisionAuthoringSurface, } from "../setting/SettingAuthoringTransaction.js";
9
12
  import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
10
- import { WorldRevisionAuthoringTransaction, worldRevisionRuntimeContract, worldRevisionToolDefinitions, } from "./WorldRevisionAuthoringTransaction.js";
13
+ import { WorldRevisionAuthoringTransaction, inspectWorldRevisionFiles, worldRevisionRuntimeContract, worldRevisionToolDefinitions, } from "./WorldRevisionAuthoringTransaction.js";
11
14
  import { WorldRevisionWorkspaceError } from "./WorldRevisionWorkspace.js";
12
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
+ });
13
35
  /** Conversation orchestration over the shared locked revision worktree. */
14
36
  export class WorldRevisionSession {
15
37
  #store;
@@ -21,9 +43,9 @@ export class WorldRevisionSession {
21
43
  #bindExistingModelHost;
22
44
  #bindPlayPreset;
23
45
  #preview;
24
- #conversation = new ModelToolConversation();
46
+ #live;
47
+ #conversation;
25
48
  #changed;
26
- #mutationTail = Promise.resolve();
27
49
  constructor(input) {
28
50
  this.#changed =
29
51
  input.changed ??
@@ -31,6 +53,21 @@ export class WorldRevisionSession {
31
53
  /* No observers attached. */
32
54
  });
33
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;
34
71
  this.#workspace = input.workspace;
35
72
  this.#worlds = input.worlds;
36
73
  this.#compiler = input.compiler;
@@ -55,19 +92,21 @@ export class WorldRevisionSession {
55
92
  }
56
93
  async overview(worldId) {
57
94
  return this.#mutate(async () => {
58
- const activeEpoch = await this.#workspace.active(worldId);
59
- const [sessions, sealedEpochs] = await Promise.all([
60
- this.#recoverWorldSessions(worldId),
95
+ const sessions = await this.#recoverWorldSessions(worldId);
96
+ const [activeEpoch, sealedEpochs] = await Promise.all([
97
+ this.#workspace.active(worldId),
61
98
  this.#workspace.sealed(worldId),
62
99
  ]);
63
- const latest = sessions[0] ?? null;
100
+ const latest = sessions.find((session) => !session.damagedContinuation) ?? null;
64
101
  const shownEpoch = activeEpoch ?? (await this.#workspace.latest(worldId));
65
102
  return {
66
103
  epoch: shownEpoch === null
67
104
  ? null
68
105
  : this.#workspace.view(shownEpoch, activeEpoch?.epochId === shownEpoch.epochId),
69
106
  sealedEpochs,
70
- latest: latest === null ? null : this.#view(latest),
107
+ latest: latest === null
108
+ ? null
109
+ : this.#view(latest, activeEpoch?.epochId ?? null),
71
110
  history: sessions.map(historyItem),
72
111
  };
73
112
  });
@@ -89,6 +128,7 @@ export class WorldRevisionSession {
89
128
  }
90
129
  async status(worldId, sessionId) {
91
130
  return this.#mutate(async () => {
131
+ await this.#workspace.active(worldId);
92
132
  const sessions = await this.#store.listSessions(worldId);
93
133
  const selectedIndex = sessionId === undefined
94
134
  ? sessions.length === 0
@@ -115,7 +155,8 @@ export class WorldRevisionSession {
115
155
  return this.#mutate(async () => {
116
156
  const session = await this.#store.readSession(sessionId);
117
157
  assertWorld(session, worldId);
118
- 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);
119
160
  });
120
161
  }
121
162
  async deleteSession(worldId, sessionId) {
@@ -188,19 +229,30 @@ export class WorldRevisionSession {
188
229
  throw new Error("Another message in this world-revision conversation is still running");
189
230
  return { session, run: running.promise };
190
231
  }
232
+ if (session.googleOutcomeUnknown)
233
+ throw new Error("The Google request outcome is unknown; start a fresh model context.");
234
+ if (session.runStatus === "paused")
235
+ throw new Error("Resume the paused authoring request before sending another message");
191
236
  }
192
237
  await this.#assertNoRunningConversation(input.worldId, session?.sessionId);
238
+ let host;
239
+ if (session !== null) {
240
+ host = await this.#bindExistingModelHost(session.modelBinding);
241
+ if (!equalModelHostBinding(session.modelBinding, host.binding()))
242
+ throw new Error("No saved model connection matches this world-revision conversation");
243
+ }
193
244
  const epoch = await this.#workspace.open(input.worldId);
194
245
  session ??= await this.#create(input.worldId, epoch, requestId);
195
246
  if (session.epochId !== epoch.epochId) {
196
247
  retargetSession(session, epoch, this.#locale());
197
248
  await this.#store.saveSession(session);
198
249
  }
199
- const host = await this.#bindExistingModelHost(session.modelBinding);
250
+ host ??= await this.#bindExistingModelHost(session.modelBinding);
200
251
  if (!equalModelHostBinding(session.modelBinding, host.binding()))
201
252
  throw new Error("No saved model connection matches this world-revision conversation");
202
253
  const { playPreset, bootstrap, preload } = await this.#compileCurrent(input.worldId, session.modelBinding);
203
- session.schemaVersion = 2;
254
+ session.schemaVersion = 3;
255
+ delete session.retryDispatch;
204
256
  session.requests ??= [];
205
257
  session.requests.push({
206
258
  requestId,
@@ -214,13 +266,127 @@ export class WorldRevisionSession {
214
266
  if (preload !== null)
215
267
  session.authorization = authoringPreloadAuthorization(session.authorization, preload.record);
216
268
  appendAuthoringUserMessage(session, message, requestId);
269
+ checkpointNextAuthoringDispatch(session, "dispatching");
270
+ if (session.modelBinding.provider === "google_interactions")
271
+ session.googleOutcomeUnknown = false;
272
+ const boundaries = host.binding().provider === "google_interactions"
273
+ ? await this.#validatedEpochBoundaries(session)
274
+ : undefined;
275
+ if (boundaries !== undefined &&
276
+ host.previewAuthoringRequest !== undefined)
277
+ session.requests.at(-1).wireRequest = host.previewAuthoringRequest(authoringExchange(session, requestId, boundaries));
217
278
  await this.#store.saveSession(session);
218
279
  return {
219
280
  session,
220
- run: this.#startRun(session, host, requestId),
281
+ run: this.#startRun(session, host, requestId, boundaries),
221
282
  };
222
283
  });
223
- 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));
224
390
  }
225
391
  /** Continues a paused conversation within the revision it paused in. */
226
392
  async resume(input) {
@@ -244,11 +410,25 @@ export class WorldRevisionSession {
244
410
  const host = await this.#bindExistingModelHost(session.modelBinding);
245
411
  if (!equalModelHostBinding(session.modelBinding, host.binding()))
246
412
  throw new Error("No saved model connection matches this world-revision conversation");
247
- 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);
421
+ const boundaries = host.binding().provider === "google_interactions"
422
+ ? await this.#validatedEpochBoundaries(session)
423
+ : undefined;
248
424
  await this.#store.saveSession(session);
249
- return { session, run: this.#startRun(session, host, requestId) };
425
+ return {
426
+ session,
427
+ run: this.#startRun(session, host, requestId, boundaries),
428
+ };
250
429
  });
251
- return prepared.run ?? this.#view(prepared.session);
430
+ return (prepared.run ??
431
+ this.#view(prepared.session, (await this.#workspace.active(input.worldId))?.epochId ?? null));
252
432
  }
253
433
  async preview(worldId, sessionId) {
254
434
  const session = sessionId === undefined ? null : await this.#store.readSession(sessionId);
@@ -263,8 +443,8 @@ export class WorldRevisionSession {
263
443
  const { bootstrap } = await this.#compileCurrent(worldId, host.binding());
264
444
  return bootstrap;
265
445
  }
266
- async #compileCurrent(worldId, modelBinding) {
267
- const epoch = await this.#workspace.active(worldId);
446
+ async #compileCurrent(worldId, modelBinding, overrideEpoch) {
447
+ const epoch = overrideEpoch ?? (await this.#workspace.active(worldId));
268
448
  const worlds = await this.#worlds.listWorlds();
269
449
  const playPreset = await this.#bindPlayPreset();
270
450
  const locale = this.#locale();
@@ -361,7 +541,8 @@ export class WorldRevisionSession {
361
541
  });
362
542
  const now = Date.now();
363
543
  const session = {
364
- schemaVersion: 1,
544
+ schemaVersion: 3,
545
+ requests: [],
365
546
  sessionId: this.#store.createSessionId(),
366
547
  worldId,
367
548
  worldTitle,
@@ -385,27 +566,58 @@ export class WorldRevisionSession {
385
566
  authorization: null,
386
567
  pendingSettlement: null,
387
568
  };
388
- await this.#store.saveSession(session);
389
569
  return session;
390
570
  }
391
- #startRun(session, host, requestId) {
571
+ #startRun(session, host, requestId, worldRevisionBoundaries) {
392
572
  let activeTransaction = null;
393
573
  return this.#conversation.run({
394
574
  changed: (durable) => this.#changed(session.worldId, durable),
395
575
  session,
396
576
  host,
397
577
  requestId,
578
+ exchangeRequestId: session.activeRequestId ?? requestId,
579
+ ...(worldRevisionBoundaries === undefined
580
+ ? {}
581
+ : { worldRevisionBoundaries }),
398
582
  save: (next) => this.#store.saveSession(next),
583
+ recordGoogleHop: (id, hop) => this.#store.recordGoogleHop(session.sessionId, id, hop),
399
584
  settleToolResponse: async (next, assistantItemIndex, calls) => {
400
585
  activeTransaction = await this.#settleToolResponse(next, assistantItemIndex, calls, activeTransaction);
401
586
  },
402
587
  view: (next) => this.#view(next),
588
+ retrySupported: session.schemaVersion === 3,
403
589
  failure: {
404
590
  cancelled: "The world-revision response was cancelled. Settled worktree changes were retained.",
405
591
  interrupted: "The world-revision response was interrupted",
406
592
  },
407
593
  });
408
594
  }
595
+ async #validatedEpochBoundaries(session) {
596
+ const entries = [];
597
+ for (const [itemIndex, item] of session.modelItems.entries()) {
598
+ if (item.kind !== "prompt_delta")
599
+ continue;
600
+ const source = item.logicalMessages[0]?.blocks[0]?.source;
601
+ if (source?.startsWith("runtime:world-revision-epoch:") !== true)
602
+ continue;
603
+ const epochId = source.slice("runtime:world-revision-epoch:".length);
604
+ const epoch = await this.#store.readEpoch(epochId);
605
+ if (epoch.worldId !== session.worldId)
606
+ throw new Error("World-revision epoch boundary belongs to another world");
607
+ const valid = ["en", "zh-CN"].some((locale) => isDeepStrictEqual(item, epochBoundaryItem(epoch, locale)));
608
+ if (!valid)
609
+ throw new Error("World-revision epoch boundary is damaged");
610
+ entries.push({
611
+ itemIndex,
612
+ epochId,
613
+ baseHead: epoch.baseHead,
614
+ markdown: item.logicalMessages[0].markdown,
615
+ });
616
+ }
617
+ if (entries.length > 0 && entries.at(-1)?.epochId !== session.epochId)
618
+ throw new Error("World-revision epoch boundary does not match the current epoch");
619
+ return { currentEpochId: session.epochId, entries };
620
+ }
409
621
  async #settleToolResponse(session, assistantItemIndex, calls, active) {
410
622
  const epoch = await this.#requiredActiveEpoch(session);
411
623
  const beforeRevision = epoch.revision;
@@ -455,6 +667,8 @@ export class WorldRevisionSession {
455
667
  toolResults,
456
668
  });
457
669
  finalizeSettlement(session, prepared);
670
+ if (session.schemaVersion === 3)
671
+ checkpointNextAuthoringDispatch(session, "dispatching");
458
672
  session.updatedAt = Date.now();
459
673
  await this.#store.saveSession(session);
460
674
  return { revision: afterRevision, transaction };
@@ -512,27 +726,66 @@ export class WorldRevisionSession {
512
726
  }
513
727
  }
514
728
  async #recoverIfNeeded(session) {
729
+ if (session.damagedContinuation)
730
+ return session;
515
731
  if (this.#conversation.isRunning(session.sessionId))
516
732
  return session;
733
+ await this.#workspace.active(session.worldId);
734
+ session = await this.#store.readSession(session.sessionId);
735
+ const lastItem = session.modelItems.at(-1);
736
+ if (session.pendingSettlement === null &&
737
+ session.modelBinding.provider === "google_interactions" &&
738
+ session.runStatus === "interrupted" &&
739
+ session.googleOutcomeUnknown === undefined &&
740
+ (lastItem?.kind === "user" || lastItem?.kind === "tool")) {
741
+ session.googleOutcomeUnknown = true;
742
+ session.lastFailure =
743
+ "The Google request outcome is unknown; start a fresh model context.";
744
+ session.updatedAt = Date.now();
745
+ await this.#store.saveSession(session);
746
+ return session;
747
+ }
748
+ if (session.pendingSettlement === null &&
749
+ lastItem?.kind === "assistant" &&
750
+ lastItem.toolCalls.length > 0) {
751
+ finalizeUnconfirmed(session, session.modelItems.length - 1, new Error("Runtime found a complete tool response without a persisted worktree settlement intent"));
752
+ if (session.modelBinding.provider === "google_interactions")
753
+ session.googleOutcomeUnknown = false;
754
+ const requestId = session.activeRequestId;
755
+ session.runStatus = "interrupted";
756
+ session.activeRequestId = null;
757
+ if (requestId !== null)
758
+ session.completedRequestIds = rememberAuthoringRequest(session.completedRequestIds, requestId);
759
+ session.lastFailure =
760
+ "The complete tool response could not be settled; its calls received failure receipts. Re-read the worktree before editing.";
761
+ session.updatedAt = Date.now();
762
+ await this.#store.saveSession(session);
763
+ return session;
764
+ }
765
+ if (session.runStatus === "running" &&
766
+ session.pendingSettlement === null &&
767
+ session.modelBinding.provider === "google_interactions" &&
768
+ session.googleOutcomeUnknown !== false)
769
+ session.googleOutcomeUnknown = true;
770
+ const recoveringSettlement = session.pendingSettlement !== null;
771
+ let settlementPublished = false;
517
772
  if (session.pendingSettlement !== null) {
773
+ if (session.modelBinding.provider === "google_interactions")
774
+ session.googleOutcomeUnknown = false;
518
775
  const pending = session.pendingSettlement;
519
776
  if (pending.phase === "publication_prepared") {
520
777
  const active = await this.#workspace.active(session.worldId);
521
778
  if (active?.epochId === pending.epochId) {
522
- try {
523
- await this.#workspace.publishAi({
524
- worldId: session.worldId,
525
- epochId: pending.epochId,
526
- expectedRevision: pending.beforeRevision,
527
- afterRevision: pending.afterRevision,
528
- files: pending.afterFiles,
529
- toolResults: pending.toolResults,
530
- });
531
- finalizeSettlement(session, pending);
532
- }
533
- catch (error) {
534
- finalizeUnconfirmed(session, pending.assistantItemIndex, error);
535
- }
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;
536
789
  }
537
790
  else
538
791
  finalizeUnconfirmed(session, pending.assistantItemIndex, new Error("The revision epoch ended before tool publication recovered"));
@@ -540,15 +793,27 @@ export class WorldRevisionSession {
540
793
  else
541
794
  finalizeUnconfirmed(session, pending.assistantItemIndex, new Error("Runtime restarted before the complete tool response was prepared"));
542
795
  }
543
- if (session.runStatus !== "running" && session.pendingSettlement === null)
796
+ if (session.runStatus !== "running" && !recoveringSettlement)
544
797
  return session;
545
798
  const requestId = session.activeRequestId;
546
- session.runStatus = "interrupted";
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);
806
+ session.runStatus =
807
+ session.modelBinding.provider === "google_interactions" &&
808
+ session.googleOutcomeUnknown === false
809
+ ? "paused"
810
+ : "interrupted";
547
811
  session.activeRequestId = null;
548
812
  if (requestId !== null)
549
813
  session.completedRequestIds = rememberAuthoringRequest(session.completedRequestIds, requestId);
550
- session.lastFailure =
551
- "The previous model request stopped after its last settled worktree change. Continue this conversation when ready.";
814
+ session.lastFailure = session.googleOutcomeUnknown
815
+ ? "The Google request outcome is unknown; start a fresh model context."
816
+ : "The previous model request stopped after its last settled worktree change. Continue this conversation when ready.";
552
817
  session.updatedAt = Date.now();
553
818
  await this.#store.saveSession(session);
554
819
  return session;
@@ -571,7 +836,7 @@ export class WorldRevisionSession {
571
836
  this.#conversation.isRunning(sessionId)))
572
837
  throw new WorldRevisionWorkspaceError("Wait for or cancel the running AI response before changing the revision");
573
838
  }
574
- #view(session) {
839
+ #view(session, activeEpochId = session.epochId) {
575
840
  const streaming = session.runStatus === "running"
576
841
  ? this.#conversation.streaming(session.sessionId)
577
842
  : null;
@@ -591,6 +856,7 @@ export class WorldRevisionSession {
591
856
  updatedAt: session.updatedAt,
592
857
  },
593
858
  lastFailure: session.lastFailure,
859
+ canRetry: activeEpochId === session.epochId && canRetryAuthoringDispatch(session),
594
860
  };
595
861
  }
596
862
  #statusView(session) {
@@ -609,9 +875,9 @@ export class WorldRevisionSession {
609
875
  };
610
876
  }
611
877
  async #mutate(operation) {
612
- const previous = this.#mutationTail;
878
+ const previous = this.#live.mutationTail;
613
879
  let release = () => undefined;
614
- this.#mutationTail = new Promise((resolveMutation) => {
880
+ this.#live.mutationTail = new Promise((resolveMutation) => {
615
881
  release = resolveMutation;
616
882
  });
617
883
  await previous;
@@ -624,10 +890,18 @@ export class WorldRevisionSession {
624
890
  }
625
891
  }
626
892
  function retargetSession(session, epoch, locale) {
893
+ session.modelItems.push(epochBoundaryItem(epoch, locale));
894
+ session.epochId = epoch.epochId;
895
+ session.authorization = null;
896
+ session.pendingSettlement = null;
897
+ session.lastFailure = null;
898
+ session.updatedAt = Date.now();
899
+ }
900
+ function epochBoundaryItem(epoch, locale) {
627
901
  const markdown = locale === "zh-CN"
628
902
  ? `# 新的世界修订 epoch\n\n上一轮修订已经应用或放弃。当前工作树是 ${epoch.epochId},从世界端点 ${epoch.baseHead} 重新建立。此前全部 search cursor 和 read 写授权已失效;修改前必须重新读取。`
629
903
  : `# New world-revision epoch\n\nThe previous revision was applied or discarded. Worktree ${epoch.epochId} was rebuilt from world endpoint ${epoch.baseHead}. Every prior search cursor and read authorization is invalid; re-read before writing.`;
630
- session.modelItems.push({
904
+ return {
631
905
  kind: "prompt_delta",
632
906
  logicalMessages: [
633
907
  {
@@ -641,12 +915,7 @@ function retargetSession(session, epoch, locale) {
641
915
  ],
642
916
  },
643
917
  ],
644
- });
645
- session.epochId = epoch.epochId;
646
- session.authorization = null;
647
- session.pendingSettlement = null;
648
- session.lastFailure = null;
649
- session.updatedAt = Date.now();
918
+ };
650
919
  }
651
920
  function finalizeSettlement(session, pending) {
652
921
  for (const result of pending.toolResults)
@@ -745,6 +1014,11 @@ function historyItem(session) {
745
1014
  const changed = new Set(session.modelItems.flatMap((item) => item.kind === "tool" ? item.changes.map(({ path }) => path) : []));
746
1015
  return {
747
1016
  sessionId: session.sessionId,
1017
+ ...(session.damagedContinuation
1018
+ ? {
1019
+ unavailableReason: "Google continuation is damaged; start a fresh model context.",
1020
+ }
1021
+ : {}),
748
1022
  epochId: session.epochId,
749
1023
  runStatus: session.runStatus,
750
1024
  createdAt: session.createdAt,
@@ -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)