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.
- package/dist/node/protocol/modelConnections.js +15 -0
- package/dist/node/protocol/modelUsage.js +4 -0
- package/dist/node/protocol/sillyTavernContent.js +1 -0
- package/dist/node/protocol/v1.js +97 -0
- package/dist/node/runtime/V1Runtime.js +105 -3
- package/dist/node/runtime/authoring/AuthoringEditPlan.js +79 -0
- package/dist/node/runtime/authoring/AuthoringRequestSnapshot.js +15 -3
- package/dist/node/runtime/authoring/AuthoringRetry.js +59 -0
- package/dist/node/runtime/authoring/DamagedGoogleContinuation.js +68 -0
- package/dist/node/runtime/authoring/ModelToolConversation.js +66 -25
- package/dist/node/runtime/content/ContentWorkspace.js +10 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +14 -8
- package/dist/node/runtime/content/SillyTavernContent.js +796 -0
- package/dist/node/runtime/model/AiFailureLog.js +8 -1
- package/dist/node/runtime/model/DispatchFailure.js +8 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +627 -18
- package/dist/node/runtime/model/GoogleHopJournal.js +90 -0
- package/dist/node/runtime/model/GoogleInteractionsStream.js +242 -0
- package/dist/node/runtime/model/GoogleRequestPreview.js +184 -0
- package/dist/node/runtime/model/ModelConnectionStore.js +4 -1
- package/dist/node/runtime/model/ProviderExchangeState.js +44 -1
- package/dist/node/runtime/model/ProviderStream.js +4 -2
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +4 -4
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +150 -78
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +199 -10
- package/dist/node/runtime/play/OrderedPlayPrompts.js +139 -178
- package/dist/node/runtime/play/PlayAdvanceLifecycle.js +51 -23
- package/dist/node/runtime/play/PlayCallChain.js +92 -8
- package/dist/node/runtime/play/PlayFollowupRequests.js +23 -3
- package/dist/node/runtime/prompt/PromptCompilationCodec.js +2 -1
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +321 -15
- package/dist/node/runtime/setting/SettingImprovementSession.js +285 -21
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +236 -15
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +324 -50
- package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +47 -1
- package/dist/node/shared/application-preset-files.js +29 -0
- package/dist/node/shared/default-preset-host.js +50 -38
- package/dist/node/shared/first-party-action-choices.js +7 -11
- package/dist/node/shared/first-party-generic-panels.js +7 -11
- package/dist/node/shared/first-party-player-view.js +7 -11
- package/dist/node/shared/ordered-author-prompts.js +6 -4
- package/dist/node/shared/ordered-play-prompts.js +4 -2
- package/dist/node/shared/prompt-arrangement.js +205 -0
- package/dist/node/shared/sillytavern-preset.js +454 -0
- package/dist/web/assets/index-BthiDIQu.js +1432 -0
- package/dist/web/assets/{index-DH2BVsYd.css → index-wO9Y4HT1.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- 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
|
-
|
|
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
|
|
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
|
-
![
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
|
171
|
+
if (!document)
|
|
221
172
|
return files;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
305
|
-
export function
|
|
306
|
-
|
|
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
|
}
|