@sublang/playbook 17.3.0 → 17.4.0

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 (41) hide show
  1. package/README.md +5 -2
  2. package/docs/configuration.md +45 -4
  3. package/docs/embedding.md +94 -0
  4. package/package.json +34 -5
  5. package/reference/sdlc/captain.md +6 -4
  6. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +5 -0
  7. package/reference/sdlc/captain.playbook/captain.fsm.js +4 -3
  8. package/reference/sdlc/captain.playbook/captain.fsm.ts +9 -4
  9. package/reference/sdlc/captain.playbook/captain.gears.md +6 -3
  10. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +2 -0
  11. package/reference/sdlc/captain.playbook/captain.playbook.js +7 -3
  12. package/reference/sdlc/captain.playbook/captain.playbook.ts +8 -4
  13. package/reference/sdlc/code.playbook/asset-types.d.ts +32 -0
  14. package/reference/sdlc/code.playbook/bin/launch-config.js +2 -0
  15. package/reference/sdlc/code.playbook/bin/portable-codec.js +12 -5
  16. package/reference/sdlc/code.playbook/bin/run.js +68 -4
  17. package/reference/sdlc/code.playbook/bin/session-host.js +16 -4
  18. package/reference/sdlc/code.playbook/bin/session-media.js +46 -0
  19. package/reference/sdlc/code.playbook/bin/session-store.js +139 -13
  20. package/reference/sdlc/code.playbook/playbook-captain.d.ts +17 -1
  21. package/reference/sdlc/code.playbook/playbook-captain.js +159 -18
  22. package/reference/sdlc/code.playbook/playbook-captain.ts +176 -23
  23. package/reference/sdlc/code.playbook/playbook.config.template.yaml +16 -3
  24. package/reference/sdlc/code.playbook/session-assets.d.ts +53 -0
  25. package/reference/sdlc/code.playbook/session-assets.js +302 -0
  26. package/reference/sdlc/code.playbook/session-host.d.ts +9 -1
  27. package/reference/sdlc/code.playbook/session-store.d.ts +26 -2
  28. package/reference/sdlc/code.playbook/worker-evidence.d.ts +8 -0
  29. package/reference/sdlc/code.playbook/worker-evidence.js +51 -0
  30. package/reference/sdlc/inspect.md +40 -0
  31. package/reference/sdlc/inspect.playbook/inspect.fsm.d.ts +168 -0
  32. package/reference/sdlc/inspect.playbook/inspect.fsm.js +354 -0
  33. package/reference/sdlc/inspect.playbook/inspect.fsm.ts +455 -0
  34. package/reference/sdlc/inspect.playbook/inspect.gears.md +36 -0
  35. package/reference/sdlc/inspect.playbook/inspect.playbook.d.ts +17 -0
  36. package/reference/sdlc/inspect.playbook/inspect.playbook.js +142 -0
  37. package/reference/sdlc/inspect.playbook/inspect.playbook.ts +184 -0
  38. package/reference/sdlc/inspect.playbook/inspect.registry.d.ts +41 -0
  39. package/reference/sdlc/inspect.playbook/inspect.registry.js +51 -0
  40. package/reference/sdlc/inspect.playbook/inspect.registry.ts +110 -0
  41. package/slc/workflow-contracts.json +43 -1
@@ -2149,6 +2149,7 @@ function sessionAgentFromHostAgent(agent, path) {
2149
2149
  }
2150
2150
  return {
2151
2151
  adapter: agent.adapter,
2152
+ ...(agent.browser === undefined ? {} : { browser: fastModeSelection(agent.browser, `${path}.browser`) }),
2152
2153
  model: tuningSelection(agent.model, `${path}.model`),
2153
2154
  effort: tuningSelection(agent.effort, `${path}.effort`),
2154
2155
  ...(agent.fastMode === undefined
@@ -2186,6 +2187,7 @@ export function projectHostAgent(agent, path = "agent") {
2186
2187
  const normalized = cloneJson(agent, path);
2187
2188
  return {
2188
2189
  adapter: normalized.adapter,
2190
+ ...(normalized.browser === undefined ? {} : { browser: fastModeSelection(normalized.browser, `${path}.browser`) }),
2189
2191
  ...(normalized.model?.kind === "value"
2190
2192
  ? { model: normalized.model.value }
2191
2193
  : {}),
@@ -2,6 +2,7 @@
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
4
  import { createHash } from 'node:crypto';
5
+ import { validateAssetRef } from '../session-assets.js';
5
6
  import { posix, win32 } from 'node:path';
6
7
  import { isDeepStrictEqual } from 'node:util';
7
8
  import {
@@ -16,7 +17,7 @@ export const EMPTY_REPLAY_SHA256 = createHash('sha256').digest('hex');
16
17
  export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
17
18
  const COMMON = ['schemaVersion', 'kind', 'sessionId', 'cwd', 'createdAt', 'updatedAt', 'state', 'replay', 'contextSeq'];
18
19
  const RECOVERY = ['structuralProjection', 'lastAppliedExecutionProjection', 'snapshot', 'effectLedger', 'unresolvedEffects'];
19
- const OPTIONAL = ['retainedGenerations', 'settledAbandonment'];
20
+ const OPTIONAL = ['retainedGenerations', 'settledAbandonment', 'assets'];
20
21
  const HEX = /^[0-9a-f]{64}$/;
21
22
  const clone = (value) => structuredClone(value);
22
23
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -44,11 +45,17 @@ export function validateReplayCheckpoint(value) {
44
45
  export function validateSessionManifest(value) {
45
46
  if (!object(value) || value.schemaVersion !== 7) throw new Error(`unsupported session manifest schema ${value?.schemaVersion}`);
46
47
  const history = value.state === 'history-only';
47
- exact(value, [...COMMON, ...(history ? ['reason'] : RECOVERY), ...(value.state === 'uncertain' ? ['uncertain'] : [])], history ? [] : OPTIONAL);
48
+ exact(value, [...COMMON, ...(history ? ['reason'] : RECOVERY), ...(value.state === 'uncertain' ? ['uncertain'] : [])], history ? ['assets'] : OPTIONAL);
48
49
  if (value.kind !== 'captain-session' || !SESSION_ID_PATTERN.test(value.sessionId) || !isRecordedAbsolutePath(value.cwd)) throw new Error('invalid session manifest identity');
49
50
  iso(value.createdAt); iso(value.updatedAt);
50
51
  if (Date.parse(value.updatedAt) < Date.parse(value.createdAt)) throw new Error('session update precedes creation');
51
52
  validateReplayCheckpoint(value.replay);
53
+ if (value.assets !== undefined) {
54
+ exact(value.assets, ['version', 'entries']);
55
+ if (value.assets.version !== 1 || !Array.isArray(value.assets.entries)) throw new Error('unsupported session asset manifest');
56
+ const ids = new Set();
57
+ for (const item of value.assets.entries) { const reference = validateAssetRef(item); if (ids.has(reference.assetId)) throw new Error('duplicate session asset reference'); ids.add(reference.assetId); }
58
+ }
52
59
  if (!(history && value.contextSeq === null) && (!Number.isSafeInteger(value.contextSeq) || value.contextSeq <= 0 || value.contextSeq > value.replay.seq)) throw new Error('invalid session context reference');
53
60
  if (history) {
54
61
  if (!nonempty(value.reason)) throw new Error('history-only session needs a reason');
@@ -60,7 +67,7 @@ export function validateSessionManifest(value) {
60
67
  return clone(value);
61
68
  }
62
69
  function recoveryFromManifestUnchecked(value) {
63
- const { replay, contextSeq, ...recovery } = value;
70
+ const { replay, contextSeq, assets, ...recovery } = value;
64
71
  return { ...recovery, schemaVersion: 6 };
65
72
  }
66
73
  export function recoveryFromManifest(value) {
@@ -68,9 +75,9 @@ export function recoveryFromManifest(value) {
68
75
  if (manifest.state === 'history-only') throw new Error(manifest.reason);
69
76
  return validateCaptainSessionRecord(recoveryFromManifestUnchecked(manifest));
70
77
  }
71
- export function manifestFromRecovery(value, replay, contextSeq) {
78
+ export function manifestFromRecovery(value, replay, contextSeq, assets) {
72
79
  const recovery = projectRecovery(validateCaptainSessionRecord(value));
73
- return validateSessionManifest({ ...recovery, schemaVersion: 7, replay, contextSeq });
80
+ return validateSessionManifest({ ...recovery, schemaVersion: 7, replay, contextSeq, ...(assets === undefined ? {} : { assets }) });
74
81
  }
75
82
  export function projectRecovery(value) {
76
83
  const source = clone(value);
@@ -7,6 +7,8 @@
7
7
  // presenter; it does not construct a registry runtime or PlaybookPorts itself.
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
+ import { workerReportExcerpt, workerRecoveryReportBlock } from "../worker-evidence.js";
11
+ import { createSessionMediaProjector } from "./session-media.js";
10
12
  import { attachSessionHints, validateSessionContext } from "./portable-codec.js";
11
13
  import { homedir } from "node:os";
12
14
  import { resolve } from "node:path";
@@ -995,6 +997,13 @@ export function restoreInterruptedProgress(record, ledger) {
995
997
  : `Captain preparation for ${name}: ${step.result.summary}`;
996
998
  return `${name} ${step.kind} call: ${step.result === undefined ? 'started; no result was saved' : step.result?.status === undefined ? 'result saved' : `returned ${step.result.status}`}.`;
997
999
  });
1000
+ const workerReports = changedBoundaries.flatMap((boundary) => {
1001
+ const completedStep = steps.find((step) => step.kind === 'player' &&
1002
+ step.workerEvidence?.boundaryId === boundary.boundaryId);
1003
+ if (!completedStep) return [];
1004
+ const report = workerReportExcerpt(boundary, completedStep.workerEvidence.playerId, completedStep.result.guard);
1005
+ return report === undefined ? [] : [report];
1006
+ });
998
1007
  const completedRoots = steps.filter((step) => step.kind === 'completion' && step.result.retention !== 'keep').map((step) => ({ kind: 'clear', rootPlaybookId: step.playbookId }));
999
1008
  const report = {
1000
1009
  retentionUpdates: completedRoots,
@@ -1008,7 +1017,7 @@ export function restoreInterruptedProgress(record, ledger) {
1008
1017
  ? completion && !saved.frames?.length
1009
1018
  ? 'The completed run is restored; no work was repeated.'
1010
1019
  : 'The run stopped. Its saved position is restored; no work was repeated. Choose how to continue. Before repeating unfinished work, confirm that the earlier worker has stopped and check any outside actions it may have taken.'
1011
- : 'The exact stopping point was not saved. Files and repository evidence are preserved; no work was repeated. Check the saved work and stop any earlier worker before starting another attempt.', ...facts].join('\n\n'),
1020
+ : 'The exact stopping point was not saved. Files and repository evidence are preserved; no work was repeated. Check the saved work and stop any earlier worker before starting another attempt.', ...facts, ...(workerReports.length ? [workerRecoveryReportBlock(workerReports)] : [])].join('\n\n'),
1012
1021
  };
1013
1022
  if (noWork) return { snapshot: base, report };
1014
1023
  if (saved) {
@@ -1068,6 +1077,7 @@ export async function createCaptainSessionHost({
1068
1077
  signal,
1069
1078
  graphs = [],
1070
1079
  initialVisible = [],
1080
+ approvalHandler,
1071
1081
  }) {
1072
1082
  if (restoreSnapshot !== undefined) await sessionLease.assertContinuable({ cwd, executionProjection: config });
1073
1083
  const hostCapabilities = await createRepositoryEffectCapabilities({
@@ -1115,23 +1125,30 @@ export async function createCaptainSessionHost({
1115
1125
  if (sourceSnapshot !== undefined && !interrupted && typeof sessionLease.consumeHints === "function") {
1116
1126
  sourceSnapshot = attachSessionHints(sourceSnapshot, await sessionLease.consumeHints());
1117
1127
  }
1128
+ let presentationAttachments = [];
1129
+ const mediaProjector = createSessionMediaProjector({ lease: sessionLease, onEvidence: (asset, origin) => shell.recordMediaEvidence(asset, origin.runtimeSessionId) });
1118
1130
  const bufferedRecords = [];
1119
1131
  let presentationReady = false;
1120
1132
  const presentationTurnOffset = restoreSnapshot?.sequences.turn ?? 0;
1133
+ const approvals = createHostApprovalScope(approvalHandler, presentationTurnOffset);
1121
1134
  const forwardRecord = async (record) => {
1135
+ if (record.type === 'turn_started' && presentationAttachments.length) record = { ...record, turn: { ...record.turn, attachments: presentationAttachments } };
1122
1136
  const projected = presentationTurnOffset > 0 && typeof record.turnId === "number"
1123
1137
  ? { ...record, turnId: record.turnId + presentationTurnOffset, ...(record.type === "turn_started" ? { turn: { ...record.turn, id: record.turn.id + presentationTurnOffset } } : {}) }
1124
1138
  : record;
1125
- for (const observer of observers ?? []) await observer.onRecord?.(projected);
1139
+ for (const value of await mediaProjector.project(projected)) for (const observer of observers ?? []) await observer.onRecord?.(value);
1126
1140
  };
1127
1141
  const bufferedObservers = [{ async onRecord(record) {
1128
1142
  if (!presentationReady) bufferedRecords.push(record);
1129
1143
  else await forwardRecord(record);
1130
1144
  } }];
1131
1145
  const shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
1146
+ onTurnAttachments: (references) => { presentationAttachments = references; },
1147
+ beginPreparationMedia: (runtimeSessionId) => mediaProjector.beginPreparation(runtimeSessionId),
1132
1148
  abortPreparation: (reason) => host?.abortActiveTurn(typeof reason === 'string' ? reason : 'Captain preparation exceeded its 150-second limit'),
1133
1149
  loadModule,
1134
1150
  hostCapabilities,
1151
+ ...(typeof sessionLease.resolveAttachments === 'function' ? { resolveAttachments: (references, signal) => sessionLease.resolveAttachments(references, { signal }) } : {}),
1135
1152
  ...(typeof sessionLease.recordProgress === 'function' ? {
1136
1153
  recordProgress: (change) => sessionLease.recordProgress(change),
1137
1154
  } : {}),
@@ -1166,6 +1183,7 @@ export async function createCaptainSessionHost({
1166
1183
  })),
1167
1184
  cwd,
1168
1185
  observers: bufferedObservers,
1186
+ ...(approvals.handler ? { approvalHandler: approvals.handler } : {}),
1169
1187
  ...(signal ? { signal } : {}),
1170
1188
  ...(adapterImports ? { adapterImports } : {}),
1171
1189
  });
@@ -1193,9 +1211,13 @@ export async function createCaptainSessionHost({
1193
1211
  }
1194
1212
  presentationReady = true;
1195
1213
  for (const record of bufferedRecords) await forwardRecord(record);
1196
- if (interruptedReport) shell.selectInterruptedReport(interrupted.uncertain.input, interruptedReport);
1197
- return { shell, host, snapshot, reconcileRepositoryEffects, getInterruptedSettlement: (attemptId) => attemptId !== undefined && interruptedSettlement?.attemptId === attemptId ? interruptedSettlement.record : undefined };
1214
+ if (interruptedReport) {
1215
+ shell.setTurnAttachments(interrupted.uncertain.attachments ?? []);
1216
+ shell.selectInterruptedReport(interrupted.uncertain.input, interruptedReport);
1217
+ }
1218
+ return { shell, host, snapshot, reconcileRepositoryEffects, cancelPendingApprovals: approvals.close, getInterruptedSettlement: (attemptId) => attemptId !== undefined && interruptedSettlement?.attemptId === attemptId ? interruptedSettlement.record : undefined };
1198
1219
  } catch (error) {
1220
+ approvals.close();
1199
1221
  let cleanupError;
1200
1222
  try {
1201
1223
  if (host !== undefined) await host.dispose();
@@ -1213,6 +1235,48 @@ export async function createCaptainSessionHost({
1213
1235
  }
1214
1236
  }
1215
1237
 
1238
+ // Callback liveness belongs to the host, independently of durable turn state.
1239
+ // Closing this scope denies waits without cancelling ordinary graceful work.
1240
+ function createHostApprovalScope(handler, turnOffset) {
1241
+ const lifetime = new AbortController();
1242
+ return {
1243
+ close: () => lifetime.abort('session host closing'),
1244
+ handler: typeof handler !== 'function' ? undefined : (envelope, { signal }) => {
1245
+ if (lifetime.signal.aborted || signal.aborted) return Promise.resolve('deny');
1246
+ const controller = new AbortController();
1247
+ return new Promise((resolve, reject) => {
1248
+ let settled = false;
1249
+ const finish = (decision) => {
1250
+ if (settled) return;
1251
+ settled = true;
1252
+ signal.removeEventListener('abort', cancel);
1253
+ lifetime.signal.removeEventListener('abort', cancel);
1254
+ if (!envelope.request.choices.includes(decision)) controller.abort('Invalid approval decision');
1255
+ resolve(decision);
1256
+ };
1257
+ const fail = (error) => {
1258
+ if (settled) return;
1259
+ settled = true;
1260
+ signal.removeEventListener('abort', cancel);
1261
+ lifetime.signal.removeEventListener('abort', cancel);
1262
+ controller.abort(error);
1263
+ reject(error);
1264
+ };
1265
+ const cancel = () => {
1266
+ controller.abort(signal.aborted ? signal.reason : lifetime.signal.reason);
1267
+ finish('deny');
1268
+ };
1269
+ signal.addEventListener('abort', cancel, { once: true });
1270
+ lifetime.signal.addEventListener('abort', cancel, { once: true });
1271
+ Promise.resolve().then(() => {
1272
+ if (settled) return 'deny';
1273
+ return handler({ ...envelope, turnId: envelope.turnId + turnOffset }, { signal: controller.signal });
1274
+ }).then(finish, fail);
1275
+ });
1276
+ },
1277
+ };
1278
+ }
1279
+
1216
1280
  // PBCLI-55: both presentations install one authoritative retention map at the
1217
1281
  // same post-shell, pre-turn boundary. Fresh-session guarded initialization
1218
1282
  // publishes either the intentional empty boundary or the transferred map.
@@ -2,6 +2,7 @@
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { normalizeSessionTurnInput } from '../session-assets.js';
5
6
  import { createCaptainSessionStore, isUncertainTurnDiscardable, projectCaptainSessionStructure } from './session-store.js';
6
7
  import { createCaptainSessionHost, executionConfigFromPlan, installRetainedGenerationsForLaunch, validateFrozenExecutionConfig } from './run.js';
7
8
  import { createReplayRecordObserver } from './replay-observer.js';
@@ -33,9 +34,10 @@ export async function openSessionHost(options) {
33
34
  if (record !== undefined) await lease.assertContinuable({ cwd, executionProjection: selected });
34
35
  const config = await validateFrozenExecutionConfig(structure, selected, { loadModule, prepareRegistryModule: options.prepareRegistryModule });
35
36
  const replay = createReplayRecordObserver({ lease, onIncomplete: options.onIncomplete ?? (() => {}), onStored: options.onStoredRecord });
36
- let terminal, replies = [];
37
+ let terminal, replies = [], submittedAttachments = [];
37
38
  const observe = {
38
39
  async onRecord(value) {
40
+ if (value.type === 'turn_started' && submittedAttachments.length) value = { ...value, turn: { ...value.turn, attachments: submittedAttachments } };
39
41
  if (value.type === "turn_finished" || value.type === "turn_aborted") terminal = value;
40
42
  if (value.type === "captain_reply") replies.push(value);
41
43
  await replay.observer.onRecord(value);
@@ -53,14 +55,21 @@ export async function openSessionHost(options) {
53
55
  const execute = async (input, retry, actionId, shellActionId) => {
54
56
  assertIdle();
55
57
  let attemptId;
58
+ let attachments = [];
56
59
  const operation = (async () => {
57
60
  let prior = await lease.read();
58
61
  if (retry) {
59
62
  if (prior?.state !== 'uncertain') throw new Error('session has no uncertain turn to retry');
60
63
  input = prior.uncertain.input;
64
+ attachments = prior.uncertain.attachments ?? [];
61
65
  if (!retryPending) throw new Error(RECOVERY_REOPEN);
62
66
  } else if (prior?.state !== 'settled') throw new Error(RECOVERY_REOPEN);
63
- if (actionId === undefined && shellActionId === undefined && (typeof input !== 'string' || input.trim().length === 0)) throw new Error('session input must be nonempty');
67
+ if (actionId === undefined && shellActionId === undefined) {
68
+ const normalized = normalizeSessionTurnInput(retry ? { text: input, attachments } : input);
69
+ input = normalized.text; attachments = normalized.attachments;
70
+ if (!input.trim() && attachments.length === 0) throw new Error('session input must contain text or attachments');
71
+ if (attachments.length) await lease.resolveAttachments(attachments, { signal: options.signal });
72
+ }
64
73
  await created.reconcileRepositoryEffects();
65
74
  // The selection is made after reconciliation, so the advertised
66
75
  // reading it is validated against is the one this turn settles
@@ -74,11 +83,13 @@ export async function openSessionHost(options) {
74
83
  }
75
84
  terminal = undefined; replies = [];
76
85
  attemptId = options.createAttemptId?.() ?? randomUUID();
77
- const marked = retry ? await lease.beginRetry({ expectedAttemptId: prior.uncertain.attemptId, nextAttemptId: attemptId }) : await lease.beginTurn({ input, attemptId, attemptedExecutionProjection: config });
86
+ const marked = retry ? await lease.beginRetry({ expectedAttemptId: prior.uncertain.attemptId, nextAttemptId: attemptId }) : await lease.beginTurn({ input, attachments, attemptId, attemptedExecutionProjection: config });
78
87
  retryPending = false;
79
88
  await replay.flushStoredRecords();
80
89
  await options.onCheckpoint?.(marked);
81
90
  await lease.assertOwner();
91
+ submittedAttachments = attachments;
92
+ created.shell.setTurnAttachments?.(attachments);
82
93
  await created.host.runBossTurn(input);
83
94
  if (terminal?.type !== "turn_finished" || replies.length !== 1 || typeof replies[0].text !== "string" || replies[0].text.trim().length === 0) throw new Error("Captain turn did not finish with one reply; session remains uncertain");
84
95
  const settlement = created.shell.exportSettlement();
@@ -101,12 +112,13 @@ export async function openSessionHost(options) {
101
112
  throw error;
102
113
  });
103
114
  active = operation;
104
- try { return await operation; } finally { active = undefined; }
115
+ try { return await operation; } finally { active = undefined; submittedAttachments = []; }
105
116
  };
106
117
 
107
118
  const dispose = () => {
108
119
  if (closed) return Promise.resolve();
109
120
  if (closing) return closing;
121
+ created.cancelPendingApprovals();
110
122
  closing = (async () => {
111
123
  try { await active; } catch { /* Preserve the durable uncertain marker. */ }
112
124
  await created.host.dispose();
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ import { randomUUID } from 'node:crypto';
5
+ import { externalizeAgentEvent } from '../session-assets.js';
6
+
7
+ /** Externalize authentic observed media before the opaque replay writer sees it. */
8
+ export function createSessionMediaProjector({ lease, onEvidence }) {
9
+ const players = new Map();
10
+ let preparation;
11
+ const seen = new Map();
12
+ const beginPreparation = (runtimeSessionId) => {
13
+ if (preparation) throw new Error('a preparation media scope is already active');
14
+ const scope = { callId: randomUUID(), origin: { kind: 'preparation', actorId: 'captain', runtimeSessionId } };
15
+ preparation = scope;
16
+ return () => { if (preparation === scope) preparation = undefined; seen.delete(scope.callId); };
17
+ };
18
+ const project = async (record) => {
19
+ if (record.type === 'captain_telemetry' && record.topic === 'playbook.trace') {
20
+ const trace = record.payload;
21
+ if (trace?.schemaVersion === 4 && trace.type === 'player.call.started' && typeof trace.callId === 'string' && typeof trace.sessionId === 'string' && typeof trace.payload?.playerId === 'string') {
22
+ players.set(trace.payload.playerId, { callId: trace.callId, origin: { kind: 'player', actorId: trace.payload.playerId, runtimeSessionId: trace.sessionId } });
23
+ }
24
+ }
25
+ if (record.type === 'player_prompt' && !players.has(record.playerId)) players.set(record.playerId, { callId: randomUUID(), origin: { kind: 'player', actorId: record.playerId } });
26
+ if (record.type === 'player_finished') { const scope = players.get(record.playerId); if (scope) seen.delete(scope.callId); players.delete(record.playerId); }
27
+ if (record.type !== 'player_event' && record.type !== 'captain_event') return [record];
28
+ const event = record.event;
29
+ const scope = record.type === 'player_event' ? players.get(record.playerId) : preparation;
30
+ try {
31
+ const { event: projected, asset } = await externalizeAgentEvent(lease, event);
32
+ const stored = projected === event ? record : { ...record, event: projected };
33
+ if (!asset || !scope || typeof record.turnId !== 'number') return [stored];
34
+ let emitted = seen.get(scope.callId);
35
+ if (!emitted) { emitted = new Set(); seen.set(scope.callId, emitted); }
36
+ if (emitted.has(asset.assetId)) return [stored];
37
+ emitted.add(asset.assetId);
38
+ const origin = { ...scope.origin, ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}) };
39
+ await onEvidence?.(asset, origin);
40
+ return [stored, { type: 'playbook_evidence', timestamp: record.timestamp, turnId: record.turnId, callId: scope.callId, origin, asset }];
41
+ } catch (error) {
42
+ return [{ type: 'runtime_error', timestamp: record.timestamp, turnId: record.turnId, sourceRecordType: record.type, message: `Media could not be saved: ${error instanceof Error ? error.message : String(error)}` }];
43
+ }
44
+ };
45
+ return Object.freeze({ project, beginPreparation });
46
+ }