narraeon 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +164 -0
  3. package/README.zh-CN.md +164 -0
  4. package/dist/node/cli/NarraeonCli.js +163 -0
  5. package/dist/node/cli/main.js +38 -0
  6. package/dist/node/protocol/appPreferences.js +5 -0
  7. package/dist/node/protocol/contentTree.js +14 -0
  8. package/dist/node/protocol/modelConnections.js +110 -0
  9. package/dist/node/protocol/playerViews.js +1 -0
  10. package/dist/node/protocol/v1.js +278 -0
  11. package/dist/node/runtime/V1Runtime.js +625 -0
  12. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +1914 -0
  13. package/dist/node/runtime/config/AppPreferencesStore.js +92 -0
  14. package/dist/node/runtime/config/appPaths.js +19 -0
  15. package/dist/node/runtime/content/ContentLimits.js +1 -0
  16. package/dist/node/runtime/content/ContentTreeFile.js +1 -0
  17. package/dist/node/runtime/content/ContentWorkspace.js +601 -0
  18. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +721 -0
  19. package/dist/node/runtime/content/FileNativeContentTree.js +512 -0
  20. package/dist/node/runtime/extension/FrontendExtensionBundle.js +229 -0
  21. package/dist/node/runtime/extension/PlayerViewPanelProjector.js +121 -0
  22. package/dist/node/runtime/model/AiFailureLog.js +332 -0
  23. package/dist/node/runtime/model/FileNativeModelAdapters.js +1246 -0
  24. package/dist/node/runtime/model/ModelConnectionStore.js +378 -0
  25. package/dist/node/runtime/model/ModelHost.js +89 -0
  26. package/dist/node/runtime/model/ModelHostStream.js +367 -0
  27. package/dist/node/runtime/model/ProviderExchangeState.js +1 -0
  28. package/dist/node/runtime/model/ProviderStream.js +90 -0
  29. package/dist/node/runtime/model/SettingAuthorStream.js +118 -0
  30. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +304 -0
  31. package/dist/node/runtime/play/FileNativePlayPresetStore.js +1856 -0
  32. package/dist/node/runtime/play/PlayCallChain.js +1280 -0
  33. package/dist/node/runtime/play/PlayDocumentTools.js +1121 -0
  34. package/dist/node/runtime/play/PlayFollowupRequests.js +183 -0
  35. package/dist/node/runtime/play/PlayPresetWorkbench.js +185 -0
  36. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +1425 -0
  37. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +192 -0
  38. package/dist/node/runtime/prompt/MaterialSelection.js +95 -0
  39. package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +1726 -0
  40. package/dist/node/runtime/world/FileNativeWorldStore.js +2028 -0
  41. package/dist/node/runtime/world/PlayerViewRenderer.js +322 -0
  42. package/dist/node/runtime/world/WorldDocumentStore.js +2808 -0
  43. package/dist/node/runtime/world/WorldOperationCoordinator.js +490 -0
  44. package/dist/node/runtime/world/WorldStateRevision.js +75 -0
  45. package/dist/node/server/createRuntime.js +6 -0
  46. package/dist/node/server/createServer.js +218 -0
  47. package/dist/node/server/main.js +25 -0
  48. package/dist/node/server/startWebServer.js +28 -0
  49. package/dist/node/shared/default-play-prompts.js +85 -0
  50. package/dist/node/shared/default-preset-host.js +538 -0
  51. package/dist/node/shared/default-setting-improvement-prompt.js +77 -0
  52. package/dist/node/shared/first-party-action-choices.js +164 -0
  53. package/dist/node/shared/first-party-generic-panels.js +105 -0
  54. package/dist/node/shared/first-party-play-preset-templates.js +41 -0
  55. package/dist/node/shared/first-party-player-view.js +179 -0
  56. package/dist/node/shared/play-preset-prompt-roles.js +4 -0
  57. package/dist/web/assets/index-CRGTZ9Ak.css +1 -0
  58. package/dist/web/assets/index-DDx1itzT.js +1192 -0
  59. package/dist/web/index.html +14 -0
  60. package/package.json +86 -0
@@ -0,0 +1,1856 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { parseDocument } from "yaml";
6
+ import { defaultPortableContentTreeLimits, portableContentTreePathKey, } from "../../protocol/contentTree.js";
7
+ import { defaultAppLocale, } from "../../protocol/appPreferences.js";
8
+ import { defaultNarrationPromptForLocale } from "../../shared/default-play-prompts.js";
9
+ import { defaultPresetHostFilesForLocale } from "../../shared/default-preset-host.js";
10
+ import { defaultSettingImprovementPromptForLocale, defaultSettingImprovementPromptPath, } from "../../shared/default-setting-improvement-prompt.js";
11
+ import { isPlayPresetPromptRole, } from "../../shared/play-preset-prompt-roles.js";
12
+ /**
13
+ * The frame plus its block library, projected in the shape the prompt compiler
14
+ * already consumes. A preset owns both halves now: `frame.yaml` decides which
15
+ * blocks are enabled and in what order, while unlisted blocks stay in the tree
16
+ * as an editable library.
17
+ */
18
+ export function presetHostBinding(binding) {
19
+ const files = {};
20
+ for (const [path, contents] of Object.entries(binding.definition.files))
21
+ if (path === "frame.yaml" || path.startsWith("blocks/"))
22
+ files[path] = contents;
23
+ return { hostPresetId: binding.id, files };
24
+ }
25
+ /** Strict persisted-binding guard shared by every durable play operation. */
26
+ export function isPlayPresetBinding(value) {
27
+ if (!isRecord(value) ||
28
+ !hasExactKeys(value, [
29
+ "id",
30
+ "name",
31
+ "revision",
32
+ "definition",
33
+ "files",
34
+ "scriptsEnabled",
35
+ ]) ||
36
+ typeof value.id !== "string" ||
37
+ value.id.trim() === "" ||
38
+ value.id !== value.id.trim() ||
39
+ value.id.includes("\0") ||
40
+ typeof value.name !== "string" ||
41
+ !/^[^\r\n]{1,160}$/u.test(value.name) ||
42
+ value.name !== value.name.trim() ||
43
+ value.name.includes("\0") ||
44
+ typeof value.revision !== "string" ||
45
+ !isRecord(value.definition) ||
46
+ !isRecord(value.files) ||
47
+ Object.keys(value.files).length === 0 ||
48
+ !Object.values(value.files).every((contents) => typeof contents === "string") ||
49
+ typeof value.scriptsEnabled !== "boolean")
50
+ return false;
51
+ const files = value.files;
52
+ const parsed = parsePlayPresetFiles(files);
53
+ const builtInDefault = value.id === "builtin-default" &&
54
+ value.name === "default" &&
55
+ value.revision === "builtin-default-v1" &&
56
+ value.scriptsEnabled === true &&
57
+ (isDeepStrictEqual(files, defaultPlayPresetFilesForLocale("en")) ||
58
+ isDeepStrictEqual(files, defaultPlayPresetFilesForLocale("zh-CN")));
59
+ return (parsed.kind === "valid" &&
60
+ isDeepStrictEqual(value.definition, parsed.definition) &&
61
+ (builtInDefault || value.revision === revisionForPlayPresetFiles(files)));
62
+ }
63
+ export class FileNativePlayPresetError extends Error {
64
+ code;
65
+ constructor(code, message) {
66
+ super(message);
67
+ this.code = code;
68
+ this.name = "FileNativePlayPresetError";
69
+ }
70
+ }
71
+ export function defaultPlayPresetFilesForLocale(locale) {
72
+ return {
73
+ "preset.yaml": `format: narraeon.play-preset/v1
74
+ name: ${locale === "zh-CN" ? "系统推荐" : "System recommended"}
75
+ callChain: call-chain.yaml
76
+ settingImprovement:
77
+ markdown: prompts/setting-improvement.md
78
+ mounts: []
79
+ extensions: []
80
+ `,
81
+ "call-chain.yaml": `format: narraeon.play-call-chain/v1
82
+ narrative:
83
+ - markdown: prompts/narrate.md
84
+ followups: []
85
+ `,
86
+ ...defaultPresetHostFilesForLocale(locale),
87
+ "prompts/narrate.md": defaultNarrationPromptForLocale(locale),
88
+ [defaultSettingImprovementPromptPath]: defaultSettingImprovementPromptForLocale(locale),
89
+ };
90
+ }
91
+ export const defaultPlayPresetFiles = defaultPlayPresetFilesForLocale(defaultAppLocale);
92
+ /**
93
+ * Resolve the author-owned part of setting improvement from a frozen preset.
94
+ * Older v1 presets had no declaration; they keep the shipped behavior without
95
+ * their persisted file tree being silently rewritten.
96
+ */
97
+ export function settingImprovementPromptForBinding(binding, locale = defaultAppLocale) {
98
+ const reference = binding.definition.settingImprovementPrompt;
99
+ if (reference === undefined)
100
+ return defaultSettingImprovementPromptForLocale(locale);
101
+ const prompt = binding.files[reference.path];
102
+ if (prompt === undefined || prompt.trim() === "")
103
+ throw new FileNativePlayPresetError("setting_improvement_prompt_missing", `Setting-improvement prompt block does not exist: ${reference.path}`);
104
+ return prompt;
105
+ }
106
+ /** Stable built-in binding for direct Runtime seams without a local library. */
107
+ export function builtinDefaultPlayPresetBinding(locale = defaultAppLocale) {
108
+ const files = cloneFiles(defaultPlayPresetFilesForLocale(locale));
109
+ const parsed = parsePlayPresetFiles(files);
110
+ if (parsed.kind !== "valid")
111
+ throw parsed.error;
112
+ return {
113
+ id: "builtin-default",
114
+ name: "default",
115
+ revision: "builtin-default-v1",
116
+ definition: parsed.definition,
117
+ files,
118
+ scriptsEnabled: true,
119
+ };
120
+ }
121
+ const defaultLimits = defaultPortableContentTreeLimits;
122
+ const revisionPattern = /^rev-[0-9a-f]{64}$/u;
123
+ const namePattern = /^[^\r\n]{1,160}$/u;
124
+ export const maxPlayPresetFollowups = 16;
125
+ const defaultMaxArtifactBytes = 32_768;
126
+ const maxArtifactBytesCeiling = 1_048_576;
127
+ /**
128
+ * File-native local library for play presets. Revisions are retained in the
129
+ * local store, while portable files contain only author assets and no local
130
+ * identity or runtime state.
131
+ */
132
+ export class FileNativePlayPresetStore {
133
+ #path;
134
+ #root;
135
+ #limits;
136
+ #locale;
137
+ #tail = Promise.resolve();
138
+ /**
139
+ * Set when an unreadable library was moved aside on load. Surfaced as a
140
+ * workspace diagnostic so the recovery is never silent.
141
+ */
142
+ recovery = null;
143
+ constructor(root, options = {}) {
144
+ this.#root = root;
145
+ this.#path = join(root, "file-native-play-presets.json");
146
+ this.#limits = {
147
+ ...defaultLimits,
148
+ ...options.limits,
149
+ };
150
+ this.#locale = options.locale ?? (() => defaultAppLocale);
151
+ }
152
+ async initialize() {
153
+ await this.#change(async () => {
154
+ const document = await this.#read();
155
+ const migrated = this.#markLegacyBuiltinDefault(document);
156
+ const synchronized = this.#synchronizeBuiltinDefault(document);
157
+ if (migrated || synchronized)
158
+ await this.#write(document);
159
+ });
160
+ }
161
+ /**
162
+ * Update only the Runtime-owned seed preset. User-created, copied, imported,
163
+ * renamed, or edited presets remain byte-for-byte unchanged.
164
+ */
165
+ async syncBuiltinDefaultLocale() {
166
+ await this.#change(async () => {
167
+ const document = await this.#read();
168
+ if (this.#synchronizeBuiltinDefault(document))
169
+ await this.#write(document);
170
+ });
171
+ }
172
+ async list() {
173
+ await this.#tail;
174
+ const document = await this.#read();
175
+ return {
176
+ schemaVersion: 1,
177
+ currentPresetId: document.currentPresetId,
178
+ presets: document.presets.map((preset) => this.#view(preset)),
179
+ };
180
+ }
181
+ async create(name, files) {
182
+ return this.#change(async () => {
183
+ const document = await this.#read();
184
+ const stored = this.#stored(randomUUID(), name, files ?? defaultPlayPresetFilesForLocale(this.#locale()));
185
+ document.presets.push(stored);
186
+ await this.#write(document);
187
+ return {
188
+ currentPresetId: document.currentPresetId,
189
+ preset: this.#view(stored),
190
+ };
191
+ });
192
+ }
193
+ async copy(id) {
194
+ return this.#change(async () => {
195
+ const document = await this.#read();
196
+ const source = findStored(document, id);
197
+ const files = source.revisions[source.currentRevision];
198
+ if (files === undefined)
199
+ throw new FileNativePlayPresetError("store_invalid", "Play-preset revision is missing");
200
+ const stored = this.#stored(randomUUID(), source.name, files);
201
+ stored.scriptsEnabled = source.scriptsEnabled;
202
+ document.presets.push(stored);
203
+ await this.#write(document);
204
+ return {
205
+ currentPresetId: document.currentPresetId,
206
+ preset: this.#view(stored),
207
+ };
208
+ });
209
+ }
210
+ /** Save a draft. Invalid drafts remain inspectable but cannot be selected. */
211
+ async save(input) {
212
+ return this.#change(async () => {
213
+ const document = await this.#read();
214
+ const stored = findStored(document, input.presetId);
215
+ delete stored.builtinDefault;
216
+ let files = cloneFiles(input.files);
217
+ if (input.structure !== undefined) {
218
+ files = applyPlayPresetStructuredEditor(input.files, parsePlayPresetStructuredEditor(input.structure));
219
+ }
220
+ const revision = revisionForFiles(files, this.#limits);
221
+ stored.name = normalizeName(input.name);
222
+ stored.revisions[revision] = cloneFiles(files);
223
+ if (revision === stored.currentRevision)
224
+ delete stored.draftRevision;
225
+ else
226
+ stored.draftRevision = revision;
227
+ await this.#write(document);
228
+ return {
229
+ currentPresetId: document.currentPresetId,
230
+ preset: this.#view(stored),
231
+ };
232
+ });
233
+ }
234
+ async select(id) {
235
+ return this.#change(async () => {
236
+ const document = await this.#read();
237
+ const stored = findStored(document, id);
238
+ if (!stored.enabled)
239
+ throw new FileNativePlayPresetError("preset_disabled", "A disabled play preset cannot become the current selection");
240
+ const preset = this.#view(stored);
241
+ const candidateValidation = preset.draft?.validation ?? preset.validation;
242
+ if (candidateValidation.status === "invalid")
243
+ throw new FileNativePlayPresetError("invalid_play_preset", `Draft cannot be applied: ${candidateValidation.message}`);
244
+ if (stored.draftRevision !== undefined) {
245
+ stored.currentRevision = stored.draftRevision;
246
+ delete stored.draftRevision;
247
+ }
248
+ document.currentPresetId = id;
249
+ await this.#write(document);
250
+ return { currentPresetId: id, preset: this.#view(stored) };
251
+ });
252
+ }
253
+ async bindCurrent() {
254
+ await this.#tail;
255
+ const document = await this.#read();
256
+ const stored = findStored(document, document.currentPresetId);
257
+ if (!stored.enabled)
258
+ throw new FileNativePlayPresetError("preset_disabled", "The current play preset is disabled; enable it or choose another preset");
259
+ return this.bindRevision(document.currentPresetId);
260
+ }
261
+ async bindCurrentFiles() {
262
+ return this.bindCurrent();
263
+ }
264
+ async bindRevision(id, revision) {
265
+ await this.#tail;
266
+ const document = await this.#read();
267
+ const stored = findStored(document, id);
268
+ const selectedRevision = revision ?? stored.currentRevision;
269
+ const files = stored.revisions[selectedRevision];
270
+ if (files === undefined)
271
+ throw new FileNativePlayPresetError("revision_not_found", "The requested play-preset revision was not found");
272
+ const parsed = parsePlayPresetFiles(files, this.#limits);
273
+ if (parsed.kind === "invalid")
274
+ throw new FileNativePlayPresetError(parsed.error.code, parsed.error.message);
275
+ return {
276
+ id: stored.id,
277
+ name: stored.name,
278
+ revision: selectedRevision,
279
+ definition: parsed.definition,
280
+ files: cloneFiles(files),
281
+ scriptsEnabled: stored.scriptsEnabled,
282
+ };
283
+ }
284
+ async rename(id, name) {
285
+ return this.#change(async () => {
286
+ const document = await this.#read();
287
+ const stored = findStored(document, id);
288
+ delete stored.builtinDefault;
289
+ stored.name = normalizeName(name);
290
+ await this.#write(document);
291
+ return {
292
+ currentPresetId: document.currentPresetId,
293
+ preset: this.#view(stored),
294
+ };
295
+ });
296
+ }
297
+ async setEnabled(id, enabled) {
298
+ return this.#change(async () => {
299
+ const document = await this.#read();
300
+ const stored = findStored(document, id);
301
+ stored.enabled = enabled;
302
+ if (!enabled && document.currentPresetId === id) {
303
+ const fallback = document.presets.find((candidate) => candidate.id !== id && candidate.enabled);
304
+ if (fallback === undefined) {
305
+ throw new FileNativePlayPresetError("cannot_disable_current_preset", "The only current play preset cannot be disabled; select another enabled preset first");
306
+ }
307
+ document.currentPresetId = fallback.id;
308
+ }
309
+ await this.#write(document);
310
+ return {
311
+ currentPresetId: document.currentPresetId,
312
+ preset: this.#view(stored),
313
+ };
314
+ });
315
+ }
316
+ async setScriptsEnabled(id, enabled) {
317
+ return this.#change(async () => {
318
+ const document = await this.#read();
319
+ const stored = findStored(document, id);
320
+ stored.scriptsEnabled = enabled;
321
+ await this.#write(document);
322
+ return {
323
+ currentPresetId: document.currentPresetId,
324
+ preset: this.#view(stored),
325
+ };
326
+ });
327
+ }
328
+ async delete(id) {
329
+ return this.#change(async () => {
330
+ const document = await this.#read();
331
+ findStored(document, id);
332
+ document.presets = document.presets.filter((preset) => preset.id !== id);
333
+ // Deleting the last preset — or the last enabled one — rebuilds the
334
+ // default rather than refusing. Nothing a user deletes should leave the
335
+ // library in a state they cannot get out of.
336
+ const restored = document.presets.some(({ enabled }) => enabled)
337
+ ? document
338
+ : this.#defaultDocument();
339
+ if (restored.presets.every(({ id: kept }) => kept !== restored.currentPresetId))
340
+ restored.currentPresetId = restored.presets.find(({ enabled }) => enabled).id;
341
+ await this.#write(restored);
342
+ return { currentPresetId: restored.currentPresetId, deleted: true };
343
+ });
344
+ }
345
+ async readRevision(id, revision) {
346
+ return this.bindRevision(id, revision);
347
+ }
348
+ /** Freeze a revision for a model operation; later drafts cannot alter it. */
349
+ async freeze(input = {}) {
350
+ await this.#tail;
351
+ const document = await this.#read();
352
+ return this.bindRevision(input.presetId ?? document.currentPresetId, input.revision);
353
+ }
354
+ async freezeRevision(input = {}) {
355
+ return this.freeze(input);
356
+ }
357
+ /** Portable export deliberately contains no id, revision, world, operation or provider state. */
358
+ async exportPortable(id) {
359
+ const binding = await this.bindRevision(id);
360
+ return Object.keys(binding.files)
361
+ .sort()
362
+ .map((path) => ({ path, contents: binding.files[path] }));
363
+ }
364
+ async importPortable(input) {
365
+ const files = toFileMap(input.files, this.#limits);
366
+ return this.#change(async () => {
367
+ const document = await this.#read();
368
+ const stored = this.#stored(randomUUID(), input.name, files);
369
+ // Portable assets are untrusted until the local user explicitly opts
370
+ // in. Executable code may be embedded in renderer HTML as well as under
371
+ // scripts/, so every imported identity starts with code disabled.
372
+ stored.scriptsEnabled = false;
373
+ document.presets.push(stored);
374
+ await this.#write(document);
375
+ return {
376
+ currentPresetId: document.currentPresetId,
377
+ preset: this.#view(stored),
378
+ };
379
+ });
380
+ }
381
+ async #change(work) {
382
+ const prior = this.#tail;
383
+ let done;
384
+ this.#tail = new Promise((resolve) => {
385
+ done = resolve;
386
+ });
387
+ await prior;
388
+ try {
389
+ return await work();
390
+ }
391
+ finally {
392
+ done();
393
+ }
394
+ }
395
+ /**
396
+ * Read the library, or recover from one that cannot be parsed at all.
397
+ *
398
+ * A preset the current schema rejects is an ordinary invalid preset: it
399
+ * loads, reports why, and can be deleted from the UI. But a library file
400
+ * that will not parse — bad JSON, a mismatched content hash, a hand-edited
401
+ * record — used to fail startup outright, which left the user with no
402
+ * surface to fix it from. The damaged file is preserved under a new name
403
+ * instead, so nothing is lost and the workspace can still open.
404
+ */
405
+ async #read() {
406
+ let raw;
407
+ try {
408
+ raw = await readFile(this.#path, "utf8");
409
+ }
410
+ catch (error) {
411
+ if (error.code !== "ENOENT")
412
+ throw error;
413
+ const document = this.#defaultDocument();
414
+ await this.#write(document);
415
+ return document;
416
+ }
417
+ try {
418
+ return parseStoredDocument(JSON.parse(raw), this.#limits);
419
+ }
420
+ catch (error) {
421
+ const quarantined = await this.#quarantine();
422
+ this.recovery = {
423
+ quarantinedPath: quarantined,
424
+ message: error instanceof Error
425
+ ? error.message
426
+ : "The play-preset library could not be parsed",
427
+ };
428
+ const document = this.#defaultDocument();
429
+ await this.#write(document);
430
+ return document;
431
+ }
432
+ }
433
+ /**
434
+ * Move the unreadable library aside rather than overwriting it. The user
435
+ * keeps the original bytes and can hand-repair or discard them.
436
+ */
437
+ async #quarantine() {
438
+ const target = join(this.#root, `file-native-play-presets.unreadable-${Date.now()}.json`);
439
+ await rename(this.#path, target);
440
+ return target;
441
+ }
442
+ #defaultDocument() {
443
+ const stored = this.#stored(randomUUID(), "default", defaultPlayPresetFilesForLocale(this.#locale()));
444
+ stored.builtinDefault = true;
445
+ return {
446
+ schemaVersion: 1,
447
+ currentPresetId: stored.id,
448
+ presets: [stored],
449
+ };
450
+ }
451
+ #markLegacyBuiltinDefault(document) {
452
+ if (document.presets.length !== 1)
453
+ return false;
454
+ const [preset] = document.presets;
455
+ if (preset === undefined ||
456
+ preset.builtinDefault === true ||
457
+ preset.name !== "default" ||
458
+ preset.draftRevision !== undefined)
459
+ return false;
460
+ const files = preset.revisions[preset.currentRevision];
461
+ if (files === undefined ||
462
+ (!isDeepStrictEqual(files, defaultPlayPresetFilesForLocale("en")) &&
463
+ !isDeepStrictEqual(files, defaultPlayPresetFilesForLocale("zh-CN"))))
464
+ return false;
465
+ preset.builtinDefault = true;
466
+ return true;
467
+ }
468
+ #synchronizeBuiltinDefault(document) {
469
+ const preset = document.presets.find(({ builtinDefault }) => builtinDefault === true);
470
+ if (preset === undefined || preset.draftRevision !== undefined)
471
+ return false;
472
+ const current = preset.revisions[preset.currentRevision];
473
+ if (current === undefined ||
474
+ (!isDeepStrictEqual(current, defaultPlayPresetFilesForLocale("en")) &&
475
+ !isDeepStrictEqual(current, defaultPlayPresetFilesForLocale("zh-CN")))) {
476
+ delete preset.builtinDefault;
477
+ return true;
478
+ }
479
+ const desired = defaultPlayPresetFilesForLocale(this.#locale());
480
+ const revision = revisionForFiles(desired, this.#limits);
481
+ if (preset.currentRevision === revision)
482
+ return false;
483
+ preset.revisions[revision] = cloneFiles(desired);
484
+ preset.currentRevision = revision;
485
+ return true;
486
+ }
487
+ #stored(id, rawName, rawFiles) {
488
+ const name = normalizeName(rawName);
489
+ const files = cloneFiles(rawFiles);
490
+ const revision = revisionForFiles(files, this.#limits);
491
+ return {
492
+ id,
493
+ name,
494
+ currentRevision: revision,
495
+ revisions: { [revision]: files },
496
+ enabled: true,
497
+ scriptsEnabled: true,
498
+ };
499
+ }
500
+ #view(stored) {
501
+ const files = stored.revisions[stored.currentRevision];
502
+ if (files === undefined)
503
+ throw new FileNativePlayPresetError("store_invalid", "The current play-preset revision does not exist");
504
+ const validation = validatePlayPresetFiles(files, this.#limits);
505
+ const view = {
506
+ id: stored.id,
507
+ name: stored.name,
508
+ revision: stored.currentRevision,
509
+ files: cloneFiles(files),
510
+ validation,
511
+ enabled: stored.enabled,
512
+ scriptsEnabled: stored.scriptsEnabled,
513
+ };
514
+ if (validation.status === "valid") {
515
+ const parsed = parsePlayPresetFiles(files, this.#limits);
516
+ if (parsed.kind === "valid")
517
+ view.structure = toPlayPresetStructuredEditor(parsed.definition);
518
+ }
519
+ if (stored.draftRevision !== undefined) {
520
+ const draftFiles = stored.revisions[stored.draftRevision];
521
+ if (draftFiles === undefined)
522
+ throw new FileNativePlayPresetError("store_invalid", "The play-preset draft revision does not exist");
523
+ view.draft = {
524
+ revision: stored.draftRevision,
525
+ files: cloneFiles(draftFiles),
526
+ validation: validatePlayPresetFiles(draftFiles, this.#limits),
527
+ };
528
+ if (view.draft.validation.status === "valid") {
529
+ const parsedDraft = parsePlayPresetFiles(draftFiles, this.#limits);
530
+ if (parsedDraft.kind === "valid")
531
+ view.draft.structure = toPlayPresetStructuredEditor(parsedDraft.definition);
532
+ }
533
+ }
534
+ return view;
535
+ }
536
+ async #write(document) {
537
+ await mkdir(this.#root, { recursive: true, mode: 0o700 });
538
+ const temporary = `${this.#path}.${randomUUID()}.tmp`;
539
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
540
+ encoding: "utf8",
541
+ mode: 0o600,
542
+ });
543
+ await rename(temporary, this.#path);
544
+ }
545
+ }
546
+ export function validatePlayPresetFiles(files, limits = {}) {
547
+ try {
548
+ const parsed = parsePlayPresetFiles(files, { ...defaultLimits, ...limits });
549
+ if (parsed.kind === "invalid")
550
+ throw parsed.error;
551
+ return { status: "valid" };
552
+ }
553
+ catch (error) {
554
+ if (error instanceof FileNativePlayPresetError)
555
+ return {
556
+ status: "invalid",
557
+ code: error.code,
558
+ message: error.message,
559
+ location: locatePlayPresetError(error.message),
560
+ };
561
+ return {
562
+ status: "invalid",
563
+ code: "play_preset_invalid",
564
+ message: error instanceof Error ? error.message : "Play preset is invalid",
565
+ location: "preset.yaml",
566
+ };
567
+ }
568
+ }
569
+ export function toPlayPresetStructuredEditor(definition) {
570
+ return {
571
+ name: definition.name,
572
+ callChainPath: definition.callChainPath,
573
+ ...(definition.settingImprovementPrompt === undefined
574
+ ? {}
575
+ : {
576
+ settingImprovementPrompt: structuredClone(definition.settingImprovementPrompt),
577
+ }),
578
+ mounts: structuredClone(definition.mounts),
579
+ playerViewPanels: structuredClone(definition.playerViewPanels),
580
+ extensionRefs: [...definition.extensionRefs],
581
+ narrativePrompts: structuredClone(definition.narrativePrompts),
582
+ followups: structuredClone(definition.followups),
583
+ };
584
+ }
585
+ /**
586
+ * Apply a structured workbench edit to the portable file tree. The result
587
+ * still has to pass `parsePlayPresetFiles`; this function only serializes the
588
+ * public structure and never bypasses the normal codec.
589
+ */
590
+ export function applyPlayPresetStructuredEditor(files, input) {
591
+ const next = cloneFiles(files);
592
+ const presetSource = next["preset.yaml"];
593
+ const callChainSource = next[input.callChainPath];
594
+ if (presetSource === undefined || callChainSource === undefined)
595
+ throw new FileNativePlayPresetError("structured_editor_missing_file", "Structured editing could not find preset.yaml or call-chain.yaml");
596
+ const preset = parseDocument(presetSource, {
597
+ schema: "core",
598
+ uniqueKeys: true,
599
+ strict: true,
600
+ });
601
+ const callChain = parseDocument(callChainSource, {
602
+ schema: "core",
603
+ uniqueKeys: true,
604
+ strict: true,
605
+ });
606
+ if (preset.errors.length > 0 || callChain.errors.length > 0)
607
+ throw new FileNativePlayPresetError("structured_editor_yaml_invalid", "Structured editing can be applied only to parseable preset/call-chain YAML");
608
+ preset.set("name", input.name);
609
+ preset.set("callChain", input.callChainPath);
610
+ if (input.settingImprovementPrompt === undefined)
611
+ preset.delete("settingImprovement");
612
+ else
613
+ preset.set("settingImprovement", {
614
+ role: input.settingImprovementPrompt.role,
615
+ markdown: input.settingImprovementPrompt.path,
616
+ });
617
+ preset.set("mounts", input.mounts.map(({ channel, mount }) => ({ channel, mount })));
618
+ preset.set("extensions", [...input.extensionRefs]);
619
+ preset.set("playerViewPanels", input.playerViewPanels.map((panel) => structuredClone(panel)));
620
+ callChain.set("narrative", input.narrativePrompts.map(({ role, path }) => ({
621
+ role,
622
+ markdown: path,
623
+ })));
624
+ callChain.set("followups", input.followups.map((followup) => ({
625
+ id: followup.id,
626
+ displayName: followup.displayName,
627
+ prompt: {
628
+ role: followup.prompt.role,
629
+ markdown: followup.prompt.path,
630
+ },
631
+ artifacts: structuredClone(followup.artifacts),
632
+ maxArtifactBytes: followup.maxArtifactBytes,
633
+ })));
634
+ next["preset.yaml"] = String(preset);
635
+ next[input.callChainPath] = String(callChain);
636
+ return next;
637
+ }
638
+ export function parsePlayPresetStructuredEditor(value) {
639
+ if (!isRecord(value))
640
+ throw new FileNativePlayPresetError("structured_editor_invalid", "Structured play editing must be a map");
641
+ const strings = (key) => {
642
+ const entries = value[key];
643
+ if (!Array.isArray(entries) ||
644
+ entries.some((entry) => typeof entry !== "string"))
645
+ throw new FileNativePlayPresetError("structured_editor_invalid", `Structured play field ${key} must be an array of strings`);
646
+ return entries.map((entry) => entry.trim());
647
+ };
648
+ const rawNarrative = value.narrativePrompts;
649
+ const rawFollowups = value.followups ?? [];
650
+ const rawMounts = value.mounts;
651
+ if (!Array.isArray(rawNarrative) ||
652
+ !Array.isArray(rawFollowups) ||
653
+ !Array.isArray(rawMounts))
654
+ throw new FileNativePlayPresetError("structured_editor_invalid", "Structured play editing must include narrativePrompts, followups, and mounts arrays");
655
+ if (typeof value.name !== "string" || typeof value.callChainPath !== "string")
656
+ throw new FileNativePlayPresetError("structured_editor_invalid", "Structured play name/callChainPath is invalid");
657
+ if (!namePattern.test(value.name.trim()) ||
658
+ value.name.includes(String.fromCharCode(0)) ||
659
+ value.callChainPath !== "call-chain.yaml")
660
+ throw new FileNativePlayPresetError("structured_editor_invalid", "Structured play name/callChainPath is invalid");
661
+ const mounts = rawMounts.map((entry) => {
662
+ if (!isRecord(entry) ||
663
+ typeof entry.channel !== "string" ||
664
+ !/^[a-z][a-z0-9._/-]{1,127}$/u.test(entry.channel.trim()) ||
665
+ ![
666
+ "story",
667
+ "sidebar",
668
+ "composer_above",
669
+ "composer_below",
670
+ "overlay",
671
+ "debug",
672
+ ].includes(String(entry.mount)))
673
+ throw new FileNativePlayPresetError("structured_editor_invalid", "Structured play mount is invalid");
674
+ return {
675
+ channel: entry.channel.trim(),
676
+ mount: entry.mount,
677
+ };
678
+ });
679
+ if (new Set(mounts.map(({ channel }) => channel)).size !== mounts.length)
680
+ throw new FileNativePlayPresetError("structured_editor_invalid", "Structured play mount channels cannot be duplicated");
681
+ const promptBlock = (entry) => isRecord(entry)
682
+ ? { role: entry.role, path: entry.path }
683
+ : {
684
+ role: "invalid",
685
+ path: "",
686
+ };
687
+ const narrativePrompts = rawNarrative.map(promptBlock);
688
+ const settingImprovementPrompt = value.settingImprovementPrompt === undefined
689
+ ? undefined
690
+ : promptBlock(value.settingImprovementPrompt);
691
+ const followups = rawFollowups.map((entry) => {
692
+ if (!isRecord(entry))
693
+ throw new FileNativePlayPresetError("structured_editor_invalid", "Structured play followup is invalid");
694
+ return {
695
+ ...entry,
696
+ prompt: promptBlock(entry.prompt),
697
+ artifacts: Array.isArray(entry.artifacts) ? entry.artifacts : [],
698
+ };
699
+ });
700
+ if (new Set(followups.map(({ id }) => id)).size !== followups.length ||
701
+ followups.some(({ id }) => typeof id !== "string"))
702
+ throw new FileNativePlayPresetError("structured_editor_invalid", "Structured play follow-up id is invalid or duplicated");
703
+ if (value.playerViewPanels !== undefined &&
704
+ (!Array.isArray(value.playerViewPanels) ||
705
+ value.playerViewPanels.some((panel) => !isRecord(panel))))
706
+ throw new FileNativePlayPresetError("structured_editor_invalid", "Structured play playerViewPanels must be an array of maps");
707
+ return {
708
+ name: value.name,
709
+ callChainPath: value.callChainPath,
710
+ ...(settingImprovementPrompt === undefined
711
+ ? {}
712
+ : {
713
+ settingImprovementPrompt: settingImprovementPrompt,
714
+ }),
715
+ mounts,
716
+ playerViewPanels: (value.playerViewPanels ??
717
+ []),
718
+ extensionRefs: strings("extensionRefs"),
719
+ narrativePrompts: narrativePrompts,
720
+ followups,
721
+ };
722
+ }
723
+ export function parsePlayPresetFiles(files, limits = {}) {
724
+ try {
725
+ const normalizedLimits = { ...defaultLimits, ...limits };
726
+ validateFileTree(files, normalizedLimits);
727
+ assertRequiredPresetFiles(files);
728
+ const preset = readYaml(files, "preset.yaml", "preset.yaml");
729
+ assertNoEditableMechanics(preset, "preset.yaml");
730
+ assertKnownKeys(preset, [
731
+ "format",
732
+ "name",
733
+ "callChain",
734
+ "settingImprovement",
735
+ "mounts",
736
+ "playerViewPanels",
737
+ "extensions",
738
+ ], "preset.yaml");
739
+ const callChainPath = stringValue(preset.callChain);
740
+ if (callChainPath !== "call-chain.yaml")
741
+ invalid("call_chain_path_invalid", "Entry callChain must be call-chain.yaml", "preset.yaml");
742
+ if (preset.format !== "narraeon.play-preset/v1")
743
+ invalid("preset_format_invalid", "preset.yaml format must be narraeon.play-preset/v1", "preset.yaml");
744
+ const name = stringValue(preset.name);
745
+ if (!namePattern.test(name.trim()) || name.includes(String.fromCharCode(0)))
746
+ invalid("invalid_name", "Play-preset name must contain 1 to 160 characters", "preset.yaml");
747
+ const callChain = readYaml(files, callChainPath, "call-chain.yaml");
748
+ assertNoEditableMechanics(callChain, callChainPath);
749
+ assertKnownKeys(callChain, ["format", "narrative", "followups"], callChainPath);
750
+ if (callChain.format !== "narraeon.play-call-chain/v1")
751
+ invalid("call_chain_format_invalid", "call-chain.yaml format must be narraeon.play-call-chain/v1", callChainPath);
752
+ // The host preset already carries the general narrative criteria, so a
753
+ // preset that adds nothing of its own is legitimate.
754
+ const narrativePrompts = parsePromptBlocks(callChain.narrative, files, "call-chain.yaml#narrative", true);
755
+ const settingImprovementPrompt = preset.settingImprovement === undefined
756
+ ? undefined
757
+ : parsePromptBlocks([preset.settingImprovement], files, "preset.yaml#settingImprovement")[0];
758
+ const followups = parseFollowups(callChain.followups, files);
759
+ const mounts = parseMounts(preset.mounts);
760
+ const playerViewPanels = parsePlayerViewPanels(preset.playerViewPanels, files);
761
+ validatePanelChannels(followups, playerViewPanels);
762
+ const extensionRefs = validatePresetReferences(preset, files);
763
+ validateArtifactExtensionReferences(followups, extensionRefs);
764
+ return {
765
+ kind: "valid",
766
+ definition: {
767
+ format: "narraeon.play-preset/v1",
768
+ name: name.trim(),
769
+ callChainPath,
770
+ ...(settingImprovementPrompt === undefined
771
+ ? {}
772
+ : { settingImprovementPrompt }),
773
+ mounts,
774
+ playerViewPanels,
775
+ extensionRefs,
776
+ narrativePrompts,
777
+ followups,
778
+ files: cloneFiles(files),
779
+ },
780
+ };
781
+ }
782
+ catch (error) {
783
+ if (error instanceof FileNativePlayPresetError)
784
+ return { kind: "invalid", error };
785
+ return {
786
+ kind: "invalid",
787
+ error: new FileNativePlayPresetError("play_preset_invalid", error instanceof Error ? error.message : "Play preset is invalid"),
788
+ };
789
+ }
790
+ }
791
+ function parsePromptBlocks(value, files, location, allowEmpty = false) {
792
+ if (allowEmpty && (value === undefined || value === null))
793
+ return [];
794
+ if (!Array.isArray(value) || (value.length === 0 && !allowEmpty))
795
+ invalid("prompts_missing", "At least one Markdown prompt block is required", location);
796
+ return value.map((raw, index) => {
797
+ const promptLocation = `${location}#prompts[${index}]`;
798
+ if (!isRecord(raw))
799
+ invalid("prompt_ref_invalid", "A prompt-block reference must be a map containing markdown", promptLocation);
800
+ const entry = raw;
801
+ assertKnownKeys(entry, ["role", "markdown"], promptLocation);
802
+ // author_instruction is the only role a preset may contribute, so an
803
+ // omitted role is the normal spelling rather than a missing field.
804
+ const role = entry.role ?? "author_instruction";
805
+ if (!isPlayPresetPromptRole(role))
806
+ invalid("prompt_role_invalid", "A play prompt block may use only the author_instruction role", promptLocation);
807
+ const path = stringValue(entry.markdown).trim();
808
+ if (!/^prompts\/[A-Za-z0-9][A-Za-z0-9._-]*\.md$/u.test(path))
809
+ invalid("prompt_path_invalid", "A prompt block must reference prompts/*.md", promptLocation);
810
+ const body = files[path];
811
+ if (body === undefined || body.trim() === "")
812
+ invalid("prompt_missing", `Prompt block does not exist: ${path}`, path);
813
+ return { role, path };
814
+ });
815
+ }
816
+ /**
817
+ * Follow-ups have no ordering contract. The parser needs only each request's
818
+ * identity, its single author prompt, and the artifacts it may emit.
819
+ */
820
+ function parseFollowups(value, files) {
821
+ if (value === undefined || value === null)
822
+ return [];
823
+ if (!Array.isArray(value))
824
+ invalid("followups_invalid", "callChain.followups must be an array", "call-chain.yaml");
825
+ if (value.length > maxPlayPresetFollowups)
826
+ invalid("followups_invalid", `callChain.followups cannot exceed ${maxPlayPresetFollowups} items`, "call-chain.yaml");
827
+ const followups = value.map((raw, index) => {
828
+ const location = `call-chain.yaml#followups[${index}]`;
829
+ if (!isRecord(raw))
830
+ invalid("followup_invalid", "A follow-up request must be a map", location);
831
+ const id = stringValue(raw.id).trim();
832
+ assertKnownKeys(raw, ["id", "displayName", "prompt", "artifacts", "maxArtifactBytes"], location);
833
+ const displayName = stringValue(raw.displayName).trim();
834
+ if (!/^[a-z][a-z0-9_-]{1,63}$/u.test(id))
835
+ invalid("followup_id_invalid", "A follow-up request id must be a stable lowercase ASCII identifier", location);
836
+ if (!namePattern.test(displayName))
837
+ invalid("followup_name_invalid", "Follow-up request display name is invalid", location);
838
+ const prompts = parsePromptBlocks([raw.prompt], files, location);
839
+ if (prompts.length !== 1)
840
+ invalid("followup_prompt_invalid", "A follow-up request must declare exactly one prompt block", location);
841
+ const artifacts = parseArtifacts(raw.artifacts ?? [], files, location);
842
+ if (artifacts.length === 0)
843
+ invalid("followup_artifact_missing", "A follow-up request must declare at least one artifact", location);
844
+ if (artifacts.some((artifact) => artifact.strategy === "upsert" && artifact.key === undefined))
845
+ invalid("upsert_key_missing", "An upsert channel must declare a key", location);
846
+ return {
847
+ id,
848
+ displayName,
849
+ prompt: prompts[0],
850
+ artifacts,
851
+ maxArtifactBytes: parseMaxArtifactBytes(raw.maxArtifactBytes, location),
852
+ };
853
+ });
854
+ if (new Set(followups.map(({ id }) => id)).size !== followups.length)
855
+ invalid("duplicate_followup_id", "Follow-up request id is duplicated", "call-chain.yaml");
856
+ // One channel has one projection meaning. Two followups writing the same
857
+ // channel with different strategies would make the visible set depend on
858
+ // dispatch order, which followups deliberately do not have.
859
+ const strategies = new Map();
860
+ for (const { artifacts } of followups)
861
+ for (const { channel, strategy } of artifacts) {
862
+ const known = strategies.get(channel);
863
+ if (known !== undefined && known !== strategy)
864
+ invalid("channel_strategy_conflict", `One channel cannot declare conflicting projection policies: ${channel}`, "call-chain.yaml");
865
+ strategies.set(channel, strategy);
866
+ }
867
+ return followups;
868
+ }
869
+ function parseMaxArtifactBytes(value, location) {
870
+ if (value === undefined)
871
+ return defaultMaxArtifactBytes;
872
+ if (typeof value !== "number" ||
873
+ !Number.isSafeInteger(value) ||
874
+ value <= 0 ||
875
+ value > maxArtifactBytesCeiling)
876
+ invalid("followup_bytes_invalid", `maxArtifactBytes must be an integer from 1 to ${maxArtifactBytesCeiling}`, location);
877
+ return value;
878
+ }
879
+ function parseArtifacts(value, files, location) {
880
+ if (value === undefined || value === null)
881
+ return [];
882
+ if (!Array.isArray(value))
883
+ invalid("artifacts_invalid", "Follow-up request artifacts must be an array", location);
884
+ const names = new Set();
885
+ return value.map((raw, index) => {
886
+ const artifactLocation = `${location}#artifacts[${index}]`;
887
+ if (!isRecord(raw))
888
+ invalid("artifact_invalid", "An artifact declaration must be a map", artifactLocation);
889
+ assertKnownKeys(raw, [
890
+ "name",
891
+ "channel",
892
+ "strategy",
893
+ "key",
894
+ "contentType",
895
+ "renderer",
896
+ "rendererRevision",
897
+ "rendererMode",
898
+ "regex",
899
+ "scripts",
900
+ "assets",
901
+ "save",
902
+ "invalidation",
903
+ "required",
904
+ "maxEmits",
905
+ "payloadContract",
906
+ ], artifactLocation);
907
+ const name = stringValue(raw.name).trim();
908
+ if (!/^[a-z][a-z0-9._/-]{1,127}$/u.test(name))
909
+ invalid("artifact_output_name_invalid", "An artifact must declare a stable lowercase output name", artifactLocation);
910
+ if (names.has(name))
911
+ invalid("duplicate_artifact_output_name", `Artifact name is duplicated: ${name}`, artifactLocation);
912
+ names.add(name);
913
+ const channel = stringValue(raw.channel).trim();
914
+ if (!/^[a-z][a-z0-9._/-]{1,127}$/u.test(channel))
915
+ invalid("channel_invalid", "Artifact channel must be a stable lowercase address", artifactLocation);
916
+ const strategy = raw.strategy;
917
+ if (!["append", "replace", "upsert", "transient", "hidden"].includes(String(strategy)))
918
+ invalid("channel_strategy_invalid", "Artifact channel policy is invalid", artifactLocation);
919
+ const contentType = raw.contentType ?? "text/plain";
920
+ if (typeof contentType !== "string" ||
921
+ ![
922
+ "text/plain",
923
+ "text/markdown",
924
+ "application/json",
925
+ "text/html",
926
+ ].includes(contentType))
927
+ invalid("artifact_content_type_invalid", "Artifact contentType is invalid", artifactLocation);
928
+ const save = raw.save ?? "operation";
929
+ if (typeof save !== "string" ||
930
+ !["none", "operation", "commit"].includes(save))
931
+ invalid("artifact_save_invalid", "Artifact retention policy is invalid", artifactLocation);
932
+ const renderer = raw.renderer === undefined ? undefined : stringValue(raw.renderer).trim();
933
+ if (renderer !== undefined &&
934
+ !/^renderers\/[A-Za-z0-9][A-Za-z0-9._-]*\.html$/u.test(renderer))
935
+ invalid("renderer_path_invalid", "renderer must reference renderers/*.html", artifactLocation);
936
+ if (renderer !== undefined && files[renderer] === undefined)
937
+ invalid("renderer_missing", `Renderer does not exist: ${renderer}`, renderer);
938
+ const rendererRevision = raw.rendererRevision === undefined
939
+ ? undefined
940
+ : stringValue(raw.rendererRevision).trim();
941
+ if (renderer !== undefined && !rendererRevision)
942
+ invalid("renderer_revision_missing", "renderer must also declare a content revision", artifactLocation);
943
+ if (renderer === undefined && rendererRevision !== undefined)
944
+ invalid("renderer_revision_without_path", "rendererRevision cannot be declared without a renderer path", artifactLocation);
945
+ const rendererModeValue = raw.rendererMode ?? "document";
946
+ if (rendererModeValue !== "document" && rendererModeValue !== "app")
947
+ invalid("renderer_mode_invalid", "rendererMode must be document or app", artifactLocation);
948
+ if (rendererModeValue === "app" && renderer === undefined)
949
+ invalid("renderer_mode_renderer_missing", "An app renderer must declare a custom renderer", artifactLocation);
950
+ const regex = raw.regex === undefined ? undefined : stringValue(raw.regex).trim();
951
+ if (regex !== undefined &&
952
+ !/^regex\/[A-Za-z0-9][A-Za-z0-9._-]*\.yaml$/u.test(regex))
953
+ invalid("regex_path_invalid", "regex must reference regex/*.yaml", artifactLocation);
954
+ if (regex !== undefined && files[regex] === undefined)
955
+ invalid("regex_missing", `Regex resource does not exist: ${regex}`, regex);
956
+ const scripts = parseExtensionPaths(raw.scripts, /^scripts\/[A-Za-z0-9][A-Za-z0-9._-]*\.js$/u, "script", artifactLocation, files, 16);
957
+ const assets = parseExtensionPaths(raw.assets, /^assets\/[A-Za-z0-9][A-Za-z0-9._/-]*$/u, "asset", artifactLocation, files, 64);
958
+ const key = raw.key === undefined ? undefined : stringValue(raw.key).trim();
959
+ if (key !== undefined && !/^[a-z][a-z0-9._/-]{0,127}$/u.test(key))
960
+ invalid("artifact_key_invalid", "Artifact key is invalid", artifactLocation);
961
+ const invalidationValue = raw.invalidation ??
962
+ (save === "commit"
963
+ ? "head_change"
964
+ : save === "operation"
965
+ ? "operation_end"
966
+ : "explicit_clear");
967
+ if (typeof invalidationValue !== "string" ||
968
+ ![
969
+ "new_operation",
970
+ "head_change",
971
+ "operation_end",
972
+ "explicit_clear",
973
+ "never",
974
+ ].includes(invalidationValue))
975
+ invalid("artifact_invalidation_invalid", "Artifact invalidation policy is invalid", artifactLocation);
976
+ const required = raw.required ?? false;
977
+ if (typeof required !== "boolean")
978
+ invalid("artifact_required_invalid", "Artifact required must be a boolean", artifactLocation);
979
+ const maxEmits = finiteInteger(raw.maxEmits ?? 1);
980
+ if (maxEmits === null || maxEmits < 1 || maxEmits > 128)
981
+ invalid("artifact_emit_budget_invalid", "Artifact maxEmits must be a bounded integer from 1 to 128", artifactLocation);
982
+ const payloadContract = parseArtifactPayloadContract(raw.payloadContract, `${artifactLocation}#payloadContract`);
983
+ if (payloadContract !== undefined && contentType !== "application/json")
984
+ invalid("artifact_payload_contract_type_invalid", "A structured payload contract can be used only with an application/json artifact", artifactLocation);
985
+ return {
986
+ name,
987
+ channel,
988
+ strategy: strategy,
989
+ ...(key === undefined ? {} : { key }),
990
+ contentType: contentType,
991
+ ...(renderer === undefined ? {} : { renderer }),
992
+ ...(rendererRevision === undefined ? {} : { rendererRevision }),
993
+ rendererMode: rendererModeValue === "app" ? "app" : "document",
994
+ ...(regex === undefined ? {} : { regex }),
995
+ ...(scripts === undefined ? {} : { scripts }),
996
+ ...(assets === undefined ? {} : { assets }),
997
+ save: save,
998
+ invalidation: invalidationValue,
999
+ required,
1000
+ maxEmits,
1001
+ ...(payloadContract === undefined ? {} : { payloadContract }),
1002
+ };
1003
+ });
1004
+ }
1005
+ function parseArtifactPayloadContract(value, location, state = { nodes: 0 }, depth = 0) {
1006
+ if (value === undefined)
1007
+ return undefined;
1008
+ state.nodes += 1;
1009
+ if (depth > 32)
1010
+ invalid("artifact_payload_contract_too_deep", "payloadContract nesting exceeds 32 levels", location);
1011
+ if (state.nodes > 512)
1012
+ invalid("artifact_payload_contract_too_complex", "payloadContract contains more than 512 nodes", location);
1013
+ if (!isRecord(value))
1014
+ invalid("artifact_payload_contract_invalid", "payloadContract must be a map", location);
1015
+ const type = value.type;
1016
+ if (type !== "object" &&
1017
+ type !== "array" &&
1018
+ type !== "string" &&
1019
+ type !== "number" &&
1020
+ type !== "integer" &&
1021
+ type !== "boolean" &&
1022
+ type !== "null")
1023
+ invalid("artifact_payload_contract_type_invalid", "payloadContract.type is invalid", location);
1024
+ const propertiesValue = value.properties;
1025
+ let properties;
1026
+ if (propertiesValue !== undefined) {
1027
+ if (!isRecord(propertiesValue))
1028
+ invalid("artifact_payload_contract_properties_invalid", "payloadContract.properties must be a map", location);
1029
+ properties = {};
1030
+ for (const [key, child] of Object.entries(propertiesValue)) {
1031
+ if (!/^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/u.test(key))
1032
+ invalid("artifact_payload_contract_property_invalid", `payloadContract property name is invalid: ${key}`, location);
1033
+ properties[key] = parseArtifactPayloadContract(child, `${location}.properties.${key}`, state, depth + 1);
1034
+ }
1035
+ }
1036
+ const requiredValue = value.required;
1037
+ let required;
1038
+ if (requiredValue !== undefined) {
1039
+ if (!Array.isArray(requiredValue) ||
1040
+ requiredValue.some((entry) => typeof entry !== "string"))
1041
+ invalid("artifact_payload_contract_required_invalid", "payloadContract.required must be an array of strings", location);
1042
+ required = requiredValue.map((entry) => entry.trim());
1043
+ if (required.some((entry) => entry === "") ||
1044
+ new Set(required).size !== required.length)
1045
+ invalid("artifact_payload_contract_required_invalid", "payloadContract.required cannot contain empty or duplicate fields", location);
1046
+ if (properties !== undefined &&
1047
+ required.some((entry) => properties?.[entry] === undefined))
1048
+ invalid("artifact_payload_contract_required_invalid", "payloadContract.required must reference fields in properties", location);
1049
+ }
1050
+ const itemsValue = value.items;
1051
+ const items = itemsValue === undefined
1052
+ ? undefined
1053
+ : parseArtifactPayloadContract(itemsValue, `${location}.items`, state, depth + 1);
1054
+ if (type === "object" && properties === undefined)
1055
+ invalid("artifact_payload_contract_properties_invalid", "An object payloadContract must declare properties", location);
1056
+ if (type === "array" && items === undefined)
1057
+ invalid("artifact_payload_contract_items_invalid", "An array payloadContract must declare items", location);
1058
+ if (type !== "object" && (properties !== undefined || required !== undefined))
1059
+ invalid("artifact_payload_contract_shape_invalid", "Only an object payloadContract may declare properties/required", location);
1060
+ if (type !== "array" &&
1061
+ (items !== undefined ||
1062
+ value.minItems !== undefined ||
1063
+ value.maxItems !== undefined ||
1064
+ value.uniqueBy !== undefined))
1065
+ invalid("artifact_payload_contract_shape_invalid", "Only an array payloadContract may declare items/minItems/maxItems/uniqueBy", location);
1066
+ const minItems = boundedContractInteger(value.minItems, location, "minItems");
1067
+ const maxItems = boundedContractInteger(value.maxItems, location, "maxItems");
1068
+ const minLength = boundedContractInteger(value.minLength, location, "minLength");
1069
+ const maxLength = boundedContractInteger(value.maxLength, location, "maxLength");
1070
+ const maxBytes = boundedContractInteger(value.maxBytes, location, "maxBytes");
1071
+ if (minItems !== undefined && maxItems !== undefined && minItems > maxItems)
1072
+ invalid("artifact_payload_contract_bounds_invalid", "payloadContract.minItems cannot exceed maxItems", location);
1073
+ if (minLength !== undefined &&
1074
+ maxLength !== undefined &&
1075
+ minLength > maxLength)
1076
+ invalid("artifact_payload_contract_bounds_invalid", "payloadContract.minLength cannot exceed maxLength", location);
1077
+ if (type !== "string" && (minLength !== undefined || maxLength !== undefined))
1078
+ invalid("artifact_payload_contract_shape_invalid", "Only a string payloadContract may declare length bounds", location);
1079
+ if (value.uniqueBy !== undefined) {
1080
+ if (typeof value.uniqueBy !== "string" || value.uniqueBy.trim() === "")
1081
+ invalid("artifact_payload_contract_unique_invalid", "payloadContract.uniqueBy must be a non-empty field name", location);
1082
+ if (type !== "array" || items?.type !== "object")
1083
+ invalid("artifact_payload_contract_unique_invalid", "payloadContract.uniqueBy can be used only for an array of object items", location);
1084
+ if (items.properties?.[value.uniqueBy.trim()] === undefined)
1085
+ invalid("artifact_payload_contract_unique_invalid", "payloadContract.uniqueBy must reference an item properties field", location);
1086
+ }
1087
+ if (value.additionalProperties !== undefined && type !== "object")
1088
+ invalid("artifact_payload_contract_shape_invalid", "Only an object payloadContract may declare additionalProperties", location);
1089
+ return {
1090
+ type,
1091
+ ...(properties === undefined ? {} : { properties }),
1092
+ ...(required === undefined ? {} : { required }),
1093
+ ...(value.additionalProperties === undefined
1094
+ ? {}
1095
+ : {
1096
+ additionalProperties: booleanValue(value.additionalProperties, location),
1097
+ }),
1098
+ ...(items === undefined ? {} : { items }),
1099
+ ...(minItems === undefined ? {} : { minItems }),
1100
+ ...(maxItems === undefined ? {} : { maxItems }),
1101
+ ...(minLength === undefined ? {} : { minLength }),
1102
+ ...(maxLength === undefined ? {} : { maxLength }),
1103
+ ...(value.uniqueBy === undefined
1104
+ ? {}
1105
+ : { uniqueBy: value.uniqueBy.trim() }),
1106
+ ...(maxBytes === undefined ? {} : { maxBytes }),
1107
+ };
1108
+ }
1109
+ function boundedContractInteger(value, location, field) {
1110
+ if (value === undefined)
1111
+ return undefined;
1112
+ if (!Number.isSafeInteger(value) ||
1113
+ value < 0 ||
1114
+ value > 4 * 1024 * 1024)
1115
+ invalid("artifact_payload_contract_bounds_invalid", `payloadContract.${field} must be a bounded non-negative integer`, location);
1116
+ return value;
1117
+ }
1118
+ function booleanValue(value, location) {
1119
+ if (typeof value !== "boolean")
1120
+ invalid("artifact_payload_contract_boolean_invalid", "payloadContract.additionalProperties must be a boolean", location);
1121
+ return value;
1122
+ }
1123
+ function parseMounts(value) {
1124
+ if (value === undefined || value === null)
1125
+ return [];
1126
+ const entries = Array.isArray(value)
1127
+ ? value.map((entry) => isRecord(entry)
1128
+ ? { channel: entry.channel, mount: entry.mount }
1129
+ : { channel: undefined, mount: undefined })
1130
+ : isRecord(value)
1131
+ ? Object.entries(value).map(([channel, mount]) => ({ channel, mount }))
1132
+ : [];
1133
+ if (!Array.isArray(value) && !isRecord(value))
1134
+ invalid("mounts_invalid", "preset.mounts must be a map or array", "preset.yaml");
1135
+ const channels = new Set();
1136
+ return entries.map(({ channel, mount }, index) => {
1137
+ const location = `preset.yaml#mounts[${index}]`;
1138
+ const normalizedChannel = stringValue(channel).trim();
1139
+ if (!/^[a-z][a-z0-9._/-]{1,127}$/u.test(normalizedChannel))
1140
+ invalid("mount_channel_invalid", "Channel mount channel is invalid", location);
1141
+ if (channels.has(normalizedChannel))
1142
+ invalid("duplicate_mount_channel", `Channel mount is duplicated: ${normalizedChannel}`, location);
1143
+ channels.add(normalizedChannel);
1144
+ if (![
1145
+ "story",
1146
+ "sidebar",
1147
+ "composer_above",
1148
+ "composer_below",
1149
+ "overlay",
1150
+ "debug",
1151
+ ].includes(String(mount)))
1152
+ invalid("mount_invalid", "A channel mount must use a supported standard mount point", location);
1153
+ return {
1154
+ channel: normalizedChannel,
1155
+ mount: mount,
1156
+ };
1157
+ });
1158
+ }
1159
+ function parsePlayerViewPanels(value, files) {
1160
+ if (value === undefined || value === null)
1161
+ return [];
1162
+ if (!Array.isArray(value))
1163
+ invalid("player_view_panels_invalid", "preset.playerViewPanels must be an array", "preset.yaml");
1164
+ const ids = new Set();
1165
+ const channelKeys = new Set();
1166
+ return value.map((raw, index) => {
1167
+ const location = `preset.yaml#playerViewPanels[${index}]`;
1168
+ if (!isRecord(raw))
1169
+ invalid("player_view_panel_invalid", "A player-view panel must be a map", location);
1170
+ assertKnownKeys(raw, [
1171
+ "id",
1172
+ "source",
1173
+ "channel",
1174
+ "key",
1175
+ "mount",
1176
+ "renderer",
1177
+ "rendererRevision",
1178
+ "rendererMode",
1179
+ "regex",
1180
+ "scripts",
1181
+ "assets",
1182
+ "config",
1183
+ ], location);
1184
+ const id = stringValue(raw.id).trim();
1185
+ if (!/^[a-z][a-z0-9._/-]{1,127}$/u.test(id))
1186
+ invalid("player_view_panel_id_invalid", "A player-view panel id must be a stable lowercase identifier", location);
1187
+ if (ids.has(id))
1188
+ invalid("duplicate_player_view_panel_id", `Player-view panel id is duplicated: ${id}`, location);
1189
+ ids.add(id);
1190
+ const sourceValue = raw.source;
1191
+ if (!isRecord(sourceValue) || sourceValue.kind !== "player_view")
1192
+ invalid("player_view_panel_source_invalid", "A player-view panel source must declare kind: player_view", location);
1193
+ assertKnownKeys(sourceValue, ["kind", "view", "itemIds"], location);
1194
+ const view = stringValue(sourceValue.view).trim();
1195
+ if (!/^[a-z][a-z0-9._/-]{1,127}$/u.test(view))
1196
+ invalid("player_view_panel_view_invalid", "A player-view panel must reference a stable view id", location);
1197
+ const rawItemIds = sourceValue.itemIds;
1198
+ let itemIds;
1199
+ if (rawItemIds !== undefined) {
1200
+ if (!Array.isArray(rawItemIds) ||
1201
+ rawItemIds.length > 128 ||
1202
+ rawItemIds.some((item) => typeof item !== "string" ||
1203
+ !/^[a-z][a-z0-9._/-]{0,127}$/u.test(item.trim())))
1204
+ invalid("player_view_panel_items_invalid", "Player-view panel itemIds must be a bounded array of stable ids", location);
1205
+ itemIds = rawItemIds.map((item) => item.trim());
1206
+ if (new Set(itemIds).size !== itemIds.length)
1207
+ invalid("duplicate_player_view_panel_item", "Player-view panel itemIds cannot be duplicated", location);
1208
+ }
1209
+ const channel = stringValue(raw.channel).trim();
1210
+ if (!/^[a-z][a-z0-9._/-]{1,127}$/u.test(channel))
1211
+ invalid("player_view_panel_channel_invalid", "A player-view panel channel must be a stable lowercase address", location);
1212
+ const key = stringValue(raw.key).trim();
1213
+ if (!/^[a-z][a-z0-9._/-]{0,127}$/u.test(key))
1214
+ invalid("player_view_panel_key_invalid", "A player-view panel upsert must declare a stable key", location);
1215
+ const channelKey = `${channel}\0${key}`;
1216
+ if (channelKeys.has(channelKey))
1217
+ invalid("duplicate_player_view_panel_key", `Player-view panel channel/key cannot be duplicated: ${channel}/${key}`, location);
1218
+ channelKeys.add(channelKey);
1219
+ const mount = raw.mount;
1220
+ if (![
1221
+ "story",
1222
+ "sidebar",
1223
+ "composer_above",
1224
+ "composer_below",
1225
+ "overlay",
1226
+ "debug",
1227
+ ].includes(String(mount)))
1228
+ invalid("player_view_panel_mount_invalid", "A player-view panel mount must use a supported standard mount point", location);
1229
+ const renderer = raw.renderer === undefined ? undefined : stringValue(raw.renderer).trim();
1230
+ if (renderer !== undefined &&
1231
+ !/^renderers\/[A-Za-z0-9][A-Za-z0-9._-]*\.html$/u.test(renderer))
1232
+ invalid("player_view_panel_renderer_invalid", "A player-view panel renderer must reference renderers/*.html", location);
1233
+ if (renderer !== undefined && files[renderer] === undefined)
1234
+ invalid("player_view_panel_renderer_missing", `Renderer does not exist: ${renderer}`, renderer);
1235
+ const rendererRevision = raw.rendererRevision === undefined
1236
+ ? undefined
1237
+ : stringValue(raw.rendererRevision).trim();
1238
+ if (renderer !== undefined && !rendererRevision)
1239
+ invalid("player_view_panel_renderer_revision_missing", "A player-view panel renderer must also declare a content revision", location);
1240
+ if (renderer === undefined && rendererRevision !== undefined)
1241
+ invalid("player_view_panel_renderer_revision_without_path", "rendererRevision cannot be declared without a renderer path", location);
1242
+ const rendererModeValue = raw.rendererMode ?? (renderer === undefined ? "document" : "app");
1243
+ if (rendererModeValue !== "document" && rendererModeValue !== "app")
1244
+ invalid("player_view_panel_renderer_mode_invalid", "Player-view panel rendererMode must be document or app", location);
1245
+ if (rendererModeValue === "app" && renderer === undefined)
1246
+ invalid("player_view_panel_renderer_missing", "An app player-view panel must declare a renderer", location);
1247
+ const regex = raw.regex === undefined ? undefined : stringValue(raw.regex).trim();
1248
+ if (regex !== undefined &&
1249
+ !/^regex\/[A-Za-z0-9][A-Za-z0-9._-]*\.yaml$/u.test(regex))
1250
+ invalid("player_view_panel_regex_invalid", "Player-view panel regex must reference regex/*.yaml", location);
1251
+ if (regex !== undefined && files[regex] === undefined)
1252
+ invalid("player_view_panel_regex_missing", `Regex does not exist: ${regex}`, regex);
1253
+ const scripts = parseExtensionPaths(raw.scripts, /^scripts\/[A-Za-z0-9][A-Za-z0-9._-]*\.js$/u, "script", location, files, 16);
1254
+ const assets = parseExtensionPaths(raw.assets, /^assets\/[A-Za-z0-9][A-Za-z0-9._/-]*$/u, "asset", location, files, 64);
1255
+ const config = parsePlayerViewPanelConfig(raw.config, location);
1256
+ if (itemIds !== undefined) {
1257
+ const sourceItemIds = new Set(itemIds);
1258
+ for (const group of config.groups)
1259
+ if (group.itemIds.some((itemId) => !sourceItemIds.has(itemId)))
1260
+ invalid("player_view_panel_group_outside_source", "Player-view panel group itemIds must be within source.itemIds", location);
1261
+ }
1262
+ return {
1263
+ id,
1264
+ source: {
1265
+ kind: "player_view",
1266
+ view,
1267
+ ...(itemIds === undefined ? {} : { itemIds }),
1268
+ },
1269
+ channel,
1270
+ key,
1271
+ mount: mount,
1272
+ ...(renderer === undefined ? {} : { renderer }),
1273
+ ...(rendererRevision === undefined ? {} : { rendererRevision }),
1274
+ rendererMode: rendererModeValue === "app" ? "app" : "document",
1275
+ ...(regex === undefined ? {} : { regex }),
1276
+ ...(scripts === undefined ? {} : { scripts }),
1277
+ ...(assets === undefined ? {} : { assets }),
1278
+ config,
1279
+ };
1280
+ });
1281
+ }
1282
+ function parsePlayerViewPanelConfig(value, location) {
1283
+ if (value !== undefined && !isRecord(value))
1284
+ invalid("player_view_panel_config_invalid", "Player-view panel config must be a map", location);
1285
+ const config = value ?? {};
1286
+ assertKnownKeys(config, ["title", "layout", "theme", "empty", "emptyMessage", "groups"], location);
1287
+ const title = config.title === undefined ? undefined : stringValue(config.title).trim();
1288
+ if (title !== undefined && !namePattern.test(title))
1289
+ invalid("player_view_panel_title_invalid", "Player-view panel title is invalid", location);
1290
+ const layout = config.layout ?? "stack";
1291
+ if (layout !== "stack" && layout !== "grid")
1292
+ invalid("player_view_panel_layout_invalid", "Player-view panel layout must be stack or grid", location);
1293
+ const theme = config.theme === undefined ? "default" : stringValue(config.theme).trim();
1294
+ if (!/^[a-z][a-z0-9._/-]{0,63}$/u.test(theme))
1295
+ invalid("player_view_panel_theme_invalid", "Player-view panel theme must be a stable identifier", location);
1296
+ const empty = config.empty ?? "message";
1297
+ if (empty !== "hide" && empty !== "message" && empty !== "show")
1298
+ invalid("player_view_panel_empty_invalid", "Player-view panel empty must be hide, message, or show", location);
1299
+ const emptyMessage = config.emptyMessage === undefined
1300
+ ? "There is no content to display."
1301
+ : stringValue(config.emptyMessage);
1302
+ if (emptyMessage.length > 256)
1303
+ invalid("player_view_panel_empty_message_invalid", "Player-view panel emptyMessage is too long", location);
1304
+ const rawGroups = config.groups;
1305
+ if (rawGroups !== undefined && !Array.isArray(rawGroups))
1306
+ invalid("player_view_panel_groups_invalid", "Player-view panel groups must be an array", location);
1307
+ const groups = [];
1308
+ const groupIds = new Set();
1309
+ for (const [index, rawGroup] of (rawGroups ?? []).entries()) {
1310
+ const groupLocation = `${location}#config.groups[${index}]`;
1311
+ if (!isRecord(rawGroup))
1312
+ invalid("player_view_panel_group_invalid", "A player-view panel group must be a map", groupLocation);
1313
+ assertKnownKeys(rawGroup, ["id", "label", "itemIds"], groupLocation);
1314
+ const id = stringValue(rawGroup.id).trim();
1315
+ const label = stringValue(rawGroup.label).trim();
1316
+ if (!/^[a-z][a-z0-9._/-]{1,63}$/u.test(id) || !namePattern.test(label))
1317
+ invalid("player_view_panel_group_invalid", "Player-view panel group id/label is invalid", groupLocation);
1318
+ if (groupIds.has(id))
1319
+ invalid("duplicate_player_view_panel_group", `Player-view panel group is duplicated: ${id}`, groupLocation);
1320
+ groupIds.add(id);
1321
+ const rawItems = rawGroup.itemIds ?? [];
1322
+ if (!Array.isArray(rawItems) ||
1323
+ rawItems.length > 128 ||
1324
+ rawItems.some((item) => typeof item !== "string" ||
1325
+ !/^[a-z][a-z0-9._/-]{0,127}$/u.test(item.trim())))
1326
+ invalid("player_view_panel_group_items_invalid", "Player-view panel group itemIds are invalid", groupLocation);
1327
+ const itemIds = rawItems.map((item) => item.trim());
1328
+ if (new Set(itemIds).size !== itemIds.length)
1329
+ invalid("duplicate_player_view_panel_group_item", "Player-view panel group itemIds cannot be duplicated", groupLocation);
1330
+ groups.push({ id, label, itemIds });
1331
+ }
1332
+ return {
1333
+ ...(title === undefined ? {} : { title }),
1334
+ layout,
1335
+ theme,
1336
+ empty,
1337
+ emptyMessage,
1338
+ groups,
1339
+ };
1340
+ }
1341
+ function validatePanelChannels(followups, panels) {
1342
+ const artifactChannels = new Set(followups.flatMap(({ artifacts }) => artifacts.map(({ channel }) => channel)));
1343
+ for (const panel of panels)
1344
+ if (artifactChannels.has(panel.channel))
1345
+ invalid("player_view_panel_channel_conflict", `A player-view panel channel cannot be shared with a follow-up artifact: ${panel.channel}`, "preset.yaml");
1346
+ }
1347
+ function parseExtensionPaths(value, pattern, kind, location, files, max) {
1348
+ if (value === undefined)
1349
+ return undefined;
1350
+ if (!Array.isArray(value))
1351
+ invalid(`${kind}_refs_invalid`, `${kind} must be an array of resource paths`, location);
1352
+ if (value.length > max)
1353
+ invalid(`${kind}_refs_too_many`, `${kind} count exceeds the limit of ${max}`, location);
1354
+ const paths = value.map((raw, index) => {
1355
+ const path = stringValue(raw).trim();
1356
+ const itemLocation = `${location}#${kind}s[${index}]`;
1357
+ if (!pattern.test(path))
1358
+ invalid(`${kind}_path_invalid`, `${kind} path is invalid`, itemLocation);
1359
+ if (files[path] === undefined)
1360
+ invalid(`${kind}_missing`, `${kind} resource does not exist: ${path}`, path);
1361
+ return path;
1362
+ });
1363
+ if (new Set(paths).size !== paths.length)
1364
+ invalid(`${kind}_duplicate`, `${kind} resources cannot be duplicated`, location);
1365
+ return paths;
1366
+ }
1367
+ function validatePresetReferences(preset, files) {
1368
+ const extensions = preset.extensions;
1369
+ if (extensions === undefined)
1370
+ return [];
1371
+ if (!Array.isArray(extensions))
1372
+ invalid("extensions_invalid", "preset.extensions must be an array of references", "preset.yaml");
1373
+ const paths = [];
1374
+ for (const raw of extensions) {
1375
+ const path = raw;
1376
+ if (typeof path !== "string" ||
1377
+ !/^(?:regex\/[^/]+\.yaml|renderers\/[^/]+\.html|scripts\/[^/]+\.js|assets\/.+)$/u.test(path))
1378
+ invalid("extension_ref_invalid", "An extension reference must be a regex, renderer, script, or asset path", "preset.yaml");
1379
+ if (files[path] === undefined)
1380
+ invalid("extension_ref_missing", `Extension resource does not exist: ${path}`, path);
1381
+ if (paths.includes(path))
1382
+ invalid("duplicate_extension_ref", `Extension resource reference is duplicated: ${path}`, path);
1383
+ paths.push(path);
1384
+ if (path.startsWith("regex/"))
1385
+ validateRegexAsset(files[path], path);
1386
+ }
1387
+ return paths;
1388
+ }
1389
+ export function parsePlayPresetRegexAsset(source, path = "regex/inline.yaml") {
1390
+ const document = parseDocument(source, {
1391
+ schema: "core",
1392
+ uniqueKeys: true,
1393
+ strict: true,
1394
+ });
1395
+ if (document.errors.length > 0 || document.warnings.length > 0)
1396
+ invalid("regex_asset_invalid", `Regex-resource YAML is invalid: ${path}`, path);
1397
+ const value = document.toJS({ maxAliasCount: 0 });
1398
+ const rules = Array.isArray(value)
1399
+ ? value
1400
+ : isRecord(value) && Array.isArray(value.rules)
1401
+ ? value.rules
1402
+ : null;
1403
+ if (rules === null || rules.length > 256)
1404
+ invalid("regex_rules_invalid", `A regex resource may contain at most 256 rules: ${path}`, path);
1405
+ const orders = new Set();
1406
+ const parsed = rules.map((raw, index) => {
1407
+ const location = `${path}#rules[${index}]`;
1408
+ if (!isRecord(raw))
1409
+ invalid("regex_rule_invalid", "A regex rule must be a map", location);
1410
+ assertKnownKeys(raw, [
1411
+ "order",
1412
+ "scope",
1413
+ "pattern",
1414
+ "flags",
1415
+ "replace",
1416
+ "maxMatches",
1417
+ "errorPolicy",
1418
+ ], location);
1419
+ const order = raw.order;
1420
+ if (!Number.isSafeInteger(order) || order < 0)
1421
+ invalid("regex_order_invalid", "A regex rule must declare a non-negative integer order", location);
1422
+ if (orders.has(order))
1423
+ invalid("regex_order_duplicate", "Regex rule order cannot be duplicated", location);
1424
+ orders.add(order);
1425
+ const scope = raw.scope;
1426
+ if (scope !== "raw_text" &&
1427
+ scope !== "markdown_html" &&
1428
+ scope !== "structured_payload")
1429
+ invalid("regex_scope_invalid", "A regex rule must declare raw_text, markdown_html, or structured_payload scope", location);
1430
+ const pattern = raw.pattern;
1431
+ if (typeof pattern !== "string" ||
1432
+ pattern.length === 0 ||
1433
+ pattern.length > 4_096)
1434
+ invalid("regex_pattern_invalid", "Regex pattern must be a bounded non-empty string", location);
1435
+ const flags = raw.flags === undefined ? "" : raw.flags;
1436
+ if (typeof flags !== "string" || flags.length > 16)
1437
+ invalid("regex_flags_invalid", "Regex flags are invalid", location);
1438
+ try {
1439
+ new RegExp(pattern, flags);
1440
+ }
1441
+ catch {
1442
+ invalid("regex_pattern_invalid", "Regex pattern could not be compiled", location);
1443
+ }
1444
+ if (typeof raw.replace !== "string")
1445
+ invalid("regex_replace_invalid", "Regex replace must be text", location);
1446
+ const maxMatches = raw.maxMatches ?? 1;
1447
+ if (!Number.isSafeInteger(maxMatches) ||
1448
+ maxMatches < 1 ||
1449
+ maxMatches > 1_024)
1450
+ invalid("regex_limit_invalid", "Regex replacement count must be a bounded positive integer", location);
1451
+ const errorPolicy = raw.errorPolicy;
1452
+ if (errorPolicy !== "fallback" &&
1453
+ errorPolicy !== "skip" &&
1454
+ errorPolicy !== "fail")
1455
+ invalid("regex_error_policy_invalid", "A regex rule must declare fallback, skip, or fail errorPolicy", location);
1456
+ return {
1457
+ order: order,
1458
+ scope,
1459
+ pattern,
1460
+ flags,
1461
+ replace: raw.replace,
1462
+ maxMatches: maxMatches,
1463
+ errorPolicy,
1464
+ };
1465
+ });
1466
+ return parsed.sort((left, right) => left.order - right.order);
1467
+ }
1468
+ function validateRegexAsset(source, path) {
1469
+ parsePlayPresetRegexAsset(source, path);
1470
+ }
1471
+ function validateArtifactExtensionReferences(followups, extensionRefs) {
1472
+ const refs = new Set(extensionRefs);
1473
+ for (const followup of followups)
1474
+ for (const artifact of followup.artifacts) {
1475
+ const paths = [
1476
+ artifact.regex,
1477
+ artifact.renderer,
1478
+ ...(artifact.scripts ?? []),
1479
+ ...(artifact.assets ?? []),
1480
+ ].filter((path) => path !== undefined);
1481
+ for (const path of paths)
1482
+ if (!refs.has(path))
1483
+ invalid("artifact_extension_ref_missing", `Artifact resource must also be listed in preset.extensions: ${path}`, path);
1484
+ }
1485
+ }
1486
+ function assertNoEditableMechanics(value, location) {
1487
+ if (Array.isArray(value)) {
1488
+ value.forEach((entry, index) => assertNoEditableMechanics(entry, `${location}[${index}]`));
1489
+ return;
1490
+ }
1491
+ if (!isRecord(value))
1492
+ return;
1493
+ for (const [key, child] of Object.entries(value)) {
1494
+ if (/^(?:inputSchema|toolSchema|toolDefinitions|toolResults|providerState|cacheKey|cache|operation|operationId|worldId|worldState|modelId|hostPresetId|currentPresetId|machinePath|localPath)$/u.test(key))
1495
+ invalid("editable_mechanics_forbidden", `A play preset cannot store Runtime tool schemas or results: ${key}`, location);
1496
+ assertNoEditableMechanics(child, `${location}.${key}`);
1497
+ }
1498
+ }
1499
+ function assertKnownKeys(value, allowed, location) {
1500
+ const known = new Set(allowed);
1501
+ const unknown = Object.keys(value).find((key) => !known.has(key));
1502
+ if (unknown !== undefined)
1503
+ invalid("unsupported_field", `The current play format does not support field: ${unknown}`, location);
1504
+ }
1505
+ function validateFileTree(files, limits) {
1506
+ const entries = Object.entries(files);
1507
+ if (entries.length > limits.maxFiles)
1508
+ invalid("too_many_files", "Play-preset file count exceeds the safety limit", "preset.yaml");
1509
+ let total = 0;
1510
+ const keys = new Set();
1511
+ for (const [path, contents] of entries) {
1512
+ validatePath(path);
1513
+ if (typeof contents !== "string")
1514
+ invalid("text_encoding_invalid", `A play-preset file must be UTF-8 text: ${path}`, path);
1515
+ if (Buffer.from(contents, "utf8").toString("utf8") !== contents)
1516
+ invalid("text_encoding_invalid", `A play-preset file is not valid UTF-8 text: ${path}`, path);
1517
+ const bytes = Buffer.byteLength(contents, "utf8");
1518
+ if (bytes > limits.maxFileBytes)
1519
+ invalid("file_too_large", `Play-preset file is too large: ${path}`, path);
1520
+ total += bytes;
1521
+ if (total > limits.maxTotalBytes)
1522
+ invalid("total_too_large", "Total play-preset size exceeds the safety limit", "preset.yaml");
1523
+ const key = portableContentTreePathKey(path);
1524
+ if (keys.has(key))
1525
+ invalid("duplicate_path", `Play-preset path is duplicated: ${path}`, path);
1526
+ keys.add(key);
1527
+ }
1528
+ }
1529
+ /**
1530
+ * Schema completeness, checked only when a tree is parsed as a preset.
1531
+ *
1532
+ * This is deliberately not part of `validateFileTree`: a revision id is a
1533
+ * content hash, and hashing a tree must not depend on whether the current
1534
+ * schema accepts it. Invalid revisions remain inspectable so the user can
1535
+ * delete them through the library surface.
1536
+ */
1537
+ function assertRequiredPresetFiles(files) {
1538
+ if (files["preset.yaml"] === undefined)
1539
+ invalid("preset_missing", "Play preset is missing preset.yaml", "preset.yaml");
1540
+ if (files["call-chain.yaml"] === undefined)
1541
+ invalid("call_chain_missing", "Play preset is missing call-chain.yaml", "call-chain.yaml");
1542
+ if (files["frame.yaml"] === undefined)
1543
+ invalid("frame_missing", "Preset is missing frame.yaml", "frame.yaml");
1544
+ }
1545
+ function validatePath(path) {
1546
+ if (!path ||
1547
+ path.includes("\\") ||
1548
+ path.includes("\u0000") ||
1549
+ path.startsWith("/") ||
1550
+ /^[A-Za-z]:/u.test(path) ||
1551
+ path
1552
+ .split("/")
1553
+ .some((segment) => segment === "" || segment === "." || segment === ".."))
1554
+ invalid("path_invalid", `Play-preset path is unsafe: ${path}`, path || "preset.yaml");
1555
+ if (path !== "preset.yaml" &&
1556
+ path !== "call-chain.yaml" &&
1557
+ path !== "frame.yaml" &&
1558
+ !/^(?:blocks\/[^/]+\.md|prompts\/[^/]+\.md|regex\/[^/]+\.yaml|renderers\/[^/]+\.html|scripts\/[^/]+\.js|assets\/[^/]+(?:\/[^/]+)*)$/u.test(path))
1559
+ invalid("path_invalid", `Preset does not support file path: ${path}`, path);
1560
+ }
1561
+ function readYaml(files, path, location) {
1562
+ const source = files[path];
1563
+ if (source === undefined || source.trim() === "")
1564
+ invalid("required_file_missing", `Play-preset file does not exist: ${path}`, location);
1565
+ if (/(^|\s)[&*!][^\s,\]}]+/mu.test(source) ||
1566
+ /^\s*<<\s*:/mu.test(source) ||
1567
+ /^---\s*$/mu.test(source) ||
1568
+ /^\.\.\.\s*$/mu.test(source))
1569
+ invalid("unsafe_yaml", `Play-preset YAML uses syntax forbidden by the restricted codec: ${path}`, location);
1570
+ const document = parseDocument(source, {
1571
+ schema: "core",
1572
+ uniqueKeys: true,
1573
+ strict: true,
1574
+ });
1575
+ if (document.errors.length > 0 || document.warnings.length > 0)
1576
+ invalid("unsafe_yaml", `Play-preset YAML is invalid: ${path}`, location);
1577
+ const value = document.toJS({ maxAliasCount: 0 });
1578
+ if (!isRecord(value))
1579
+ invalid("yaml_shape_invalid", `Top-level play-preset YAML must be a map: ${path}`, location);
1580
+ return value;
1581
+ }
1582
+ function revisionForFiles(files, limits) {
1583
+ validateFileTree(files, limits);
1584
+ const canonical = JSON.stringify(Object.keys(files)
1585
+ .sort()
1586
+ .map((path) => [path, files[path]]));
1587
+ return `rev-${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
1588
+ }
1589
+ export function revisionForPlayPresetFiles(files) {
1590
+ return revisionForFiles(files, defaultLimits);
1591
+ }
1592
+ function toFileMap(input, limits) {
1593
+ if (!Array.isArray(input))
1594
+ return cloneFiles(input);
1595
+ const files = {};
1596
+ for (const file of input) {
1597
+ if (file.encoding !== undefined)
1598
+ invalid("text_encoding_invalid", `Play presets accept only UTF-8 text: ${file.path}`, file.path);
1599
+ if (files[file.path] !== undefined)
1600
+ invalid("duplicate_path", `Play-preset path is duplicated: ${file.path}`, file.path);
1601
+ files[file.path] = file.contents;
1602
+ }
1603
+ validateFileTree(files, limits);
1604
+ return files;
1605
+ }
1606
+ function parseStoredDocument(value, limits = defaultLimits) {
1607
+ if (!isRecord(value) ||
1608
+ !hasExactKeys(value, ["schemaVersion", "currentPresetId", "presets"]) ||
1609
+ value.schemaVersion !== 1 ||
1610
+ typeof value.currentPresetId !== "string" ||
1611
+ value.currentPresetId.trim() === "" ||
1612
+ !Array.isArray(value.presets) ||
1613
+ value.presets.length === 0)
1614
+ throw new FileNativePlayPresetError("store_invalid", "File-native play-preset library is invalid");
1615
+ const ids = new Set();
1616
+ const presets = value.presets.map((raw) => {
1617
+ if (!isRecord(raw) ||
1618
+ !hasExactKeys(raw, [
1619
+ "id",
1620
+ "name",
1621
+ "currentRevision",
1622
+ "revisions",
1623
+ "enabled",
1624
+ "scriptsEnabled",
1625
+ ], ["draftRevision", "builtinDefault"]) ||
1626
+ typeof raw.id !== "string" ||
1627
+ typeof raw.name !== "string" ||
1628
+ typeof raw.currentRevision !== "string" ||
1629
+ !isRecord(raw.revisions) ||
1630
+ raw.id.trim() === "" ||
1631
+ raw.id !== raw.id.trim() ||
1632
+ raw.id.includes(String.fromCharCode(0)) ||
1633
+ !namePattern.test(raw.name) ||
1634
+ raw.name !== raw.name.trim() ||
1635
+ raw.name.includes(String.fromCharCode(0)) ||
1636
+ typeof raw.enabled !== "boolean" ||
1637
+ typeof raw.scriptsEnabled !== "boolean" ||
1638
+ (raw.builtinDefault !== undefined && raw.builtinDefault !== true))
1639
+ throw new FileNativePlayPresetError("store_invalid", "File-native play-preset record is invalid");
1640
+ if (ids.has(raw.id))
1641
+ throw new FileNativePlayPresetError("store_invalid", "File-native play-preset identity is duplicated");
1642
+ ids.add(raw.id);
1643
+ const revisions = {};
1644
+ if (Object.keys(raw.revisions).length === 0)
1645
+ throw new FileNativePlayPresetError("store_invalid", "Play preset has no revision snapshot");
1646
+ for (const [revision, files] of Object.entries(raw.revisions)) {
1647
+ if (!revisionPattern.test(revision) ||
1648
+ !isRecord(files) ||
1649
+ Object.keys(files).length === 0 ||
1650
+ Object.values(files).some((contents) => typeof contents !== "string"))
1651
+ throw new FileNativePlayPresetError("store_invalid", "Play-preset revision snapshot is invalid");
1652
+ const clone = cloneFiles(files);
1653
+ let expectedRevision;
1654
+ try {
1655
+ expectedRevision = revisionForFiles(clone, limits);
1656
+ }
1657
+ catch {
1658
+ throw new FileNativePlayPresetError("store_invalid", "Play-preset revision file tree is invalid");
1659
+ }
1660
+ if (expectedRevision !== revision)
1661
+ throw new FileNativePlayPresetError("store_invalid", "Play-preset revision content hash does not match");
1662
+ if (revision !== raw.draftRevision &&
1663
+ validatePlayPresetFiles(clone, limits).status !== "valid")
1664
+ throw new FileNativePlayPresetError("store_invalid", "Play-preset revision does not match the current format");
1665
+ revisions[revision] = clone;
1666
+ }
1667
+ if (revisions[raw.currentRevision] === undefined)
1668
+ throw new FileNativePlayPresetError("store_invalid", "Current play-preset revision is missing");
1669
+ if (raw.draftRevision !== undefined &&
1670
+ (typeof raw.draftRevision !== "string" ||
1671
+ !revisionPattern.test(raw.draftRevision) ||
1672
+ revisions[raw.draftRevision] === undefined ||
1673
+ raw.draftRevision === raw.currentRevision))
1674
+ throw new FileNativePlayPresetError("store_invalid", "Play-preset draft revision is invalid");
1675
+ return {
1676
+ id: raw.id,
1677
+ name: raw.name,
1678
+ currentRevision: raw.currentRevision,
1679
+ revisions,
1680
+ enabled: raw.enabled,
1681
+ scriptsEnabled: raw.scriptsEnabled,
1682
+ ...(raw.builtinDefault === true ? { builtinDefault: true } : {}),
1683
+ ...(raw.draftRevision === undefined
1684
+ ? {}
1685
+ : { draftRevision: raw.draftRevision }),
1686
+ };
1687
+ });
1688
+ const current = presets.find(({ id }) => id === value.currentPresetId);
1689
+ if (current === undefined)
1690
+ throw new FileNativePlayPresetError("store_invalid", "Current play preset does not exist");
1691
+ if (!current.enabled)
1692
+ throw new FileNativePlayPresetError("store_invalid", "Current play preset cannot be a disabled identity");
1693
+ return { schemaVersion: 1, currentPresetId: value.currentPresetId, presets };
1694
+ }
1695
+ function findStored(document, id) {
1696
+ const preset = document.presets.find(({ id: candidate }) => candidate === id);
1697
+ if (preset === undefined)
1698
+ throw new FileNativePlayPresetError("not_found", "Requested play preset was not found");
1699
+ return preset;
1700
+ }
1701
+ function normalizeName(raw) {
1702
+ const name = raw.trim();
1703
+ if (!namePattern.test(name) || name.includes(String.fromCharCode(0)))
1704
+ throw new FileNativePlayPresetError("invalid_name", "Play-preset name must contain 1 to 160 characters");
1705
+ return name;
1706
+ }
1707
+ function locatePlayPresetError(message) {
1708
+ const match = /(?:blocks|prompts|regex|renderers|scripts|assets)\/[^:\s]+|call-chain\.yaml|preset\.yaml|frame\.yaml/u.exec(message);
1709
+ return match?.[0] ?? "call-chain.yaml";
1710
+ }
1711
+ function finiteInteger(value) {
1712
+ return Number.isSafeInteger(value) && value >= 0
1713
+ ? value
1714
+ : null;
1715
+ }
1716
+ function stringValue(value) {
1717
+ return typeof value === "string" ? value : "";
1718
+ }
1719
+ function cloneFiles(files) {
1720
+ return Object.fromEntries(Object.entries(files).map(([path, contents]) => [path, contents]));
1721
+ }
1722
+ export function validatePlayPresetArtifactPayload(contract, value) {
1723
+ if (contract === undefined)
1724
+ return { ok: true };
1725
+ if (!isJsonValueValue(value))
1726
+ return {
1727
+ ok: false,
1728
+ message: "JSON artifact payload must be a valid JSON value",
1729
+ };
1730
+ return validatePayloadNode(contract, value, "$", 0);
1731
+ }
1732
+ function validatePayloadNode(contract, value, path, depth) {
1733
+ if (depth > 32)
1734
+ return { ok: false, message: `${path} payload nesting exceeds 32 levels` };
1735
+ const actual = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
1736
+ const typeMatches = contract.type === actual ||
1737
+ (contract.type === "number" && actual === "number") ||
1738
+ (contract.type === "integer" &&
1739
+ actual === "number" &&
1740
+ Number.isInteger(value));
1741
+ if (!typeMatches)
1742
+ return {
1743
+ ok: false,
1744
+ message: `${path} payload type must be ${contract.type}; received ${actual}`,
1745
+ };
1746
+ if (contract.maxBytes !== undefined) {
1747
+ const encoded = JSON.stringify(value);
1748
+ if (encoded === undefined ||
1749
+ Buffer.byteLength(encoded, "utf8") > contract.maxBytes)
1750
+ return {
1751
+ ok: false,
1752
+ message: `${path} payload exceeds its declared ${contract.maxBytes}-byte limit`,
1753
+ };
1754
+ }
1755
+ if (contract.type === "string") {
1756
+ const text = value;
1757
+ if (contract.minLength !== undefined &&
1758
+ [...text].length < contract.minLength)
1759
+ return {
1760
+ ok: false,
1761
+ message: `${path} text length is less than ${contract.minLength}`,
1762
+ };
1763
+ if (contract.maxLength !== undefined &&
1764
+ [...text].length > contract.maxLength)
1765
+ return {
1766
+ ok: false,
1767
+ message: `${path} text length exceeds ${contract.maxLength}`,
1768
+ };
1769
+ }
1770
+ if (contract.type === "array") {
1771
+ const entries = value;
1772
+ if (contract.minItems !== undefined && entries.length < contract.minItems)
1773
+ return {
1774
+ ok: false,
1775
+ message: `${path} has fewer than ${contract.minItems} items`,
1776
+ };
1777
+ if (contract.maxItems !== undefined && entries.length > contract.maxItems)
1778
+ return {
1779
+ ok: false,
1780
+ message: `${path} has more than ${contract.maxItems} items`,
1781
+ };
1782
+ const seen = new Set();
1783
+ for (const [index, entry] of entries.entries()) {
1784
+ if (contract.items !== undefined) {
1785
+ const result = validatePayloadNode(contract.items, entry, `${path}[${index}]`, depth + 1);
1786
+ if (!result.ok)
1787
+ return result;
1788
+ }
1789
+ if (contract.uniqueBy !== undefined && isRecordValue(entry)) {
1790
+ const unique = entry[contract.uniqueBy];
1791
+ if (typeof unique !== "string" &&
1792
+ typeof unique !== "number" &&
1793
+ typeof unique !== "boolean")
1794
+ return {
1795
+ ok: false,
1796
+ message: `${path}[${index}].${contract.uniqueBy} must be a uniquely comparable scalar`,
1797
+ };
1798
+ const key = JSON.stringify(unique);
1799
+ if (seen.has(key))
1800
+ return {
1801
+ ok: false,
1802
+ message: `${path} ${contract.uniqueBy} values cannot be duplicated`,
1803
+ };
1804
+ seen.add(key);
1805
+ }
1806
+ }
1807
+ }
1808
+ if (contract.type === "object") {
1809
+ const object = value;
1810
+ const properties = contract.properties ?? {};
1811
+ for (const required of contract.required ?? [])
1812
+ if (!(required in object))
1813
+ return { ok: false, message: `${path}.${required} is required` };
1814
+ if (contract.additionalProperties === false)
1815
+ for (const key of Object.keys(object))
1816
+ if (!(key in properties))
1817
+ return {
1818
+ ok: false,
1819
+ message: `${path}.${key} is not a declared field`,
1820
+ };
1821
+ for (const [key, child] of Object.entries(properties)) {
1822
+ if (!(key in object))
1823
+ continue;
1824
+ const result = validatePayloadNode(child, object[key], `${path}.${key}`, depth + 1);
1825
+ if (!result.ok)
1826
+ return result;
1827
+ }
1828
+ }
1829
+ return { ok: true };
1830
+ }
1831
+ function isJsonValueValue(value) {
1832
+ if (value === null || typeof value === "string" || typeof value === "boolean")
1833
+ return true;
1834
+ if (typeof value === "number")
1835
+ return Number.isFinite(value);
1836
+ if (Array.isArray(value))
1837
+ return value.every(isJsonValueValue);
1838
+ if (!isRecordValue(value))
1839
+ return false;
1840
+ return Object.values(value).every(isJsonValueValue);
1841
+ }
1842
+ function isRecordValue(value) {
1843
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1844
+ }
1845
+ function invalid(code, message, location) {
1846
+ void location;
1847
+ throw new FileNativePlayPresetError(code, message);
1848
+ }
1849
+ function isRecord(value) {
1850
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1851
+ }
1852
+ function hasExactKeys(value, required, optional = []) {
1853
+ const allowed = new Set([...required, ...optional]);
1854
+ return (required.every((key) => Object.hasOwn(value, key)) &&
1855
+ Object.keys(value).every((key) => allowed.has(key)));
1856
+ }