narraeon 0.7.1 → 0.7.4

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 (28) hide show
  1. package/dist/node/protocol/v1.js +8 -2
  2. package/dist/node/runtime/V1Runtime.js +104 -71
  3. package/dist/node/runtime/archive/WorldArchives.js +17 -5
  4. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +59 -12
  5. package/dist/node/runtime/config/dataRootCleanup.js +52 -0
  6. package/dist/node/runtime/content/ContentWorkspace.js +2 -2
  7. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +34 -2
  8. package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
  9. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +70 -3
  10. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +236 -26
  11. package/dist/node/runtime/play/PlayCallChain.js +172 -54
  12. package/dist/node/runtime/play/PlayPromptRun.js +157 -3
  13. package/dist/node/runtime/play/PlayTraceRetention.js +2 -2
  14. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +10 -0
  15. package/dist/node/runtime/world/FileNativeWorldStore.js +11 -4
  16. package/dist/node/runtime/world/WorldLedgerAuthority.js +13 -10
  17. package/dist/node/runtime/world/WorldOperationCoordinator.js +4 -0
  18. package/dist/node/runtime/world/WorldQueue.js +14 -1
  19. package/dist/node/runtime/world/WorldTurnLedger.js +48 -20
  20. package/dist/node/server/createServer.js +44 -15
  21. package/dist/node/server/startWebServer.js +2 -5
  22. package/dist/web/assets/index-9N9kO8kY.js +1408 -0
  23. package/dist/web/assets/index-BjxBYYQi.css +1 -0
  24. package/dist/web/index.html +2 -2
  25. package/package.json +1 -1
  26. package/dist/node/runtime/config/dataRootLock.js +0 -108
  27. package/dist/web/assets/index-BUCslLEK.css +0 -1
  28. package/dist/web/assets/index-DkamBH6c.js +0 -1408
@@ -8,6 +8,8 @@ import { renderPromptDeltaMessage, } from "../prompt/FileNativePromptCompiler.js
8
8
  import { aggregateAnthropicModelStream, aggregateChatModelStream, aggregateResponsesModelStream, } from "./ModelHostStream.js";
9
9
  import { defaultRuntimeToolDefinitionStrategy, portableRuntimeToolInputSchema, } from "../prompt/FileNativeToolRegistry.js";
10
10
  import { AiExchangeCapture, errorDescription, } from "./AiFailureLog.js";
11
+ /** Anthropic allows four cache_control breakpoints per request. */
12
+ const explicitCacheBreakpointLimit = 4;
11
13
  const providerContinuationCodecVersion = 1;
12
14
  function resolveConnection(connection) {
13
15
  const resolved = structuredClone({
@@ -17,6 +19,7 @@ function resolveConnection(connection) {
17
19
  reasoningSummary: connection.reasoningSummary ?? "provider_default",
18
20
  thinkingMode: connection.thinkingMode ?? "provider_default",
19
21
  thinkingBudgetTokens: connection.thinkingBudgetTokens ?? null,
22
+ cacheSuffixBreakpoint: connection.cacheSuffixBreakpoint ?? true,
20
23
  });
21
24
  const issue = modelReasoningPolicyIssue({
22
25
  provider: resolved.provider,
@@ -95,6 +98,8 @@ export class FileNativeModelHost {
95
98
  throw new ModelHostBindingMismatchError("The request protocol does not match the selected model host");
96
99
  return new _a({
97
100
  ...request.modelConfig,
101
+ // A frozen request without this choice predates the transcript breakpoint.
102
+ cacheSuffixBreakpoint: request.modelConfig.cacheSuffixBreakpoint === true,
98
103
  baseUrl: this.#connection.baseUrl,
99
104
  apiKey: this.#connection.apiKey,
100
105
  }, this.#fetch, this.#failureLog);
@@ -458,19 +463,16 @@ async function trace(kind, value) {
458
463
  // Explicit acceptance diagnostics must never change provider semantics.
459
464
  }
460
465
  }
461
- function items(request) {
462
- return request.appended;
463
- }
464
466
  function chatRequestBody(connection, request) {
465
467
  const tools = providerToolDefinitions(request);
466
468
  const allowedTools = providerAllowedTools(request);
467
469
  const hasPolicy = hasFrozenToolPolicy(request);
468
- return {
470
+ return checkedCacheBreakpoints({
469
471
  model: connection.modelId,
470
472
  ...chatReasoningRequest(connection),
471
473
  messages: [
472
474
  ...chatBootstrapMessages(connection, request),
473
- ...chatAppend(request),
475
+ ...chatAppend(connection, request),
474
476
  ],
475
477
  ...(connection.dialect === "cliproxyapi"
476
478
  ? { prompt_cache_key: promptCacheKey(connection, request) }
@@ -495,17 +497,17 @@ function chatRequestBody(connection, request) {
495
497
  max_tokens: request.maxOutputTokens,
496
498
  stream: true,
497
499
  stream_options: { include_usage: true },
498
- };
500
+ });
499
501
  }
500
502
  function anthropicRequestBody(connection, request) {
501
503
  const tools = providerToolDefinitions(request);
502
- return {
504
+ return checkedCacheBreakpoints({
503
505
  model: connection.modelId,
504
506
  ...anthropicReasoningRequest(connection),
505
507
  system: request.bootstrap.provider.system,
506
508
  messages: [
507
509
  ...anthropicBootstrapMessages(request),
508
- ...anthropicAppend(request),
510
+ ...anthropicAppend(connection, request),
509
511
  ],
510
512
  ...(tools.length === 0
511
513
  ? {}
@@ -522,14 +524,14 @@ function anthropicRequestBody(connection, request) {
522
524
  : {}),
523
525
  max_tokens: request.maxOutputTokens,
524
526
  stream: true,
525
- };
527
+ });
526
528
  }
527
529
  function responsesRequestInput(connection, request) {
528
530
  return {
529
531
  connection,
530
532
  input: [
531
533
  ...responsesBootstrapMessages(connection, request),
532
- ...responsesAppend(request),
534
+ ...responsesAppend(connection, request),
533
535
  ],
534
536
  tools: providerToolDefinitions(request).map((tool) => ({
535
537
  name: tool.name,
@@ -555,7 +557,7 @@ function responsesRequestBody(input) {
555
557
  tools: allowedTools.map((name) => ({ type: "function", name })),
556
558
  }
557
559
  : "auto";
558
- return {
560
+ return checkedCacheBreakpoints({
559
561
  model: input.connection.modelId,
560
562
  ...responsesReasoningRequest(resolveConnection(input.connection)),
561
563
  input: input.input,
@@ -582,7 +584,7 @@ function responsesRequestBody(input) {
582
584
  store: false,
583
585
  include: ["reasoning.encrypted_content"],
584
586
  stream: true,
585
- };
587
+ });
586
588
  }
587
589
  /**
588
590
  * A frozen policy is present when the caller supplies the logical operation's
@@ -758,8 +760,88 @@ function promptCacheKey(connection, request) {
758
760
  .update(`${connection.provider}\0${connection.dialect}\0${connection.modelId}\0${scope}`)
759
761
  .digest("hex");
760
762
  }
761
- function chatAppend(request) {
762
- return items(request).flatMap((item) => {
763
+ /**
764
+ * Explicit-breakpoint encodings mark the last transcript item before the
765
+ * request-level suffix, so the next dispatch reads back the transcript it sent.
766
+ * A request without a suffix keeps its request end as the transcript's entry,
767
+ * so it is encoded exactly as before.
768
+ */
769
+ function appendWithSuffixBreakpoint(connection, request, encode, markLast) {
770
+ const { transcript, suffix } = splitRequestSuffix(request.appended);
771
+ const encoded = encode(transcript);
772
+ const breakpoint = suffix.length > 0 &&
773
+ encoded.length > 0 &&
774
+ connection.cacheSuffixBreakpoint &&
775
+ modelCacheStrategy(connection) !== "provider_managed";
776
+ return [...(breakpoint ? markLast(encoded) : encoded), ...encode(suffix)];
777
+ }
778
+ /** Request-level suffix items are recompiled for every dispatch and never stored. */
779
+ function splitRequestSuffix(appended) {
780
+ let end = appended.length;
781
+ while (end > 0 && isRequestSuffix(appended[end - 1]))
782
+ end -= 1;
783
+ return { transcript: appended.slice(0, end), suffix: appended.slice(end) };
784
+ }
785
+ function isRequestSuffix(item) {
786
+ return item.kind === "runtime_notice" && item.notice === "turn_guide";
787
+ }
788
+ /** Message-level cache_control, the form the CLIProxyAPI prefix breakpoints use. */
789
+ function markLastMessage(messages) {
790
+ const last = messages.at(-1);
791
+ if (!isRecord(last))
792
+ throw new Error("The transcript tail cannot carry a cache breakpoint.");
793
+ return [
794
+ ...messages.slice(0, -1),
795
+ { ...last, cache_control: { type: "ephemeral" } },
796
+ ];
797
+ }
798
+ /** Thinking blocks reject cache_control, so the breakpoint lands on the last block that accepts it. */
799
+ function markLastAnthropicBlock(messages) {
800
+ const last = messages.at(-1);
801
+ if (!isRecord(last))
802
+ throw new Error("The transcript tail cannot carry a cache breakpoint.");
803
+ const content = typeof last.content === "string"
804
+ ? [{ type: "text", text: last.content }]
805
+ : Array.isArray(last.content)
806
+ ? last.content
807
+ : [];
808
+ const index = content.findLastIndex((block) => isRecord(block) &&
809
+ block.type !== "thinking" &&
810
+ block.type !== "redacted_thinking");
811
+ if (index < 0)
812
+ throw new Error("The Anthropic transcript tail has no block that can carry a cache breakpoint.");
813
+ return [
814
+ ...messages.slice(0, -1),
815
+ {
816
+ ...last,
817
+ content: content.map((block, position) => position === index
818
+ ? {
819
+ ...block,
820
+ cache_control: { type: "ephemeral" },
821
+ }
822
+ : block),
823
+ },
824
+ ];
825
+ }
826
+ /** Refuses a request over the breakpoint limit instead of silently dropping one. */
827
+ function checkedCacheBreakpoints(body) {
828
+ const count = countCacheBreakpoints(body);
829
+ if (count > explicitCacheBreakpointLimit)
830
+ throw new Error(`The request has ${count} cache breakpoints; the Anthropic limit is ${explicitCacheBreakpointLimit}.`);
831
+ return body;
832
+ }
833
+ function countCacheBreakpoints(value) {
834
+ if (Array.isArray(value))
835
+ return value.reduce((total, item) => total + countCacheBreakpoints(item), 0);
836
+ if (!isRecord(value))
837
+ return 0;
838
+ return Object.entries(value).reduce((total, [key, item]) => total + (key === "cache_control" ? 1 : 0) + countCacheBreakpoints(item), 0);
839
+ }
840
+ function chatAppend(connection, request) {
841
+ return appendWithSuffixBreakpoint(connection, request, chatItems, markLastMessage);
842
+ }
843
+ function chatItems(items) {
844
+ return items.flatMap((item) => {
763
845
  if (item.kind === "player" ||
764
846
  item.kind === "user" ||
765
847
  item.kind === "runtime_notice")
@@ -782,7 +864,10 @@ function chatAppend(request) {
782
864
  throw continuationError("Chat Completions");
783
865
  });
784
866
  }
785
- function anthropicAppend(request) {
867
+ function anthropicAppend(connection, request) {
868
+ return appendWithSuffixBreakpoint(connection, request, anthropicItems, markLastAnthropicBlock);
869
+ }
870
+ function anthropicItems(items) {
786
871
  const messages = [];
787
872
  let toolResults = [];
788
873
  const flushToolResults = () => {
@@ -791,7 +876,7 @@ function anthropicAppend(request) {
791
876
  messages.push({ role: "user", content: toolResults });
792
877
  toolResults = [];
793
878
  };
794
- for (const item of items(request)) {
879
+ for (const item of items) {
795
880
  if (item.kind === "tool") {
796
881
  toolResults.push({
797
882
  type: "tool_result",
@@ -825,8 +910,11 @@ function anthropicAppend(request) {
825
910
  flushToolResults();
826
911
  return messages;
827
912
  }
828
- function responsesAppend(request) {
829
- return items(request).flatMap((item) => {
913
+ function responsesAppend(connection, request) {
914
+ return appendWithSuffixBreakpoint(connection, request, responsesItems, markLastMessage);
915
+ }
916
+ function responsesItems(items) {
917
+ return items.flatMap((item) => {
830
918
  if (item.kind === "player" ||
831
919
  item.kind === "user" ||
832
920
  item.kind === "runtime_notice")
@@ -1,15 +1,18 @@
1
1
  import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile, } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
+ /** Written last by the released-record cleanup (ADR-0034); its absence means "not yet run". */
7
+ const LAYOUT_MARKER = "play-advance-layout.json";
6
8
  /**
7
9
  * The single in-progress play advance of a call chain, if any.
8
10
  *
9
11
  * It exists only between `begin` and `markSettled`. A marker that survives a
10
12
  * restart means the advance was interrupted; the runtime marks the chain
11
- * interrupted and the player retries. Legacy records from earlier layouts are
12
- * ignored, so they never block a world from opening.
13
+ * interrupted and the player retries. Released records from earlier layouts
14
+ * never block a world from opening, and the storage upgrade removes them
15
+ * (see `migrateLayout`).
13
16
  */
14
17
  export class FileNativePlayAdvanceStore {
15
18
  #worldsRoot;
@@ -52,6 +55,50 @@ export class FileNativePlayAdvanceStore {
52
55
  force: true,
53
56
  });
54
57
  }
58
+ /**
59
+ * Whether the world's advance records are free of the pre-ADR-0044 layout
60
+ * (ADR-0034). Reads never write: a world with no released records is current
61
+ * without a marker.
62
+ */
63
+ async layoutCurrent(worldId) {
64
+ assertIdentity(worldId, "World ID");
65
+ if ((await readOptionalJson(this.#layoutMarkerPath(worldId))) !==
66
+ null)
67
+ return true;
68
+ return (await this.#releasedChainRoots(worldId)).length === 0;
69
+ }
70
+ /**
71
+ * Remove the released `records/` and `current.json` of every chain. Each
72
+ * chain's `in-progress.json` stays as it is, and a chain left empty is removed.
73
+ * The marker is written last, so an interrupted run simply runs again.
74
+ */
75
+ async migrateLayout(worldId) {
76
+ assertIdentity(worldId, "World ID");
77
+ if (await this.layoutCurrent(worldId))
78
+ return;
79
+ for (const chainRoot of await this.#releasedChainRoots(worldId)) {
80
+ await rm(join(chainRoot, "records"), { recursive: true, force: true });
81
+ await rm(join(chainRoot, "current.json"), { force: true });
82
+ if ((await readdir(chainRoot)).length === 0)
83
+ await rm(chainRoot, { recursive: true, force: true });
84
+ }
85
+ await publishJson(this.#layoutMarkerPath(worldId), layoutMarker());
86
+ }
87
+ /** Chain directories that still hold a released record or pointer. */
88
+ async #releasedChainRoots(worldId) {
89
+ const advancesRoot = join(this.#worldsRoot, worldId, "runtime", "play-advances");
90
+ const roots = [];
91
+ for (const name of await readDirectoryNames(advancesRoot)) {
92
+ const chainRoot = join(advancesRoot, name);
93
+ if ((await pathExists(join(chainRoot, "records"))) ||
94
+ (await pathExists(join(chainRoot, "current.json"))))
95
+ roots.push(chainRoot);
96
+ }
97
+ return roots;
98
+ }
99
+ #layoutMarkerPath(worldId) {
100
+ return join(this.#worldsRoot, worldId, "runtime", LAYOUT_MARKER);
101
+ }
55
102
  #markerPath(worldId, chainId) {
56
103
  return join(this.#worldsRoot, worldId, "runtime", "play-advances", digest(chainId), "in-progress.json");
57
104
  }
@@ -120,6 +167,26 @@ async function readOptionalJson(path) {
120
167
  throw error;
121
168
  }
122
169
  }
170
+ async function readDirectoryNames(directory) {
171
+ try {
172
+ return await readdir(directory);
173
+ }
174
+ catch (error) {
175
+ if (isNodeError(error) && error.code === "ENOENT")
176
+ return [];
177
+ throw error;
178
+ }
179
+ }
180
+ async function pathExists(path) {
181
+ return stat(path).then(() => true, (error) => {
182
+ if (isNodeError(error) && error.code === "ENOENT")
183
+ return false;
184
+ throw error;
185
+ });
186
+ }
187
+ function layoutMarker() {
188
+ return { schemaVersion: 1, kind: "play_advance_layout" };
189
+ }
123
190
  /** Atomic publish: write a temporary file, then rename over the target. */
124
191
  async function publishJson(path, value) {
125
192
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });