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.
@@ -1,3 +1,6 @@
1
+ import { HistoryQuery, historyInputs } from "../history/HistoryQuery.js";
2
+ import { renderHistoryResult } from "../history/HistoryRendering.js";
3
+ import { stateDirectoryHandle, parseStateDirectoryHandle, } from "../world/StateDirectoryHandles.js";
1
4
  import { renderDocumentWritePosition } from "../prompt/WorldMaintenanceReport.js";
2
5
  import { createHash } from "node:crypto";
3
6
  import { stringify } from "yaml";
@@ -113,19 +116,61 @@ export class FileNativePlayDocuments {
113
116
  documents,
114
117
  };
115
118
  }
116
- execute(call, history) {
119
+ execute(call, history, options = {}) {
120
+ let historyQuery;
121
+ const query = () => (historyQuery ??= new HistoryQuery(options.historyScope ?? "local-document-context", historyInputs(history)));
122
+ const render = (result) => renderHistoryResult(result, options.locale ?? "en");
123
+ const legacy = options.legacyHistoryTools !== false;
124
+ const args = call.arguments;
125
+ if (call.name === "history_search")
126
+ return render(query().search(args));
127
+ if (call.name === "history_read")
128
+ return render(query().read(args));
129
+ if (call.name === "history_list") {
130
+ return render(legacy && record(args) && args.order !== undefined
131
+ ? query().legacyList(args, "history_list")
132
+ : query().list(args));
133
+ }
134
+ if (call.name === "context_list" &&
135
+ record(args) &&
136
+ args.source === "history") {
137
+ const { source: _source, ...parameters } = args;
138
+ void _source;
139
+ return legacy
140
+ ? render(query().legacyList(parameters, "context_list"))
141
+ : toolFailure("Use history_list for committed history.");
142
+ }
143
+ if (call.name === "context_search" &&
144
+ record(args) &&
145
+ args.source === "history") {
146
+ const { source: _source, ...parameters } = args;
147
+ void _source;
148
+ return legacy
149
+ ? render(query().legacySearch(parameters))
150
+ : toolFailure("Use history_search for committed history.");
151
+ }
152
+ if (call.name === "context_read" &&
153
+ record(args) &&
154
+ typeof args.ref === "string" &&
155
+ /^@?history-message-/u.test(args.ref)) {
156
+ if (!legacy)
157
+ return toolFailure("Use history_read with refs supplied in injections or tool results.");
158
+ if (!hasOnlyToolKeys(args, ["ref", "cursor", "maxBytes"]))
159
+ return toolFailure("Legacy context_read requires ref and accepts only obsolete cursor/maxBytes in addition.");
160
+ return renderHistoryResult(query().read({
161
+ ref: args.ref.startsWith("@") ? args.ref : `@${args.ref}`,
162
+ }), options.locale ?? "en", true);
163
+ }
117
164
  if (call.name === "context_search")
118
- return executeContextSearch(this.#candidate.snapshot, history, call.arguments);
165
+ return executeContextSearch(this.#candidate.snapshot, call.arguments);
119
166
  if (call.name === "state_list")
120
- return executeStateList(this.#candidate.snapshot, history, call.arguments, this.#declaredDirectories);
121
- if (call.name === "history_list")
122
- return executeHistoryList(this.#candidate.snapshot, history, call.arguments);
167
+ return executeStateList(this.#candidate.snapshot, call.arguments, this.#declaredDirectories);
123
168
  // Frozen contexts created before runtime-tools-v5 retain this exact name
124
169
  // and argument shape. New contexts never advertise it.
125
170
  if (call.name === "context_list")
126
- return executeContextList(this.#candidate.snapshot, history, call.arguments, this.#declaredDirectories);
171
+ return executeContextList(this.#candidate.snapshot, call.arguments, this.#declaredDirectories);
127
172
  if (call.name === "context_read") {
128
- const result = executeContextRead(this.#candidate.snapshot, history, call.arguments);
173
+ const result = executeContextRead(this.#candidate.snapshot, call.arguments);
129
174
  authorizeToolRead(this.#reads, this.#candidate.snapshot, result);
130
175
  const ref = result.readAuthorization?.shortRef;
131
176
  if (ref !== undefined && result.markdown !== undefined)
@@ -518,7 +563,7 @@ function toolFailure(message, failureKind = "protocol") {
518
563
  failureKind,
519
564
  };
520
565
  }
521
- function executeContextSearch(snapshot, history, args) {
566
+ function executeContextSearch(snapshot, args) {
522
567
  if (!record(args) ||
523
568
  !hasOnlyToolKeys(args, [
524
569
  "source",
@@ -559,38 +604,7 @@ function executeContextSearch(snapshot, history, args) {
559
604
  ? { cursor: args.cursor }
560
605
  : {}),
561
606
  });
562
- const source = history.map(({ path, contents }) => [
563
- historyRef(path),
564
- contents,
565
- [`history-commit-${path.split("-")[0] ?? ""}`],
566
- ]);
567
- const normalizedQuery = normalizeSearch(query, caseSensitive);
568
- const within = typeof args.within === "string" ? args.within.replace(/^@/u, "") : null;
569
- const allHits = source.filter(([ref, text, parentScopes]) => (within === null || ref === within || parentScopes.includes(within)) &&
570
- normalizeSearch(text, caseSensitive).includes(normalizedQuery));
571
- const scope = JSON.stringify({
572
- kind: "search",
573
- source: "history",
574
- query,
575
- caseSensitive,
576
- within: args.within ?? null,
577
- limit,
578
- });
579
- const offset = parseCursor(args.cursor, scope);
580
- if (offset === null)
581
- return {
582
- ok: false,
583
- markdown: "# Runtime argument error\n\nThe cursor does not match the search criteria or endpoint.",
584
- };
585
- const hits = allHits.slice(offset, offset + limit);
586
- const complete = offset + hits.length >= allHits.length;
587
- const renderedHits = hits
588
- .map(([ref, text]) => `- @${ref}\n Exact-match excerpt:\n${quoteMarkdown(snippet(text, query), " ")}`)
589
- .join("\n");
590
- return {
591
- ok: true,
592
- markdown: `# Literal search\n\nScope: history${args.within === undefined ? "" : ` · ${args.within}`}\nNormalization: ${caseSensitive ? "original text" : "NFKC + case folding"}\nTotal matches: ${allHits.length}\n${renderedHits || "Zero literal matches do not prove that the fact is absent from the world."}\n\n---\nThis page: ${offset}..${offset + hits.length} / ${allHits.length} matches\nComplete: ${complete ? "yes" : "no"}${complete ? "" : `\nNext-page cursor: ${cursorFor(scope, offset + hits.length)}`}`,
593
- };
607
+ return toolFailure("Use history_search for committed history.");
594
608
  }
595
609
  function searchState(snapshot, input) {
596
610
  const within = parseStateSearchScope(input.within);
@@ -632,37 +646,6 @@ function parseStateSearchScope(handle) {
632
646
  ? invalidStateHandle
633
647
  : { document: { shortRef: document[1] } };
634
648
  }
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
649
  function validStateDirectory(directory) {
667
650
  if (directory.length === 0)
668
651
  return false;
@@ -682,7 +665,7 @@ function knownStateDirectory(snapshot, directory, declaredDirectories) {
682
665
  const logicalPrefix = `${snapshot.logicalRoot}/${relativePrefix}`;
683
666
  return snapshot.files.some(({ path }) => path.startsWith(logicalPrefix));
684
667
  }
685
- function executeStateList(snapshot, history, args, declaredDirectories) {
668
+ function executeStateList(snapshot, args, declaredDirectories) {
686
669
  if (!record(args) ||
687
670
  !hasOnlyToolKeys(args, ["parent", "cursor", "limit"]) ||
688
671
  typeof args.parent !== "string" ||
@@ -693,21 +676,9 @@ function executeStateList(snapshot, history, args, declaredDirectories) {
693
676
  ok: false,
694
677
  markdown: "# Runtime argument error\n\nstate_list requires an @dir-* parent returned by Runtime and accepts only cursor and limit in addition; use history_list for committed history.",
695
678
  };
696
- return executeContextList(snapshot, history, { ...args, source: "state" }, declaredDirectories);
679
+ return executeContextList(snapshot, { ...args, source: "state" }, declaredDirectories);
697
680
  }
698
- function executeHistoryList(snapshot, history, args) {
699
- if (!record(args) ||
700
- !hasOnlyToolKeys(args, ["order", "cursor", "limit"]) ||
701
- (args.order !== "newest_first" && args.order !== "oldest_first") ||
702
- (args.limit !== undefined && typeof args.limit !== "number") ||
703
- !validOptionalCursor(args.cursor))
704
- return {
705
- ok: false,
706
- markdown: "# Runtime argument error\n\nhistory_list requires newest_first or oldest_first order and accepts only cursor and limit in addition; use state_list for state directories.",
707
- };
708
- return executeContextList(snapshot, history, { ...args, source: "history" });
709
- }
710
- function executeContextList(snapshot, history, args, declaredDirectories = []) {
681
+ function executeContextList(snapshot, args, declaredDirectories = []) {
711
682
  if (!record(args) || (args.source !== "state" && args.source !== "history"))
712
683
  return {
713
684
  ok: false,
@@ -774,31 +745,9 @@ function executeContextList(snapshot, history, args, declaredDirectories = []) {
774
745
  markdown: `# Directory listing\n\nScope: state · ${parent}\nCoverage: ${result.coverage.status === "complete" ? "complete" : "partial"}\n${entries.join("\n") || "(empty)"}\n\n---\nThis page: ${result.page.start}..${result.page.end} / ${result.page.total} items\nComplete: ${result.page.complete ? "yes" : "no"}${result.page.nextCursor === null ? "" : `\nNext-page cursor: ${result.page.nextCursor}`}`,
775
746
  };
776
747
  }
777
- const entries = [...history]
778
- .sort((a, b) => (args.order ?? "newest_first") === "oldest_first"
779
- ? a.path.localeCompare(b.path)
780
- : b.path.localeCompare(a.path))
781
- .map(({ path, contents }) => `- @${historyRef(path)}, ${Buffer.byteLength(contents, "utf8")} bytes`);
782
- const scope = JSON.stringify({
783
- kind: "list",
784
- source: "history",
785
- order: args.order,
786
- limit,
787
- });
788
- const offset = parseCursor(args.cursor, scope);
789
- if (offset === null)
790
- return {
791
- ok: false,
792
- markdown: "# Runtime argument error\n\nThe cursor does not match the listing criteria or endpoint.",
793
- };
794
- const page = entries.slice(offset, offset + limit);
795
- const complete = offset + page.length >= entries.length;
796
- return {
797
- ok: true,
798
- markdown: `# Directory listing\n\nScope: history · ${String(args.order)}\n${page.join("\n") || "(empty)"}\n\n---\nThis page: ${offset}..${offset + page.length} / ${entries.length} items\nComplete: ${complete ? "yes" : "no"}${complete ? "" : `\nNext-page cursor: ${cursorFor(scope, offset + page.length)}`}`,
799
- };
748
+ return toolFailure("Use history_list for committed history.");
800
749
  }
801
- function executeContextRead(snapshot, history, args) {
750
+ function executeContextRead(snapshot, args) {
802
751
  if (!record(args) ||
803
752
  // Frozen contexts created before complete exact reads still advertise
804
753
  // cursor/maxBytes. Accept and ignore those obsolete arguments so an
@@ -809,13 +758,6 @@ function executeContextRead(snapshot, history, args) {
809
758
  ok: false,
810
759
  markdown: "# Runtime argument error\n\nA stable ref returned by list or search is required.",
811
760
  };
812
- const historyRefValue = args.ref.replace(/^@/u, "");
813
- const historyEntry = history.find(({ path }) => historyRef(path) === historyRefValue);
814
- if (historyEntry !== undefined)
815
- return {
816
- ok: true,
817
- markdown: `# Exact read @${historyRefValue}\n\n${historyEntry.contents}\n\n---\nSource: @${historyRefValue}\nComplete: yes`,
818
- };
819
761
  const handle = parseStateReadHandle(args.ref);
820
762
  if (handle === null)
821
763
  return {
@@ -956,45 +898,6 @@ function hasOnlyToolKeys(value, allowed) {
956
898
  function validOptionalCursor(value) {
957
899
  return value === undefined || value === null || typeof value === "string";
958
900
  }
959
- function normalizeSearch(value, caseSensitive) {
960
- const normalized = value.normalize("NFKC");
961
- return caseSensitive ? normalized : normalized.toLocaleLowerCase("und");
962
- }
963
- function snippet(text, query) {
964
- const position = text
965
- .normalize("NFKC")
966
- .toLocaleLowerCase("und")
967
- .indexOf(query.normalize("NFKC").toLocaleLowerCase("und"));
968
- const start = Math.max(0, position - 120);
969
- return text.slice(start, start + 240).replace(/\s+/gu, " ");
970
- }
971
- function historyRef(path) {
972
- return `history-message-${path.replace(/\.md$/u, "")}`;
973
- }
974
- function cursorFor(scope, offset) {
975
- return Buffer.from(JSON.stringify({
976
- scope: createHash("sha256").update(scope).digest("hex"),
977
- offset,
978
- }), "utf8").toString("base64url");
979
- }
980
- function parseCursor(cursor, scope) {
981
- if (cursor === undefined || cursor === null)
982
- return 0;
983
- if (typeof cursor !== "string")
984
- return null;
985
- try {
986
- const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
987
- if (!record(value) ||
988
- value.scope !== createHash("sha256").update(scope).digest("hex") ||
989
- !Number.isSafeInteger(value.offset) ||
990
- Number(value.offset) < 0)
991
- return null;
992
- return Number(value.offset);
993
- }
994
- catch {
995
- return null;
996
- }
997
- }
998
901
  export function parseNarrative(args) {
999
902
  if (!record(args) ||
1000
903
  typeof args.text !== "string" ||
@@ -0,0 +1,264 @@
1
+ import { FileNativePlayDocuments, } from "./PlayDocumentTools.js";
2
+ import { currentPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
3
+ import { isPlayerRoundMarker, playerInputAppend } from "./PlayContinuity.js";
4
+ import { PlayCallChainError } from "./PlayCallChainError.js";
5
+ /**
6
+ * Prepare the selected player's prefix at the existing pre-commit seam.
7
+ * Endpoint reads, revision submission and publication belong to the operation.
8
+ */
9
+ export function preparePlayerRevisionTrace(source, eventIndex) {
10
+ const prefix = structuredClone(source.events.slice(0, eventIndex));
11
+ const transcript = transcriptThroughEvents(source.transcript, prefix);
12
+ const player = source.events[eventIndex];
13
+ return {
14
+ // This append precedes the operation's materialization check, as before.
15
+ withReplacement(selected, replacement) {
16
+ const events = [
17
+ ...prefix,
18
+ {
19
+ id: player.id,
20
+ kind: "player",
21
+ exchangeId: replacement.exchangeId,
22
+ text: replacement.text,
23
+ context: player.context,
24
+ committedHead: replacement.head,
25
+ },
26
+ ];
27
+ const continuedTranscript = [
28
+ ...transcript,
29
+ ...playerInputAppend({
30
+ history: Object.fromEntries(selected.history.map(({ messageId, exactText }) => [
31
+ messageId,
32
+ exactText,
33
+ ])),
34
+ checkpoint: selected.narrativeCheckpoint,
35
+ text: replacement.text,
36
+ locale: replacement.locale,
37
+ checkpointAvailable: source.tools.some(({ name }) => name === "world_checkpoint"),
38
+ }),
39
+ ];
40
+ return {
41
+ atEndpoint(endpoint) {
42
+ return assembleRetainedTrace(source, endpoint, {
43
+ events,
44
+ transcript: continuedTranscript,
45
+ toolEvents: prefix,
46
+ authorizationEvents: prefix,
47
+ exchangeEvents: prefix,
48
+ complete: false,
49
+ materials: endpoint.binding.additionalMaterials,
50
+ });
51
+ },
52
+ };
53
+ },
54
+ pagePrefixAtEndpoint(endpoint) {
55
+ if (prefix.length === 0)
56
+ return null;
57
+ // Preserve the fresh path's post-commit prefix reconstruction seam.
58
+ const prefixTranscript = transcriptThroughEvents(source.transcript, prefix);
59
+ return assembleRetainedTrace(source, endpoint, {
60
+ events: prefix,
61
+ transcript: prefixTranscript,
62
+ toolEvents: prefix,
63
+ authorizationEvents: prefix,
64
+ exchangeEvents: prefix,
65
+ complete: false,
66
+ materials: endpoint.selected.additionalMaterials,
67
+ });
68
+ },
69
+ };
70
+ }
71
+ /** A complete context defines its own closure; partial contexts use page events. */
72
+ export function prepareForkTrace(source, events) {
73
+ const complete = events.length === source.events.length;
74
+ const transcript = complete
75
+ ? structuredClone(source.transcript)
76
+ : transcriptThroughEvents(source.transcript, events);
77
+ return {
78
+ atEndpoint(endpoint) {
79
+ return assembleRetainedTrace(source, endpoint, {
80
+ events,
81
+ transcript,
82
+ toolEvents: events,
83
+ authorizationEvents: events,
84
+ exchangeEvents: events,
85
+ complete,
86
+ materials: endpoint.binding.additionalMaterials,
87
+ });
88
+ },
89
+ };
90
+ }
91
+ function assembleRetainedTrace(source, endpoint, selection) {
92
+ const completedKeys = selection.complete
93
+ ? null
94
+ : completedToolKeys(selection.toolEvents);
95
+ const documents = restorePlayDocuments(endpoint.binding.files, source, selection.authorizationEvents);
96
+ return {
97
+ continuityContextId: source.continuityContextId ?? source.chainId,
98
+ baselineHead: source.baselineHead,
99
+ baselineHistoryLength: source.baselineHistoryLength ?? endpoint.baseline.history.length,
100
+ promptRuns: playPromptRunsThroughEvents(source, selection.events),
101
+ ...(source.presetFiles === undefined
102
+ ? {}
103
+ : { presetFiles: structuredClone(source.presetFiles) }),
104
+ playPreset: structuredClone(source.playPreset),
105
+ ...(source.followups === undefined
106
+ ? {}
107
+ : { followups: structuredClone(source.followups) }),
108
+ ...(source.playPresetScriptsEnabled === undefined
109
+ ? {}
110
+ : { playPresetScriptsEnabled: source.playPresetScriptsEnabled }),
111
+ ...(source.modelBinding === undefined
112
+ ? {}
113
+ : { modelBinding: structuredClone(source.modelBinding) }),
114
+ status: "ready",
115
+ canRetry: false,
116
+ bootstrap: structuredClone(source.bootstrap),
117
+ tools: structuredClone(source.tools),
118
+ transcript: structuredClone(selection.transcript),
119
+ events: structuredClone([...selection.events]),
120
+ completedTools: source.completedTools
121
+ .filter(({ key }) => completedKeys === null || completedKeys.has(key))
122
+ .map((item) => structuredClone(item)),
123
+ documentAuthorizationCheckpoints: authorizationCheckpointsThroughEvents(source, selection.events, documents.authorizationCheckpoint()),
124
+ changedDocuments: changedDocumentsAtHead(source.changedDocuments, endpoint.baseline.state, endpoint.selected.state),
125
+ nextMaterials: structuredClone(selection.materials),
126
+ nextEventId: Math.max(0, ...selection.events.map(({ id }) => id)) + 1,
127
+ exchange: completedAssistantExchange(selection.exchangeEvents),
128
+ lastRequest: null,
129
+ lastRequestAttempt: 0,
130
+ lastFailure: null,
131
+ };
132
+ }
133
+ export function restorePlayDocuments(files, context, events) {
134
+ const documents = new FileNativePlayDocuments(files);
135
+ try {
136
+ const checkpoint = documentAuthorizationThroughEvents(context, events);
137
+ const runs = playPromptRunsThroughEvents(context, events);
138
+ if (checkpoint === undefined || runs.length > 0)
139
+ documents.bindBootstrap(currentPlayPrompt({ ...context, promptRuns: runs }).bootstrap);
140
+ if (checkpoint !== undefined)
141
+ documents.restoreAuthorizationCheckpoint(checkpoint.authorization);
142
+ }
143
+ catch (error) {
144
+ throw new PlayCallChainError(`Call-chain document authorization could not be restored: ${error instanceof Error ? error.message : "invalid checkpoint"}。`);
145
+ }
146
+ return documents;
147
+ }
148
+ function documentAuthorizationThroughEvents(context, events) {
149
+ const checkpoints = context.documentAuthorizationCheckpoints;
150
+ if (checkpoints === undefined || checkpoints.length === 0)
151
+ return undefined;
152
+ const selectedEventIds = new Set(events.map(({ id }) => id));
153
+ const selected = checkpoints.findLast(({ afterEventId }) => afterEventId === 0 || selectedEventIds.has(afterEventId));
154
+ // Legacy V1 records had no durable dynamic authorization. If the selected
155
+ // prefix predates their first lazily-written checkpoint, retain the exact
156
+ // old recovery behavior and rebuild only bootstrap authorization.
157
+ return selected === undefined ? undefined : structuredClone(selected);
158
+ }
159
+ export function independentContextCopy(source) {
160
+ const context = structuredClone(source);
161
+ context.continuityContextId ??= source.chainId;
162
+ delete context.derivedFrom;
163
+ delete context.branchedBeforePlayer;
164
+ return context;
165
+ }
166
+ function authorizationCheckpointsThroughEvents(context, events, fallback) {
167
+ const eventIds = new Set(events.map(({ id }) => id));
168
+ const selected = (context.documentAuthorizationCheckpoints ?? [])
169
+ .filter(({ afterEventId }) => afterEventId === 0 || eventIds.has(afterEventId))
170
+ .map((checkpoint) => structuredClone(checkpoint));
171
+ if (selected.length > 0)
172
+ return selected;
173
+ return [
174
+ {
175
+ afterEventId: Math.max(0, ...events.map(({ id }) => id)),
176
+ authorization: structuredClone(fallback),
177
+ },
178
+ ];
179
+ }
180
+ function transcriptThroughEvents(transcript, events) {
181
+ const result = [];
182
+ let cursor = 0;
183
+ for (const [index, event] of events.entries()) {
184
+ if (event.kind === "player") {
185
+ const marker = transcript[cursor];
186
+ if (isPlayerRoundMarker(marker)) {
187
+ result.push(structuredClone(marker));
188
+ cursor += 1;
189
+ }
190
+ const item = transcript[cursor];
191
+ if (item?.kind !== "player" || item.text !== event.text)
192
+ throw new PlayCallChainError("Source call-chain events do not match the model transcript, so a fork cannot be created safely.");
193
+ result.push(structuredClone(item));
194
+ cursor += 1;
195
+ continue;
196
+ }
197
+ if (event.kind === "assistant" && event.status === "completed") {
198
+ const notice = transcript[cursor];
199
+ const hasContinuationNotice = notice?.kind === "runtime_notice" && notice.notice === "continuation";
200
+ const item = transcript[cursor + (hasContinuationNotice ? 1 : 0)];
201
+ const hasToolCall = events
202
+ .slice(index + 1)
203
+ .find((candidate) => candidate.kind === "player" ||
204
+ candidate.kind === "assistant" ||
205
+ candidate.kind === "tool_call");
206
+ const recorded = event.text.trim().length > 0 ||
207
+ hasToolCall?.kind === "tool_call" ||
208
+ item?.kind === "assistant";
209
+ if (!recorded)
210
+ continue;
211
+ if (item?.kind !== "assistant" || item.text !== event.text)
212
+ throw new PlayCallChainError("Source call-chain responses do not match the model transcript, so a fork cannot be created safely.");
213
+ // A continuation notice belongs to the generation it precedes, never
214
+ // to a fork ending at the previously completed narrative.
215
+ if (hasContinuationNotice) {
216
+ result.push(structuredClone(notice));
217
+ cursor += 1;
218
+ }
219
+ result.push(structuredClone(item));
220
+ cursor += 1;
221
+ continue;
222
+ }
223
+ if (event.kind === "tool_result") {
224
+ const item = transcript[cursor];
225
+ if (item?.kind !== "tool" || item.toolCallId !== event.callId)
226
+ throw new PlayCallChainError("Source call-chain tool results do not match the model transcript, so a fork cannot be created safely.");
227
+ result.push(structuredClone(item));
228
+ cursor += 1;
229
+ // A tool-step notice follows the entire settled batch and remains part
230
+ // of its closure even when the selected endpoint stops at that batch.
231
+ const notice = transcript[cursor];
232
+ if (notice?.kind === "runtime_notice" && notice.notice === "tool_step") {
233
+ result.push(structuredClone(notice));
234
+ cursor += 1;
235
+ }
236
+ }
237
+ }
238
+ return result;
239
+ }
240
+ function completedToolKeys(events) {
241
+ const keys = new Set();
242
+ let exchange = null;
243
+ for (const event of events) {
244
+ if (event.kind === "player")
245
+ exchange = null;
246
+ else if (event.kind === "assistant")
247
+ exchange = event.exchange;
248
+ else if (event.kind === "tool_call" && exchange !== null)
249
+ keys.add(`${exchange}:${event.callId}`);
250
+ }
251
+ return keys;
252
+ }
253
+ function changedDocumentsAtHead(changes, baseline, selected) {
254
+ const before = new Map(baseline.map((file) => [file.path, file.contents]));
255
+ const after = new Map(selected.map((file) => [file.path, file.contents]));
256
+ return changes
257
+ .filter(({ path }) => after.has(path) && before.get(path) !== after.get(path))
258
+ .map((change) => structuredClone(change));
259
+ }
260
+ function completedAssistantExchange(events) {
261
+ return Math.max(0, ...events
262
+ .filter((event) => event.kind === "assistant" && event.status === "completed")
263
+ .map(({ exchange }) => exchange));
264
+ }