narraeon 0.7.10 → 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 (44) 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/SettingImprovementSession.js +48 -7
  29. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +84 -8
  30. package/dist/node/runtime/world-revision/WorldRevisionSession.js +125 -17
  31. package/dist/node/shared/application-preset-files.js +29 -0
  32. package/dist/node/shared/default-preset-host.js +50 -38
  33. package/dist/node/shared/first-party-action-choices.js +7 -11
  34. package/dist/node/shared/first-party-generic-panels.js +7 -11
  35. package/dist/node/shared/first-party-player-view.js +7 -11
  36. package/dist/node/shared/ordered-author-prompts.js +6 -4
  37. package/dist/node/shared/ordered-play-prompts.js +4 -2
  38. package/dist/node/shared/prompt-arrangement.js +205 -0
  39. package/dist/node/shared/sillytavern-preset.js +454 -0
  40. package/dist/web/assets/{index-DH2BVsYd.css → index-BKA1D0Rz.css} +1 -1
  41. package/dist/web/assets/index-C_Qta8UL.js +1432 -0
  42. package/dist/web/index.html +2 -2
  43. package/package.json +1 -1
  44. package/dist/web/assets/index-B2kbaOGe.js +0 -1408
@@ -1,15 +1,17 @@
1
+ import { applicationPresetFilesForLocale } from "../../shared/application-preset-files.js";
1
2
  import { parseDisplayRegex, DisplayRegexError, } from "../../shared/display-regex.js";
2
3
  export { validatePlayPresetArtifactPayload } from "../../shared/artifact-payload-contract.js";
3
4
  import { defaultFollowupItems, } from "../../shared/ordered-followups.js";
4
5
  import { parseFollowupItems } from "./OrderedFollowups.js";
5
6
  import { builtinAuthorPrompts, defaultOrderedAuthorPrompts, } from "../../shared/ordered-author-prompts.js";
6
- import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, withCurrentPromptBuiltins, } from "./OrderedPlayPrompts.js";
7
- import { defaultOrderedPlayPrompts, } from "../../shared/ordered-play-prompts.js";
7
+ import { parseOrderedPlayPrompts, parseReleasedOrderedPrompts, releasedPromptRequiredIdentities, parsePromptArrangement, migrateLegacyPlayPrompts, upgradeOrderedPromptFormat, } from "./OrderedPlayPrompts.js";
8
+ import { builtinPlayPrompts, } from "../../shared/ordered-play-prompts.js";
9
+ import { customizePromptArrangement, retiredBuiltinReplacements, } from "../../shared/prompt-arrangement.js";
8
10
  import { createHash, randomUUID } from "node:crypto";
9
11
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
10
12
  import { join } from "node:path";
11
13
  import { isDeepStrictEqual } from "node:util";
12
- import { parseDocument, stringify, visit, isAlias } from "yaml";
14
+ import { parseDocument, visit, isAlias } from "yaml";
13
15
  import { defaultPortableContentTreeLimits, portableContentTreePathKey, } from "../../protocol/contentTree.js";
14
16
  import { defaultAppLocale, } from "../../protocol/appPreferences.js";
15
17
  import { defaultNarrationPromptForLocale } from "../../shared/default-play-prompts.js";
@@ -95,27 +97,78 @@ export function isPlayPresetBinding(value) {
95
97
  const builtInDefault = value.id === "builtin-default" &&
96
98
  value.name === "default" &&
97
99
  value.revision === "builtin-default-v1" &&
98
- value.scriptsEnabled === true &&
99
- (isDeepStrictEqual(files, defaultPlayPresetFilesForLocale("en")) ||
100
- isDeepStrictEqual(files, defaultPlayPresetFilesForLocale("zh-CN")) ||
101
- isDeepStrictEqual(files, legacyDefaultPlayPresetFilesForLocale("en")) ||
102
- isDeepStrictEqual(files, legacyDefaultPlayPresetFilesForLocale("zh-CN")));
100
+ value.scriptsEnabled === true;
101
+ // This internal sentinel predates content-hash revisions. Its original
102
+ // snapshot must validate on its own, never against today's seed template.
103
103
  return (parsed.kind === "valid" &&
104
- isDeepStrictEqual(normalizedFrozenDefinition(value.definition), parsed.definition) &&
104
+ frozenDefinitionMatches(value.definition, parsed.definition, files) &&
105
105
  (builtInDefault || value.revision === revisionForPlayPresetFiles(files)));
106
106
  }
107
+ /** A frozen v3 binding validates against the catalog identities it captured.
108
+ * Newly shipped defaults belong to new sends, never to a saved request. */
109
+ function frozenDefinitionMatches(snapshot, current, files) {
110
+ if (current.format !== "narraeon.play-preset/v3")
111
+ return isDeepStrictEqual(normalizedFrozenDefinition(snapshot), current);
112
+ try {
113
+ const source = readYaml(files, "preset.yaml", "preset.yaml");
114
+ const expected = { ...current };
115
+ const normalizedSnapshot = { ...snapshot };
116
+ for (const key of ["playPrompts", "authorPrompts"]) {
117
+ if (source[key] === undefined && snapshot[key] === undefined)
118
+ continue;
119
+ const author = key === "authorPrompts";
120
+ const catalog = author
121
+ ? builtinAuthorPrompts("en")
122
+ : builtinPlayPrompts("en");
123
+ const raw = snapshot[key];
124
+ if (!Array.isArray(raw))
125
+ return false;
126
+ const identities = new Set(raw.flatMap((e) => isRecord(e) && e.kind === "builtin" && typeof e.builtin === "string"
127
+ ? (retiredBuiltinReplacements[e.builtin]?.map((r) => r.builtin) ?? [
128
+ e.builtin,
129
+ ])
130
+ : []));
131
+ // v3's original mandatory identities are part of its validation contract.
132
+ // Later required additions are mandatory only for newly compiled requests.
133
+ const requiredAtRelease = releasedPromptRequiredIdentities(!author);
134
+ const capturedCatalog = catalog.filter((c) => identities.has(c.id) || requiredAtRelease.includes(c.id));
135
+ const captured = parseOrderedPlayPrompts(raw, capturedCatalog, !author);
136
+ const frozenCatalog = captured.flatMap((entry) => {
137
+ if (entry.kind !== "builtin")
138
+ return [];
139
+ const item = capturedCatalog.find((item) => item.id === entry.builtin);
140
+ return item === undefined
141
+ ? []
142
+ : [{ ...item, defaultEnabled: entry.enabled }];
143
+ });
144
+ if (!isDeepStrictEqual(captured, parsePromptArrangement(source[key], frozenCatalog, !author)))
145
+ return false;
146
+ expected[key] = captured;
147
+ normalizedSnapshot[key] = captured;
148
+ }
149
+ return isDeepStrictEqual(normalizedSnapshot, expected);
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
107
155
  /**
108
156
  * A frozen definition captured before a builtin was retired still lists the
109
- * old entry; reading its ordered list through the parser yields the same
110
- * successors the files now parse to, so the binding stays consistent.
157
+ * old entry; reading both ordered lists through their respective parsers yields
158
+ * the same successors as the files, so the binding stays consistent.
111
159
  */
112
160
  function normalizedFrozenDefinition(definition) {
113
- if (!Array.isArray(definition.playPrompts))
114
- return definition;
115
161
  try {
116
162
  return {
117
163
  ...definition,
118
- playPrompts: parseOrderedPlayPrompts(definition.playPrompts),
164
+ ...(Array.isArray(definition.playPrompts)
165
+ ? { playPrompts: parseReleasedOrderedPrompts(definition.playPrompts) }
166
+ : {}),
167
+ ...(Array.isArray(definition.authorPrompts)
168
+ ? {
169
+ authorPrompts: parseReleasedOrderedPrompts(definition.authorPrompts, builtinAuthorPrompts("en"), false),
170
+ }
171
+ : {}),
119
172
  };
120
173
  }
121
174
  catch {
@@ -151,14 +204,7 @@ followups: []
151
204
  };
152
205
  }
153
206
  export function defaultPlayPresetFilesForLocale(locale) {
154
- const files = legacyDefaultPlayPresetFilesForLocale(locale);
155
- const preset = parseDocument(files["preset.yaml"]);
156
- preset.set("format", "narraeon.play-preset/v2");
157
- preset.set("playPrompts", defaultOrderedPlayPrompts());
158
- preset.set("authorPrompts", defaultOrderedAuthorPrompts());
159
- preset.set("mergeConsecutiveMessages", false);
160
- files["preset.yaml"] = stringify(preset.toJS());
161
- return files;
207
+ return applicationPresetFilesForLocale(locale);
162
208
  }
163
209
  export const defaultPlayPresetFiles = defaultPlayPresetFilesForLocale(defaultAppLocale);
164
210
  /**
@@ -230,8 +276,8 @@ export class FileNativePlayPresetStore {
230
276
  const document = await this.#read();
231
277
  const migrated = this.#markLegacyBuiltinDefault(document);
232
278
  const synchronized = this.#synchronizeBuiltinDefault(document);
233
- const tabletop = this.#migrateTabletopPromptBuiltins(document);
234
- if (migrated || synchronized || tabletop)
279
+ const customizations = this.#migratePromptCustomizations(document);
280
+ if (migrated || synchronized || customizations)
235
281
  await this.#write(document);
236
282
  });
237
283
  }
@@ -291,10 +337,18 @@ export class FileNativePlayPresetStore {
291
337
  const stored = findStored(document, input.presetId);
292
338
  delete stored.builtinDefault;
293
339
  let files = cloneFiles(input.files);
340
+ const previous = parsePlayPresetFiles(stored.revisions[stored.currentRevision]);
294
341
  if (input.structure !== undefined) {
295
- files = applyPlayPresetStructuredEditor(input.files, parsePlayPresetStructuredEditor(input.structure));
342
+ const structure = parsePlayPresetStructuredEditor(input.structure);
343
+ if (previous.kind === "valid") {
344
+ for (const key of ["playPrompts", "authorPrompts"]) {
345
+ if (structure[key] === undefined)
346
+ continue;
347
+ assertReadonlyPromptsRetained(previous.definition[key] ?? [], structure[key]);
348
+ }
349
+ }
350
+ files = applyPlayPresetStructuredEditor(input.files, structure);
296
351
  }
297
- const previous = parsePlayPresetFiles(stored.revisions[stored.currentRevision]);
298
352
  if (previous.kind === "valid" &&
299
353
  previous.definition.followupItems !== undefined) {
300
354
  const candidate = readYaml(files, "call-chain.yaml", "call-chain.yaml");
@@ -305,31 +359,23 @@ export class FileNativePlayPresetStore {
305
359
  if (previous.kind === "valid" &&
306
360
  previous.definition.playPrompts !== undefined) {
307
361
  const candidate = readYaml(files, "preset.yaml", "preset.yaml");
308
- if (candidate.format !== "narraeon.play-preset/v2")
362
+ if (candidate.format !== "narraeon.play-preset/v2" &&
363
+ candidate.format !== "narraeon.play-preset/v3")
309
364
  throw new FileNativePlayPresetError("readonly_prompt_removed", "Ordered presets cannot downgrade to v1");
310
- const candidates = parseOrderedPlayPrompts(candidate.playPrompts);
311
- for (const entry of previous.definition.playPrompts.filter((entry) => entry.kind === "builtin" || entry.kind === "world")) {
312
- const retained = candidates.find((item) => item.id === entry.id);
313
- if (retained?.kind !== entry.kind ||
314
- (entry.kind === "builtin" &&
315
- retained.kind === "builtin" &&
316
- retained.builtin !== entry.builtin))
317
- throw new FileNativePlayPresetError("readonly_prompt_removed", "System prompts and the world placeholder cannot be deleted or replaced; disable optional prompts or clone them instead");
318
- }
365
+ const candidates = candidate.format === "narraeon.play-preset/v3"
366
+ ? parsePromptArrangement(candidate.playPrompts)
367
+ : parseOrderedPlayPrompts(candidate.playPrompts);
368
+ assertReadonlyPromptsRetained(previous.definition.playPrompts, candidates);
319
369
  }
320
370
  if (previous.kind === "valid" &&
321
371
  previous.definition.authorPrompts !== undefined) {
322
372
  const candidate = readYaml(files, "preset.yaml", "preset.yaml");
323
- const entries = parseOrderedAuthorPrompts(candidate.authorPrompts);
324
- for (const entry of previous.definition.authorPrompts) {
325
- if (entry.kind !== "builtin")
326
- continue;
327
- const retained = entries.find((item) => item.id === entry.id);
328
- if (retained?.kind !== "builtin" ||
329
- retained.builtin !== entry.builtin)
330
- throw new FileNativePlayPresetError("readonly_prompt_removed", "Authoring system prompts cannot be deleted or replaced; disable or clone optional prompts");
331
- }
373
+ const entries = candidate.format === "narraeon.play-preset/v3"
374
+ ? parsePromptArrangement(candidate.authorPrompts, builtinAuthorPrompts("en"), false)
375
+ : parseOrderedAuthorPrompts(candidate.authorPrompts);
376
+ assertReadonlyPromptsRetained(previous.definition.authorPrompts, entries);
332
377
  }
378
+ files = upgradeValidOrderedPromptFormat(files, this.#limits);
333
379
  const revision = revisionForFiles(files, this.#limits);
334
380
  stored.name = normalizeName(input.name);
335
381
  stored.revisions[revision] = cloneFiles(files);
@@ -472,14 +518,16 @@ export class FileNativePlayPresetStore {
472
518
  }
473
519
  /** Portable export deliberately contains no id, revision, world, operation or provider state. */
474
520
  async exportPortable(id) {
475
- const binding = await this.bindRevision(id);
476
- return Object.keys(binding.files)
521
+ await this.#tail;
522
+ const stored = findStored(await this.#read(), id);
523
+ const files = stored.revisions[stored.currentRevision];
524
+ return Object.keys(files)
477
525
  .sort()
478
- .map((path) => ({ path, contents: binding.files[path] }));
526
+ .map((path) => ({ path, contents: files[path] }));
479
527
  }
480
528
  async importPortable(input) {
481
529
  // A released preset gains the current builtins at import, as at upgrade.
482
- const files = withCurrentPromptBuiltins(toFileMap(input.files, this.#limits));
530
+ const files = upgradeOrderedPromptFormat(toFileMap(input.files, this.#limits));
483
531
  const parsed = parsePlayPresetFiles(files, this.#limits);
484
532
  if (parsed.kind === "invalid")
485
533
  throw parsed.error;
@@ -537,7 +585,7 @@ export class FileNativePlayPresetStore {
537
585
  return document;
538
586
  }
539
587
  try {
540
- // Validation hashes and parses every stored revision, so a library
588
+ // Validation checks the structure and hash of every stored revision, so a library
541
589
  // whose bytes were already validated is served from that result. Each
542
590
  // caller gets its own copy because callers edit what they read.
543
591
  if (this.#validated !== undefined && raw === this.#validated.raw)
@@ -597,19 +645,17 @@ export class FileNativePlayPresetStore {
597
645
  return true;
598
646
  }
599
647
  /**
600
- * One-time upgrade for ordered presets saved before the tabletop or
601
- * continuation builtins existed. An affected revision is replaced by a new revision that inserts
602
- * them; the old revision stays for frozen requests. A pending draft gets the
603
- * same insertion, because a draft that kept the old list would fail the
604
- * builtin-retention rule on save. The library is one atomically written file,
605
- * so an interrupted upgrade leaves the released bytes and reruns cleanly.
648
+ * Convert released ordered lists to sparse customizations exactly once.
649
+ * Original revisions remain immutable. Current and valid draft endpoints
650
+ * advance together in one atomic library replacement; invalid drafts retain
651
+ * their original bytes for repair. Catalog evolution never changes v3 files.
606
652
  */
607
- #migrateTabletopPromptBuiltins(document) {
653
+ #migratePromptCustomizations(document) {
608
654
  let changed = false;
609
655
  for (const stored of document.presets) {
610
656
  const current = stored.revisions[stored.currentRevision];
611
657
  if (current !== undefined) {
612
- const migrated = withCurrentPromptBuiltins(current);
658
+ const migrated = upgradeValidOrderedPromptFormat(current, this.#limits);
613
659
  if (migrated !== current) {
614
660
  const revision = revisionForFiles(migrated, this.#limits);
615
661
  stored.revisions[revision] = cloneFiles(migrated);
@@ -621,7 +667,7 @@ export class FileNativePlayPresetStore {
621
667
  ? undefined
622
668
  : stored.revisions[stored.draftRevision];
623
669
  if (draft !== undefined) {
624
- const migrated = withCurrentPromptBuiltins(draft);
670
+ const migrated = upgradeValidOrderedPromptFormat(draft, this.#limits);
625
671
  if (migrated !== draft) {
626
672
  const revision = revisionForFiles(migrated, this.#limits);
627
673
  stored.revisions[revision] = cloneFiles(migrated);
@@ -653,7 +699,7 @@ export class FileNativePlayPresetStore {
653
699
  }
654
700
  #stored(id, rawName, rawFiles) {
655
701
  const name = normalizeName(rawName);
656
- const files = cloneFiles(rawFiles);
702
+ const files = cloneFiles(upgradeValidOrderedPromptFormat(rawFiles, this.#limits));
657
703
  const revision = revisionForFiles(files, this.#limits);
658
704
  return {
659
705
  id,
@@ -784,7 +830,7 @@ export function toPlayPresetStructuredEditor(definition) {
784
830
  * public structure and never bypasses the normal codec.
785
831
  */
786
832
  export function applyPlayPresetStructuredEditor(files, input) {
787
- const next = cloneFiles(files);
833
+ const next = cloneFiles(upgradeOrderedPromptFormat(files));
788
834
  const presetSource = next["preset.yaml"];
789
835
  const callChainSource = next[input.callChainPath];
790
836
  if (presetSource === undefined || callChainSource === undefined)
@@ -805,10 +851,10 @@ export function applyPlayPresetStructuredEditor(files, input) {
805
851
  if (input.mergeConsecutiveMessages !== undefined)
806
852
  preset.set("mergeConsecutiveMessages", parseMessageMerging(input.mergeConsecutiveMessages));
807
853
  if (input.authorPrompts !== undefined)
808
- preset.set("authorPrompts", parseOrderedAuthorPrompts(input.authorPrompts));
854
+ preset.set("authorPrompts", customizePromptArrangement(parseOrderedAuthorPrompts(input.authorPrompts), builtinAuthorPrompts("en"), false));
809
855
  if (input.playPrompts !== undefined) {
810
- preset.set("format", "narraeon.play-preset/v2");
811
- preset.set("playPrompts", parseOrderedPlayPrompts(input.playPrompts));
856
+ preset.set("format", "narraeon.play-preset/v3");
857
+ preset.set("playPrompts", customizePromptArrangement(parseOrderedPlayPrompts(input.playPrompts), builtinPlayPrompts("en")));
812
858
  }
813
859
  preset.set("callChain", input.callChainPath);
814
860
  if (input.settingImprovementPrompt === undefined)
@@ -965,7 +1011,8 @@ export function parsePlayPresetFiles(files, limits = {}) {
965
1011
  if (callChainPath !== "call-chain.yaml")
966
1012
  invalid("call_chain_path_invalid", "Entry callChain must be call-chain.yaml", "preset.yaml");
967
1013
  if (preset.format !== "narraeon.play-preset/v1" &&
968
- preset.format !== "narraeon.play-preset/v2")
1014
+ preset.format !== "narraeon.play-preset/v2" &&
1015
+ preset.format !== "narraeon.play-preset/v3")
969
1016
  invalid("preset_format_invalid", "preset.yaml format must be narraeon.play-preset/v1", "preset.yaml");
970
1017
  if (preset.format === "narraeon.play-preset/v1" &&
971
1018
  (preset.playPrompts !== undefined ||
@@ -1009,12 +1056,21 @@ export function parsePlayPresetFiles(files, limits = {}) {
1009
1056
  : {
1010
1057
  mergeConsecutiveMessages: parseMessageMerging(preset.mergeConsecutiveMessages),
1011
1058
  }),
1012
- ...(preset.format === "narraeon.play-preset/v2"
1013
- ? { playPrompts: parseOrderedPlayPrompts(preset.playPrompts) }
1059
+ ...(preset.format === "narraeon.play-preset/v2" ||
1060
+ preset.format === "narraeon.play-preset/v3"
1061
+ ? {
1062
+ playPrompts: preset.format === "narraeon.play-preset/v3"
1063
+ ? parsePromptArrangement(preset.playPrompts)
1064
+ : parseReleasedOrderedPrompts(preset.playPrompts),
1065
+ }
1014
1066
  : {}),
1015
1067
  ...(preset.authorPrompts === undefined
1016
1068
  ? {}
1017
- : { authorPrompts: parseOrderedAuthorPrompts(preset.authorPrompts) }),
1069
+ : {
1070
+ authorPrompts: preset.format === "narraeon.play-preset/v3"
1071
+ ? parsePromptArrangement(preset.authorPrompts, builtinAuthorPrompts("en"), false)
1072
+ : parseReleasedOrderedPrompts(preset.authorPrompts, builtinAuthorPrompts("en"), false),
1073
+ }),
1018
1074
  name: name.trim(),
1019
1075
  callChainPath,
1020
1076
  ...(settingImprovementPrompt === undefined
@@ -1819,6 +1875,13 @@ function toFileMap(input, limits) {
1819
1875
  validateFileTree(files, limits);
1820
1876
  return files;
1821
1877
  }
1878
+ function upgradeValidOrderedPromptFormat(files, limits) {
1879
+ const upgraded = upgradeOrderedPromptFormat(files);
1880
+ return upgraded !== files &&
1881
+ validatePlayPresetFiles(upgraded, limits).status === "valid"
1882
+ ? upgraded
1883
+ : files;
1884
+ }
1822
1885
  function parseStoredDocument(value, limits = defaultLimits) {
1823
1886
  if (!isRecord(value) ||
1824
1887
  !hasExactKeys(value, ["schemaVersion", "currentPresetId", "presets"]) ||
@@ -1875,14 +1938,6 @@ function parseStoredDocument(value, limits = defaultLimits) {
1875
1938
  }
1876
1939
  if (expectedRevision !== revision)
1877
1940
  throw new FileNativePlayPresetError("store_invalid", "Play-preset revision content hash does not match");
1878
- // A revision saved by an earlier release may still name builtins that
1879
- // the catalog has since replaced; it counts as current-format when the
1880
- // load-time migration makes it so, and its bytes stay as written.
1881
- if (revision !== raw.draftRevision &&
1882
- validatePlayPresetFiles(clone, limits).status !== "valid" &&
1883
- validatePlayPresetFiles(withCurrentPromptBuiltins(clone), limits)
1884
- .status !== "valid")
1885
- throw new FileNativePlayPresetError("store_invalid", "Play-preset revision does not match the current format");
1886
1941
  revisions[revision] = clone;
1887
1942
  }
1888
1943
  if (revisions[raw.currentRevision] === undefined)
@@ -1919,6 +1974,18 @@ function findStored(document, id) {
1919
1974
  throw new FileNativePlayPresetError("not_found", "Requested play preset was not found");
1920
1975
  return preset;
1921
1976
  }
1977
+ function assertReadonlyPromptsRetained(previous, candidate) {
1978
+ for (const entry of previous) {
1979
+ if (entry.kind !== "builtin" && entry.kind !== "world")
1980
+ continue;
1981
+ const retained = candidate.find((item) => item.id === entry.id);
1982
+ if (retained?.kind !== entry.kind ||
1983
+ (entry.kind === "builtin" &&
1984
+ retained.kind === "builtin" &&
1985
+ retained.builtin !== entry.builtin))
1986
+ throw new FileNativePlayPresetError("readonly_prompt_removed", "System prompts and the world placeholder cannot be deleted or replaced; disable optional prompts or clone them instead");
1987
+ }
1988
+ }
1922
1989
  function normalizeName(raw) {
1923
1990
  const name = raw.trim();
1924
1991
  if (!namePattern.test(name) || name.includes(String.fromCharCode(0)))
@@ -1959,13 +2026,18 @@ export function legacyAuthorPrompts(definition) {
1959
2026
  const entries = defaultOrderedAuthorPrompts();
1960
2027
  const path = definition.settingImprovementPrompt?.path;
1961
2028
  if (path !== undefined)
1962
- entries[1] = {
2029
+ entries.splice(1, 1, {
2030
+ id: "author.guidance",
2031
+ kind: "builtin",
2032
+ builtin: "author.guidance",
2033
+ enabled: false,
2034
+ }, {
1963
2035
  id: "legacy-author",
1964
2036
  kind: "user",
1965
2037
  name: "Authoring",
1966
2038
  body: definition.files[path],
1967
2039
  enabled: true,
1968
- };
2040
+ });
1969
2041
  return entries;
1970
2042
  }
1971
2043
  function parseMessageMerging(value) {
@@ -1,4 +1,6 @@
1
1
  import { isResolutionReceipt } from "../../protocol/resolution.js";
2
+ import { isSafeGoogleWireRequest } from "../model/GoogleRequestPreview.js";
3
+ import { readGoogleHops, writeGoogleHop } from "../model/GoogleHopJournal.js";
2
4
  import { legacyModelBindingFailure, projectLegacyModelBindingFailure, } from "./LegacyModelBindingFailure.js";
3
5
  import { isPlayFailureKind, } from "../../protocol/playFailure.js";
4
6
  import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
@@ -43,11 +45,7 @@ export class FileNativePlayTimelineStore {
43
45
  const transcriptCount = prefixLength(input.source.transcript, input.target.transcript);
44
46
  const eventCount = prefixLength(input.source.events, input.target.events);
45
47
  const completedToolCount = prefixLength(input.source.completedTools, input.target.completedTools);
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);
48
- if (encoding !== null)
49
- await publishImmutableJson(join(targetRoot, encodingFileName(exchange)), encoding);
50
- }
48
+ await this.#copyRetainedEncodings(input.sourceWorldId, input.source.chainId, targetRoot, input.target);
51
49
  await Promise.all([
52
50
  cloneNumberedPrefix({
53
51
  sourceRoot,
@@ -346,18 +344,96 @@ export class FileNativePlayTimelineStore {
346
344
  await pruneEncodings(root);
347
345
  }
348
346
  async readInitialEncoding(worldId, chainId, exchange = 1) {
349
- const value = await readOptionalJson(join(this.#contextRoot(worldId, chainId), encodingFileName(exchange)));
347
+ const root = this.#contextRoot(worldId, chainId);
348
+ if (!(await retainedEncodingExchanges(root)).includes(exchange))
349
+ return null;
350
+ const value = await readOptionalJson(join(root, encodingFileName(exchange)));
350
351
  if (value === null)
351
352
  return null;
353
+ if (isRecord(value) &&
354
+ value.provider === "google_interactions" &&
355
+ !isSafeGoogleWireRequest(value))
356
+ return null;
352
357
  if (!isRecord(value) ||
353
358
  value.method !== "POST" ||
354
- !["chat_completions", "openai_responses", "anthropic_messages"].includes(String(value.provider)) ||
359
+ ![
360
+ "chat_completions",
361
+ "openai_responses",
362
+ "anthropic_messages",
363
+ "google_interactions",
364
+ ].includes(String(value.provider)) ||
355
365
  !isRecord(value.body) ||
356
366
  typeof value.endpointPath !== "string" ||
357
367
  !Array.isArray(value.headerNames) ||
358
368
  !value.headerNames.every((name) => typeof name === "string"))
359
369
  throw new Error("Initial request encoding has an invalid shape");
360
- return value;
370
+ const wire = value;
371
+ return wire.provider === "google_interactions"
372
+ ? readGoogleHops(join(root, "google-hops", String(exchange)), "main", wire)
373
+ : wire;
374
+ }
375
+ async recordGoogleHop(worldId, chainId, runExchange, hop, followupId) {
376
+ const root = this.#contextRoot(worldId, chainId);
377
+ await writeGoogleHop(join(root, "google-hops", String(runExchange)), followupId === undefined ? "main" : `followup:${followupId}`, hop, async () => (await retainedEncodingExchanges(root)).includes(runExchange));
378
+ }
379
+ async copyRetainedEncodings(worldId, sourceChainId, target) {
380
+ await this.#copyRetainedEncodings(worldId, sourceChainId, this.#contextRoot(worldId, target.chainId), target);
381
+ }
382
+ async #copyRetainedEncodings(worldId, sourceChainId, targetRoot, target) {
383
+ const sourceRoot = this.#contextRoot(worldId, sourceChainId);
384
+ const retainedResponses = new Set(target.events.flatMap((event) => event.kind === "assistant"
385
+ ? [`${event.exchange}:${event.attempt}`]
386
+ : []));
387
+ const targetFollowups = new Map(target.events.flatMap((event) => event.kind === "followup" && event.requestPreviewExchange !== undefined
388
+ ? [
389
+ [
390
+ followupEncodingFileName(event.requestPreviewExchange, event.followupId),
391
+ event.followupId,
392
+ ],
393
+ ]
394
+ : []));
395
+ for (const exchange of (await retainedEncodingExchanges(sourceRoot)).filter((value) => value <= target.exchange)) {
396
+ const encoding = await this.readInitialEncoding(worldId, sourceChainId, exchange).catch(() => null);
397
+ if (encoding === null)
398
+ continue;
399
+ if (encoding.googleHops !== undefined)
400
+ encoding.googleHops = encoding.googleHops.filter((hop) => retainedResponses.has(`${hop.exchange}:${hop.attempt}`));
401
+ await publishImmutableJson(join(targetRoot, encodingFileName(exchange)), encoding);
402
+ for (const name of await followupEncodingNames(sourceRoot, exchange)) {
403
+ const followupId = targetFollowups.get(name);
404
+ if (followupId === undefined)
405
+ continue;
406
+ try {
407
+ const value = await this.readFollowupEncoding(worldId, sourceChainId, exchange, followupId);
408
+ if (safeFollowupEncoding(value))
409
+ await publishImmutableJson(join(targetRoot, name), value);
410
+ }
411
+ catch {
412
+ // A single optional diagnostic must not prevent other evidence or a fork.
413
+ }
414
+ }
415
+ }
416
+ }
417
+ async recordFollowupEncoding(worldId, chainId, exchange, requestId, encoding) {
418
+ const root = this.#contextRoot(worldId, chainId);
419
+ // The main encoding owns the bounded run slot; never create an orphan slot.
420
+ if (!(await retainedEncodingExchanges(root)).includes(exchange) ||
421
+ !safeFollowupEncoding(encoding))
422
+ return false;
423
+ await publishImmutableJson(join(root, followupEncodingFileName(exchange, requestId)), encoding);
424
+ await pruneEncodings(root);
425
+ return true;
426
+ }
427
+ async readFollowupEncoding(worldId, chainId, exchange, requestId) {
428
+ const root = this.#contextRoot(worldId, chainId);
429
+ if (!(await retainedEncodingExchanges(root)).includes(exchange))
430
+ return null;
431
+ const value = await readOptionalJson(join(root, followupEncodingFileName(exchange, requestId)));
432
+ if (value === null)
433
+ return null;
434
+ if (!safeFollowupEncoding(value))
435
+ throw new Error("Follow-up request encoding has an invalid shape");
436
+ return readGoogleHops(join(root, "google-hops", String(exchange)), `followup:${requestId}`, value);
361
437
  }
362
438
  async readAllContexts(worldId) {
363
439
  const current = await this.readCurrent(worldId);
@@ -538,6 +614,18 @@ export class FileNativePlayTimelineStore {
538
614
  assertPlayEvent(event);
539
615
  if (event.id !== eventId)
540
616
  throw new Error("Play timeline event identity is inconsistent");
617
+ if (event.kind === "followup") {
618
+ const wireRequest = event.requestPreviewExchange === undefined ||
619
+ event.requestPreviewStatus === undefined
620
+ ? null
621
+ : await this.readFollowupEncoding(worldId, chainId, event.requestPreviewExchange, event.followupId).catch(() => null);
622
+ return {
623
+ ...event,
624
+ requestPreview: wireRequest === null || event.requestPreviewStatus === undefined
625
+ ? { status: "unavailable" }
626
+ : { status: event.requestPreviewStatus, wireRequest },
627
+ };
628
+ }
541
629
  return projectLegacyModelBindingFailure(event);
542
630
  }
543
631
  newGeneration() {
@@ -900,7 +988,8 @@ function validModelHostBinding(value) {
900
988
  return (isRecord(value) &&
901
989
  (value.provider === "chat_completions" ||
902
990
  value.provider === "openai_responses" ||
903
- value.provider === "anthropic_messages") &&
991
+ value.provider === "anthropic_messages" ||
992
+ value.provider === "google_interactions") &&
904
993
  typeof value.endpointFingerprint === "string" &&
905
994
  value.endpointFingerprint.length > 0 &&
906
995
  typeof value.modelId === "string" &&
@@ -972,6 +1061,13 @@ function assertPlayEvent(value) {
972
1061
  typeof value.text !== "string" ||
973
1062
  !validOptionalString(value.reasoning) ||
974
1063
  !validOptionalUsage(value.usage) ||
1064
+ (value.requestPreviewExchange !== undefined &&
1065
+ (!Number.isSafeInteger(value.requestPreviewExchange) ||
1066
+ Number(value.requestPreviewExchange) < 1)) ||
1067
+ (value.requestPreviewStatus !== undefined &&
1068
+ value.requestPreviewStatus !== "attempted" &&
1069
+ value.requestPreviewStatus !== "completed" &&
1070
+ value.requestPreviewStatus !== "cancelled") ||
975
1071
  !Array.isArray(value.toolCalls) ||
976
1072
  !value.toolCalls.every(validFollowupToolCall) ||
977
1073
  !validOptionalString(value.failure))
@@ -1073,6 +1169,11 @@ function validOptionalUsage(value) {
1073
1169
  function validFullUsage(value) {
1074
1170
  if (!isRecord(value) || !isRecord(value.provenance))
1075
1171
  return false;
1172
+ if (value.providerRaw !== undefined && !isRecord(value.providerRaw))
1173
+ return false;
1174
+ if (value.accounting !== undefined &&
1175
+ value.accounting !== "google_interactions")
1176
+ return false;
1076
1177
  const provenance = value.provenance;
1077
1178
  const fields = [
1078
1179
  "inputTokens",
@@ -1192,6 +1293,31 @@ function encodingFileName(exchange) {
1192
1293
  ? "initial-request-encoding.json"
1193
1294
  : `request-encoding-${exchange}.json`;
1194
1295
  }
1296
+ function followupEncodingFileName(exchange, requestId) {
1297
+ return `followup-encoding-${exchange}-${createHash("sha256").update(requestId).digest("hex")}.json`;
1298
+ }
1299
+ function safeFollowupEncoding(value) {
1300
+ if (!isSafeGoogleWireRequest(value) ||
1301
+ value.headerNames.length !== 3 ||
1302
+ value.headerNames[0] !== "Accept" ||
1303
+ value.headerNames[1] !== "x-goog-api-key" ||
1304
+ value.headerNames[2] !== "Content-Type" ||
1305
+ !isRecord(value.body) ||
1306
+ !Array.isArray(value.body.input))
1307
+ return false;
1308
+ // Provider function calls must have been redacted; only the Runtime's
1309
+ // explicitly marked bootstrap call can remain in the projected input.
1310
+ return value.body.input.every((item) => !isRecord(item) ||
1311
+ item.type !== "function_call" ||
1312
+ (item.name === "runtime_load_context" &&
1313
+ isRecord(item.arguments) &&
1314
+ Object.keys(item.arguments).length === 0));
1315
+ }
1316
+ async function followupEncodingNames(root, exchange) {
1317
+ const prefix = `followup-encoding-${exchange}-`;
1318
+ return (await readDirectoryNames(root)).filter((name) => name.startsWith(prefix) &&
1319
+ /^[a-f0-9]{64}\.json$/u.test(name.slice(prefix.length)));
1320
+ }
1195
1321
  /** The exchanges with a recorded encoding in a context, latest first. */
1196
1322
  async function recordedEncodingExchanges(contextRoot) {
1197
1323
  return (await readDirectoryNames(contextRoot))
@@ -1204,12 +1330,26 @@ async function recordedEncodingExchanges(contextRoot) {
1204
1330
  .filter((exchange) => exchange !== null)
1205
1331
  .sort((left, right) => right - left);
1206
1332
  }
1333
+ async function retainedEncodingExchanges(contextRoot) {
1334
+ return (await recordedEncodingExchanges(contextRoot)).slice(0, 2);
1335
+ }
1207
1336
  /**
1208
1337
  * Keep the encodings of the two latest exchanges, the pair the AI-reading
1209
1338
  * diagnostics compare. A context without runs is read at exchange 1.
1210
1339
  */
1211
1340
  async function pruneEncodings(contextRoot) {
1212
- for (const exchange of (await recordedEncodingExchanges(contextRoot)).slice(2))
1341
+ const exchanges = await recordedEncodingExchanges(contextRoot);
1342
+ const retained = new Set(exchanges.slice(0, 2));
1343
+ const hopRoot = join(contextRoot, "google-hops");
1344
+ for (const name of await readDirectoryNames(hopRoot))
1345
+ if (/^[1-9]\d*$/u.test(name) && !retained.has(Number(name)))
1346
+ await rm(join(hopRoot, name), { recursive: true, force: true });
1347
+ for (const name of await readDirectoryNames(contextRoot)) {
1348
+ const match = /^followup-encoding-([1-9][0-9]*)-[a-f0-9]{64}\.json$/u.exec(name);
1349
+ if (match !== null && !retained.has(Number(match[1])))
1350
+ await rm(join(contextRoot, name), { force: true });
1351
+ }
1352
+ for (const exchange of exchanges.slice(2))
1213
1353
  await rm(join(contextRoot, encodingFileName(exchange)), { force: true });
1214
1354
  }
1215
1355
  /**