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.
@@ -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
- if (!isRecord(entry) ||
372
- Object.keys(entry).length !== 1 ||
373
- typeof entry.markdown !== "string" ||
374
- entry.markdown.startsWith("/") ||
375
- entry.markdown.includes("..") ||
376
- !files.has(`control/${entry.markdown}`)) {
377
- issue(issues, "invalid_world_frame", "control/frame.yaml", "A Markdown block referenced by the world frame is missing or escapes the control directory");
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, withTabletopPromptBuiltins, } from "./OrderedPlayPrompts.js";
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 !== "user")) {
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 tabletop builtins at import, as at upgrade.
461
- const files = withTabletopPromptBuiltins(toFileMap(input.files, this.#limits));
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
- return parseStoredDocument(JSON.parse(raw), this.#limits);
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 builtins
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 = withTabletopPromptBuiltins(current);
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 = withTabletopPromptBuiltins(draft);
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.map((raw) => {
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
- /^[^\r\n\0]{1,160}$/u.test(item.name) &&
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
- id: item.id,
46
- kind: "user",
47
- name: item.name,
48
- enabled: item.enabled,
49
- body: item.body,
50
- ...messageRole,
51
- ...(item.gameplay === "tabletop"
52
- ? { gameplay: "tabletop" }
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
- id: item.id,
68
- kind: "builtin",
69
- builtin: builtin.id,
70
- enabled: item.enabled,
71
- ...messageRole,
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 source = files["preset.yaml"];
88
- if (source === undefined)
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
- // The tabletop builtin joins converted presets just before the world placeholder.
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.appended.slice(advance.transcriptStart)),
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([...appended]),
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 without resubmitting it. A committed reply does not mean an in-world activity has ended.",
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
- if (readAuthorization.locator === null) {
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.