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,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() {
@@ -661,6 +749,55 @@ export class FileNativePlayTimelineStore {
661
749
  indexValue.chainId !== chainId ||
662
750
  stateValue.chainId !== chainId)
663
751
  throw new Error("Play context durable identity is inconsistent");
752
+ // Older releases disallowed retry after a player cancellation. The tail
753
+ // cancellation and interrupted assistant prove this was a dispatch ending,
754
+ // not a settlement failure. Validate the frozen request before exposing it.
755
+ if (!indexValue.importedFromArchive &&
756
+ stateValue.status === "interrupted" &&
757
+ !stateValue.canRetry &&
758
+ stateValue.eventCount >= 2) {
759
+ try {
760
+ const [tail, previous] = await Promise.all([
761
+ readJson(join(root, "events", numbered(stateValue.eventCount))),
762
+ readJson(join(root, "events", numbered(stateValue.eventCount - 1))),
763
+ ]);
764
+ assertPlayEvent(tail);
765
+ assertPlayEvent(previous);
766
+ if (tail.kind === "cancellation" &&
767
+ previous.kind === "assistant" &&
768
+ previous.status === "interrupted") {
769
+ const [base, continuation] = await Promise.all([
770
+ readJson(join(root, "base.json")),
771
+ readJson(join(root, "continuations", `${stateValue.continuationDigest}.json`)),
772
+ ]);
773
+ assertContextBase(base);
774
+ assertContextContinuation(continuation);
775
+ if (base.worldId === worldId &&
776
+ base.chainId === chainId &&
777
+ continuation.chainId === chainId &&
778
+ continuation.lastRequestDigest !== null &&
779
+ createHash("sha256")
780
+ .update(JSON.stringify(continuation))
781
+ .digest("hex") === stateValue.continuationDigest) {
782
+ const request = await readJson(join(root, "requests", `${continuation.lastRequestDigest}.json`));
783
+ const provider = request.modelConfig?.provider ?? base.modelBinding?.provider;
784
+ if (provider !== undefined &&
785
+ provider !== "google_interactions" &&
786
+ Array.isArray(request.appended) &&
787
+ // The legacy record carries no codec-validation receipt. Only
788
+ // requests without native assistant fragments can be proven safe.
789
+ request.appended.every((item) => item.kind !== "assistant") &&
790
+ createHash("sha256")
791
+ .update(JSON.stringify(request))
792
+ .digest("hex") === continuation.lastRequestDigest)
793
+ stateValue.canRetry = true;
794
+ }
795
+ }
796
+ }
797
+ catch {
798
+ // Missing or damaged legacy evidence never grants retry or breaks metadata reads.
799
+ }
800
+ }
664
801
  // Old versions persisted a configuration preflight failure as a terminal
665
802
  // context failure. Recover only the disposition proven by its event prefix.
666
803
  // Archive contexts and genuine provider failures retain their restrictions.
@@ -900,7 +1037,8 @@ function validModelHostBinding(value) {
900
1037
  return (isRecord(value) &&
901
1038
  (value.provider === "chat_completions" ||
902
1039
  value.provider === "openai_responses" ||
903
- value.provider === "anthropic_messages") &&
1040
+ value.provider === "anthropic_messages" ||
1041
+ value.provider === "google_interactions") &&
904
1042
  typeof value.endpointFingerprint === "string" &&
905
1043
  value.endpointFingerprint.length > 0 &&
906
1044
  typeof value.modelId === "string" &&
@@ -972,6 +1110,13 @@ function assertPlayEvent(value) {
972
1110
  typeof value.text !== "string" ||
973
1111
  !validOptionalString(value.reasoning) ||
974
1112
  !validOptionalUsage(value.usage) ||
1113
+ (value.requestPreviewExchange !== undefined &&
1114
+ (!Number.isSafeInteger(value.requestPreviewExchange) ||
1115
+ Number(value.requestPreviewExchange) < 1)) ||
1116
+ (value.requestPreviewStatus !== undefined &&
1117
+ value.requestPreviewStatus !== "attempted" &&
1118
+ value.requestPreviewStatus !== "completed" &&
1119
+ value.requestPreviewStatus !== "cancelled") ||
975
1120
  !Array.isArray(value.toolCalls) ||
976
1121
  !value.toolCalls.every(validFollowupToolCall) ||
977
1122
  !validOptionalString(value.failure))
@@ -1073,6 +1218,11 @@ function validOptionalUsage(value) {
1073
1218
  function validFullUsage(value) {
1074
1219
  if (!isRecord(value) || !isRecord(value.provenance))
1075
1220
  return false;
1221
+ if (value.providerRaw !== undefined && !isRecord(value.providerRaw))
1222
+ return false;
1223
+ if (value.accounting !== undefined &&
1224
+ value.accounting !== "google_interactions")
1225
+ return false;
1076
1226
  const provenance = value.provenance;
1077
1227
  const fields = [
1078
1228
  "inputTokens",
@@ -1192,6 +1342,31 @@ function encodingFileName(exchange) {
1192
1342
  ? "initial-request-encoding.json"
1193
1343
  : `request-encoding-${exchange}.json`;
1194
1344
  }
1345
+ function followupEncodingFileName(exchange, requestId) {
1346
+ return `followup-encoding-${exchange}-${createHash("sha256").update(requestId).digest("hex")}.json`;
1347
+ }
1348
+ function safeFollowupEncoding(value) {
1349
+ if (!isSafeGoogleWireRequest(value) ||
1350
+ value.headerNames.length !== 3 ||
1351
+ value.headerNames[0] !== "Accept" ||
1352
+ value.headerNames[1] !== "x-goog-api-key" ||
1353
+ value.headerNames[2] !== "Content-Type" ||
1354
+ !isRecord(value.body) ||
1355
+ !Array.isArray(value.body.input))
1356
+ return false;
1357
+ // Provider function calls must have been redacted; only the Runtime's
1358
+ // explicitly marked bootstrap call can remain in the projected input.
1359
+ return value.body.input.every((item) => !isRecord(item) ||
1360
+ item.type !== "function_call" ||
1361
+ (item.name === "runtime_load_context" &&
1362
+ isRecord(item.arguments) &&
1363
+ Object.keys(item.arguments).length === 0));
1364
+ }
1365
+ async function followupEncodingNames(root, exchange) {
1366
+ const prefix = `followup-encoding-${exchange}-`;
1367
+ return (await readDirectoryNames(root)).filter((name) => name.startsWith(prefix) &&
1368
+ /^[a-f0-9]{64}\.json$/u.test(name.slice(prefix.length)));
1369
+ }
1195
1370
  /** The exchanges with a recorded encoding in a context, latest first. */
1196
1371
  async function recordedEncodingExchanges(contextRoot) {
1197
1372
  return (await readDirectoryNames(contextRoot))
@@ -1204,12 +1379,26 @@ async function recordedEncodingExchanges(contextRoot) {
1204
1379
  .filter((exchange) => exchange !== null)
1205
1380
  .sort((left, right) => right - left);
1206
1381
  }
1382
+ async function retainedEncodingExchanges(contextRoot) {
1383
+ return (await recordedEncodingExchanges(contextRoot)).slice(0, 2);
1384
+ }
1207
1385
  /**
1208
1386
  * Keep the encodings of the two latest exchanges, the pair the AI-reading
1209
1387
  * diagnostics compare. A context without runs is read at exchange 1.
1210
1388
  */
1211
1389
  async function pruneEncodings(contextRoot) {
1212
- for (const exchange of (await recordedEncodingExchanges(contextRoot)).slice(2))
1390
+ const exchanges = await recordedEncodingExchanges(contextRoot);
1391
+ const retained = new Set(exchanges.slice(0, 2));
1392
+ const hopRoot = join(contextRoot, "google-hops");
1393
+ for (const name of await readDirectoryNames(hopRoot))
1394
+ if (/^[1-9]\d*$/u.test(name) && !retained.has(Number(name)))
1395
+ await rm(join(hopRoot, name), { recursive: true, force: true });
1396
+ for (const name of await readDirectoryNames(contextRoot)) {
1397
+ const match = /^followup-encoding-([1-9][0-9]*)-[a-f0-9]{64}\.json$/u.exec(name);
1398
+ if (match !== null && !retained.has(Number(match[1])))
1399
+ await rm(join(contextRoot, name), { force: true });
1400
+ }
1401
+ for (const exchange of exchanges.slice(2))
1213
1402
  await rm(join(contextRoot, encodingFileName(exchange)), { force: true });
1214
1403
  }
1215
1404
  /**
@@ -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
  }