narraeon 0.7.9 → 0.8.0

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 (45) 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 +75 -0
  5. package/dist/node/runtime/V1Runtime.js +71 -1
  6. package/dist/node/runtime/authoring/AuthoringRequestSnapshot.js +15 -3
  7. package/dist/node/runtime/authoring/DamagedGoogleContinuation.js +68 -0
  8. package/dist/node/runtime/authoring/ModelToolConversation.js +47 -22
  9. package/dist/node/runtime/content/ContentWorkspace.js +3 -0
  10. package/dist/node/runtime/content/SillyTavernContent.js +796 -0
  11. package/dist/node/runtime/model/AiFailureLog.js +8 -1
  12. package/dist/node/runtime/model/FileNativeModelAdapters.js +610 -18
  13. package/dist/node/runtime/model/GoogleHopJournal.js +90 -0
  14. package/dist/node/runtime/model/GoogleInteractionsStream.js +242 -0
  15. package/dist/node/runtime/model/GoogleRequestPreview.js +184 -0
  16. package/dist/node/runtime/model/ModelConnectionStore.js +4 -1
  17. package/dist/node/runtime/model/ProviderExchangeState.js +44 -1
  18. package/dist/node/runtime/model/ProviderStream.js +4 -2
  19. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +4 -4
  20. package/dist/node/runtime/play/FileNativePlayPresetStore.js +150 -78
  21. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +150 -10
  22. package/dist/node/runtime/play/OrderedPlayPrompts.js +139 -178
  23. package/dist/node/runtime/play/PlayAdvanceLifecycle.js +33 -9
  24. package/dist/node/runtime/play/PlayCallChain.js +76 -4
  25. package/dist/node/runtime/play/PlayFollowupRequests.js +23 -3
  26. package/dist/node/runtime/prompt/PromptCompilationCodec.js +2 -1
  27. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +112 -8
  28. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +100 -41
  29. package/dist/node/runtime/setting/SettingImprovementSession.js +48 -7
  30. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +84 -8
  31. package/dist/node/runtime/world-revision/WorldRevisionSession.js +125 -17
  32. package/dist/node/shared/application-preset-files.js +29 -0
  33. package/dist/node/shared/default-preset-host.js +50 -38
  34. package/dist/node/shared/first-party-action-choices.js +7 -11
  35. package/dist/node/shared/first-party-generic-panels.js +7 -11
  36. package/dist/node/shared/first-party-player-view.js +7 -11
  37. package/dist/node/shared/ordered-author-prompts.js +6 -4
  38. package/dist/node/shared/ordered-play-prompts.js +4 -2
  39. package/dist/node/shared/prompt-arrangement.js +205 -0
  40. package/dist/node/shared/sillytavern-preset.js +454 -0
  41. package/dist/web/assets/{index-DH2BVsYd.css → index-BKA1D0Rz.css} +1 -1
  42. package/dist/web/assets/index-C_Qta8UL.js +1432 -0
  43. package/dist/web/index.html +2 -2
  44. package/package.json +1 -1
  45. package/dist/web/assets/index-B2kbaOGe.js +0 -1408
@@ -1,5 +1,7 @@
1
+ import { builtinAuthorPrompts } from "../../shared/ordered-author-prompts.js";
2
+ import { expandRetiredPrompts, customizePromptArrangement, resolvePromptArrangement, retiredBuiltinReplacements, } from "../../shared/prompt-arrangement.js";
1
3
  import { createHash } from "node:crypto";
2
- import { isMap, isSeq, parseDocument } from "yaml";
4
+ import { isMap, parseDocument } from "yaml";
3
5
  import { builtinPlayPrompts, isPromptMessageRole, isTurnGuideLine, promptGroups, } from "../../shared/ordered-play-prompts.js";
4
6
  const promptName = /^[^\r\n\0]{1,160}$/u;
5
7
  /** The same strict permission gate is used by portable imports and structured saves. */
@@ -86,24 +88,22 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
86
88
  ? retiredBuiltinReplacements[item.builtin]
87
89
  : undefined;
88
90
  if (retired !== undefined &&
89
- exact(["id", "kind", "builtin", "enabled"]) &&
91
+ exact(["id", "kind", "builtin", "enabled"], ["turnGuide"]) &&
92
+ validTurnGuide &&
90
93
  typeof item.enabled === "boolean") {
91
- const enabled = item.enabled;
92
- return retired.flatMap(({ builtin, selected }) => {
93
- if (builtins.has(builtin) || ids.has(builtin))
94
- return [];
95
- builtins.add(builtin);
96
- ids.add(builtin);
97
- return [
98
- {
99
- id: builtin,
100
- kind: "builtin",
101
- builtin,
102
- enabled: selected === false ? false : enabled,
103
- ...messageRole,
104
- },
105
- ];
106
- });
94
+ if (builtins.has(item.builtin))
95
+ return fail();
96
+ builtins.add(item.builtin);
97
+ return [
98
+ {
99
+ id: item.id,
100
+ kind: "builtin",
101
+ builtin: item.builtin,
102
+ enabled: item.enabled,
103
+ ...messageRole,
104
+ ...turnGuide,
105
+ },
106
+ ];
107
107
  }
108
108
  const builtin = catalog.find((entry) => entry.id === item.builtin);
109
109
  if (item.kind !== "builtin" ||
@@ -128,10 +128,29 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
128
128
  },
129
129
  ];
130
130
  });
131
+ const expanded = expandRetiredPrompts(entries);
131
132
  if (worlds !== (worldRequired ? 1 : 0) ||
132
- catalog.some((item) => item.required && !builtins.has(item.id)))
133
+ catalog.some((item) => {
134
+ if (!item.required)
135
+ return false;
136
+ const entry = expanded.find((entry) => entry.kind === "builtin" && entry.builtin === item.id);
137
+ return (entry?.kind !== "builtin" ||
138
+ !entry.enabled ||
139
+ (entry.messageRole !== undefined && entry.messageRole !== "system"));
140
+ }))
133
141
  return fail();
134
- return entries;
142
+ return expanded;
143
+ }
144
+ /** The required identities are part of each released list-format contract.
145
+ * New catalog requirements apply after conversion, never while validating old bytes. */
146
+ export function releasedPromptRequiredIdentities(worldRequired) {
147
+ return worldRequired
148
+ ? ["play.mechanics"]
149
+ : ["author.mechanics", "author.target"];
150
+ }
151
+ export function parseReleasedOrderedPrompts(value, catalog = builtinPlayPrompts("en"), worldRequired = true) {
152
+ const required = releasedPromptRequiredIdentities(worldRequired);
153
+ return parseOrderedPlayPrompts(value, catalog.map((item) => ({ ...item, required: required.includes(item.id) })), worldRequired);
135
154
  }
136
155
  function orderedPresetDocument(files) {
137
156
  const source = files["preset.yaml"];
@@ -145,165 +164,104 @@ function orderedPresetDocument(files) {
145
164
  return undefined;
146
165
  return document;
147
166
  }
148
- /**
149
- * Insert the tabletop builtins that an ordered preset does not list yet.
150
- * play.tabletop goes just before the world placeholder and author.tabletop just
151
- * after author.guidance. Files that are not ordered v2 or do not parse come back
152
- * unchanged, and nothing else in them is rewritten. The same object is returned
153
- * when nothing needs inserting, so callers can detect a change by identity.
154
- */
155
- export function withTabletopPromptBuiltins(files) {
156
- const document = orderedPresetDocument(files);
157
- if (document === undefined)
158
- return files;
159
- let changed = false;
160
- const playPrompts = document.get("playPrompts", true);
161
- if (isSeq(playPrompts) && !hasEntry(playPrompts, "play.tabletop")) {
162
- const world = playPrompts.items.findIndex((item) => entryField(item, "kind") === "world");
163
- playPrompts.items.splice(world < 0 ? playPrompts.items.length : world, 0, document.createNode({
164
- id: "play.tabletop",
165
- kind: "builtin",
166
- builtin: "play.tabletop",
167
- enabled: true,
168
- }));
169
- changed = true;
170
- }
171
- const authorPrompts = document.get("authorPrompts", true);
172
- if (isSeq(authorPrompts) && !hasEntry(authorPrompts, "author.tabletop")) {
173
- const guidance = authorPrompts.items.findIndex((item) => entryField(item, "id") === "author.guidance");
174
- authorPrompts.items.splice(guidance < 0 ? authorPrompts.items.length : guidance + 1, 0, document.createNode({
175
- id: "author.tabletop",
176
- kind: "builtin",
177
- builtin: "author.tabletop",
178
- enabled: true,
179
- }));
180
- changed = true;
181
- }
182
- return changed ? { ...files, "preset.yaml": document.toString() } : files;
183
- }
184
- /**
185
- * The retired composite builtins and the blocks that replaced each of them.
186
- * A stored entry for a retired builtin is replaced in place by its successors,
187
- * which inherit its enabled state and message role; the unselected members of
188
- * a choice group arrive disabled. User copies of the old text are not touched.
189
- */
190
- const retiredBuiltinReplacements = {
191
- "play.style": [{ builtin: "play.prose" }, { builtin: "play.scene" }],
192
- "play.adjudication": [
193
- { builtin: "play.agency-default" },
194
- { builtin: "play.agency-strict", selected: false },
195
- { builtin: "play.agency-open", selected: false },
196
- { builtin: "play.facts" },
197
- { builtin: "play.npc" },
198
- ],
199
- "play.narrative": [
200
- { builtin: "play.stop-beat" },
201
- { builtin: "play.stop-scene", selected: false },
202
- { builtin: "play.ending" },
203
- { builtin: "play.output" },
204
- ],
205
- "play.state": [
206
- { builtin: "play.save-timing" },
207
- { builtin: "play.ownership" },
208
- { builtin: "play.situation" },
209
- { builtin: "play.checkpoint-policy" },
210
- ],
211
- };
212
167
  export const retiredPlayBuiltins = Object.keys(retiredBuiltinReplacements);
213
- /**
214
- * Replace the retired composite builtins (style, adjudication, narrative and
215
- * state) with the single-rule blocks that succeeded them, each at the old
216
- * entry's position. Same identity contract as the other insertions.
217
- */
218
- export function withRestructuredPromptBuiltins(files) {
168
+ /** One-time released v2 -> v3 encoding conversion, never a catalog migration. */
169
+ export function upgradeOrderedPromptFormat(files) {
219
170
  const document = orderedPresetDocument(files);
220
- if (document === undefined)
171
+ if (!document)
221
172
  return files;
222
- const playPrompts = document.get("playPrompts", true);
223
- if (!isSeq(playPrompts))
224
- return files;
225
- let changed = false;
226
- for (const [retired, replacements] of Object.entries(retiredBuiltinReplacements)) {
227
- const at = playPrompts.items.findIndex((item) => entryField(item, "kind") === "builtin" &&
228
- entryField(item, "builtin") === retired);
229
- if (at < 0)
230
- continue;
231
- const old = playPrompts.items[at];
232
- const enabled = entryField(old, "enabled") === true;
233
- const messageRole = entryField(old, "messageRole");
234
- const successors = replacements
235
- .filter(({ builtin }) => !hasEntry(playPrompts, builtin))
236
- .map(({ builtin, selected }) => document.createNode({
237
- id: builtin,
238
- kind: "builtin",
239
- builtin,
240
- enabled: selected === false ? false : enabled,
241
- ...(typeof messageRole === "string" ? { messageRole } : {}),
242
- }));
243
- playPrompts.items.splice(at, 1, ...successors);
244
- changed = true;
173
+ try {
174
+ const source = document.toJS();
175
+ document.set("playPrompts", customizePromptArrangement(parseReleasedOrderedPrompts(source.playPrompts), builtinPlayPrompts("en")));
176
+ const author = document.get("authorPrompts");
177
+ if (author !== undefined)
178
+ document.set("authorPrompts", customizePromptArrangement(parseReleasedOrderedPrompts(source.authorPrompts, builtinAuthorPrompts("en"), false), builtinAuthorPrompts("en"), false));
179
+ document.set("format", "narraeon.play-preset/v3");
180
+ return { ...files, "preset.yaml": document.toString() };
245
181
  }
246
- return changed ? { ...files, "preset.yaml": document.toString() } : files;
247
- }
248
- const alternativeGroups = () => {
249
- const members = (group) => builtinPlayPrompts("en")
250
- .filter((item) => item.group === group)
251
- .map(({ id }) => id);
252
- const order = promptGroups.map((group) => members(group));
253
- // The default list runs mechanics, then the choice groups in `promptGroups`
254
- // order, then the policy blocks: a missing group lands after the previous
255
- // group (or mechanics), else ahead of the first later group or policy block.
256
- return promptGroups.map((group, index) => ({
257
- after: [...order.slice(0, index).flat().reverse(), "play.mechanics"],
258
- before: [
259
- ...order.slice(index + 1).flat(),
260
- "play.prose",
261
- "play.scene",
262
- "play.ending",
263
- "play.output",
264
- "play.facts",
265
- "play.tabletop",
266
- ],
267
- entries: groupPromptEntries(group),
268
- }));
269
- };
270
- /**
271
- * Insert the choice-group builtins (person, continuation, agency, stopping)
272
- * that an ordered preset does not list yet. Same identity contract as the
273
- * tabletop insertion.
274
- */
275
- export function withAlternativePromptBuiltins(files) {
276
- const document = orderedPresetDocument(files);
277
- if (document === undefined)
278
- return files;
279
- const playPrompts = document.get("playPrompts", true);
280
- if (!isSeq(playPrompts))
182
+ catch {
183
+ // Invalid drafts stay repairable, without dropping their original bytes.
281
184
  return files;
282
- let changed = false;
283
- for (const group of alternativeGroups()) {
284
- if (group.entries.some((entry) => hasEntry(playPrompts, entry.id)))
285
- continue;
286
- const anchor = group.after
287
- .map((id) => playPrompts.items.findIndex((item) => entryField(item, "id") === id))
288
- .find((index) => index >= 0);
289
- const fallback = playPrompts.items.findIndex((item) => {
290
- const id = entryField(item, "id");
291
- return ((typeof id === "string" && group.before.includes(id)) ||
292
- entryField(item, "kind") === "world");
293
- });
294
- const at = anchor !== undefined
295
- ? anchor + 1
296
- : fallback >= 0
297
- ? fallback
298
- : playPrompts.items.length;
299
- playPrompts.items.splice(at, 0, ...group.entries.map((entry) => document.createNode(entry)));
300
- changed = true;
301
185
  }
302
- return changed ? { ...files, "preset.yaml": document.toString() } : files;
303
186
  }
304
- /** Every builtin upgrade a stored or imported ordered preset must receive. */
305
- export function withCurrentPromptBuiltins(files) {
306
- return withAlternativePromptBuiltins(withRestructuredPromptBuiltins(withTabletopPromptBuiltins(files)));
187
+ /** Decode portable customizations through the same resolver used by every caller. */
188
+ export function parsePromptArrangement(value, catalog = builtinPlayPrompts("en"), worldRequired = true) {
189
+ const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
190
+ const fail = () => {
191
+ throw new Error("Invalid prompt customizations");
192
+ };
193
+ if (!record(value) ||
194
+ Object.keys(value).some((k) => !["entries", "overrides", "order", "groups"].includes(k)))
195
+ return fail();
196
+ if (value.entries !== undefined &&
197
+ (!Array.isArray(value.entries) ||
198
+ value.entries.length > 256 ||
199
+ value.entries.some((e) => !record(e) || e.kind === "builtin")))
200
+ return fail();
201
+ const entries = (value.entries ?? []);
202
+ const entryIds = new Set(entries.map((e) => e.id));
203
+ if (entryIds.size !== entries.length ||
204
+ entries.filter((e) => e.kind === "world").length > (worldRequired ? 1 : 0))
205
+ return fail();
206
+ if (value.overrides !== undefined) {
207
+ if (!Array.isArray(value.overrides) || value.overrides.length > 256)
208
+ return fail();
209
+ const seen = new Set();
210
+ const overrideIds = new Set();
211
+ for (const o of value.overrides) {
212
+ if (!record(o) ||
213
+ typeof o.builtin !== "string" ||
214
+ seen.has(o.builtin) ||
215
+ (!catalog.some((c) => c.id === o.builtin) &&
216
+ !retiredBuiltinReplacements[o.builtin]) ||
217
+ Object.keys(o).some((k) => !["builtin", "id", "enabled", "messageRole", "turnGuide"].includes(k)) ||
218
+ (o.enabled !== undefined && typeof o.enabled !== "boolean") ||
219
+ (o.messageRole !== undefined && !isPromptMessageRole(o.messageRole)) ||
220
+ (o.turnGuide !== undefined &&
221
+ (typeof o.turnGuide !== "string" || !isTurnGuideLine(o.turnGuide))) ||
222
+ (o.id !== undefined && typeof o.id !== "string"))
223
+ return fail();
224
+ if (typeof o.id === "string") {
225
+ if (entryIds.has(o.id) || overrideIds.has(o.id))
226
+ return fail();
227
+ overrideIds.add(o.id);
228
+ }
229
+ seen.add(o.builtin);
230
+ }
231
+ }
232
+ if (value.order !== undefined &&
233
+ (!Array.isArray(value.order) ||
234
+ value.order.length > 512 ||
235
+ value.order.some((k) => typeof k !== "string") ||
236
+ new Set(value.order).size !== value.order.length))
237
+ return fail();
238
+ if (Array.isArray(value.order) &&
239
+ value.order.some((key) => {
240
+ if (typeof key !== "string")
241
+ return true;
242
+ if (key.startsWith("entry:"))
243
+ return (!entryIds.has(key.slice(6)) &&
244
+ !(worldRequired && key === "entry:world"));
245
+ return (!key.startsWith("builtin:") ||
246
+ (!catalog.some((c) => c.id === key.slice(8)) &&
247
+ !retiredBuiltinReplacements[key.slice(8)]));
248
+ }))
249
+ return fail();
250
+ if (value.groups !== undefined) {
251
+ if (!record(value.groups))
252
+ return fail();
253
+ for (const [group, ids] of Object.entries(value.groups)) {
254
+ if (!promptGroups.some((item) => item === group) ||
255
+ !Array.isArray(ids) ||
256
+ ids.some((id) => typeof id !== "string" ||
257
+ !catalog.some((c) => (c.id === id ||
258
+ retiredBuiltinReplacements[id]?.some((r) => r.builtin === c.id)) &&
259
+ c.group === group)) ||
260
+ new Set(ids).size !== ids.length)
261
+ return fail();
262
+ }
263
+ }
264
+ return parseOrderedPlayPrompts(resolvePromptArrangement(value, catalog, worldRequired), catalog, worldRequired);
307
265
  }
308
266
  /** A choice group's entries: the first member enabled, the rest disabled. */
309
267
  function groupPromptEntries(group) {
@@ -316,12 +274,6 @@ function groupPromptEntries(group) {
316
274
  enabled: index === 0,
317
275
  }));
318
276
  }
319
- function hasEntry(sequence, id) {
320
- return sequence.items.some((item) => entryField(item, "id") === id);
321
- }
322
- function entryField(item, key) {
323
- return isMap(item) ? item.get(key) : undefined;
324
- }
325
277
  /** Recognized v1 content becomes user text, never a locked builtin inferred from a path. */
326
278
  export function migrateLegacyPlayPrompts(files, narrative, excludedPaths = []) {
327
279
  const document = parseDocument(files["frame.yaml"] ?? "", {
@@ -447,5 +399,14 @@ export function migrateLegacyPlayPrompts(files, narrative, excludedPaths = []) {
447
399
  builtin: "play.tabletop",
448
400
  enabled: true,
449
401
  });
402
+ const listed = new Set(entries.flatMap((entry) => entry.kind === "builtin" ? [entry.builtin] : []));
403
+ entries.splice(entries.findIndex((entry) => entry.kind === "builtin" && entry.builtin === "play.tabletop"), 0, ...builtinPlayPrompts("en")
404
+ .filter((item) => !listed.has(item.id))
405
+ .map((item) => ({
406
+ id: item.id,
407
+ kind: "builtin",
408
+ builtin: item.id,
409
+ enabled: false,
410
+ })));
450
411
  return entries;
451
412
  }
@@ -10,6 +10,7 @@ const messages = {
10
10
  serviceStoppedAfterHead: "The service stopped after the world was updated; use a fresh context.",
11
11
  serviceStoppedSavingPlayer: "The service stopped while saving the player message; use a fresh context.",
12
12
  serviceStoppedRetryable: "The service stopped while the model request was in progress; retry to call the model again.",
13
+ serviceStoppedUnknown: "The service stopped after the model request may have been dispatched; its outcome is unknown. Use a fresh context.",
13
14
  serviceStoppedNoRequest: "The service stopped before preparing a model request; use a fresh context.",
14
15
  dispatchInterrupted: "The model request was interrupted.",
15
16
  };
@@ -43,29 +44,52 @@ export class PlayAdvanceLifecycle {
43
44
  notify?.();
44
45
  }
45
46
  /**
46
- * Ends an advance that a process exit cut off (ADR-0044). Nothing is
47
- * replayed: the exchange is interrupted, and only the chain's own frozen
48
- * request can be retried, and only while the world has not moved on.
47
+ * Recovers an advance cut off by a process exit. A persisted ending wins;
48
+ * otherwise Google requests that may have been dispatched cannot be replayed
49
+ * (ADR-0035), while other recovery follows ADR-0044.
49
50
  */
50
51
  async recoverAbandoned(session, advance, headMoved) {
52
+ // Endings are persisted before their marker is cleared. If the process
53
+ // stopped in that gap, keep the durable ending rather than inventing a
54
+ // new retry decision for an advance that already settled.
55
+ if (advance !== null &&
56
+ session.status !== "running" &&
57
+ !(advance.advanceKind === "player" && session.status === "ready") &&
58
+ !headMoved) {
59
+ await this.#ports.clearMarker(advance);
60
+ return;
61
+ }
51
62
  const playerPhase = advance?.advanceKind === "player";
52
63
  const streaming = playerPhase
53
64
  ? undefined
54
65
  : session.events.findLast((event) => event.kind === "assistant" &&
55
66
  event.status === "streaming" &&
56
67
  (advance === null || event.id === advance.eventId));
57
- const canRetry = !headMoved && !playerPhase && session.lastRequest !== null;
68
+ const unknownGoogleDispatch = advance?.advanceKind === "response" &&
69
+ session.status === "running" &&
70
+ (session.lastRequest?.modelConfig?.provider ??
71
+ session.modelBinding?.provider) === "google_interactions";
72
+ const canRetry = !headMoved &&
73
+ !playerPhase &&
74
+ !unknownGoogleDispatch &&
75
+ session.lastRequest !== null;
58
76
  const message = headMoved
59
77
  ? messages.serviceStoppedAfterHead
60
78
  : playerPhase
61
79
  ? messages.serviceStoppedSavingPlayer
62
- : canRetry
63
- ? messages.serviceStoppedRetryable
64
- : messages.serviceStoppedNoRequest;
80
+ : unknownGoogleDispatch
81
+ ? messages.serviceStoppedUnknown
82
+ : canRetry
83
+ ? messages.serviceStoppedRetryable
84
+ : messages.serviceStoppedNoRequest;
65
85
  await this.end(session, {
66
86
  kind: "failed",
67
87
  message,
68
- failureKind: canRetry ? "retryable" : "other",
88
+ failureKind: unknownGoogleDispatch
89
+ ? "outcome_unknown"
90
+ : canRetry
91
+ ? "retryable"
92
+ : "other",
69
93
  canRetry,
70
94
  streamingEventId: streaming?.id,
71
95
  }, advance);
@@ -203,7 +227,7 @@ function interruptStreamingEvent(session, eventId) {
203
227
  if (eventId === undefined)
204
228
  return;
205
229
  const event = session.events.find(({ id }) => id === eventId);
206
- if (event?.kind === "assistant")
230
+ if (event?.kind === "assistant" && event.status === "streaming")
207
231
  event.status = "interrupted";
208
232
  }
209
233
  function errorMessage(error, fallback) {
@@ -16,7 +16,8 @@ import { turnGuideWithTurnNote } from "../../shared/turn-guide.js";
16
16
  import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepNotice, turnGuideNotice, } from "./PlayContinuity.js";
17
17
  import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
18
18
  import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
19
- import { equalModelHostBinding, ModelHostCancelledError, } from "../model/ModelHost.js";
19
+ import { equalModelHostBinding, ModelHostCancelledError, ModelHostOutcomeUnknownError, } from "../model/ModelHost.js";
20
+ import { validGoogleInteractionsContinuation } from "../model/ProviderExchangeState.js";
20
21
  import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
21
22
  import { parsePlayPresetFiles, } from "./FileNativePlayPresetStore.js";
22
23
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
@@ -234,6 +235,12 @@ export class PlayCallChain {
234
235
  const session = await this.#requireChain(input.worldId, input.chainId);
235
236
  await this.#assertCurrentHead(session);
236
237
  assertSessionModelHostBinding(session, input.modelHost);
238
+ if (session.status === "ready" &&
239
+ session.modelBinding?.provider === "google_interactions" &&
240
+ session.transcript.some((item) => item.kind === "assistant" &&
241
+ (item.providerState?.protocol !== "google_interactions" ||
242
+ !validGoogleInteractionsContinuation(item.providerState.steps))))
243
+ throw new PlayCallChainError("The Google Interactions continuation is missing or corrupted; start a fresh context.");
237
244
  if (input.playerText.trim().length === 0) {
238
245
  if (session.status === "running")
239
246
  throw new PlayCallChainError("A model request is still running.");
@@ -869,6 +876,10 @@ export class PlayCallChain {
869
876
  updatedAt: Date.now(),
870
877
  };
871
878
  }
879
+ if (intent.continuation === "continue_context")
880
+ await this.#worlds.playTimeline
881
+ .copyRetainedEncodings(worldId, sourceContext.chainId, revised)
882
+ .catch(() => forgetFollowupPreviews(revised));
872
883
  await this.#worlds.playTimeline.persist(revised);
873
884
  if (intent.continuation === "fresh_context")
874
885
  await this.#worlds.completeArchiveFreshContext(worldId);
@@ -965,6 +976,9 @@ export class PlayCallChain {
965
976
  parentHead: intent.restoresHead,
966
977
  updatedAt: Date.now(),
967
978
  };
979
+ await this.#worlds.playTimeline
980
+ .copyRetainedEncodings(intent.worldId, sourceContext.chainId, prefix)
981
+ .catch(() => forgetFollowupPreviews(prefix));
968
982
  await this.#worlds.playTimeline.persistDetached(structuredClone(prefix));
969
983
  previousChainId = prefix.chainId;
970
984
  }
@@ -1254,6 +1268,19 @@ export class PlayCallChain {
1254
1268
  if (existing?.kind !== "assistant")
1255
1269
  throw new PlayCallChainError("The durable Provider result no longer matches its assistant event.");
1256
1270
  const calls = durable.toolCalls ?? [];
1271
+ if ((session.lastRequest?.modelConfig?.provider ??
1272
+ session.modelBinding?.provider) === "google_interactions" &&
1273
+ calls.length > 0) {
1274
+ const boundary = session.events.findLastIndex((event) => event.kind === "player" ||
1275
+ (event.kind === "assistant" &&
1276
+ event.status === "completed" &&
1277
+ event.responseKind === "narrative"));
1278
+ if (session.events
1279
+ .slice(boundary + 1)
1280
+ .some((event) => event.kind === "tool_call" &&
1281
+ calls.some((call) => call.id === event.callId)))
1282
+ throw new ModelHostOutcomeUnknownError("Google Interactions repeated a tool-call ID during the same send; its outcome cannot be safely settled. Use a fresh context.");
1283
+ }
1257
1284
  const text = durable.text ?? existing.text;
1258
1285
  const hasText = text.trim().length > 0;
1259
1286
  const responseKind = calls.length > 0 ? "tool_step" : hasText ? "narrative" : "empty";
@@ -1561,6 +1588,8 @@ export class PlayCallChain {
1561
1588
  }
1562
1589
  response = await modelHost.exchange(request, {
1563
1590
  signal,
1591
+ diagnosticAttempt: attempt,
1592
+ onGoogleHop: (hop) => this.#worlds.playTimeline.recordGoogleHop(session.worldId, session.chainId, session.promptRuns?.at(-1)?.exchange ?? 1, hop),
1564
1593
  onDelta: (delta) => {
1565
1594
  this.#observeInvocation(session.chainId, delta.kind, {
1566
1595
  ...(delta.kind === "reasoning"
@@ -1661,7 +1690,7 @@ export class PlayCallChain {
1661
1690
  },
1662
1691
  ],
1663
1692
  });
1664
- await this.#advances.end(session, ending, advance, () => this.#notifySnapshot(observer, session));
1693
+ await this.#advances.end(session, { ...ending, streamingEventId: event.id }, advance, () => this.#notifySnapshot(observer, session));
1665
1694
  return projectView(session);
1666
1695
  }
1667
1696
  }
@@ -1691,6 +1720,13 @@ export class PlayCallChain {
1691
1720
  event.committedHead === session.parentHead);
1692
1721
  if (reply === undefined)
1693
1722
  throw new PlayCallChainError("Follow-up reply is not committed");
1723
+ const run = session.promptRuns?.at(-1);
1724
+ const runReplies = session.events.filter((event) => event.kind === "assistant" &&
1725
+ event.responseKind === "narrative" &&
1726
+ event.committedHead !== undefined &&
1727
+ event.id >= (run?.firstEventId ?? 1));
1728
+ // Without a new prompt run there is no distinct bounded encoding slot.
1729
+ const previewExchange = runReplies.length === 1 ? (run?.exchange ?? 1) : undefined;
1694
1730
  const observeFollowupDelta = (delta) => {
1695
1731
  this.#observeInvocation(session.chainId, "followup", {
1696
1732
  ...(delta.kind === "reasoning"
@@ -1715,6 +1751,14 @@ export class PlayCallChain {
1715
1751
  ? {}
1716
1752
  : { reasoning: outcome.reasoning }),
1717
1753
  ...(outcome.usage === undefined ? {} : { usage: outcome.usage }),
1754
+ ...(outcome.requestPreviewRecorded !== true ||
1755
+ outcome.requestPreviewStatus === undefined ||
1756
+ previewExchange === undefined
1757
+ ? {}
1758
+ : {
1759
+ requestPreviewExchange: previewExchange,
1760
+ requestPreviewStatus: outcome.requestPreviewStatus,
1761
+ }),
1718
1762
  toolCalls: structuredClone(outcome.toolCalls),
1719
1763
  ...(outcome.failure === undefined ? {} : { failure: outcome.failure }),
1720
1764
  });
@@ -1757,6 +1801,21 @@ export class PlayCallChain {
1757
1801
  ? {}
1758
1802
  : { failureLog: this.#failureLog }),
1759
1803
  observer: {
1804
+ onGoogleHop: async (id, hop) => {
1805
+ if (previewExchange !== undefined)
1806
+ await this.#worlds.playTimeline.recordGoogleHop(session.worldId, session.chainId, previewExchange, hop, id);
1807
+ },
1808
+ onRequest: async (id, request) => {
1809
+ if (previewExchange === undefined)
1810
+ return false;
1811
+ // Only Google currently has a continuation-redacting encoder.
1812
+ const preview = modelHost.binding().provider === "google_interactions"
1813
+ ? modelHost.previewAuthoringRequest?.(request)
1814
+ : undefined;
1815
+ if (preview === undefined)
1816
+ return false;
1817
+ return this.#worlds.playTimeline.recordFollowupEncoding(session.worldId, session.chainId, previewExchange, id, preview);
1818
+ },
1760
1819
  onProviderDispatch: () => {
1761
1820
  this.#setInvocationAbortable(session.chainId, true);
1762
1821
  this.#observeInvocation(session.chainId, "followup", {
@@ -1837,8 +1896,9 @@ export class PlayCallChain {
1837
1896
  }
1838
1897
  /**
1839
1898
  * An in-progress advance, or a session still marked running, means a model
1840
- * dispatch or settlement was cut off by a process exit. Nothing is replayed:
1841
- * the exchange is interrupted and the player retries (ADR-0044).
1899
+ * dispatch or settlement was cut off by a process exit. Recovery preserves
1900
+ * persisted endings and applies the protocol's retry boundary (ADR-0035,
1901
+ * ADR-0044).
1842
1902
  */
1843
1903
  async #reconcileSessionAdvance(session, loaded) {
1844
1904
  const advance = loaded === undefined
@@ -2364,6 +2424,9 @@ function assertSessionModelHostBinding(session, modelHost) {
2364
2424
  throw new PlayCallChainError("This model context predates durable provider continuation bindings; start a fresh context.");
2365
2425
  if (session.modelBinding.provider !== modelHost.binding().provider)
2366
2426
  throw new PlayCallChainError("The selected model protocol differs from this context. Switch back to its original protocol to continue, or start a fresh context.", "model_protocol_mismatch");
2427
+ if (session.modelBinding.provider === "google_interactions" &&
2428
+ !equalModelHostBinding(session.modelBinding, modelHost.binding()))
2429
+ throw new PlayCallChainError("The selected model connection does not match the frozen Google binding; start a fresh context.");
2367
2430
  }
2368
2431
  function validateIdentity(value, label) {
2369
2432
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u.test(value))
@@ -2398,6 +2461,15 @@ function turnNoteField(turnNote) {
2398
2461
  function touch(session) {
2399
2462
  session.updatedAt = Date.now();
2400
2463
  }
2464
+ /** Failed diagnostic copies cannot attach unrelated encodings to retained events. */
2465
+ function forgetFollowupPreviews(session) {
2466
+ for (const event of session.events) {
2467
+ if (event.kind !== "followup")
2468
+ continue;
2469
+ delete event.requestPreviewExchange;
2470
+ delete event.requestPreviewStatus;
2471
+ }
2472
+ }
2401
2473
  function notifyAssistantDelta(observer, delta) {
2402
2474
  try {
2403
2475
  observer?.onAssistantDelta?.(structuredClone(delta));