narraeon 0.6.3 → 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
  }
@@ -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);
@@ -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();
@@ -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,39 +184,100 @@ export function withTabletopPromptBuiltins(files) {
118
184
  }
119
185
  return changed ? { ...files, "preset.yaml": document.toString() } : files;
120
186
  }
121
- const alternativeGroups = () => [
122
- {
123
- anchor: "play.style",
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
- "play.adjudication",
126
- "play.state",
127
- "play.narrative",
128
- "play.continue-stage",
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: personPromptEntries(),
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 narrative-person and continuation builtins that an ordered preset
141
- * does not list yet. Same identity contract as the tabletop insertion: files
142
- * that are not ordered v2 or do not parse come back unchanged, and the same
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 source = files["preset.yaml"];
147
- if (source === undefined)
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 = playPrompts.items.findIndex((item) => entryField(item, "id") === group.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 >= 0
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 insertion a stored or imported ordered preset must receive. */
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
- function alternativeEntries(prefix, ids, enabledId) {
183
- return ids.map((id) => ({
184
- id: `${prefix}${id}`,
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: `${prefix}${id}`,
187
- enabled: id === enabledId,
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 person and continuation builtins and then the
318
- // tabletop builtin just before the world placeholder. The legacy style and
319
- // narrative copies keep their own wording as user text; the current builtins
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, ...personPromptEntries(), ...continuationPromptEntries(), {
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.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",