narraeon 0.5.1 → 0.5.3

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) ||