narraeon 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/node/protocol/appPreferences.js +25 -0
  2. package/dist/node/protocol/conversationObservation.js +67 -0
  3. package/dist/node/protocol/v1.js +186 -34
  4. package/dist/node/protocol/worldExtensions.js +16 -0
  5. package/dist/node/protocol/worldMaintenance.js +77 -0
  6. package/dist/node/protocol/worldPromptDiagnostics.js +1 -0
  7. package/dist/node/runtime/ConversationChanges.js +24 -0
  8. package/dist/node/runtime/V1Runtime.js +445 -121
  9. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +326 -12
  10. package/dist/node/runtime/authoring/AuthoringRequestSnapshot.js +63 -0
  11. package/dist/node/runtime/authoring/ModelToolConversation.js +218 -0
  12. package/dist/node/runtime/config/AppPreferencesStore.js +46 -17
  13. package/dist/node/runtime/content/ContentTreeFingerprint.js +13 -0
  14. package/dist/node/runtime/content/ContentWorkspace.js +43 -13
  15. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +128 -46
  16. package/dist/node/runtime/content/FileNativeContentTree.js +28 -10
  17. package/dist/node/runtime/content/PackageFollowups.js +80 -0
  18. package/dist/node/runtime/extension/FrontendExtensionBundle.js +19 -14
  19. package/dist/node/runtime/extension/PackageScriptPermissions.js +70 -0
  20. package/dist/node/runtime/extension/WorldExtensionControls.js +205 -0
  21. package/dist/node/runtime/extension/WorldExtensionRequests.js +64 -0
  22. package/dist/node/runtime/model/FileNativeModelAdapters.js +21 -466
  23. package/dist/node/runtime/model/ModelConnectionStore.js +13 -0
  24. package/dist/node/runtime/model/ModelHost.js +31 -1
  25. package/dist/node/runtime/model/ModelHostStream.js +51 -5
  26. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +55 -45
  27. package/dist/node/runtime/play/FileNativePlayPresetStore.js +256 -238
  28. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +118 -9
  29. package/dist/node/runtime/play/OrderedFollowups.js +71 -0
  30. package/dist/node/runtime/play/OrderedPlayPrompts.js +180 -0
  31. package/dist/node/runtime/play/PlayCallChain.js +814 -61
  32. package/dist/node/runtime/play/PlayContinuity.js +88 -0
  33. package/dist/node/runtime/play/PlayDocumentTools.js +205 -287
  34. package/dist/node/runtime/play/PlayFollowupRequests.js +34 -3
  35. package/dist/node/runtime/play/PlayPresetWorkbench.js +45 -54
  36. package/dist/node/runtime/play/PlayPromptRun.js +86 -0
  37. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +670 -93
  38. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -21
  39. package/dist/node/runtime/prompt/PromptCompilationCodec.js +322 -0
  40. package/dist/node/runtime/prompt/WorldMaintenanceReport.js +251 -0
  41. package/dist/node/runtime/prompt/WorldMaterialCoverage.js +83 -0
  42. package/dist/node/runtime/prompt/WorldPromptDiagnostics.js +185 -0
  43. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +1027 -0
  44. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +1339 -0
  45. package/dist/node/runtime/setting/SettingImprovementSession.js +714 -0
  46. package/dist/node/runtime/world/FileNativeAuthorityV3.js +88 -1
  47. package/dist/node/runtime/world/FileNativeWorldStore.js +292 -27
  48. package/dist/node/runtime/world/PlayerViewBindings.js +30 -0
  49. package/dist/node/runtime/world/PlayerViewRenderer.js +27 -34
  50. package/dist/node/runtime/world/WorldDocumentMaintenance.js +174 -0
  51. package/dist/node/runtime/world/WorldDocumentStore.js +166 -96
  52. package/dist/node/runtime/world/WorldOperationCoordinator.js +103 -2
  53. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +696 -0
  54. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +380 -0
  55. package/dist/node/runtime/world-revision/WorldRevisionSession.js +709 -0
  56. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +494 -0
  57. package/dist/node/server/ConversationStream.js +109 -0
  58. package/dist/node/server/createServer.js +5 -1
  59. package/dist/node/shared/artifact-payload-contract.js +123 -0
  60. package/dist/node/shared/default-play-prompts.js +25 -45
  61. package/dist/node/shared/default-preset-host.js +114 -138
  62. package/dist/node/shared/default-setting-improvement-prompt.js +52 -38
  63. package/dist/node/shared/display-regex.js +114 -0
  64. package/dist/node/shared/first-party-action-choices.js +1 -6
  65. package/dist/node/shared/first-party-player-view.js +1 -6
  66. package/dist/node/shared/ordered-author-prompts.js +64 -0
  67. package/dist/node/shared/ordered-followups.js +42 -0
  68. package/dist/node/shared/ordered-play-prompts.js +73 -0
  69. package/dist/web/assets/index-DG85hpWC.css +1 -0
  70. package/dist/web/assets/index-DfxvCjb6.js +1207 -0
  71. package/dist/web/index.html +2 -2
  72. package/package.json +1 -1
  73. package/dist/node/runtime/model/SettingAuthorStream.js +0 -21
  74. package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +0 -1731
  75. package/dist/web/assets/index-D8YKfmNG.js +0 -1192
  76. package/dist/web/assets/index-Xa0s35CJ.css +0 -1
@@ -1,3 +1,4 @@
1
+ import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { readFileSync, unlinkSync } from "node:fs";
3
4
  import { link, open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
@@ -54,18 +55,20 @@ export class CurrentTreeContentLibrary {
54
55
  this.#beforePackagePublish = options.beforePackagePublish;
55
56
  this.#beforeCurrentTreeReplace = options.beforeCurrentTreeReplace;
56
57
  }
57
- createPackage(files = []) {
58
+ createPackage(input) {
58
59
  return this.#mutate(async () => {
59
- const normalized = normalizeCurrentContentTreeFiles(files, this.#limits);
60
- const package_ = await this.#publishNewPackage(normalized);
60
+ const title = normalizePackageTitle(input.title);
61
+ const normalized = normalizeCurrentContentTreeFiles(input.files ?? [], this.#limits);
62
+ const package_ = await this.#publishNewPackage(normalized, title);
61
63
  return packageSummary(package_);
62
64
  });
63
65
  }
64
- importPackage(files, options = {}) {
66
+ importPackage(input, options = {}) {
65
67
  return this.#mutate(async () => {
66
68
  options.signal?.throwIfAborted();
67
- const normalized = normalizeCurrentContentTreeFiles(files, this.#limits);
68
- return this.#publishNewPackage(normalized, options.signal);
69
+ const title = normalizePackageTitle(input.title);
70
+ const normalized = normalizeCurrentContentTreeFiles(input.files, this.#limits);
71
+ return this.#publishNewPackage(normalized, title, options.signal);
69
72
  });
70
73
  }
71
74
  copyPackage(localId, options = {}) {
@@ -73,7 +76,7 @@ export class CurrentTreeContentLibrary {
73
76
  options.signal?.throwIfAborted();
74
77
  const source = await this.readPackage(localId);
75
78
  options.signal?.throwIfAborted();
76
- return this.#publishNewPackage(source.files, options.signal);
79
+ return this.#publishNewPackage(source.files, source.title, options.signal);
77
80
  });
78
81
  }
79
82
  deletePackage(localId, options = {}) {
@@ -127,7 +130,38 @@ export class CurrentTreeContentLibrary {
127
130
  }
128
131
  return packages;
129
132
  }
133
+ async readScriptGrants(localId) {
134
+ await this.readPackage(localId);
135
+ return PackageScriptPermissions.read(this.#packageRoot(localId));
136
+ }
137
+ async scriptPermissions(localId, enabled) {
138
+ const lease = await this.acquireOperationLease(localId, { wait: true });
139
+ try {
140
+ const root = this.#packageRoot(localId);
141
+ if (enabled !== undefined)
142
+ await PackageScriptPermissions.write(root, enabled
143
+ ? PackageScriptPermissions.currentGrants(lease.package.files)
144
+ : []);
145
+ return PackageScriptPermissions.status(lease.package.files, await PackageScriptPermissions.read(root));
146
+ }
147
+ finally {
148
+ lease.release();
149
+ }
150
+ }
130
151
  async readPackage(localId) {
152
+ const snapshot = await this.#readPackageSnapshot(localId);
153
+ if (snapshot.metadata.schemaVersion === 2)
154
+ return snapshot.package;
155
+ const lease = await this.acquireOperationLease(localId, { wait: true });
156
+ try {
157
+ await ensureStoredPackageTitle(this.#packageRoot(localId), localId, lease.package.title);
158
+ return structuredClone(lease.package);
159
+ }
160
+ finally {
161
+ lease.release();
162
+ }
163
+ }
164
+ async #readPackageSnapshot(localId) {
131
165
  const packageRoot = this.#packageRoot(localId);
132
166
  const metadata = await readJsonFile(join(packageRoot, localMetadataFileName), () => new CurrentTreeContentPackageNotFoundError(), "The local content-package envelope is damaged");
133
167
  if (!isStoredMetadata(metadata) || metadata.localId !== localId) {
@@ -146,18 +180,20 @@ export class CurrentTreeContentLibrary {
146
180
  cause: error,
147
181
  });
148
182
  }
149
- return detail(localId, files, inspectContentPackageCurrentTree(files), metadata.name);
183
+ const inspection = inspectContentPackageCurrentTree(files);
184
+ return {
185
+ metadata,
186
+ package: detail(localId, effectivePackageTitle(metadata, inspection), files, inspection),
187
+ };
150
188
  }
151
189
  /**
152
- * Name a package in the local library. The name lives in the package's own
190
+ * Title a package in the local library. The title lives in the package's own
153
191
  * local shell, not in its content, so renaming never touches a document the
154
192
  * runtime reads during play.
155
193
  */
156
- renamePackage(localId, name, options = {}) {
194
+ renamePackage(localId, title, options = {}) {
157
195
  return this.#mutate(async () => {
158
- const trimmed = name.trim();
159
- if (!validPackageName(trimmed))
160
- throw new CurrentTreeContentLibraryError("A content-package name must contain 1 to 160 characters and no line breaks");
196
+ const normalizedTitle = normalizePackageTitle(title);
161
197
  const lease = await this.acquireOperationLease(localId, {
162
198
  wait: options.waitForLease !== false,
163
199
  });
@@ -167,20 +203,11 @@ export class CurrentTreeContentLibrary {
167
203
  const metadata = await readJsonFile(join(packageRoot, localMetadataFileName), () => new CurrentTreeContentPackageNotFoundError(), "The local content-package envelope is damaged");
168
204
  if (!isStoredMetadata(metadata) || metadata.localId !== localId)
169
205
  throw new CurrentTreeContentLibraryError("The local content-package envelope is damaged");
170
- const temporaryPath = join(packageRoot, `.local-${randomUUID()}.tmp`);
171
- try {
172
- await writeDurableNewFile(temporaryPath, serialize({
173
- schemaVersion: 1,
174
- localId,
175
- name: trimmed,
176
- }));
177
- await rename(temporaryPath, join(packageRoot, localMetadataFileName));
178
- await syncDirectory(packageRoot);
179
- }
180
- catch (error) {
181
- await rm(temporaryPath, { force: true }).catch(() => undefined);
182
- throw error;
183
- }
206
+ await writeStoredLocalMetadata(packageRoot, {
207
+ schemaVersion: 2,
208
+ localId,
209
+ title: normalizedTitle,
210
+ });
184
211
  return packageSummary(await this.readPackage(localId));
185
212
  }
186
213
  finally {
@@ -246,7 +273,7 @@ export class CurrentTreeContentLibrary {
246
273
  }
247
274
  let released = false;
248
275
  try {
249
- const package_ = await this.readPackage(localId);
276
+ const package_ = (await this.#readPackageSnapshot(localId)).package;
250
277
  return {
251
278
  package: structuredClone(package_),
252
279
  replace: async (files, options = {}) => {
@@ -254,7 +281,7 @@ export class CurrentTreeContentLibrary {
254
281
  throw new CurrentTreeContentPackageBusyError();
255
282
  options.signal?.throwIfAborted();
256
283
  const normalized = normalizeCurrentContentTreeFiles(files, this.#limits);
257
- return this.#replaceNormalizedCurrentTree(localId, normalized, options.signal);
284
+ return this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
258
285
  },
259
286
  release: () => {
260
287
  if (released)
@@ -269,12 +296,13 @@ export class CurrentTreeContentLibrary {
269
296
  throw error;
270
297
  }
271
298
  }
272
- async #replaceNormalizedCurrentTree(localId, normalized, signal) {
299
+ async #replaceNormalizedCurrentTree(localId, title, normalized, signal) {
273
300
  const packageRoot = this.#packageRoot(localId);
274
301
  const temporaryPath = join(packageRoot, `.current-tree-${randomUUID()}.tmp`);
275
302
  const previousPath = join(packageRoot, currentTreePreviousFileName);
276
303
  try {
277
304
  signal?.throwIfAborted();
305
+ await ensureStoredPackageTitle(packageRoot, localId, title);
278
306
  await writeDurableNewFile(temporaryPath, serialize({
279
307
  schemaVersion: 1,
280
308
  files: normalized,
@@ -319,9 +347,9 @@ export class CurrentTreeContentLibrary {
319
347
  ? error.message
320
348
  : "Unable to replace the content package's current tree atomically", { cause: error });
321
349
  }
322
- return detail(localId, normalized, inspectContentPackageCurrentTree(normalized));
350
+ return detail(localId, title, normalized, inspectContentPackageCurrentTree(normalized));
323
351
  }
324
- async #publishNewPackage(normalized, signal) {
352
+ async #publishNewPackage(normalized, title, signal) {
325
353
  const localId = `package-${randomUUID()}`;
326
354
  const stagingPath = join(this.#stagingRoot, localId);
327
355
  const finalPath = join(this.#packagesRoot, localId);
@@ -333,8 +361,9 @@ export class CurrentTreeContentLibrary {
333
361
  signal?.throwIfAborted();
334
362
  await mkdir(stagingPath, { mode: 0o700 });
335
363
  await writeDurableNewFile(join(stagingPath, localMetadataFileName), serialize({
336
- schemaVersion: 1,
364
+ schemaVersion: 2,
337
365
  localId,
366
+ title,
338
367
  }));
339
368
  await writeDurableNewFile(join(stagingPath, currentTreeFileName), serialize({
340
369
  schemaVersion: 1,
@@ -357,7 +386,7 @@ export class CurrentTreeContentLibrary {
357
386
  cause: error,
358
387
  });
359
388
  }
360
- return detail(localId, normalized, inspectContentPackageCurrentTree(normalized));
389
+ return detail(localId, title, normalized, inspectContentPackageCurrentTree(normalized));
361
390
  }
362
391
  #packageRoot(localId) {
363
392
  if (!localIdPattern.test(localId)) {
@@ -445,9 +474,9 @@ export class CurrentTreeContentLibrary {
445
474
  }
446
475
  }
447
476
  }
448
- function detail(localId, files, inspection, name) {
477
+ function detail(localId, title, files, inspection) {
449
478
  return {
450
- ...summary(localId, inspection, name),
479
+ ...summary(localId, title, inspection),
451
480
  files: files.map((file) => ({ ...file })),
452
481
  documents: structuredClone(inspection.documents),
453
482
  issues: structuredClone(inspection.issues),
@@ -455,12 +484,10 @@ function detail(localId, files, inspection, name) {
455
484
  control: structuredClone(inspection.control),
456
485
  };
457
486
  }
458
- function summary(localId, inspection, name) {
487
+ function summary(localId, title, inspection) {
459
488
  return {
460
489
  localId,
461
- // An author-chosen name wins; without one the name still tracks whatever
462
- // the package currently contains.
463
- displayName: name ?? inspection.displayName,
490
+ title,
464
491
  status: inspection.status,
465
492
  documentCount: inspection.documents.length,
466
493
  issueCount: inspection.issues.length,
@@ -469,7 +496,7 @@ function summary(localId, inspection, name) {
469
496
  function packageSummary(package_) {
470
497
  return {
471
498
  localId: package_.localId,
472
- displayName: package_.displayName,
499
+ title: package_.title,
473
500
  status: package_.status,
474
501
  documentCount: package_.documentCount,
475
502
  issueCount: package_.issueCount,
@@ -575,6 +602,33 @@ function normalizeLimits(input) {
575
602
  }
576
603
  return limits;
577
604
  }
605
+ async function ensureStoredPackageTitle(packageRoot, localId, title) {
606
+ const metadata = await readJsonFile(join(packageRoot, localMetadataFileName), () => new CurrentTreeContentPackageNotFoundError(), "The local content-package envelope is damaged");
607
+ if (!isStoredMetadata(metadata) || metadata.localId !== localId)
608
+ throw new CurrentTreeContentLibraryError("The local content-package envelope is damaged");
609
+ if (metadata.schemaVersion === 2) {
610
+ if (metadata.title !== title)
611
+ throw new CurrentTreeContentLibraryError("The content-package title changed while its current tree was locked");
612
+ return;
613
+ }
614
+ await writeStoredLocalMetadata(packageRoot, {
615
+ schemaVersion: 2,
616
+ localId,
617
+ title,
618
+ });
619
+ }
620
+ async function writeStoredLocalMetadata(packageRoot, metadata) {
621
+ const temporaryPath = join(packageRoot, `.local-${randomUUID()}.tmp`);
622
+ try {
623
+ await writeDurableNewFile(temporaryPath, serialize(metadata));
624
+ await rename(temporaryPath, join(packageRoot, localMetadataFileName));
625
+ await syncDirectory(packageRoot);
626
+ }
627
+ catch (error) {
628
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
629
+ throw error;
630
+ }
631
+ }
578
632
  async function writeDurableNewFile(path, contents) {
579
633
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
580
634
  const handle = await open(path, "wx", 0o600);
@@ -607,17 +661,45 @@ async function readJsonFile(path, missing, invalidMessage) {
607
661
  }
608
662
  }
609
663
  function isStoredMetadata(value) {
664
+ return isStoredLocalMetadata(value) || isLegacyStoredLocalMetadata(value);
665
+ }
666
+ function isStoredLocalMetadata(value) {
667
+ return (isPlainObject(value) &&
668
+ Object.keys(value).length === 3 &&
669
+ value.schemaVersion === 2 &&
670
+ typeof value.localId === "string" &&
671
+ localIdPattern.test(value.localId) &&
672
+ typeof value.title === "string" &&
673
+ validPackageTitle(value.title));
674
+ }
675
+ function isLegacyStoredLocalMetadata(value) {
610
676
  return (isPlainObject(value) &&
611
677
  Object.keys(value).length === (value.name === undefined ? 2 : 3) &&
612
678
  value.schemaVersion === 1 &&
613
679
  typeof value.localId === "string" &&
614
680
  localIdPattern.test(value.localId) &&
615
681
  (value.name === undefined ||
616
- (typeof value.name === "string" && validPackageName(value.name))));
617
- }
618
- const packageNamePattern = /^[^\r\n]{1,160}$/u;
619
- function validPackageName(name) {
620
- return packageNamePattern.test(name);
682
+ (typeof value.name === "string" && validPackageTitle(value.name))));
683
+ }
684
+ function effectivePackageTitle(metadata, inspection) {
685
+ if (metadata.schemaVersion === 2)
686
+ return metadata.title;
687
+ return metadata.name ?? legacyContentDerivedTitle(inspection);
688
+ }
689
+ function legacyContentDerivedTitle(inspection) {
690
+ return (inspection.documents.find(({ id }) => id === "situation.current")?.title ??
691
+ inspection.documents[0]?.title ??
692
+ "Untitled content package");
693
+ }
694
+ const packageTitlePattern = /^[^\r\n]{1,160}$/u;
695
+ function validPackageTitle(title) {
696
+ return packageTitlePattern.test(title);
697
+ }
698
+ function normalizePackageTitle(title) {
699
+ const normalized = title.trim();
700
+ if (!validPackageTitle(normalized))
701
+ throw new CurrentTreeContentLibraryError("A content-package title must contain 1 to 160 characters and no line breaks");
702
+ return normalized;
621
703
  }
622
704
  function isStoredCurrentTree(value) {
623
705
  return (isPlainObject(value) &&
@@ -1,6 +1,6 @@
1
+ import { readPackageFollowups } from "./PackageFollowups.js";
1
2
  import { parseDocument } from "yaml";
2
3
  import { WorldDocumentStore, } from "../world/WorldDocumentStore.js";
3
- const maxOpeningBytes = 64 * 1024;
4
4
  export function isContentPackageWorldDocumentPath(path) {
5
5
  return path.startsWith("world/");
6
6
  }
@@ -35,11 +35,10 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
35
35
  const openingFile = openingFiles[0];
36
36
  if (openingFile.encoding !== undefined ||
37
37
  openingFile.contents.trim().length === 0 ||
38
- Buffer.byteLength(openingFile.contents, "utf8") > maxOpeningBytes ||
39
38
  Buffer.from(openingFile.contents, "utf8").toString("utf8") !==
40
39
  openingFile.contents) {
41
40
  opening = "invalid";
42
- issue(issues, "invalid_opening", "opening.md", "The introduction must be non-empty, well-formed UTF-8 text no larger than 64 KiB");
41
+ issue(issues, "invalid_opening", "opening.md", "The introduction must be non-empty, well-formed UTF-8 text");
43
42
  }
44
43
  else
45
44
  opening = "valid";
@@ -66,9 +65,16 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
66
65
  const frame = parseRestrictedYaml("control/frame.yaml", frameSource, issues);
67
66
  if (isRecord(frame) &&
68
67
  frame.format === "narraeon.world-frame/v1" &&
69
- Object.keys(frame).every((key) => ["format", "bindings", "instructions", "context"].includes(key)) &&
68
+ Object.keys(frame).every((key) => [
69
+ "format",
70
+ "bindings",
71
+ "instructions",
72
+ "context",
73
+ "maintenance",
74
+ ].includes(key)) &&
70
75
  isRecord(frame.bindings) &&
71
- Object.keys(frame.bindings).length === 1) {
76
+ Object.keys(frame.bindings).length === 1 &&
77
+ validMaintenance(frame)) {
72
78
  worldFrame = "valid";
73
79
  const binding = isRecord(frame.bindings)
74
80
  ? frame.bindings.currentSituation
@@ -110,12 +116,14 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
110
116
  issue(issues, "invalid_player_view", "control/player-views.yaml", `${formatProblems.length - maxReportedPlayerViewProblems} more player-view issues were omitted`);
111
117
  }
112
118
  }
113
- const displayName = documents.find(({ id }) => id === "situation.current")?.title ??
114
- documents[0]?.title ??
115
- "Untitled content package";
119
+ try {
120
+ readPackageFollowups(snapshotFiles);
121
+ }
122
+ catch (error) {
123
+ issue(issues, "invalid_package_followups", "control/followups.yaml", error instanceof Error ? error.message : "Invalid package followups");
124
+ }
116
125
  return {
117
126
  status: issues.length === 0 ? "usable" : "needs_repair",
118
- displayName,
119
127
  documents: documents.sort((a, b) => a.path.localeCompare(b.path)),
120
128
  issues,
121
129
  opening,
@@ -123,6 +131,15 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
123
131
  worldDocumentSnapshot,
124
132
  };
125
133
  }
134
+ function validMaintenance(frame) {
135
+ try {
136
+ worldMaintenanceSettings(frame);
137
+ return true;
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
126
143
  function worldDocumentSnapshotDocuments(snapshot) {
127
144
  const documents = [];
128
145
  const pendingDirectories = [""];
@@ -387,7 +404,7 @@ function validateFrameContext(value, index, snapshot, issues) {
387
404
  }
388
405
  // Naming the offending key matters: reporting the kind reads as "this slot
389
406
  // does not exist" and sends the author off deleting a valid slot.
390
- const unknownKeys = Object.keys(entry.slot).filter((key) => !allowed.includes(key));
407
+ const unknownKeys = Object.keys(entry.slot).filter((key) => !allowed.includes(key) && key !== "id" && key !== "advisoryBytes");
391
408
  if (unknownKeys.length > 0) {
392
409
  issue(issues, "invalid_world_frame", "control/frame.yaml", `Slot ${entry.slot.kind} does not accept parameters ${unknownKeys.join(", ")}; it accepts only ${allowed.join(", ")}`);
393
410
  continue;
@@ -510,3 +527,4 @@ function issue(issues, code, path, message, documentId) {
510
527
  function isRecord(value) {
511
528
  return typeof value === "object" && value !== null && !Array.isArray(value);
512
529
  }
530
+ import { worldMaintenanceSettings } from "../prompt/WorldMaintenanceReport.js";
@@ -0,0 +1,80 @@
1
+ import { isAlias, isNode, parseDocument, visit } from "yaml";
2
+ import { parseFollowups, parsePlayPresetRegexAsset, } from "../play/FileNativePlayPresetStore.js";
3
+ /** The world owns the copied control tree; source-package metadata is never consulted. */
4
+ export function readPackageFollowups(files) {
5
+ const source = files.find(({ path }) => path === "control/followups.yaml");
6
+ const resources = Object.fromEntries(files
7
+ .filter(({ path, encoding }) => path.startsWith("control/") && encoding === undefined)
8
+ .map(({ path, contents }) => [path.slice("control/".length), contents]));
9
+ if (source === undefined)
10
+ return { followups: [], files: resources };
11
+ if (source.encoding !== undefined)
12
+ throw new Error("Package followups must be UTF-8 YAML");
13
+ const document = parseDocument(source.contents, { uniqueKeys: true });
14
+ let unsafe = false;
15
+ visit(document, (_, node) => {
16
+ if (isAlias(node) ||
17
+ (isNode(node) &&
18
+ (node.tag !== undefined || ("anchor" in node && Boolean(node.anchor)))))
19
+ unsafe = true;
20
+ });
21
+ if (unsafe || document.errors.length > 0 || document.warnings.length > 0)
22
+ throw new Error("Invalid package followups YAML: aliases, tags and duplicate keys are not allowed");
23
+ const value = document.toJS({ maxAliasCount: 0 });
24
+ if (!record(value) ||
25
+ value.format !== "narraeon.package-followups/v1" ||
26
+ Object.keys(value).some((key) => !["format", "followups"].includes(key)) ||
27
+ !Array.isArray(value.followups))
28
+ throw new Error("Expected narraeon.package-followups/v1 with a followups array");
29
+ const controls = value.followups.map((raw) => {
30
+ if (!record(raw) ||
31
+ typeof raw.enabled !== "boolean" ||
32
+ typeof raw.mount !== "string" ||
33
+ ![
34
+ "story",
35
+ "sidebar",
36
+ "composer_above",
37
+ "composer_below",
38
+ "overlay",
39
+ "debug",
40
+ ].includes(raw.mount))
41
+ throw new Error("Every package followup requires a boolean enabled and valid mount");
42
+ const { enabled, mount, ...definition } = raw;
43
+ return { enabled, mount: mount, definition };
44
+ });
45
+ const definitions = parseFollowups(controls.map((item) => item.definition), resources);
46
+ for (const definition of definitions)
47
+ for (const artifact of definition.artifacts) {
48
+ if (artifact.regex !== undefined)
49
+ parsePlayPresetRegexAsset(resources[artifact.regex], artifact.regex);
50
+ }
51
+ return {
52
+ followups: definitions.map((definition, index) => ({
53
+ ...controls[index],
54
+ definition,
55
+ })),
56
+ files: resources,
57
+ };
58
+ }
59
+ function record(value) {
60
+ return typeof value === "object" && value !== null && !Array.isArray(value);
61
+ }
62
+ export function isPackageFollowupControlPath(path) {
63
+ return (path === "control/followups.yaml" ||
64
+ /^control\/(?:prompts\/[A-Za-z0-9._-]+\.md|renderers\/[A-Za-z0-9._-]+\.html|scripts\/[A-Za-z0-9._-]+\.js|regex\/[A-Za-z0-9._-]+\.yaml|assets\/[A-Za-z0-9][A-Za-z0-9._/-]*)$/u.test(path));
65
+ }
66
+ /** Authorization and compilation must freeze the exact same request resource closure. */
67
+ export function packageFollowupResources(definition, files) {
68
+ const paths = new Set(definition.artifacts.flatMap((artifact) => [
69
+ artifact.renderer,
70
+ artifact.regex,
71
+ ...(artifact.scripts ?? []),
72
+ ...(artifact.assets ?? []),
73
+ ].filter((path) => path !== undefined)));
74
+ return Object.fromEntries([...paths].map((path) => {
75
+ const source = files[path];
76
+ if (source === undefined)
77
+ throw new Error(`Missing package followup resource: ${path}`);
78
+ return [path, source];
79
+ }));
80
+ }
@@ -1,16 +1,18 @@
1
1
  import { parsePlayPresetRegexAsset, } from "../play/FileNativePlayPresetStore.js";
2
- export function projectArtifactForFrontend(artifact, binding, failure = "missing_revision") {
2
+ export function projectArtifactForFrontend(artifact, binding, failure = "missing_revision", packageScriptsEnabled = false) {
3
3
  const preset = {
4
4
  id: artifact.playPresetId,
5
5
  revision: artifact.playPresetRevision,
6
6
  };
7
- if (binding === null)
7
+ if (binding === null && artifact.frozenPresentation === undefined)
8
8
  return missingBundle(preset, "The frozen play-preset revision is unavailable and cannot be replaced with the current revision", failure);
9
- if (binding.id !== artifact.playPresetId ||
10
- binding.revision !== artifact.playPresetRevision)
9
+ if (binding !== null &&
10
+ (binding.id !== artifact.playPresetId ||
11
+ binding.revision !== artifact.playPresetRevision))
11
12
  return missingBundle(preset, "The play-preset binding does not match the artifact's frozen identity", "invalid_revision");
12
- const followup = binding.definition.followups.find(({ id }) => id === artifact.requestId);
13
- const declaration = followup?.artifacts.find(({ name }) => name === artifact.output);
13
+ const followup = binding?.definition.followups.find(({ id }) => id === artifact.requestId);
14
+ const declaration = artifact.frozenPresentation?.declaration ??
15
+ followup?.artifacts.find(({ name }) => name === artifact.output);
14
16
  if (declaration === undefined)
15
17
  return {
16
18
  ...missingBundle(preset, "The artifact declaration is missing from the frozen revision"),
@@ -23,8 +25,10 @@ export function projectArtifactForFrontend(artifact, binding, failure = "missing
23
25
  };
24
26
  try {
25
27
  const frozenBinding = {
26
- ...binding,
27
- scriptsEnabled: artifact.playPresetScriptsEnabled,
28
+ scriptsEnabled: artifact.requestId.startsWith("package:")
29
+ ? packageScriptsEnabled && declaration.rendererMode === "app"
30
+ : artifact.playPresetScriptsEnabled,
31
+ files: artifact.frozenPresentation?.files ?? binding?.files ?? {},
28
32
  };
29
33
  const regex = declaration.regex
30
34
  ? parsePlayPresetRegexAsset(frozenBinding.files[declaration.regex] ??
@@ -32,8 +36,9 @@ export function projectArtifactForFrontend(artifact, binding, failure = "missing
32
36
  throw new Error("The frozen regex resource does not exist");
33
37
  })(), declaration.regex)
34
38
  : [];
35
- const renderer = resolveRenderer(declaration, frozenBinding);
36
- const mount = frozenBinding.definition.mounts.find(({ channel }) => channel === declaration.channel)?.mount;
39
+ const renderer = resolveArtifactRenderer(declaration, frozenBinding);
40
+ const mount = artifact.frozenPresentation?.mount ??
41
+ binding?.definition.mounts.find(({ channel }) => channel === declaration.channel)?.mount;
37
42
  return {
38
43
  status: "ready",
39
44
  preset,
@@ -44,7 +49,7 @@ export function projectArtifactForFrontend(artifact, binding, failure = "missing
44
49
  declaration: declarationView(declaration),
45
50
  regex,
46
51
  ...(renderer === undefined ? {} : { renderer }),
47
- trustedLocalCode: artifact.playPresetScriptsEnabled,
52
+ trustedLocalCode: frozenBinding.scriptsEnabled,
48
53
  fallback: "none",
49
54
  };
50
55
  }
@@ -125,8 +130,8 @@ function artifactMatchesDeclaration(artifact, declaration) {
125
130
  (artifact.rendererRevision ?? undefined) ===
126
131
  (declaration.rendererRevision ?? undefined));
127
132
  }
128
- export function projectDebugArtifactForFrontend(artifact, binding, failure = "missing_revision") {
129
- return projectArtifactForFrontend(artifact, binding, failure);
133
+ export function projectDebugArtifactForFrontend(artifact, binding, failure = "missing_revision", packageScriptsEnabled = false) {
134
+ return projectArtifactForFrontend(artifact, binding, failure, packageScriptsEnabled);
130
135
  }
131
136
  function declarationView(declaration) {
132
137
  return {
@@ -145,7 +150,7 @@ function declarationView(declaration) {
145
150
  rendererMode: declaration.rendererMode ?? "document",
146
151
  };
147
152
  }
148
- function resolveRenderer(declaration, binding) {
153
+ export function resolveArtifactRenderer(declaration, binding) {
149
154
  const mode = declaration.rendererMode ?? "document";
150
155
  const rendererSource = declaration.renderer
151
156
  ? binding.files[declaration.renderer]
@@ -0,0 +1,70 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { open, readFile, rename, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { readPackageFollowups, packageFollowupResources, } from "../content/PackageFollowups.js";
5
+ const fileName = "package-script-permissions.json";
6
+ /** Local grants authorize exact resource bytes. They never travel in a portable content tree. */
7
+ export class PackageScriptPermissions {
8
+ static async read(ownerRoot) {
9
+ try {
10
+ const value = JSON.parse(await readFile(join(ownerRoot, fileName), "utf8"));
11
+ if (typeof value !== "object" ||
12
+ value === null ||
13
+ !("schemaVersion" in value) ||
14
+ value.schemaVersion !== 1 ||
15
+ !("grants" in value) ||
16
+ Object.keys(value).length !== 2 ||
17
+ !Array.isArray(value.grants) ||
18
+ !value.grants.every((entry) => typeof entry === "string" && /^[a-f0-9]{64}$/u.test(entry)))
19
+ throw new Error("Invalid local package script permissions");
20
+ return value.grants;
21
+ }
22
+ catch (error) {
23
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
24
+ return [];
25
+ throw error;
26
+ }
27
+ }
28
+ static async write(ownerRoot, grants) {
29
+ const path = join(ownerRoot, fileName);
30
+ const temporary = `${path}.${randomUUID()}.tmp`;
31
+ try {
32
+ const file = await open(temporary, "wx", 0o600);
33
+ try {
34
+ await file.writeFile(JSON.stringify({ schemaVersion: 1, grants }));
35
+ await file.sync();
36
+ }
37
+ finally {
38
+ await file.close();
39
+ }
40
+ await rename(temporary, path);
41
+ const directory = await open(ownerRoot, "r");
42
+ try {
43
+ await directory.sync();
44
+ }
45
+ finally {
46
+ await directory.close();
47
+ }
48
+ }
49
+ finally {
50
+ await rm(temporary, { force: true });
51
+ }
52
+ }
53
+ static fingerprint(files) {
54
+ return createHash("sha256")
55
+ .update(JSON.stringify(Object.entries(files).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)))
56
+ .digest("hex");
57
+ }
58
+ static currentGrants(files) {
59
+ const source = readPackageFollowups(files);
60
+ return [
61
+ ...new Set(source.followups.map(({ definition }) => this.fingerprint(packageFollowupResources(definition, source.files)))),
62
+ ];
63
+ }
64
+ static status(files, grants) {
65
+ const current = this.currentGrants(files);
66
+ return {
67
+ enabled: current.length > 0 && current.every((grant) => grants.includes(grant)),
68
+ };
69
+ }
70
+ }