narraeon 0.5.7 → 0.6.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 (62) hide show
  1. package/README.md +13 -0
  2. package/README.zh-CN.md +10 -0
  3. package/dist/node/protocol/gameplay.js +69 -0
  4. package/dist/node/protocol/resolution.js +274 -0
  5. package/dist/node/protocol/v1.js +15 -2
  6. package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
  7. package/dist/node/runtime/RuntimeErrorLog.js +95 -0
  8. package/dist/node/runtime/V1Runtime.js +58 -7
  9. package/dist/node/runtime/archive/WorldArchives.js +14 -46
  10. package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
  11. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
  12. package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
  13. package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
  14. package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
  15. package/dist/node/runtime/config/dataRootLock.js +108 -0
  16. package/dist/node/runtime/content/ContentWorkspace.js +10 -12
  17. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +50 -46
  18. package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
  19. package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
  20. package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
  21. package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
  22. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
  23. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +31 -170
  24. package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
  25. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +36 -239
  26. package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
  27. package/dist/node/runtime/play/PlayCallChain.js +359 -359
  28. package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
  29. package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
  30. package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
  31. package/dist/node/runtime/play/TabletopResolution.js +372 -0
  32. package/dist/node/runtime/play/TabletopRules.js +141 -0
  33. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
  34. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
  35. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
  36. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
  37. package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
  38. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
  39. package/dist/node/runtime/world/FileNativeWorldStore.js +266 -783
  40. package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
  41. package/dist/node/runtime/world/WorldActivity.js +14 -22
  42. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
  43. package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
  44. package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
  45. package/dist/node/runtime/world/WorldQueue.js +46 -0
  46. package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
  47. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -12
  48. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
  49. package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
  50. package/dist/node/server/ConversationStream.js +2 -0
  51. package/dist/node/server/mobile.js +20 -0
  52. package/dist/node/server/startWebServer.js +15 -8
  53. package/dist/node/shared/ordered-author-prompts.js +8 -0
  54. package/dist/node/shared/ordered-play-prompts.js +23 -0
  55. package/dist/node/shared/tabletop-guidance.js +100 -0
  56. package/dist/web/assets/index-7GzLSUpU.js +1323 -0
  57. package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
  58. package/dist/web/index.html +2 -2
  59. package/package.json +2 -1
  60. package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
  61. package/dist/web/assets/index-DxNtxzLR.js +0 -1255
  62. package/dist/web/assets/index-DzOnu9uv.css +0 -1
@@ -1,62 +1,33 @@
1
+ import { isResolutionReceipt } from "../../protocol/resolution.js";
1
2
  import { legacyModelBindingFailure, projectLegacyModelBindingFailure, } from "./LegacyModelBindingFailure.js";
2
3
  import { isPlayFailureKind, } from "../../protocol/playFailure.js";
3
4
  import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
4
5
  import { encodePlayPromptRun, decodePlayPromptRun, } from "./PlayPromptRun.js";
5
6
  import { createHash, randomUUID } from "node:crypto";
6
- import { link, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises";
7
+ import { constants } from "node:fs";
8
+ import { chmod, copyFile, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
9
+ import { linkExclusive } from "../FileNativeExclusiveLink.js";
7
10
  import { dirname, join, resolve } from "node:path";
8
11
  import { isDeepStrictEqual } from "node:util";
9
- import { cloneFilePhysically } from "../FileNativePhysicalClone.js";
10
12
  export class FileNativePlayTimelineStore {
11
13
  #worldsRoot;
12
14
  constructor(dataRoot) {
13
15
  this.#worldsRoot = join(resolve(dataRoot), "worlds-file-native");
14
16
  }
15
- /**
16
- * Convert the cumulative call-chain record written by every released
17
- * Narraeon version through v0.1.1 into immutable context segments. The
18
- * source digest makes interrupted retries reproduce the same generation and
19
- * identities; callers publish the world-wide storage marker only after this
20
- * method has completed.
21
- */
22
- async migrateReleasedRecord(worldId, value, sourceDigest) {
23
- assertIdentity(worldId, "World ID");
24
- if (!/^[a-f0-9]{64}$/u.test(sourceDigest))
25
- throw new TypeError("Released call-chain source digest is invalid");
26
- assertReleasedPersistedPlayCallChain(value, worldId);
27
- const contexts = [
28
- ...(value.previousContexts ?? []).map((context) => structuredClone(context)),
29
- releasedCurrentContext(value),
30
- ];
31
- const seen = new Set();
32
- const generation = `released:${sourceDigest}`;
33
- let previousChainId = null;
34
- for (const context of contexts) {
35
- assertIdentity(context.chainId, "Call-chain ID");
36
- if (seen.has(context.chainId))
37
- throw new Error("Released play-call-chain contexts contain a duplicate identity");
38
- seen.add(context.chainId);
39
- await this.persist({
40
- ...structuredClone(context),
41
- events: context.events.map(normalizePlayEvent),
42
- schemaVersion: 3,
43
- kind: "play_call_chain",
44
- worldId,
45
- previousChainId,
46
- timelineGeneration: generation,
47
- documentAuthorizationCheckpoints: structuredClone(context.documentAuthorizationCheckpoints ?? []),
48
- });
49
- previousChainId = context.chainId;
50
- }
51
- const migrated = await this.readAllContexts(worldId);
52
- if (migrated.length !== contexts.length ||
53
- migrated.some((context, index) => context.chainId !== contexts[index]?.chainId))
54
- throw new Error("Released play-call-chain migration did not reproduce its context chain");
55
- }
56
17
  async persist(value, cursor) {
57
18
  return this.#persistAtRuntimeRoot(this.#worldRuntimeRoot(value.worldId), value, cursor);
58
19
  }
59
20
  /** Write a sealed context segment without making it the active timeline. */
21
+ /** Physically remove the segments of contexts a rewind discarded. */
22
+ async discardContexts(worldId, chainIds) {
23
+ for (const chainId of chainIds) {
24
+ assertIdentity(chainId, "Call-chain ID");
25
+ await rm(this.#contextRoot(worldId, chainId), {
26
+ recursive: true,
27
+ force: true,
28
+ });
29
+ }
30
+ }
60
31
  async persistDetached(value, cursor) {
61
32
  return this.#persistAtRuntimeRoot(this.#worldRuntimeRoot(value.worldId), value, cursor, false);
62
33
  }
@@ -72,7 +43,6 @@ export class FileNativePlayTimelineStore {
72
43
  const transcriptCount = prefixLength(input.source.transcript, input.target.transcript);
73
44
  const eventCount = prefixLength(input.source.events, input.target.events);
74
45
  const completedToolCount = prefixLength(input.source.completedTools, input.target.completedTools);
75
- const authorizationCheckpointCount = prefixLength(input.source.documentAuthorizationCheckpoints ?? [], input.target.documentAuthorizationCheckpoints ?? []);
76
46
  if (input.retainCompleteContext ||
77
47
  input.target.events.some((event) => event.kind === "assistant" && event.status === "completed")) {
78
48
  const encoding = await this.readInitialEncoding(input.sourceWorldId, input.source.chainId).catch(() => null);
@@ -113,19 +83,11 @@ export class FileNativePlayTimelineStore {
113
83
  count: completedToolCount,
114
84
  immutable: true,
115
85
  }),
116
- cloneNumberedPrefix({
117
- sourceRoot,
118
- targetRoot,
119
- directory: "authorization",
120
- count: authorizationCheckpointCount,
121
- immutable: true,
122
- }),
123
86
  ]);
124
87
  return this.persistStaged(input.targetWorldRoot, input.target, {
125
88
  transcriptCount,
126
89
  eventCount,
127
90
  completedToolCount,
128
- authorizationCheckpointCount,
129
91
  });
130
92
  }
131
93
  async #persistAtRuntimeRoot(runtimeRoot, value, cursor, publishHead = true) {
@@ -155,7 +117,6 @@ export class FileNativePlayTimelineStore {
155
117
  eventCount: Math.min(supplied.eventCount, value.events.length),
156
118
  transcriptCount: Math.min(supplied.transcriptCount, value.transcript.length),
157
119
  completedToolCount: Math.min(supplied.completedToolCount, value.completedTools.length),
158
- authorizationCheckpointCount: Math.min(supplied.authorizationCheckpointCount, value.documentAuthorizationCheckpoints?.length ?? 0),
159
120
  };
160
121
  const lastRequestDigest = value.lastRequest === null
161
122
  ? null
@@ -186,9 +147,6 @@ export class FileNativePlayTimelineStore {
186
147
  }
187
148
  for (let index = previous.completedToolCount; index < value.completedTools.length; index += 1)
188
149
  await publishImmutableJson(join(root, "completed-tools", numbered(index + 1)), value.completedTools[index]);
189
- const checkpoints = value.documentAuthorizationCheckpoints ?? [];
190
- for (let index = previous.authorizationCheckpointCount; index < checkpoints.length; index += 1)
191
- await publishImmutableJson(join(root, "authorization", numbered(index + 1)), checkpoints[index]);
192
150
  const promptRuns = value.promptRuns ?? [];
193
151
  for (let index = supplied.promptRunCount ?? 0; index < promptRuns.length; index += 1) {
194
152
  await publishImmutableJson(join(root, "prompt-runs", numbered(index + 1)), encodePlayPromptRun(promptRuns[index]));
@@ -202,7 +160,6 @@ export class FileNativePlayTimelineStore {
202
160
  eventCount: value.events.length,
203
161
  transcriptCount: value.transcript.length,
204
162
  completedToolCount: value.completedTools.length,
205
- authorizationCheckpointCount: checkpoints.length,
206
163
  promptRunCount: promptRuns.length,
207
164
  changedDocuments: structuredClone(value.changedDocuments),
208
165
  nextEventId: value.nextEventId,
@@ -227,7 +184,6 @@ export class FileNativePlayTimelineStore {
227
184
  eventCount: value.events.length,
228
185
  transcriptCount: value.transcript.length,
229
186
  completedToolCount: value.completedTools.length,
230
- authorizationCheckpointCount: checkpoints.length,
231
187
  promptRunCount: promptRuns.length,
232
188
  };
233
189
  }
@@ -262,16 +218,14 @@ export class FileNativePlayTimelineStore {
262
218
  if (metadata === null)
263
219
  return null;
264
220
  const { index: indexValue, state: stateValue, root } = metadata;
265
- const [baseValue, continuationValue, transcript, rawEvents, completedTools, checkpoints, promptRuns,] = await Promise.all([
221
+ const [baseValue, continuationValue, transcript, events, completedTools, promptRuns,] = await Promise.all([
266
222
  readJson(join(root, "base.json")),
267
223
  readJson(join(root, "continuations", `${stateValue.continuationDigest}.json`)),
268
224
  readNumbered(join(root, "transcript"), stateValue.transcriptCount),
269
225
  readNumberedValidated(join(root, "events"), stateValue.eventCount, assertPlayEvent),
270
226
  readNumbered(join(root, "completed-tools"), stateValue.completedToolCount),
271
- readNumbered(join(root, "authorization"), stateValue.authorizationCheckpointCount),
272
227
  readNumbered(join(root, "prompt-runs"), stateValue.promptRunCount ?? 0).then((records) => records.map(decodePlayPromptRun)),
273
228
  ]);
274
- const events = rawEvents.map(normalizePlayEvent);
275
229
  assertContextBase(baseValue);
276
230
  assertContextContinuation(continuationValue);
277
231
  if (baseValue.worldId !== worldId ||
@@ -334,7 +288,6 @@ export class FileNativePlayTimelineStore {
334
288
  transcript,
335
289
  events,
336
290
  completedTools,
337
- documentAuthorizationCheckpoints: checkpoints,
338
291
  promptRuns,
339
292
  changedDocuments: structuredClone(stateValue.changedDocuments),
340
293
  nextMaterials: structuredClone(continuationValue.nextMaterials),
@@ -359,7 +312,6 @@ export class FileNativePlayTimelineStore {
359
312
  eventCount: stateValue.eventCount,
360
313
  transcriptCount: stateValue.transcriptCount,
361
314
  completedToolCount: stateValue.completedToolCount,
362
- authorizationCheckpointCount: stateValue.authorizationCheckpointCount,
363
315
  promptRunCount: stateValue.promptRunCount ?? 0,
364
316
  },
365
317
  };
@@ -546,7 +498,7 @@ export class FileNativePlayTimelineStore {
546
498
  assertPlayEvent(event);
547
499
  if (event.id !== eventId)
548
500
  throw new Error("Play timeline event identity is inconsistent");
549
- return projectLegacyModelBindingFailure(normalizePlayEvent(event));
501
+ return projectLegacyModelBindingFailure(event);
550
502
  }
551
503
  newGeneration() {
552
504
  return `timeline:${randomUUID()}`;
@@ -760,7 +712,6 @@ function emptyPersistenceCursor() {
760
712
  eventCount: 0,
761
713
  transcriptCount: 0,
762
714
  completedToolCount: 0,
763
- authorizationCheckpointCount: 0,
764
715
  };
765
716
  }
766
717
  function numbered(index) {
@@ -829,7 +780,6 @@ function assertContextState(value) {
829
780
  !validCount(value.eventCount) ||
830
781
  !validCount(value.transcriptCount) ||
831
782
  !validCount(value.completedToolCount) ||
832
- !validCount(value.authorizationCheckpointCount) ||
833
783
  (value.promptRunCount !== undefined && !validCount(value.promptRunCount)) ||
834
784
  !Array.isArray(value.changedDocuments) ||
835
785
  !validCount(value.nextEventId) ||
@@ -864,65 +814,6 @@ function assertTimelineHead(value) {
864
814
  typeof value.generation !== "string")
865
815
  throw new Error("Play timeline head has an invalid shape");
866
816
  }
867
- function assertReleasedPersistedPlayCallChain(value, worldId) {
868
- if (!isRecord(value) ||
869
- (value.schemaVersion !== 1 && value.schemaVersion !== 2) ||
870
- value.kind !== "play_call_chain" ||
871
- value.worldId !== worldId ||
872
- !isReleasedPlayContext(value) ||
873
- (value.previousContexts !== undefined &&
874
- (!Array.isArray(value.previousContexts) ||
875
- value.previousContexts.some((context) => !isReleasedPlayContext(context)))))
876
- throw new Error("Released play-call-chain durable data has an invalid shape");
877
- }
878
- function isReleasedPlayContext(value) {
879
- if (!isRecord(value))
880
- return false;
881
- if (typeof value.chainId !== "string" ||
882
- typeof value.baselineHead !== "string" ||
883
- (value.continuityContextId !== undefined &&
884
- (typeof value.continuityContextId !== "string" ||
885
- value.continuityContextId.length === 0)) ||
886
- (value.baselineHistoryLength !== undefined &&
887
- !validCount(value.baselineHistoryLength)) ||
888
- typeof value.parentHead !== "string" ||
889
- !validReleasedDerivedFrom(value.derivedFrom) ||
890
- !validReleasedBranchedBeforePlayer(value.branchedBeforePlayer) ||
891
- !isRecord(value.playPreset) ||
892
- (value.followups !== undefined && !Array.isArray(value.followups)) ||
893
- (value.playPresetScriptsEnabled !== undefined &&
894
- typeof value.playPresetScriptsEnabled !== "boolean") ||
895
- (value.modelBinding !== undefined &&
896
- !validModelHostBinding(value.modelBinding)) ||
897
- (value.status !== "ready" &&
898
- value.status !== "running" &&
899
- value.status !== "interrupted") ||
900
- typeof value.canRetry !== "boolean" ||
901
- !isRecord(value.bootstrap) ||
902
- !Array.isArray(value.tools) ||
903
- !Array.isArray(value.transcript) ||
904
- !Array.isArray(value.events) ||
905
- !Array.isArray(value.completedTools) ||
906
- !Array.isArray(value.changedDocuments) ||
907
- !Array.isArray(value.nextMaterials) ||
908
- !validCount(value.nextEventId) ||
909
- !validCount(value.exchange) ||
910
- (value.lastRequest !== null && !isRecord(value.lastRequest)) ||
911
- !validCount(value.lastRequestAttempt) ||
912
- (value.lastFailure !== null && typeof value.lastFailure !== "string") ||
913
- (value.lastFailureKind !== undefined &&
914
- !isPlayFailureKind(value.lastFailureKind)) ||
915
- typeof value.updatedAt !== "number")
916
- return false;
917
- try {
918
- for (const event of value.events)
919
- assertPlayEvent(event);
920
- }
921
- catch {
922
- return false;
923
- }
924
- return validReleasedAuthorizationCheckpoints(value.documentAuthorizationCheckpoints, value.events);
925
- }
926
817
  function validModelHostBinding(value) {
927
818
  return (isRecord(value) &&
928
819
  (value.provider === "chat_completions" ||
@@ -945,54 +836,6 @@ function validModelHostBinding(value) {
945
836
  value.cacheStrategy === "explicit_cliproxyapi_message" ||
946
837
  value.cacheStrategy === "provider_managed"));
947
838
  }
948
- function validReleasedDerivedFrom(value) {
949
- return (value === undefined ||
950
- (isRecord(value) &&
951
- typeof value.worldId === "string" &&
952
- typeof value.chainId === "string" &&
953
- typeof value.head === "string"));
954
- }
955
- function validReleasedBranchedBeforePlayer(value) {
956
- return (value === undefined ||
957
- (isRecord(value) &&
958
- typeof value.worldId === "string" &&
959
- typeof value.chainId === "string" &&
960
- Number.isSafeInteger(value.eventId) &&
961
- Number(value.eventId) > 0));
962
- }
963
- function validReleasedAuthorizationCheckpoints(value, events) {
964
- if (value === undefined)
965
- return true;
966
- if (!Array.isArray(value) || value.length > 100_000)
967
- return false;
968
- const eventIds = new Set(events.flatMap((event) => isRecord(event) && Number.isSafeInteger(event.id) && Number(event.id) > 0
969
- ? [Number(event.id)]
970
- : []));
971
- let previousEventId = -1;
972
- for (const checkpoint of value) {
973
- if (!isRecord(checkpoint) ||
974
- Object.keys(checkpoint).length !== 2 ||
975
- !Object.hasOwn(checkpoint, "afterEventId") ||
976
- !Object.hasOwn(checkpoint, "authorization") ||
977
- !Number.isSafeInteger(checkpoint.afterEventId) ||
978
- Number(checkpoint.afterEventId) < 0 ||
979
- Number(checkpoint.afterEventId) <= previousEventId ||
980
- (Number(checkpoint.afterEventId) !== 0 &&
981
- !eventIds.has(Number(checkpoint.afterEventId))) ||
982
- !isRecord(checkpoint.authorization))
983
- return false;
984
- previousEventId = Number(checkpoint.afterEventId);
985
- }
986
- return true;
987
- }
988
- function releasedCurrentContext(value) {
989
- const { schemaVersion: _schemaVersion, kind: _kind, worldId: _worldId, previousContexts: _previousContexts, ...context } = value;
990
- void _schemaVersion;
991
- void _kind;
992
- void _worldId;
993
- void _previousContexts;
994
- return structuredClone(context);
995
- }
996
839
  function assertPlayEvent(value) {
997
840
  if (!isRecord(value) ||
998
841
  !Number.isSafeInteger(value.id) ||
@@ -1028,6 +871,9 @@ function assertPlayEvent(value) {
1028
871
  return;
1029
872
  }
1030
873
  if (value.kind === "tool_result") {
874
+ if (value.resolution !== undefined &&
875
+ !isResolutionReceipt(value.resolution))
876
+ throw new Error("Invalid resolution receipt");
1031
877
  if (typeof value.callId !== "string" ||
1032
878
  typeof value.name !== "string" ||
1033
879
  typeof value.ok !== "boolean" ||
@@ -1091,6 +937,9 @@ function assertTimelineEventSummary(value) {
1091
937
  return;
1092
938
  }
1093
939
  if (value.kind === "tool_result") {
940
+ if (value.resolution !== undefined &&
941
+ !isResolutionReceipt(value.resolution))
942
+ throw new Error("Invalid resolution receipt");
1094
943
  if (typeof value.callId !== "string" ||
1095
944
  typeof value.name !== "string" ||
1096
945
  typeof value.ok !== "boolean" ||
@@ -1133,43 +982,7 @@ function validOptionalString(value) {
1133
982
  return value === undefined || typeof value === "string";
1134
983
  }
1135
984
  function validOptionalUsage(value) {
1136
- return (value === undefined ||
1137
- (isRecord(value) &&
1138
- validUsageToken(value.inputTokens) &&
1139
- validUsageToken(value.outputTokens) &&
1140
- (value.provenance === undefined || validFullUsage(value))));
1141
- }
1142
- /**
1143
- * v0.1.1 stored only input/output usage. Keep that released shape readable,
1144
- * but expose one current protocol projection with honest unavailable fields.
1145
- */
1146
- function normalizePlayEvent(event) {
1147
- if ((event.kind !== "assistant" && event.kind !== "followup") ||
1148
- event.usage === undefined ||
1149
- validFullUsage(event.usage))
1150
- return structuredClone(event);
1151
- const legacy = event.usage;
1152
- const unavailable = "unavailable";
1153
- const provider = (value) => value === null ? unavailable : "provider";
1154
- const usage = {
1155
- inputTokens: legacy.inputTokens,
1156
- uncachedInputTokens: null,
1157
- cacheReadTokens: null,
1158
- cacheWriteTokens: null,
1159
- reasoningTokens: null,
1160
- outputTokens: legacy.outputTokens,
1161
- totalTokens: null,
1162
- provenance: {
1163
- inputTokens: provider(legacy.inputTokens),
1164
- uncachedInputTokens: unavailable,
1165
- cacheReadTokens: unavailable,
1166
- cacheWriteTokens: unavailable,
1167
- reasoningTokens: unavailable,
1168
- outputTokens: provider(legacy.outputTokens),
1169
- totalTokens: unavailable,
1170
- },
1171
- };
1172
- return { ...structuredClone(event), usage };
985
+ return value === undefined || validFullUsage(value);
1173
986
  }
1174
987
  function validFullUsage(value) {
1175
988
  if (!isRecord(value) || !isRecord(value.provenance))
@@ -1225,12 +1038,18 @@ async function cloneNumberedPrefix(input) {
1225
1038
  await cloneTimelineFile(join(input.sourceRoot, input.directory, numbered(index)), join(input.targetRoot, input.directory, numbered(index)), input.immutable);
1226
1039
  }
1227
1040
  async function cloneTimelineFile(source, target, immutable) {
1228
- await cloneFilePhysically({
1229
- source,
1230
- target,
1231
- immutable,
1232
- onTargetExists: assertSameFileBytes,
1233
- });
1041
+ await mkdir(dirname(target), { recursive: true });
1042
+ try {
1043
+ await copyFile(source, target, constants.COPYFILE_EXCL);
1044
+ }
1045
+ catch (error) {
1046
+ if (!isNodeError(error) || error.code !== "EEXIST")
1047
+ throw error;
1048
+ await assertSameFileBytes(source, target);
1049
+ return;
1050
+ }
1051
+ if (immutable)
1052
+ await chmod(target, 0o444);
1234
1053
  }
1235
1054
  async function assertSameFileBytes(source, target) {
1236
1055
  const [sourceBytes, targetBytes] = await Promise.all([
@@ -1272,9 +1091,7 @@ async function publishJson(path, value) {
1272
1091
  encoding: "utf8",
1273
1092
  mode: 0o600,
1274
1093
  });
1275
- await syncFile(temporary);
1276
1094
  await rename(temporary, path);
1277
- await syncDirectory(dirname(path));
1278
1095
  }
1279
1096
  async function publishImmutableJson(path, value) {
1280
1097
  const contents = `${JSON.stringify(value, null, 2)}\n`;
@@ -1286,10 +1103,8 @@ async function publishImmutableJson(path, value) {
1286
1103
  flag: "wx",
1287
1104
  mode: 0o600,
1288
1105
  });
1289
- await syncFile(temporary);
1290
1106
  try {
1291
- await link(temporary, path);
1292
- await syncDirectory(dirname(path));
1107
+ await linkExclusive(temporary, path);
1293
1108
  }
1294
1109
  catch (error) {
1295
1110
  if (!isNodeError(error) || error.code !== "EEXIST")
@@ -1304,21 +1119,3 @@ async function publishImmutableJson(path, value) {
1304
1119
  await rm(temporary, { force: true });
1305
1120
  }
1306
1121
  }
1307
- async function syncFile(path) {
1308
- const handle = await open(path, "r");
1309
- try {
1310
- await handle.sync();
1311
- }
1312
- finally {
1313
- await handle.close();
1314
- }
1315
- }
1316
- async function syncDirectory(path) {
1317
- const handle = await open(path, "r");
1318
- try {
1319
- await handle.sync();
1320
- }
1321
- finally {
1322
- await handle.close();
1323
- }
1324
- }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { parseDocument } from "yaml";
2
+ import { isMap, isSeq, parseDocument } from "yaml";
3
3
  import { builtinPlayPrompts, isPromptMessageRole, } from "../../shared/ordered-play-prompts.js";
4
4
  /** The same strict permission gate is used by portable imports and structured saves. */
5
5
  export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"), worldRequired = true) {
@@ -24,20 +24,23 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
24
24
  !isPromptMessageRole(item.messageRole))
25
25
  return fail();
26
26
  const messageRole = item.messageRole === undefined ? {} : { messageRole: item.messageRole };
27
- const exact = (keys) => Object.keys(item).length ===
28
- keys.length + (item.messageRole === undefined ? 0 : 1) &&
29
- keys.every((key) => Object.hasOwn(item, key));
27
+ const exact = (keys, optional = []) => Object.keys(item).every((key) => keys.includes(key) ||
28
+ optional.includes(key) ||
29
+ key === "messageRole" ||
30
+ key === "id" ||
31
+ key === "kind") && keys.every((key) => Object.hasOwn(item, key));
30
32
  if (item.kind === "world" && exact(["id", "kind"])) {
31
33
  worlds++;
32
34
  return { id: item.id, kind: "world", ...messageRole };
33
35
  }
34
36
  if (item.kind === "user" &&
35
- exact(["id", "kind", "name", "enabled", "body"]) &&
37
+ exact(["id", "kind", "name", "enabled", "body"], ["gameplay"]) &&
36
38
  typeof item.name === "string" &&
37
39
  item.name.trim().length > 0 &&
38
40
  /^[^\r\n\0]{1,160}$/u.test(item.name) &&
39
41
  typeof item.body === "string" &&
40
- typeof item.enabled === "boolean")
42
+ typeof item.enabled === "boolean" &&
43
+ (item.gameplay === undefined || item.gameplay === "tabletop"))
41
44
  return {
42
45
  id: item.id,
43
46
  kind: "user",
@@ -45,6 +48,9 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
45
48
  enabled: item.enabled,
46
49
  body: item.body,
47
50
  ...messageRole,
51
+ ...(item.gameplay === "tabletop"
52
+ ? { gameplay: "tabletop" }
53
+ : {}),
48
54
  };
49
55
  const builtin = catalog.find((entry) => entry.id === item.builtin);
50
56
  if (item.kind !== "builtin" ||
@@ -70,6 +76,54 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
70
76
  return fail();
71
77
  return entries;
72
78
  }
79
+ /**
80
+ * Insert the tabletop builtins that an ordered preset does not list yet.
81
+ * play.tabletop goes just before the world placeholder and author.tabletop just
82
+ * after author.guidance. Files that are not ordered v2 or do not parse come back
83
+ * unchanged, and nothing else in them is rewritten. The same object is returned
84
+ * when nothing needs inserting, so callers can detect a change by identity.
85
+ */
86
+ export function withTabletopPromptBuiltins(files) {
87
+ const source = files["preset.yaml"];
88
+ if (source === undefined)
89
+ return files;
90
+ const document = parseDocument(source, { uniqueKeys: true });
91
+ if (document.errors.length > 0 ||
92
+ document.warnings.length > 0 ||
93
+ !isMap(document.contents) ||
94
+ document.get("format") !== "narraeon.play-preset/v2")
95
+ return files;
96
+ let changed = false;
97
+ const playPrompts = document.get("playPrompts", true);
98
+ if (isSeq(playPrompts) && !hasEntry(playPrompts, "play.tabletop")) {
99
+ const world = playPrompts.items.findIndex((item) => entryField(item, "kind") === "world");
100
+ playPrompts.items.splice(world < 0 ? playPrompts.items.length : world, 0, document.createNode({
101
+ id: "play.tabletop",
102
+ kind: "builtin",
103
+ builtin: "play.tabletop",
104
+ enabled: true,
105
+ }));
106
+ changed = true;
107
+ }
108
+ const authorPrompts = document.get("authorPrompts", true);
109
+ if (isSeq(authorPrompts) && !hasEntry(authorPrompts, "author.tabletop")) {
110
+ const guidance = authorPrompts.items.findIndex((item) => entryField(item, "id") === "author.guidance");
111
+ authorPrompts.items.splice(guidance < 0 ? authorPrompts.items.length : guidance + 1, 0, document.createNode({
112
+ id: "author.tabletop",
113
+ kind: "builtin",
114
+ builtin: "author.tabletop",
115
+ enabled: true,
116
+ }));
117
+ changed = true;
118
+ }
119
+ return changed ? { ...files, "preset.yaml": document.toString() } : files;
120
+ }
121
+ function hasEntry(sequence, id) {
122
+ return sequence.items.some((item) => entryField(item, "id") === id);
123
+ }
124
+ function entryField(item, key) {
125
+ return isMap(item) ? item.get(key) : undefined;
126
+ }
73
127
  /** Recognized v1 content becomes user text, never a locked builtin inferred from a path. */
74
128
  export function migrateLegacyPlayPrompts(files, narrative, excludedPaths = []) {
75
129
  const document = parseDocument(files["frame.yaml"] ?? "", {
@@ -185,5 +239,13 @@ export function migrateLegacyPlayPrompts(files, narrative, excludedPaths = []) {
185
239
  if ((path.startsWith("blocks/") || path.startsWith("prompts/")) &&
186
240
  path.endsWith(".md"))
187
241
  add(path, false);
242
+ // The tabletop builtin joins converted presets just before the world placeholder.
243
+ const worldIndex = entries.findIndex((entry) => entry.kind === "world");
244
+ entries.splice(worldIndex, 0, {
245
+ id: "play.tabletop",
246
+ kind: "builtin",
247
+ builtin: "play.tabletop",
248
+ enabled: true,
249
+ });
188
250
  return entries;
189
251
  }