narraeon 0.6.2 → 0.7.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.
- package/dist/node/runtime/V1Runtime.js +3 -0
- package/dist/node/runtime/content/FileNativeContentTree.js +15 -7
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +45 -11
- package/dist/node/runtime/play/OrderedPlayPrompts.js +237 -34
- package/dist/node/runtime/play/PlayCallChain.js +20 -3
- package/dist/node/runtime/play/PlayContinuity.js +10 -2
- package/dist/node/runtime/play/PlayDocumentTools.js +31 -17
- package/dist/node/runtime/play/PlayTraceRetention.js +50 -2
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +124 -13
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +5 -6
- package/dist/node/runtime/prompt/PromptCompilationCodec.js +13 -1
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +15 -10
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +2 -2
- package/dist/node/shared/default-play-prompts.js +21 -65
- package/dist/node/shared/default-preset-host.js +346 -173
- package/dist/node/shared/ordered-author-prompts.js +5 -0
- package/dist/node/shared/ordered-play-prompts.js +195 -42
- package/dist/node/shared/turn-guide.js +26 -0
- package/dist/web/assets/index-BUCslLEK.css +1 -0
- package/dist/web/assets/index-Ro7dnSsC.js +1408 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/web/assets/index-CXnFaYK9.js +0 -1323
- package/dist/web/assets/index-LPMV0pl3.css +0 -1
|
@@ -462,6 +462,9 @@ export class V1Runtime {
|
|
|
462
462
|
kind: "player",
|
|
463
463
|
text: preview.initialAppend.logical.text,
|
|
464
464
|
},
|
|
465
|
+
...(preview.initialAppend.afterPlayer === undefined
|
|
466
|
+
? []
|
|
467
|
+
: [preview.initialAppend.afterPlayer.logical]),
|
|
465
468
|
],
|
|
466
469
|
requestId: "play_call_chain",
|
|
467
470
|
operationId: "prompt-preview",
|
|
@@ -368,13 +368,21 @@ function validateFrameMarkdownFiles(frame, files, issues) {
|
|
|
368
368
|
? frame.instructions
|
|
369
369
|
: [];
|
|
370
370
|
for (const entry of instructions) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
entry.
|
|
375
|
-
entry.markdown.
|
|
376
|
-
|
|
377
|
-
|
|
371
|
+
// An instruction references a Markdown block, carries one turn-guide
|
|
372
|
+
// line for the per-send suffix, or both.
|
|
373
|
+
const keysValid = isRecord(entry) &&
|
|
374
|
+
Object.keys(entry).every((key) => key === "markdown" || key === "turnGuide") &&
|
|
375
|
+
(entry.markdown !== undefined || entry.turnGuide !== undefined) &&
|
|
376
|
+
(entry.turnGuide === undefined ||
|
|
377
|
+
(typeof entry.turnGuide === "string" &&
|
|
378
|
+
!/[\r\n]/u.test(entry.turnGuide)));
|
|
379
|
+
if (!keysValid ||
|
|
380
|
+
(entry.markdown !== undefined &&
|
|
381
|
+
(typeof entry.markdown !== "string" ||
|
|
382
|
+
entry.markdown.startsWith("/") ||
|
|
383
|
+
entry.markdown.includes("..") ||
|
|
384
|
+
!files.has(`control/${entry.markdown}`)))) {
|
|
385
|
+
issue(issues, "invalid_world_frame", "control/frame.yaml", "A world instruction must reference an existing Markdown block inside the control directory or carry a single-line turn-guide entry");
|
|
378
386
|
}
|
|
379
387
|
}
|
|
380
388
|
}
|
|
@@ -3,7 +3,7 @@ export { validatePlayPresetArtifactPayload } from "../../shared/artifact-payload
|
|
|
3
3
|
import { defaultFollowupItems, } from "../../shared/ordered-followups.js";
|
|
4
4
|
import { parseFollowupItems } from "./OrderedFollowups.js";
|
|
5
5
|
import { builtinAuthorPrompts, defaultOrderedAuthorPrompts, } from "../../shared/ordered-author-prompts.js";
|
|
6
|
-
import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts,
|
|
6
|
+
import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, withCurrentPromptBuiltins, } from "./OrderedPlayPrompts.js";
|
|
7
7
|
import { defaultOrderedPlayPrompts, } from "../../shared/ordered-play-prompts.js";
|
|
8
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
@@ -101,9 +101,27 @@ export function isPlayPresetBinding(value) {
|
|
|
101
101
|
isDeepStrictEqual(files, legacyDefaultPlayPresetFilesForLocale("en")) ||
|
|
102
102
|
isDeepStrictEqual(files, legacyDefaultPlayPresetFilesForLocale("zh-CN")));
|
|
103
103
|
return (parsed.kind === "valid" &&
|
|
104
|
-
isDeepStrictEqual(value.definition, parsed.definition) &&
|
|
104
|
+
isDeepStrictEqual(normalizedFrozenDefinition(value.definition), parsed.definition) &&
|
|
105
105
|
(builtInDefault || value.revision === revisionForPlayPresetFiles(files)));
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* 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.
|
|
111
|
+
*/
|
|
112
|
+
function normalizedFrozenDefinition(definition) {
|
|
113
|
+
if (!Array.isArray(definition.playPrompts))
|
|
114
|
+
return definition;
|
|
115
|
+
try {
|
|
116
|
+
return {
|
|
117
|
+
...definition,
|
|
118
|
+
playPrompts: parseOrderedPlayPrompts(definition.playPrompts),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return definition;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
107
125
|
export class FileNativePlayPresetError extends Error {
|
|
108
126
|
code;
|
|
109
127
|
constructor(code, message) {
|
|
@@ -290,7 +308,7 @@ export class FileNativePlayPresetStore {
|
|
|
290
308
|
if (candidate.format !== "narraeon.play-preset/v2")
|
|
291
309
|
throw new FileNativePlayPresetError("readonly_prompt_removed", "Ordered presets cannot downgrade to v1");
|
|
292
310
|
const candidates = parseOrderedPlayPrompts(candidate.playPrompts);
|
|
293
|
-
for (const entry of previous.definition.playPrompts.filter((entry) => entry.kind
|
|
311
|
+
for (const entry of previous.definition.playPrompts.filter((entry) => entry.kind === "builtin" || entry.kind === "world")) {
|
|
294
312
|
const retained = candidates.find((item) => item.id === entry.id);
|
|
295
313
|
if (retained?.kind !== entry.kind ||
|
|
296
314
|
(entry.kind === "builtin" &&
|
|
@@ -364,6 +382,9 @@ export class FileNativePlayPresetStore {
|
|
|
364
382
|
const files = stored.revisions[selectedRevision];
|
|
365
383
|
if (files === undefined)
|
|
366
384
|
throw new FileNativePlayPresetError("revision_not_found", "The requested play-preset revision was not found");
|
|
385
|
+
// A frozen revision may still name a retired composite builtin; the
|
|
386
|
+
// ordered-prompt parser reads it as the blocks that replaced it, so old
|
|
387
|
+
// requests keep a readable preset while the stored bytes stay as written.
|
|
367
388
|
const parsed = parsePlayPresetFiles(files, this.#limits);
|
|
368
389
|
if (parsed.kind === "invalid")
|
|
369
390
|
throw new FileNativePlayPresetError(parsed.error.code, parsed.error.message);
|
|
@@ -457,8 +478,8 @@ export class FileNativePlayPresetStore {
|
|
|
457
478
|
.map((path) => ({ path, contents: binding.files[path] }));
|
|
458
479
|
}
|
|
459
480
|
async importPortable(input) {
|
|
460
|
-
// A released preset gains the
|
|
461
|
-
const files =
|
|
481
|
+
// A released preset gains the current builtins at import, as at upgrade.
|
|
482
|
+
const files = withCurrentPromptBuiltins(toFileMap(input.files, this.#limits));
|
|
462
483
|
const parsed = parsePlayPresetFiles(files, this.#limits);
|
|
463
484
|
if (parsed.kind === "invalid")
|
|
464
485
|
throw parsed.error;
|
|
@@ -502,6 +523,7 @@ export class FileNativePlayPresetStore {
|
|
|
502
523
|
* surface to fix it from. The damaged file is preserved under a new name
|
|
503
524
|
* instead, so nothing is lost and the workspace can still open.
|
|
504
525
|
*/
|
|
526
|
+
#validated;
|
|
505
527
|
async #read() {
|
|
506
528
|
let raw;
|
|
507
529
|
try {
|
|
@@ -515,7 +537,14 @@ export class FileNativePlayPresetStore {
|
|
|
515
537
|
return document;
|
|
516
538
|
}
|
|
517
539
|
try {
|
|
518
|
-
|
|
540
|
+
// Validation hashes and parses every stored revision, so a library
|
|
541
|
+
// whose bytes were already validated is served from that result. Each
|
|
542
|
+
// caller gets its own copy because callers edit what they read.
|
|
543
|
+
if (this.#validated !== undefined && raw === this.#validated.raw)
|
|
544
|
+
return structuredClone(this.#validated.document);
|
|
545
|
+
const document = parseStoredDocument(JSON.parse(raw), this.#limits);
|
|
546
|
+
this.#validated = { raw, document: structuredClone(document) };
|
|
547
|
+
return document;
|
|
519
548
|
}
|
|
520
549
|
catch (error) {
|
|
521
550
|
const quarantined = await this.#quarantine();
|
|
@@ -568,8 +597,8 @@ export class FileNativePlayPresetStore {
|
|
|
568
597
|
return true;
|
|
569
598
|
}
|
|
570
599
|
/**
|
|
571
|
-
* One-time upgrade for ordered presets saved before the tabletop
|
|
572
|
-
* existed. An affected revision is replaced by a new revision that inserts
|
|
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
|
|
573
602
|
* them; the old revision stays for frozen requests. A pending draft gets the
|
|
574
603
|
* same insertion, because a draft that kept the old list would fail the
|
|
575
604
|
* builtin-retention rule on save. The library is one atomically written file,
|
|
@@ -580,7 +609,7 @@ export class FileNativePlayPresetStore {
|
|
|
580
609
|
for (const stored of document.presets) {
|
|
581
610
|
const current = stored.revisions[stored.currentRevision];
|
|
582
611
|
if (current !== undefined) {
|
|
583
|
-
const migrated =
|
|
612
|
+
const migrated = withCurrentPromptBuiltins(current);
|
|
584
613
|
if (migrated !== current) {
|
|
585
614
|
const revision = revisionForFiles(migrated, this.#limits);
|
|
586
615
|
stored.revisions[revision] = cloneFiles(migrated);
|
|
@@ -592,7 +621,7 @@ export class FileNativePlayPresetStore {
|
|
|
592
621
|
? undefined
|
|
593
622
|
: stored.revisions[stored.draftRevision];
|
|
594
623
|
if (draft !== undefined) {
|
|
595
|
-
const migrated =
|
|
624
|
+
const migrated = withCurrentPromptBuiltins(draft);
|
|
596
625
|
if (migrated !== draft) {
|
|
597
626
|
const revision = revisionForFiles(migrated, this.#limits);
|
|
598
627
|
stored.revisions[revision] = cloneFiles(migrated);
|
|
@@ -1846,8 +1875,13 @@ function parseStoredDocument(value, limits = defaultLimits) {
|
|
|
1846
1875
|
}
|
|
1847
1876
|
if (expectedRevision !== revision)
|
|
1848
1877
|
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.
|
|
1849
1881
|
if (revision !== raw.draftRevision &&
|
|
1850
|
-
validatePlayPresetFiles(clone, limits).status !== "valid"
|
|
1882
|
+
validatePlayPresetFiles(clone, limits).status !== "valid" &&
|
|
1883
|
+
validatePlayPresetFiles(withCurrentPromptBuiltins(clone), limits)
|
|
1884
|
+
.status !== "valid")
|
|
1851
1885
|
throw new FileNativePlayPresetError("store_invalid", "Play-preset revision does not match the current format");
|
|
1852
1886
|
revisions[revision] = clone;
|
|
1853
1887
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { isMap, isSeq, parseDocument } from "yaml";
|
|
3
|
-
import { builtinPlayPrompts, isPromptMessageRole, } from "../../shared/ordered-play-prompts.js";
|
|
3
|
+
import { builtinPlayPrompts, isPromptMessageRole, promptGroups, } from "../../shared/ordered-play-prompts.js";
|
|
4
|
+
/** One turn-guide line: no line breaks, bounded length; empty clears a builtin default. */
|
|
5
|
+
const turnGuideLine = /^[^\r\n\0]{0,1000}$/u;
|
|
6
|
+
const promptName = /^[^\r\n\0]{1,160}$/u;
|
|
4
7
|
/** The same strict permission gate is used by portable imports and structured saves. */
|
|
5
8
|
export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"), worldRequired = true) {
|
|
6
9
|
const fail = () => {
|
|
@@ -11,7 +14,7 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
|
|
|
11
14
|
const ids = new Set();
|
|
12
15
|
const builtins = new Set();
|
|
13
16
|
let worlds = 0;
|
|
14
|
-
const entries = value.
|
|
17
|
+
const entries = value.flatMap((raw) => {
|
|
15
18
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
16
19
|
return fail();
|
|
17
20
|
const item = raw;
|
|
@@ -29,53 +32,122 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
|
|
|
29
32
|
key === "messageRole" ||
|
|
30
33
|
key === "id" ||
|
|
31
34
|
key === "kind") && keys.every((key) => Object.hasOwn(item, key));
|
|
35
|
+
const validTurnGuide = item.turnGuide === undefined ||
|
|
36
|
+
(typeof item.turnGuide === "string" &&
|
|
37
|
+
turnGuideLine.test(item.turnGuide));
|
|
38
|
+
const turnGuide = typeof item.turnGuide === "string" ? { turnGuide: item.turnGuide } : {};
|
|
32
39
|
if (item.kind === "world" && exact(["id", "kind"])) {
|
|
33
40
|
worlds++;
|
|
34
|
-
return { id: item.id, kind: "world", ...messageRole };
|
|
41
|
+
return [{ id: item.id, kind: "world", ...messageRole }];
|
|
35
42
|
}
|
|
43
|
+
if (item.kind === "tail" &&
|
|
44
|
+
exact(["id", "kind", "name", "enabled", "body"]) &&
|
|
45
|
+
item.messageRole === undefined &&
|
|
46
|
+
typeof item.name === "string" &&
|
|
47
|
+
item.name.trim().length > 0 &&
|
|
48
|
+
promptName.test(item.name) &&
|
|
49
|
+
typeof item.body === "string" &&
|
|
50
|
+
turnGuideLine.test(item.body) &&
|
|
51
|
+
typeof item.enabled === "boolean")
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
id: item.id,
|
|
55
|
+
kind: "tail",
|
|
56
|
+
name: item.name,
|
|
57
|
+
enabled: item.enabled,
|
|
58
|
+
body: item.body,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
36
61
|
if (item.kind === "user" &&
|
|
37
|
-
exact(["id", "kind", "name", "enabled", "body"], ["gameplay"]) &&
|
|
62
|
+
exact(["id", "kind", "name", "enabled", "body"], ["gameplay", "turnGuide"]) &&
|
|
38
63
|
typeof item.name === "string" &&
|
|
39
64
|
item.name.trim().length > 0 &&
|
|
40
|
-
|
|
65
|
+
promptName.test(item.name) &&
|
|
41
66
|
typeof item.body === "string" &&
|
|
42
67
|
typeof item.enabled === "boolean" &&
|
|
68
|
+
validTurnGuide &&
|
|
43
69
|
(item.gameplay === undefined || item.gameplay === "tabletop"))
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
id: item.id,
|
|
73
|
+
kind: "user",
|
|
74
|
+
name: item.name,
|
|
75
|
+
enabled: item.enabled,
|
|
76
|
+
body: item.body,
|
|
77
|
+
...messageRole,
|
|
78
|
+
...(item.gameplay === "tabletop"
|
|
79
|
+
? { gameplay: "tabletop" }
|
|
80
|
+
: {}),
|
|
81
|
+
...turnGuide,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
// A frozen list (an old preset revision, or the preset a stored author
|
|
85
|
+
// session captured) may still name a retired composite builtin. It reads
|
|
86
|
+
// as the single-rule blocks that replaced it, exactly as the stored
|
|
87
|
+
// library migrates it, so old bindings keep parsing and compiling.
|
|
88
|
+
const retired = item.kind === "builtin" && typeof item.builtin === "string"
|
|
89
|
+
? retiredBuiltinReplacements[item.builtin]
|
|
90
|
+
: undefined;
|
|
91
|
+
if (retired !== undefined &&
|
|
92
|
+
exact(["id", "kind", "builtin", "enabled"]) &&
|
|
93
|
+
typeof item.enabled === "boolean") {
|
|
94
|
+
const enabled = item.enabled;
|
|
95
|
+
return retired.flatMap(({ builtin, selected }) => {
|
|
96
|
+
if (builtins.has(builtin) || ids.has(builtin))
|
|
97
|
+
return [];
|
|
98
|
+
builtins.add(builtin);
|
|
99
|
+
ids.add(builtin);
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
id: builtin,
|
|
103
|
+
kind: "builtin",
|
|
104
|
+
builtin,
|
|
105
|
+
enabled: selected === false ? false : enabled,
|
|
106
|
+
...messageRole,
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
});
|
|
110
|
+
}
|
|
55
111
|
const builtin = catalog.find((entry) => entry.id === item.builtin);
|
|
56
112
|
if (item.kind !== "builtin" ||
|
|
57
|
-
!exact(["id", "kind", "builtin", "enabled"]) ||
|
|
113
|
+
!exact(["id", "kind", "builtin", "enabled"], ["turnGuide"]) ||
|
|
58
114
|
!builtin ||
|
|
59
115
|
builtins.has(builtin.id) ||
|
|
60
116
|
typeof item.enabled !== "boolean" ||
|
|
117
|
+
!validTurnGuide ||
|
|
61
118
|
(builtin.required &&
|
|
62
119
|
(!item.enabled ||
|
|
63
120
|
(item.messageRole !== undefined && item.messageRole !== "system"))))
|
|
64
121
|
return fail();
|
|
65
122
|
builtins.add(builtin.id);
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
id: item.id,
|
|
126
|
+
kind: "builtin",
|
|
127
|
+
builtin: builtin.id,
|
|
128
|
+
enabled: item.enabled,
|
|
129
|
+
...messageRole,
|
|
130
|
+
...turnGuide,
|
|
131
|
+
},
|
|
132
|
+
];
|
|
73
133
|
});
|
|
74
134
|
if (worlds !== (worldRequired ? 1 : 0) ||
|
|
75
135
|
catalog.some((item) => item.required && !builtins.has(item.id)))
|
|
76
136
|
return fail();
|
|
77
137
|
return entries;
|
|
78
138
|
}
|
|
139
|
+
function orderedPresetDocument(files) {
|
|
140
|
+
const source = files["preset.yaml"];
|
|
141
|
+
if (source === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
const document = parseDocument(source, { uniqueKeys: true });
|
|
144
|
+
if (document.errors.length > 0 ||
|
|
145
|
+
document.warnings.length > 0 ||
|
|
146
|
+
!isMap(document.contents) ||
|
|
147
|
+
document.get("format") !== "narraeon.play-preset/v2")
|
|
148
|
+
return undefined;
|
|
149
|
+
return document;
|
|
150
|
+
}
|
|
79
151
|
/**
|
|
80
152
|
* Insert the tabletop builtins that an ordered preset does not list yet.
|
|
81
153
|
* play.tabletop goes just before the world placeholder and author.tabletop just
|
|
@@ -84,14 +156,8 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
|
|
|
84
156
|
* when nothing needs inserting, so callers can detect a change by identity.
|
|
85
157
|
*/
|
|
86
158
|
export function withTabletopPromptBuiltins(files) {
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
-
return files;
|
|
90
|
-
const document = parseDocument(source, { uniqueKeys: true });
|
|
91
|
-
if (document.errors.length > 0 ||
|
|
92
|
-
document.warnings.length > 0 ||
|
|
93
|
-
!isMap(document.contents) ||
|
|
94
|
-
document.get("format") !== "narraeon.play-preset/v2")
|
|
159
|
+
const document = orderedPresetDocument(files);
|
|
160
|
+
if (document === undefined)
|
|
95
161
|
return files;
|
|
96
162
|
let changed = false;
|
|
97
163
|
const playPrompts = document.get("playPrompts", true);
|
|
@@ -118,6 +184,141 @@ export function withTabletopPromptBuiltins(files) {
|
|
|
118
184
|
}
|
|
119
185
|
return changed ? { ...files, "preset.yaml": document.toString() } : files;
|
|
120
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The retired composite builtins and the blocks that replaced each of them.
|
|
189
|
+
* A stored entry for a retired builtin is replaced in place by its successors,
|
|
190
|
+
* which inherit its enabled state and message role; the unselected members of
|
|
191
|
+
* a choice group arrive disabled. User copies of the old text are not touched.
|
|
192
|
+
*/
|
|
193
|
+
const retiredBuiltinReplacements = {
|
|
194
|
+
"play.style": [{ builtin: "play.prose" }, { builtin: "play.scene" }],
|
|
195
|
+
"play.adjudication": [
|
|
196
|
+
{ builtin: "play.agency-default" },
|
|
197
|
+
{ builtin: "play.agency-strict", selected: false },
|
|
198
|
+
{ builtin: "play.agency-open", selected: false },
|
|
199
|
+
{ builtin: "play.facts" },
|
|
200
|
+
{ builtin: "play.npc" },
|
|
201
|
+
],
|
|
202
|
+
"play.narrative": [
|
|
203
|
+
{ builtin: "play.stop-beat" },
|
|
204
|
+
{ builtin: "play.stop-scene", selected: false },
|
|
205
|
+
{ builtin: "play.ending" },
|
|
206
|
+
{ builtin: "play.output" },
|
|
207
|
+
],
|
|
208
|
+
"play.state": [
|
|
209
|
+
{ builtin: "play.save-timing" },
|
|
210
|
+
{ builtin: "play.ownership" },
|
|
211
|
+
{ builtin: "play.situation" },
|
|
212
|
+
{ builtin: "play.checkpoint-policy" },
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
export const retiredPlayBuiltins = Object.keys(retiredBuiltinReplacements);
|
|
216
|
+
/**
|
|
217
|
+
* Replace the retired composite builtins (style, adjudication, narrative and
|
|
218
|
+
* state) with the single-rule blocks that succeeded them, each at the old
|
|
219
|
+
* entry's position. Same identity contract as the other insertions.
|
|
220
|
+
*/
|
|
221
|
+
export function withRestructuredPromptBuiltins(files) {
|
|
222
|
+
const document = orderedPresetDocument(files);
|
|
223
|
+
if (document === undefined)
|
|
224
|
+
return files;
|
|
225
|
+
const playPrompts = document.get("playPrompts", true);
|
|
226
|
+
if (!isSeq(playPrompts))
|
|
227
|
+
return files;
|
|
228
|
+
let changed = false;
|
|
229
|
+
for (const [retired, replacements] of Object.entries(retiredBuiltinReplacements)) {
|
|
230
|
+
const at = playPrompts.items.findIndex((item) => entryField(item, "kind") === "builtin" &&
|
|
231
|
+
entryField(item, "builtin") === retired);
|
|
232
|
+
if (at < 0)
|
|
233
|
+
continue;
|
|
234
|
+
const old = playPrompts.items[at];
|
|
235
|
+
const enabled = entryField(old, "enabled") === true;
|
|
236
|
+
const messageRole = entryField(old, "messageRole");
|
|
237
|
+
const successors = replacements
|
|
238
|
+
.filter(({ builtin }) => !hasEntry(playPrompts, builtin))
|
|
239
|
+
.map(({ builtin, selected }) => document.createNode({
|
|
240
|
+
id: builtin,
|
|
241
|
+
kind: "builtin",
|
|
242
|
+
builtin,
|
|
243
|
+
enabled: selected === false ? false : enabled,
|
|
244
|
+
...(typeof messageRole === "string" ? { messageRole } : {}),
|
|
245
|
+
}));
|
|
246
|
+
playPrompts.items.splice(at, 1, ...successors);
|
|
247
|
+
changed = true;
|
|
248
|
+
}
|
|
249
|
+
return changed ? { ...files, "preset.yaml": document.toString() } : files;
|
|
250
|
+
}
|
|
251
|
+
const alternativeGroups = () => {
|
|
252
|
+
const members = (group) => builtinPlayPrompts("en")
|
|
253
|
+
.filter((item) => item.group === group)
|
|
254
|
+
.map(({ id }) => id);
|
|
255
|
+
const order = promptGroups.map((group) => members(group));
|
|
256
|
+
// The default list runs mechanics, then the choice groups in `promptGroups`
|
|
257
|
+
// order, then the policy blocks: a missing group lands after the previous
|
|
258
|
+
// group (or mechanics), else ahead of the first later group or policy block.
|
|
259
|
+
return promptGroups.map((group, index) => ({
|
|
260
|
+
after: [...order.slice(0, index).flat().reverse(), "play.mechanics"],
|
|
261
|
+
before: [
|
|
262
|
+
...order.slice(index + 1).flat(),
|
|
263
|
+
"play.prose",
|
|
264
|
+
"play.scene",
|
|
265
|
+
"play.ending",
|
|
266
|
+
"play.output",
|
|
267
|
+
"play.facts",
|
|
268
|
+
"play.tabletop",
|
|
269
|
+
],
|
|
270
|
+
entries: groupPromptEntries(group),
|
|
271
|
+
}));
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* Insert the choice-group builtins (person, continuation, agency, stopping)
|
|
275
|
+
* that an ordered preset does not list yet. Same identity contract as the
|
|
276
|
+
* tabletop insertion.
|
|
277
|
+
*/
|
|
278
|
+
export function withAlternativePromptBuiltins(files) {
|
|
279
|
+
const document = orderedPresetDocument(files);
|
|
280
|
+
if (document === undefined)
|
|
281
|
+
return files;
|
|
282
|
+
const playPrompts = document.get("playPrompts", true);
|
|
283
|
+
if (!isSeq(playPrompts))
|
|
284
|
+
return files;
|
|
285
|
+
let changed = false;
|
|
286
|
+
for (const group of alternativeGroups()) {
|
|
287
|
+
if (group.entries.some((entry) => hasEntry(playPrompts, entry.id)))
|
|
288
|
+
continue;
|
|
289
|
+
const anchor = group.after
|
|
290
|
+
.map((id) => playPrompts.items.findIndex((item) => entryField(item, "id") === id))
|
|
291
|
+
.find((index) => index >= 0);
|
|
292
|
+
const fallback = playPrompts.items.findIndex((item) => {
|
|
293
|
+
const id = entryField(item, "id");
|
|
294
|
+
return ((typeof id === "string" && group.before.includes(id)) ||
|
|
295
|
+
entryField(item, "kind") === "world");
|
|
296
|
+
});
|
|
297
|
+
const at = anchor !== undefined
|
|
298
|
+
? anchor + 1
|
|
299
|
+
: fallback >= 0
|
|
300
|
+
? fallback
|
|
301
|
+
: playPrompts.items.length;
|
|
302
|
+
playPrompts.items.splice(at, 0, ...group.entries.map((entry) => document.createNode(entry)));
|
|
303
|
+
changed = true;
|
|
304
|
+
}
|
|
305
|
+
return changed ? { ...files, "preset.yaml": document.toString() } : files;
|
|
306
|
+
}
|
|
307
|
+
/** Every builtin upgrade a stored or imported ordered preset must receive. */
|
|
308
|
+
export function withCurrentPromptBuiltins(files) {
|
|
309
|
+
return withAlternativePromptBuiltins(withRestructuredPromptBuiltins(withTabletopPromptBuiltins(files)));
|
|
310
|
+
}
|
|
311
|
+
/** A choice group's entries: the first member enabled, the rest disabled. */
|
|
312
|
+
function groupPromptEntries(group) {
|
|
313
|
+
return builtinPlayPrompts("en")
|
|
314
|
+
.filter((item) => item.group === group)
|
|
315
|
+
.map(({ id }, index) => ({
|
|
316
|
+
id,
|
|
317
|
+
kind: "builtin",
|
|
318
|
+
builtin: id,
|
|
319
|
+
enabled: index === 0,
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
121
322
|
function hasEntry(sequence, id) {
|
|
122
323
|
return sequence.items.some((item) => entryField(item, "id") === id);
|
|
123
324
|
}
|
|
@@ -239,9 +440,11 @@ export function migrateLegacyPlayPrompts(files, narrative, excludedPaths = []) {
|
|
|
239
440
|
if ((path.startsWith("blocks/") || path.startsWith("prompts/")) &&
|
|
240
441
|
path.endsWith(".md"))
|
|
241
442
|
add(path, false);
|
|
242
|
-
//
|
|
443
|
+
// Converted presets gain the choice-group builtins and then the tabletop
|
|
444
|
+
// builtin just before the world placeholder. The legacy block copies keep
|
|
445
|
+
// their own wording as user text; the current builtins arrive beside them.
|
|
243
446
|
const worldIndex = entries.findIndex((entry) => entry.kind === "world");
|
|
244
|
-
entries.splice(worldIndex, 0, {
|
|
447
|
+
entries.splice(worldIndex, 0, ...promptGroups.flatMap((group) => groupPromptEntries(group)), {
|
|
245
448
|
id: "play.tabletop",
|
|
246
449
|
kind: "builtin",
|
|
247
450
|
builtin: "play.tabletop",
|
|
@@ -9,7 +9,7 @@ import { preparePlayerRevisionTrace, prepareForkTrace, restorePlayDocuments, ind
|
|
|
9
9
|
import { currentPlayPrompt, recordedPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
|
|
10
10
|
import { comparePromptPrefixes } from "../prompt/WorldPromptDiagnostics.js";
|
|
11
11
|
import { createHash, randomBytes } from "node:crypto";
|
|
12
|
-
import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepNotice, } from "./PlayContinuity.js";
|
|
12
|
+
import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepNotice, turnGuideNotice, } from "./PlayContinuity.js";
|
|
13
13
|
import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
|
|
14
14
|
import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
|
|
15
15
|
import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
|
|
@@ -1181,7 +1181,7 @@ export class PlayCallChain {
|
|
|
1181
1181
|
throw new PlayCallChainError("The recovered call-chain transcript is shorter than its player prefix.");
|
|
1182
1182
|
session.transcript = [
|
|
1183
1183
|
...session.transcript.slice(0, advance.transcriptStart),
|
|
1184
|
-
...structuredClone(advance.nextRequest.
|
|
1184
|
+
...structuredClone(transcriptAppendOf(advance.nextRequest).slice(advance.transcriptStart)),
|
|
1185
1185
|
];
|
|
1186
1186
|
session.nextEventId = advance.eventId + 1;
|
|
1187
1187
|
session.parentHead = outcome.head;
|
|
@@ -2008,13 +2008,19 @@ function createRequest(session, modelHost, exchange, appended = session.transcri
|
|
|
2008
2008
|
return modelHost.freezeRequest?.(request) ?? request;
|
|
2009
2009
|
}
|
|
2010
2010
|
function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, appended = session.transcript) {
|
|
2011
|
+
// The turn guide is a request-level suffix: it follows whatever this
|
|
2012
|
+
// dispatch appends and is never part of the transcript it is built on.
|
|
2013
|
+
const turnGuide = currentPlayPrompt(session).bootstrap.turnGuide;
|
|
2011
2014
|
return {
|
|
2012
2015
|
bootstrap: structuredClone(currentPlayPrompt(session).bootstrap),
|
|
2013
2016
|
tools: structuredClone(currentPlayPrompt(session).tools),
|
|
2014
2017
|
toolUniverse: structuredClone(currentPlayPrompt(session).tools),
|
|
2015
2018
|
allowedTools: currentPlayPrompt(session).tools.map(({ name }) => name),
|
|
2016
2019
|
toolStrategy: currentPlayPrompt(session).bootstrap.toolStrategy,
|
|
2017
|
-
appended: structuredClone([
|
|
2020
|
+
appended: structuredClone([
|
|
2021
|
+
...appended,
|
|
2022
|
+
...(turnGuide === undefined ? [] : [turnGuideNotice(turnGuide.text)]),
|
|
2023
|
+
]),
|
|
2018
2024
|
requestId: "play_call_chain",
|
|
2019
2025
|
operationId: session.chainId,
|
|
2020
2026
|
requestAttempt: 1,
|
|
@@ -2022,6 +2028,17 @@ function createRequestWithMaxOutputTokens(session, maxOutputTokens, exchange, ap
|
|
|
2022
2028
|
maxOutputTokens,
|
|
2023
2029
|
};
|
|
2024
2030
|
}
|
|
2031
|
+
/**
|
|
2032
|
+
* The items a frozen request contributes to the transcript: everything it
|
|
2033
|
+
* appended except the request-level turn-guide suffix, which is re-attached to
|
|
2034
|
+
* every dispatch and never stored.
|
|
2035
|
+
*/
|
|
2036
|
+
function transcriptAppendOf(request) {
|
|
2037
|
+
const last = request.appended.at(-1);
|
|
2038
|
+
return last?.kind === "runtime_notice" && last.notice === "turn_guide"
|
|
2039
|
+
? request.appended.slice(0, -1)
|
|
2040
|
+
: request.appended;
|
|
2041
|
+
}
|
|
2025
2042
|
function durableModelResponse(response) {
|
|
2026
2043
|
const { diagnostics: _diagnostics, ...durable } = response;
|
|
2027
2044
|
void _diagnostics;
|
|
@@ -37,10 +37,18 @@ export function continuationNotice(locale) {
|
|
|
37
37
|
kind: "runtime_notice",
|
|
38
38
|
notice: "continuation",
|
|
39
39
|
text: locale === "zh-CN"
|
|
40
|
-
? "[Runtime 续写提示]\n
|
|
41
|
-
: "[Runtime continuation]\nThis send adds no new player input. Existing final narrator prose has been displayed and committed as history; this response follows it
|
|
40
|
+
? "[Runtime 续写提示]\n本次发送没有追加新的玩家原文。已有的最终主持正文已展示并提交为历史;本次响应接在其后,从那段正文的最后局面继续,只推进既有的动作、NPC 和环境:不重新回答已经回应的问题,不复位已完成的递接或走动,不把旧玩家输入再演一次,不替玩家增加决定,也不代玩家接受邀请或作出承诺。回复提交不表示世界内活动已经结束。"
|
|
41
|
+
: "[Runtime continuation]\nThis send adds no new player input. Existing final narrator prose has been displayed and committed as history; this response follows it, resuming from that prose's final situation and advancing only existing actions, NPCs and the environment: do not answer an already answered question again, reset a completed handover or movement, perform the old player input again, add a decision for the player, or accept an invitation or make a promise on their behalf. A committed reply does not mean an in-world activity has ended.",
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* The per-send turn guide. It is request-level: appended after the current
|
|
46
|
+
* transcript on every dispatch and never stored in the transcript, so the next
|
|
47
|
+
* dispatch appends a fresh copy at the new end.
|
|
48
|
+
*/
|
|
49
|
+
export function turnGuideNotice(text) {
|
|
50
|
+
return { kind: "runtime_notice", notice: "turn_guide", text };
|
|
51
|
+
}
|
|
44
52
|
export function toolStepNotice(locale) {
|
|
45
53
|
return {
|
|
46
54
|
kind: "runtime_notice",
|
|
@@ -66,6 +66,15 @@ export class FileNativePlayDocuments {
|
|
|
66
66
|
// Creation capabilities come from this prefix, not past directory slots.
|
|
67
67
|
this.#declaredDirectories = declaredStateDirectories(bootstrap);
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Re-grant one exact read the model already completed in a retained
|
|
71
|
+
* transcript. The scope is resolved against the current snapshot again, so a
|
|
72
|
+
* document or node that no longer exists grants nothing. Returns whether the
|
|
73
|
+
* scope resolved.
|
|
74
|
+
*/
|
|
75
|
+
replayRead(shortRef, locator) {
|
|
76
|
+
return authorizeResolvedRead(this.#reads, this.#candidate.snapshot, shortRef, locator);
|
|
77
|
+
}
|
|
69
78
|
execute(call, history, options = {}) {
|
|
70
79
|
let historyQuery;
|
|
71
80
|
const query = () => (historyQuery ??= new HistoryQuery(options.historyScope ?? "local-document-context", historyInputs(history)));
|
|
@@ -156,26 +165,31 @@ function bootstrapAuthorizations(bootstrap, snapshot) {
|
|
|
156
165
|
for (const { status, complete, readAuthorization } of bootstrap.coverage) {
|
|
157
166
|
if (status !== "resolved" || !complete || readAuthorization === undefined)
|
|
158
167
|
continue;
|
|
159
|
-
|
|
160
|
-
const resolved = snapshot.query({
|
|
161
|
-
kind: "read_document",
|
|
162
|
-
document: { shortRef: readAuthorization.shortRef },
|
|
163
|
-
});
|
|
164
|
-
if (resolved.kind === "read_document" && resolved.ok)
|
|
165
|
-
authorizeRead(result, snapshot.id, resolved.document.shortRef, null);
|
|
166
|
-
}
|
|
167
|
-
else {
|
|
168
|
-
const selected = snapshot.query({
|
|
169
|
-
kind: "select_node",
|
|
170
|
-
document: { shortRef: readAuthorization.shortRef },
|
|
171
|
-
locator: readAuthorization.locator,
|
|
172
|
-
});
|
|
173
|
-
if (selected.kind === "select_node" && selected.ok)
|
|
174
|
-
authorizeRead(result, snapshot.id, selected.document.shortRef, selected.node.locator);
|
|
175
|
-
}
|
|
168
|
+
authorizeResolvedRead(result, snapshot, readAuthorization.shortRef, readAuthorization.locator);
|
|
176
169
|
}
|
|
177
170
|
return result;
|
|
178
171
|
}
|
|
172
|
+
function authorizeResolvedRead(authorizations, snapshot, shortRef, locator) {
|
|
173
|
+
if (locator === null) {
|
|
174
|
+
const resolved = snapshot.query({
|
|
175
|
+
kind: "read_document",
|
|
176
|
+
document: { shortRef },
|
|
177
|
+
});
|
|
178
|
+
if (resolved.kind !== "read_document" || !resolved.ok)
|
|
179
|
+
return false;
|
|
180
|
+
authorizeRead(authorizations, snapshot.id, resolved.document.shortRef, null);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
const selected = snapshot.query({
|
|
184
|
+
kind: "select_node",
|
|
185
|
+
document: { shortRef },
|
|
186
|
+
locator,
|
|
187
|
+
});
|
|
188
|
+
if (selected.kind !== "select_node" || !selected.ok)
|
|
189
|
+
return false;
|
|
190
|
+
authorizeRead(authorizations, snapshot.id, selected.document.shortRef, selected.node.locator);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
179
193
|
function declaredStateDirectories(bootstrap) {
|
|
180
194
|
// Catalog coverage is the durable compiler projection shared by current
|
|
181
195
|
// state_list contexts and legacy context_list contexts.
|