narraeon 0.5.0 → 0.5.2

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.
@@ -473,7 +473,7 @@ function anthropicRequestBody(connection, request) {
473
473
  ...anthropicReasoningRequest(connection),
474
474
  system: request.bootstrap.provider.system,
475
475
  messages: [
476
- ...request.bootstrap.provider.messages.filter(({ role }) => role !== "system"),
476
+ ...anthropicBootstrapMessages(request),
477
477
  ...anthropicAppend(request),
478
478
  ],
479
479
  ...(tools.length === 0
@@ -602,15 +602,94 @@ function providerToolStrategy(request, provider) {
602
602
  }
603
603
  function chatBootstrapMessages(connection, request) {
604
604
  const messages = request.bootstrap.provider.messages.map((message) => cloneProviderValue(message));
605
+ const prefixEnd = bootstrapPrefixEnd(request);
606
+ messages.splice(prefixEnd, 0, ...initialWorldContext(request, "chat_completions"));
605
607
  return connection.dialect === "cliproxyapi"
606
- ? attachCacheControlToLastRole(messages, "system")
608
+ ? attachCacheControlToLastRole(messages, request.bootstrap.provider.preload === undefined ? "system" : "tool")
607
609
  : messages;
608
610
  }
609
611
  function responsesBootstrapMessages(connection, request) {
610
612
  const messages = request.bootstrap.provider.messages.map((message) => responsesBootstrapMessage(message, connection.dialect === "cliproxyapi"));
611
- return connection.dialect === "cliproxyapi"
612
- ? attachCacheControlToLastRole(messages, "system")
613
- : messages;
613
+ const prefixEnd = bootstrapPrefixEnd(request);
614
+ // Legacy material arrays already mark their stable blocks, and may also contain
615
+ // player input. Only mark whole messages for the plain-text instruction prefix.
616
+ const cacheMessageIndex = request.bootstrap.provider.messages
617
+ .slice(0, prefixEnd)
618
+ .findLastIndex((message) => typeof message.content === "string");
619
+ const prefix = messages.map((message, index) => connection.dialect === "cliproxyapi" &&
620
+ index === cacheMessageIndex &&
621
+ isRecord(message)
622
+ ? { ...message, cache_control: { type: "ephemeral" } }
623
+ : message);
624
+ prefix.splice(prefixEnd, 0, ...initialWorldContext(request, "openai_responses"));
625
+ return prefix;
626
+ }
627
+ /** Configured User prompts are part of the prefix, not the player's first input. */
628
+ function bootstrapPrefixEnd(request) {
629
+ return (request.bootstrap.provider.messages.length -
630
+ request.bootstrap.logicalMessages.filter(({ role }) => role === "player_input").length);
631
+ }
632
+ function anthropicBootstrapMessages(request) {
633
+ const messages = request.bootstrap.provider.messages.map((message) => cloneProviderValue(message));
634
+ messages.splice(bootstrapPrefixEnd(request), 0, ...initialWorldContext(request, "anthropic_messages"));
635
+ return messages.filter((message) => !isRecord(message) || message.role !== "system");
636
+ }
637
+ /** Synthetic bootstrap only: never manufacture a provider continuation or a story event. */
638
+ function initialWorldContext(request, protocol) {
639
+ const preload = request.bootstrap.provider.preload;
640
+ if (preload === undefined)
641
+ return [];
642
+ const name = "runtime_load_context";
643
+ const notice = {
644
+ role: "user",
645
+ content: "Runtime preload: supply the initial world materials selected by the frame. This bootstrap-only read is not a player action or a callable tool. Later successful tool results update these initial values.",
646
+ };
647
+ if (protocol === "chat_completions")
648
+ return [
649
+ notice,
650
+ {
651
+ role: "assistant",
652
+ content: null,
653
+ tool_calls: [
654
+ {
655
+ id: preload.callId,
656
+ type: "function",
657
+ function: { name, arguments: "{}" },
658
+ },
659
+ ],
660
+ },
661
+ { role: "tool", tool_call_id: preload.callId, content: preload.markdown },
662
+ ];
663
+ if (protocol === "openai_responses")
664
+ return [
665
+ notice,
666
+ { type: "function_call", call_id: preload.callId, name, arguments: "{}" },
667
+ {
668
+ type: "function_call_output",
669
+ call_id: preload.callId,
670
+ output: preload.markdown,
671
+ },
672
+ ];
673
+ return [
674
+ notice,
675
+ {
676
+ role: "assistant",
677
+ content: [{ type: "tool_use", id: preload.callId, name, input: {} }],
678
+ },
679
+ {
680
+ role: "user",
681
+ content: [
682
+ {
683
+ type: "tool_result",
684
+ tool_use_id: preload.callId,
685
+ content: preload.markdown,
686
+ ...(request.bootstrap.cache.strategy === "explicit_anthropic_blocks"
687
+ ? { cache_control: { type: "ephemeral" } }
688
+ : {}),
689
+ },
690
+ ],
691
+ },
692
+ ];
614
693
  }
615
694
  function attachCacheControlToLastRole(messages, role) {
616
695
  const result = cloneProviderValue(messages);
@@ -858,7 +937,7 @@ function responsesBootstrapMessage(message, preserveCacheControl = false) {
858
937
  return [];
859
938
  return [
860
939
  {
861
- type: "input_text",
940
+ type: message.role === "assistant" ? "output_text" : "input_text",
862
941
  text: item.text,
863
942
  ...(isRecord(item.cache_control)
864
943
  ? { cache_control: cloneProviderValue(item.cache_control) }
@@ -138,6 +138,7 @@ export function defaultPlayPresetFilesForLocale(locale) {
138
138
  preset.set("format", "narraeon.play-preset/v2");
139
139
  preset.set("playPrompts", defaultOrderedPlayPrompts());
140
140
  preset.set("authorPrompts", defaultOrderedAuthorPrompts());
141
+ preset.set("mergeConsecutiveMessages", false);
141
142
  files["preset.yaml"] = stringify(preset.toJS());
142
143
  return files;
143
144
  }
@@ -676,6 +677,7 @@ function editorProjection(files, limits) {
676
677
  export function toPlayPresetStructuredEditor(definition) {
677
678
  return {
678
679
  name: definition.name,
680
+ mergeConsecutiveMessages: definition.mergeConsecutiveMessages ?? true,
679
681
  authorPrompts: structuredClone(definition.authorPrompts ?? legacyAuthorPrompts(definition)),
680
682
  playPrompts: structuredClone(definition.playPrompts ??
681
683
  migrateLegacyPlayPrompts(definition.files, definition.narrativePrompts, [
@@ -726,6 +728,8 @@ export function applyPlayPresetStructuredEditor(files, input) {
726
728
  if (preset.errors.length > 0 || callChain.errors.length > 0)
727
729
  throw new FileNativePlayPresetError("structured_editor_yaml_invalid", "Structured editing can be applied only to parseable preset/call-chain YAML");
728
730
  preset.set("name", input.name);
731
+ if (input.mergeConsecutiveMessages !== undefined)
732
+ preset.set("mergeConsecutiveMessages", parseMessageMerging(input.mergeConsecutiveMessages));
729
733
  if (input.authorPrompts !== undefined)
730
734
  preset.set("authorPrompts", parseOrderedAuthorPrompts(input.authorPrompts));
731
735
  if (input.playPrompts !== undefined) {
@@ -839,6 +843,11 @@ export function parsePlayPresetStructuredEditor(value) {
839
843
  ? {}
840
844
  : { followupItems: parseFollowupItems(value.followupItems, followups) }),
841
845
  name: value.name,
846
+ ...(value.mergeConsecutiveMessages === undefined
847
+ ? {}
848
+ : {
849
+ mergeConsecutiveMessages: parseMessageMerging(value.mergeConsecutiveMessages),
850
+ }),
842
851
  ...(value.authorPrompts === undefined
843
852
  ? {}
844
853
  : { authorPrompts: parseOrderedAuthorPrompts(value.authorPrompts) }),
@@ -873,6 +882,7 @@ export function parsePlayPresetFiles(files, limits = {}) {
873
882
  "settingImprovement",
874
883
  "playPrompts",
875
884
  "authorPrompts",
885
+ "mergeConsecutiveMessages",
876
886
  "mounts",
877
887
  "playerViewPanels",
878
888
  "extensions",
@@ -884,7 +894,9 @@ export function parsePlayPresetFiles(files, limits = {}) {
884
894
  preset.format !== "narraeon.play-preset/v2")
885
895
  invalid("preset_format_invalid", "preset.yaml format must be narraeon.play-preset/v1", "preset.yaml");
886
896
  if (preset.format === "narraeon.play-preset/v1" &&
887
- (preset.playPrompts !== undefined || preset.authorPrompts !== undefined))
897
+ (preset.playPrompts !== undefined ||
898
+ preset.authorPrompts !== undefined ||
899
+ preset.mergeConsecutiveMessages !== undefined))
888
900
  invalid("preset_format_invalid", "Ordered prompts require v2", "preset.yaml");
889
901
  const name = stringValue(preset.name);
890
902
  if (!namePattern.test(name.trim()) || name.includes(String.fromCharCode(0)))
@@ -918,6 +930,11 @@ export function parsePlayPresetFiles(files, limits = {}) {
918
930
  definition: {
919
931
  ...(followupItems === undefined ? {} : { followupItems }),
920
932
  format: preset.format,
933
+ ...(preset.mergeConsecutiveMessages === undefined
934
+ ? {}
935
+ : {
936
+ mergeConsecutiveMessages: parseMessageMerging(preset.mergeConsecutiveMessages),
937
+ }),
921
938
  ...(preset.format === "narraeon.play-preset/v2"
922
939
  ? { playPrompts: parseOrderedPlayPrompts(preset.playPrompts) }
923
940
  : {}),
@@ -1872,3 +1889,8 @@ export function legacyAuthorPrompts(definition) {
1872
1889
  };
1873
1890
  return entries;
1874
1891
  }
1892
+ function parseMessageMerging(value) {
1893
+ if (typeof value !== "boolean")
1894
+ throw new FileNativePlayPresetError("message_merging_invalid", "mergeConsecutiveMessages must be a boolean");
1895
+ return value;
1896
+ }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { parseDocument } from "yaml";
3
- import { builtinPlayPrompts, } from "../../shared/ordered-play-prompts.js";
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) {
6
6
  const fail = () => {
@@ -20,11 +20,16 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
20
20
  ids.has(item.id))
21
21
  return fail();
22
22
  ids.add(item.id);
23
- const exact = (keys) => Object.keys(item).length === keys.length &&
23
+ if (item.messageRole !== undefined &&
24
+ !isPromptMessageRole(item.messageRole))
25
+ return fail();
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) &&
24
29
  keys.every((key) => Object.hasOwn(item, key));
25
30
  if (item.kind === "world" && exact(["id", "kind"])) {
26
31
  worlds++;
27
- return { id: item.id, kind: "world" };
32
+ return { id: item.id, kind: "world", ...messageRole };
28
33
  }
29
34
  if (item.kind === "user" &&
30
35
  exact(["id", "kind", "name", "enabled", "body"]) &&
@@ -39,6 +44,7 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
39
44
  name: item.name,
40
45
  enabled: item.enabled,
41
46
  body: item.body,
47
+ ...messageRole,
42
48
  };
43
49
  const builtin = catalog.find((entry) => entry.id === item.builtin);
44
50
  if (item.kind !== "builtin" ||
@@ -46,7 +52,9 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
46
52
  !builtin ||
47
53
  builtins.has(builtin.id) ||
48
54
  typeof item.enabled !== "boolean" ||
49
- (builtin.required && !item.enabled))
55
+ (builtin.required &&
56
+ (!item.enabled ||
57
+ (item.messageRole !== undefined && item.messageRole !== "system"))))
50
58
  return fail();
51
59
  builtins.add(builtin.id);
52
60
  return {
@@ -54,6 +62,7 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
54
62
  kind: "builtin",
55
63
  builtin: builtin.id,
56
64
  enabled: item.enabled,
65
+ ...messageRole,
57
66
  };
58
67
  });
59
68
  if (worlds !== (worldRequired ? 1 : 0) ||
@@ -200,10 +200,8 @@ export class PlayCallChain {
200
200
  return;
201
201
  const { hostBinding, playPreset } = await input.resolvePrompt();
202
202
  const binding = await this.#worlds.bindPlayCallChain(input.worldId);
203
- const baseline = await this.#worlds.bindPlayCallChainAt(input.worldId, session.baselineHead);
204
- const documents = new FileNativePlayDocuments(binding.files);
205
- const documentMaintenance = await this.#bindDocumentMaintenance(documents, input.worldId, binding.parentHead);
206
203
  const compilation = this.#compiler.compilePlayCallChain({
204
+ frozenWorldContext: currentPlayPrompt(session).bootstrap,
207
205
  extensionControls: await this.#worlds.extensionControls(input.worldId, playPreset, this.#compiler.locale),
208
206
  endpoint: {
209
207
  id: `${input.worldId}:${binding.parentHead}`,
@@ -212,21 +210,17 @@ export class PlayCallChain {
212
210
  hostBinding,
213
211
  world: {
214
212
  controlFingerprint: fingerprintControl(binding.files),
215
- documentSnapshot: documents.snapshot,
213
+ documentSnapshot: session.documents.snapshot,
216
214
  additionalMaterials: structuredClone(binding.additionalMaterials),
217
- // Only pre-context history belongs in the prefix. Native conversation stays appended.
218
215
  history: structuredClone(binding.history),
219
- historyAlreadyAppended: Object.keys(binding.history).filter((key) => !(key in baseline.history)),
220
- narrativeCheckpoint: baseline.narrativeCheckpoint,
221
- documentMaintenance,
222
- documentMaintenanceUnavailableReason: documents.maintenanceWarning,
216
+ narrativeCheckpoint: binding.narrativeCheckpoint,
223
217
  },
224
218
  playerInputPlacement: "append",
225
219
  playerInput: input.playerText,
226
220
  modelBinding: input.modelHost.binding(),
227
221
  }, playPreset);
228
- documents.bindBootstrap(compilation.bootstrap, session.documents.authorizationCheckpoint());
229
- session.documents = documents;
222
+ // The native conversation already contains successful writes and exact reads.
223
+ // Rebinding initial coverage here would incorrectly reauthorize stale scopes.
230
224
  session.history = historyEntries(binding.history);
231
225
  session.narrativeCheckpoint = binding.narrativeCheckpoint;
232
226
  session.nextMaterials = structuredClone(binding.additionalMaterials);
@@ -248,7 +242,7 @@ export class PlayCallChain {
248
242
  });
249
243
  session.documentAuthorizationCheckpoints.push({
250
244
  afterEventId: session.nextEventId,
251
- authorization: documents.authorizationCheckpoint(),
245
+ authorization: session.documents.authorizationCheckpoint(),
252
246
  });
253
247
  await this.#persist(session);
254
248
  crashAtPlayAdvanceEdge("after_prompt_run_prepared");
@@ -1,3 +1,4 @@
1
+ import { stateDirectoryHandle, parseStateDirectoryHandle, } from "../world/StateDirectoryHandles.js";
1
2
  import { renderDocumentWritePosition } from "../prompt/WorldMaintenanceReport.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import { stringify } from "yaml";
@@ -632,37 +633,6 @@ function parseStateSearchScope(handle) {
632
633
  ? invalidStateHandle
633
634
  : { document: { shortRef: document[1] } };
634
635
  }
635
- function stateDirectoryHandle(directory) {
636
- if (directory === "")
637
- return "@dir-/";
638
- const encoded = directory.split("/").map(encodeURIComponent).join("/");
639
- return `@dir-/${encoded}`;
640
- }
641
- function parseStateDirectoryHandle(handle) {
642
- if (handle === "@dir-/")
643
- return "";
644
- if (!handle.startsWith("@dir-/") || handle.length === "@dir-/".length)
645
- return null;
646
- try {
647
- const directory = handle
648
- .slice("@dir-/".length)
649
- .split("/")
650
- .map(decodeURIComponent)
651
- .join("/");
652
- if (directory
653
- .split("/")
654
- .some((segment) => segment === "" ||
655
- segment === "." ||
656
- segment === ".." ||
657
- segment.includes("\\")) ||
658
- stateDirectoryHandle(directory) !== handle)
659
- return null;
660
- return directory;
661
- }
662
- catch {
663
- return null;
664
- }
665
- }
666
636
  function validStateDirectory(directory) {
667
637
  if (directory.length === 0)
668
638
  return false;