narraeon 0.6.3 → 0.7.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/runtime/V1Runtime.js +3 -0
- package/dist/node/runtime/content/FileNativeContentTree.js +15 -7
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +38 -4
- package/dist/node/runtime/play/OrderedPlayPrompts.js +204 -79
- package/dist/node/runtime/play/PlayCallChain.js +20 -3
- package/dist/node/runtime/play/PlayContinuity.js +10 -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 +317 -224
- package/dist/node/shared/ordered-author-prompts.js +5 -0
- package/dist/node/shared/ordered-play-prompts.js +195 -66
- package/dist/node/shared/turn-guide.js +26 -0
- package/dist/web/assets/index-BUCslLEK.css +1 -0
- package/dist/web/assets/index-DkamBH6c.js +1408 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/web/assets/index-BZwaOsvk.js +0 -1393
- package/dist/web/assets/index-eUMdHbq2.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
|
}
|
|
@@ -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);
|
|
@@ -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();
|
|
@@ -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,39 +184,100 @@ export function withTabletopPromptBuiltins(files) {
|
|
|
118
184
|
}
|
|
119
185
|
return changed ? { ...files, "preset.yaml": document.toString() } : files;
|
|
120
186
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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"],
|
|
124
261
|
before: [
|
|
125
|
-
|
|
126
|
-
"play.
|
|
127
|
-
"play.
|
|
128
|
-
"play.
|
|
262
|
+
...order.slice(index + 1).flat(),
|
|
263
|
+
"play.prose",
|
|
264
|
+
"play.scene",
|
|
265
|
+
"play.ending",
|
|
266
|
+
"play.output",
|
|
267
|
+
"play.facts",
|
|
129
268
|
"play.tabletop",
|
|
130
269
|
],
|
|
131
|
-
entries:
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
anchor: "play.narrative",
|
|
135
|
-
before: ["play.tabletop"],
|
|
136
|
-
entries: continuationPromptEntries(),
|
|
137
|
-
},
|
|
138
|
-
];
|
|
270
|
+
entries: groupPromptEntries(group),
|
|
271
|
+
}));
|
|
272
|
+
};
|
|
139
273
|
/**
|
|
140
|
-
* Insert the
|
|
141
|
-
* does not list yet. Same identity contract as the
|
|
142
|
-
*
|
|
143
|
-
* object is returned when nothing needs inserting.
|
|
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.
|
|
144
277
|
*/
|
|
145
278
|
export function withAlternativePromptBuiltins(files) {
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
148
|
-
return files;
|
|
149
|
-
const document = parseDocument(source, { uniqueKeys: true });
|
|
150
|
-
if (document.errors.length > 0 ||
|
|
151
|
-
document.warnings.length > 0 ||
|
|
152
|
-
!isMap(document.contents) ||
|
|
153
|
-
document.get("format") !== "narraeon.play-preset/v2")
|
|
279
|
+
const document = orderedPresetDocument(files);
|
|
280
|
+
if (document === undefined)
|
|
154
281
|
return files;
|
|
155
282
|
const playPrompts = document.get("playPrompts", true);
|
|
156
283
|
if (!isSeq(playPrompts))
|
|
@@ -159,13 +286,15 @@ export function withAlternativePromptBuiltins(files) {
|
|
|
159
286
|
for (const group of alternativeGroups()) {
|
|
160
287
|
if (group.entries.some((entry) => hasEntry(playPrompts, entry.id)))
|
|
161
288
|
continue;
|
|
162
|
-
const anchor =
|
|
289
|
+
const anchor = group.after
|
|
290
|
+
.map((id) => playPrompts.items.findIndex((item) => entryField(item, "id") === id))
|
|
291
|
+
.find((index) => index >= 0);
|
|
163
292
|
const fallback = playPrompts.items.findIndex((item) => {
|
|
164
293
|
const id = entryField(item, "id");
|
|
165
294
|
return ((typeof id === "string" && group.before.includes(id)) ||
|
|
166
295
|
entryField(item, "kind") === "world");
|
|
167
296
|
});
|
|
168
|
-
const at = anchor
|
|
297
|
+
const at = anchor !== undefined
|
|
169
298
|
? anchor + 1
|
|
170
299
|
: fallback >= 0
|
|
171
300
|
? fallback
|
|
@@ -175,24 +304,21 @@ export function withAlternativePromptBuiltins(files) {
|
|
|
175
304
|
}
|
|
176
305
|
return changed ? { ...files, "preset.yaml": document.toString() } : files;
|
|
177
306
|
}
|
|
178
|
-
/** Every builtin
|
|
307
|
+
/** Every builtin upgrade a stored or imported ordered preset must receive. */
|
|
179
308
|
export function withCurrentPromptBuiltins(files) {
|
|
180
|
-
return withAlternativePromptBuiltins(withTabletopPromptBuiltins(files));
|
|
309
|
+
return withAlternativePromptBuiltins(withRestructuredPromptBuiltins(withTabletopPromptBuiltins(files)));
|
|
181
310
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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,
|
|
185
317
|
kind: "builtin",
|
|
186
|
-
builtin:
|
|
187
|
-
enabled:
|
|
318
|
+
builtin: id,
|
|
319
|
+
enabled: index === 0,
|
|
188
320
|
}));
|
|
189
321
|
}
|
|
190
|
-
function personPromptEntries() {
|
|
191
|
-
return alternativeEntries("play.person-", ["second", "first", "third"], "second");
|
|
192
|
-
}
|
|
193
|
-
function continuationPromptEntries() {
|
|
194
|
-
return alternativeEntries("play.continue-", ["stage", "advance"], "stage");
|
|
195
|
-
}
|
|
196
322
|
function hasEntry(sequence, id) {
|
|
197
323
|
return sequence.items.some((item) => entryField(item, "id") === id);
|
|
198
324
|
}
|
|
@@ -314,12 +440,11 @@ export function migrateLegacyPlayPrompts(files, narrative, excludedPaths = []) {
|
|
|
314
440
|
if ((path.startsWith("blocks/") || path.startsWith("prompts/")) &&
|
|
315
441
|
path.endsWith(".md"))
|
|
316
442
|
add(path, false);
|
|
317
|
-
// Converted presets gain the
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
// arrive beside them.
|
|
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.
|
|
321
446
|
const worldIndex = entries.findIndex((entry) => entry.kind === "world");
|
|
322
|
-
entries.splice(worldIndex, 0, ...
|
|
447
|
+
entries.splice(worldIndex, 0, ...promptGroups.flatMap((group) => groupPromptEntries(group)), {
|
|
323
448
|
id: "play.tabletop",
|
|
324
449
|
kind: "builtin",
|
|
325
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",
|