@inetafrica/open-claudia 3.5.47 → 3.5.58

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 (39) hide show
  1. package/CHANGELOG.md +62 -10
  2. package/bin/cli.js +10 -3
  3. package/bot.js +11 -0
  4. package/channels/kazee/adapter.js +220 -23
  5. package/channels/kazee/call/camera.js +233 -0
  6. package/channels/kazee/call/job-steps.js +81 -0
  7. package/channels/kazee/call/manager.js +1056 -27
  8. package/channels/kazee/call/miss-capture.js +82 -0
  9. package/channels/kazee/call/quick-voice.js +610 -0
  10. package/channels/kazee/call/session.js +787 -36
  11. package/channels/kazee/call/thread-context.js +100 -0
  12. package/channels/kazee/catch-up.js +275 -0
  13. package/core/actions.js +13 -4
  14. package/core/handlers.js +14 -16
  15. package/core/identity.js +15 -1
  16. package/core/loopback.js +4 -1
  17. package/core/media.js +39 -1
  18. package/core/run-context.js +27 -3
  19. package/core/runner.js +70 -1
  20. package/core/state.js +6 -2
  21. package/core/switchboard/brain-link.js +161 -0
  22. package/core/switchboard/call-host.js +298 -0
  23. package/core/switchboard/handoff.js +80 -0
  24. package/core/switchboard/ipc.js +130 -0
  25. package/core/switchboard/supervisor.js +225 -0
  26. package/core/system-prompt.js +44 -4
  27. package/core/transcripts.js +39 -0
  28. package/core/tts-voice.js +62 -4
  29. package/core/turn-journal.js +23 -1
  30. package/package.json +2 -2
  31. package/test-call-speech.js +7 -0
  32. package/test-fixtures/switchboard-fake-brain.js +20 -0
  33. package/test-kazee-call-manager.js +22 -4
  34. package/test-kazee-call-session.js +345 -8
  35. package/test-provider-compaction.js +8 -0
  36. package/test-provider-language.js +17 -9
  37. package/test-provider-prompt-parity.js +1 -0
  38. package/test-provider-runner.js +2 -0
  39. package/test-run-lock.js +24 -0
package/core/runner.js CHANGED
@@ -20,6 +20,7 @@ const {
20
20
  resolveProviderSettings, getProjectSessions,
21
21
  } = require("./state");
22
22
  const { chatContext, currentChannelId, currentAdapter, currentThreadId } = require("./context");
23
+ const { isCallLaneKey } = require("./identity");
23
24
  const { buildTurnPrompt } = require("./system-prompt");
24
25
  const turnJournal = require("./turn-journal");
25
26
  const usageLimits = require("./usage-limits");
@@ -413,6 +414,11 @@ async function executeProviderInvocation(options = {}) {
413
414
  terminalSeen = true;
414
415
  if (event.sessionId) sessionId = event.sessionId;
415
416
  if (typeof event.text === "string" && event.text) resultText = event.text;
417
+ // The answer as it will be delivered (finalAnswerText, else the
418
+ // result's own text), known now: a live call speaks it before the
419
+ // process has exited (earlyCallAnswer below).
420
+ const segments = textSegments.map((blocks) => blocks.join("")).filter((t) => t.trim());
421
+ event.answerText = redactSensitive(String(segments.at(-1) || resultText || "").trim());
416
422
  } else if (event.type === "error") {
417
423
  terminalSeen = true;
418
424
  rawErrors.push(event);
@@ -1490,6 +1496,7 @@ function deriveCompactionRunContext(base, overrides = {}) {
1490
1496
  admittedSessionId: overrides.sessionId ?? null,
1491
1497
  previousSessionId: overrides.previousSessionId ?? base.sessionId ?? null,
1492
1498
  resumeSessionId: overrides.resumeSessionId ?? null,
1499
+ forkSession: overrides.forkSession === true,
1493
1500
  continueSession: false,
1494
1501
  fresh: !!overrides.fresh,
1495
1502
  recallQuery: overrides.recallQuery ?? null,
@@ -1580,6 +1587,11 @@ function createCompactionService(dependencies = {}) {
1580
1587
  sessionId: oldSessionId,
1581
1588
  resumeSessionId: oldSessionId,
1582
1589
  previousSessionId: oldSessionId,
1590
+ // Read the live conversation through a fork (Claude --fork-session): the
1591
+ // summarize prompt and its brief must never land in the live session. A
1592
+ // summary that fails (an OAuth refresh error, a timeout) would leave the
1593
+ // prompt there, and the next turn answers it into the chat (12 Oct).
1594
+ forkSession: true,
1583
1595
  fresh: false,
1584
1596
  });
1585
1597
  const operationId = opts.operationId || (typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `compact-${Date.now()}`);
@@ -1851,6 +1863,11 @@ function providerRunDiagnostic(result, runContext) {
1851
1863
  async function persistForegroundTranscript(result, runContext, state = currentState()) {
1852
1864
  const view = capturedStateView(state, runContext);
1853
1865
  const session = () => result.sessionId || runContext.sessionId || null;
1866
+ // A run stopped on purpose did not fail: /stop, or a call job the quick
1867
+ // voice answered itself or the caller replaced (stopCallRunWhenDropped).
1868
+ // Its words are kept; a "run failed: exited with code 143" note is not
1869
+ // (11 Oct: five such notes in one call's transcript).
1870
+ const stopped = !result.ok && (result.error?.code === "RUN_CANCELLED" || cancelTargets(laneForRun(state, runContext).cancelRequested, runContext));
1854
1871
  appendProjectTranscript(
1855
1872
  runContext.transcriptRole || "user",
1856
1873
  stripTranscriptPointerForStorage(runContext.userPrompt),
@@ -1865,6 +1882,7 @@ async function persistForegroundTranscript(result, runContext, state = currentSt
1865
1882
  session,
1866
1883
  { strict: true },
1867
1884
  );
1885
+ if (stopped) return;
1868
1886
  const role = result.ok ? "assistant" : "system-note";
1869
1887
  const text = result.ok
1870
1888
  ? (result.text || "(no output)")
@@ -2452,9 +2470,12 @@ function createForegroundService({ state, store, lane, stopTyping, capture, owns
2452
2470
  // another run then claimed) must not touch that lane's run slot.
2453
2471
  const holds = () => (holdsLane ? holdsLane() : ownsAdmissionLock);
2454
2472
  const callSpeechLeases = new Map();
2473
+ const callStopListeners = new Map();
2455
2474
  const callStage = (stage, runContext) => {
2456
2475
  if (capture || runContext?.liveCall !== true) return;
2457
2476
  console.log(`${new Date().toISOString()} Kazee call provider: stage=${stage} run=${runContext.runId} elapsed=${Math.max(0, Date.now() - runContext.admittedAt)}ms`);
2477
+ // The call's job board: this job is now the one being worked on.
2478
+ try { store?.adapter?.onCallStage?.(stage); } catch (_) {}
2458
2479
  };
2459
2480
  const persistEffects = !capture || persistCapture;
2460
2481
  // The run's lane: given by executeAdmittedRun, else the captured run's own.
@@ -2502,6 +2523,10 @@ function createForegroundService({ state, store, lane, stopTyping, capture, owns
2502
2523
  if (ownsAdmissionLock) onOwnSpawn?.(proc);
2503
2524
  if (ownsAdmissionLock || !runLane.runningProcess) runLane.runningProcess = proc;
2504
2525
  if (ownsAdmissionLock) runLane.preparingRun = false;
2526
+ if (!capture && ownsAdmissionLock) {
2527
+ const remove = stopCallRunWhenDropped(store, runLane, proc, runContext);
2528
+ if (remove) callStopListeners.set(runContext.runId, remove);
2529
+ }
2505
2530
  },
2506
2531
  shouldAbort(runContext, { capture: isCapture } = {}) {
2507
2532
  if (isCapture || !ownsAdmissionLock) return false;
@@ -2535,6 +2560,23 @@ function createForegroundService({ state, store, lane, stopTyping, capture, owns
2535
2560
  if (observer && event.type === "tool_start" && runContext.purpose === "foreground") {
2536
2561
  try { observer.onToolStart(event.name, event.detail); } catch (_) {}
2537
2562
  }
2563
+ // A live call's job board hears what the full agent is doing (tool
2564
+ // steps), so the quick voice can answer "what's the status?".
2565
+ if (!capture && runContext?.liveCall === true && event.type === "tool_start") {
2566
+ try { store?.adapter?.onCallActivity?.(event); } catch (_) {}
2567
+ }
2568
+ // A live call hears the answer once it is written, not once the CLI has
2569
+ // exited: its end-of-turn work took ~0.8 s on both answers of the 11 Oct
2570
+ // 18:57 call. The normal delivery still runs after; the call skips the
2571
+ // words it was already handed. Never for a guarded speaker (their reply
2572
+ // goes through the outbound guard first).
2573
+ if (!capture && runContext?.liveCall === true && event.type === "result" && event.answerText && !PLACEHOLDER_REPLY.test(event.answerText)) {
2574
+ let guarded = true;
2575
+ try { guarded = require("./relationship").isCurrentSpeakerGuarded(); } catch (_) {}
2576
+ if (!guarded) {
2577
+ try { Promise.resolve(store?.adapter?.earlyCallAnswer?.(event.answerText)).catch(() => {}); } catch (_) {}
2578
+ }
2579
+ }
2538
2580
  if (preview && event.type === "text_final" && typeof event.text === "string" && event.text) {
2539
2581
  preview.append(event.text, runContext);
2540
2582
  }
@@ -2552,6 +2594,8 @@ function createForegroundService({ state, store, lane, stopTyping, capture, owns
2552
2594
  try { require("./speaker-attest").endRun(runContext.runId); } catch (_) {}
2553
2595
  callSpeechLeases.get(runContext.runId)?.close();
2554
2596
  callSpeechLeases.delete(runContext.runId);
2597
+ callStopListeners.get(runContext.runId)?.();
2598
+ callStopListeners.delete(runContext.runId);
2555
2599
  const runLane = laneOf(runContext);
2556
2600
  if (runLane.runningProcess === proc) runLane.runningProcess = null;
2557
2601
  // Keep the lane's lock armed while transcript/usage/session persistence
@@ -2637,6 +2681,29 @@ function obsoleteCallTurn(store) {
2637
2681
  return store?.adapter?.liveCall === true && store.adapter.callTurn?.signal.aborted === true;
2638
2682
  }
2639
2683
 
2684
+ // A live-call turn the call dropped while its provider runs: the caller
2685
+ // changed their mind ("replaced"), the quick voice handled it alone ("self"),
2686
+ // or (with no quick voice) a newer utterance took over ("superseded"). Stop
2687
+ // it the way /stop does (named cancel flag, then the process tree), so it
2688
+ // neither holds the call's lane nor speaks. A turn whose call ENDED keeps
2689
+ // running, so "do X" said just before hanging up still gets done. Ported from
2690
+ // the 4 Oct calls build. Returns the listener's remover, or null.
2691
+ const STOPPED_CALL_TURN_REASONS = new Set(["superseded", "cancelled", "replaced", "self"]);
2692
+ function stopCallRunWhenDropped(store, lane, proc, runContext) {
2693
+ const signal = runContext?.liveCall === true ? store?.adapter?.callTurn?.signal : null;
2694
+ if (!signal || !proc?.pid) return null;
2695
+ const stop = () => {
2696
+ if (!STOPPED_CALL_TURN_REASONS.has(signal.reason) || lane.runningProcess !== proc) return;
2697
+ lane.cancelRequested = runContext.runId || true;
2698
+ console.log(`${new Date().toISOString()} Kazee call provider: stage=${signal.reason} run=${runContext.runId} elapsed=${Math.max(0, Date.now() - runContext.admittedAt)}ms`);
2699
+ killProcessTree(proc.pid, "SIGTERM");
2700
+ setTimeout(() => killProcessTree(proc.pid, "SIGKILL"), 3000).unref?.();
2701
+ };
2702
+ if (signal.aborted) { stop(); return null; }
2703
+ signal.addEventListener("abort", stop, { once: true });
2704
+ return () => signal.removeEventListener("abort", stop);
2705
+ }
2706
+
2640
2707
  function cancelledCallRunResult(runContext) {
2641
2708
  return cancelledRunResult(runContext, { id: runContext.provider, label: runContext.provider }, "Call turn superseded or ended before execution");
2642
2709
  }
@@ -2669,7 +2736,7 @@ function cancelQueuedCallOnAbort(lane, item) {
2669
2736
  function drainQueuedMessages(_state) {
2670
2737
  for (const { state, lane } of waitingLanes()) {
2671
2738
  if (laneBusy(lane) || !lane.messageQueue.length) continue;
2672
- if (slotBlocker(state)) { lane.waitingForSlot = true; continue; }
2739
+ if (!isCallLaneKey(lane.key) && slotBlocker(state)) { lane.waitingForSlot = true; continue; }
2673
2740
  const batch = takeQueueBatch(lane);
2674
2741
  const item = batch.length > 1 ? mergeQueueBatch(lane, batch) : batch[0];
2675
2742
  item.disposeCallListener?.();
@@ -3302,6 +3369,8 @@ function runAgentSilent(prompt, cwd, label, opts = {}) {
3302
3369
  }
3303
3370
 
3304
3371
  module.exports = {
3372
+ stopCallRunWhenDropped,
3373
+ persistForegroundTranscript,
3305
3374
  queuedNotice,
3306
3375
  handleQueueRemove,
3307
3376
  takeQueueBatch,
package/core/state.js CHANGED
@@ -20,6 +20,7 @@ const {
20
20
  channelKey,
21
21
  identities,
22
22
  parseConversationKey,
23
+ isCallLaneKey,
23
24
  } = require("./identity");
24
25
  const { currentChannelId, currentCanonicalUserId, currentConversationKey, currentRoomKey } = require("./context");
25
26
  const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
@@ -1005,7 +1006,10 @@ function slotBlocker(state) {
1005
1006
  function admitLane(state = currentState(), laneKey = null) {
1006
1007
  const lane = laneFor(state, laneKey);
1007
1008
  if (laneBusy(lane)) return { ok: false, reason: "busy", lane };
1008
- const blocker = slotBlocker(state);
1009
+ // A live call is real time: its turns never wait for a free slot (one call
1010
+ // at a time, so at most one run over the caps).
1011
+ const realtime = isCallLaneKey(lane.key);
1012
+ const blocker = realtime ? null : slotBlocker(state);
1009
1013
  if (lane.messageQueue.length) return { ok: false, reason: "waiting", lane, running: blocker?.running || 0 };
1010
1014
  if (blocker) {
1011
1015
  lane.waitingForSlot = true;
@@ -1013,7 +1017,7 @@ function admitLane(state = currentState(), laneKey = null) {
1013
1017
  }
1014
1018
  // A freed slot goes to the lanes already waiting for one, oldest first: a
1015
1019
  // newcomer queues behind them (the runner then admits in order).
1016
- if (waitingLanes().some((waiting) => waiting.lane !== lane && !slotBlocker(waiting.state))) {
1020
+ if (!realtime && waitingLanes().some((waiting) => waiting.lane !== lane && !slotBlocker(waiting.state))) {
1017
1021
  lane.waitingForSlot = true;
1018
1022
  return { ok: false, reason: "cap", lane, running: runningLanes(state).length, scope: "queue" };
1019
1023
  }
@@ -0,0 +1,161 @@
1
+ // The brain's end of the switchboard link (only when OC_SWITCHBOARD started
2
+ // this process as the switchboard's child). The call room lives in the
3
+ // switchboard; a call job arrives here and becomes an ordinary call turn: a
4
+ // per-job adapter made from the real Kazee adapter, as the call manager
5
+ // always made it, whose speech and answer go back over the link.
6
+
7
+ const { Link } = require("./ipc");
8
+ const { isSwitchboardChild } = require("./handoff");
9
+
10
+ let link = null;
11
+ let goneHandled = false;
12
+ // The channel also closes as this process exits on its own; not news then.
13
+ let exiting = false;
14
+ process.once("exit", () => { exiting = true; });
15
+ let options = {};
16
+ const jobs = new Map();
17
+
18
+ function findAdapter(adapterId) {
19
+ if (options.findAdapter) return options.findAdapter(adapterId);
20
+ return require("../adapter-registry").findAdapter(adapterId);
21
+ }
22
+
23
+ // channel/findAdapter/onGone are test seams; the brain uses its own process.
24
+ function connect({ channel = null, findAdapter: find = null, onGone = null } = {}) {
25
+ if (link) return link;
26
+ if (!channel && !isSwitchboardChild()) return null;
27
+ options = { findAdapter: find, onGone };
28
+ link = new Link(channel || process, { label: "switchboard" });
29
+ link.on("call.job", (payload) => {
30
+ try { startJob(payload); } catch (error) { console.error(`Switchboard call job failed to start: ${error.message}`); }
31
+ });
32
+ link.on("call.job.mirror", ({ id, mirror } = {}) => {
33
+ const job = jobs.get(id);
34
+ if (job && mirror) job.mirror = mirror;
35
+ });
36
+ link.on("call.job.abort", ({ id, reason } = {}) => {
37
+ const job = jobs.get(id);
38
+ if (job && !job.controller.signal.aborted) job.controller.abort(reason || "aborted");
39
+ });
40
+ link.on("call.job.forget", ({ id } = {}) => { jobs.delete(id); });
41
+ link.handle("call.carryOn", (payload) => carryOn(payload));
42
+ link.handle("call.greetingContext", (payload) => greetingContext(payload));
43
+ // The switchboard is gone (it crashed or was killed): this brain must go
44
+ // too, so the switchboard launchd or the pod starts next gets a free lock.
45
+ link.on("close", () => {
46
+ if (goneHandled || exiting) return;
47
+ goneHandled = true;
48
+ if (options.onGone) { options.onGone(); return; }
49
+ console.error("Switchboard link closed: shutting the brain down");
50
+ try { process.kill(process.pid, "SIGTERM"); } catch (_) {}
51
+ });
52
+ return link;
53
+ }
54
+
55
+ function hello() {
56
+ const l = connect();
57
+ if (!l) return false;
58
+ let version = null;
59
+ try { version = require("../../package.json").version; } catch (_) {}
60
+ return l.notify("brain.hello", { pid: process.pid, version });
61
+ }
62
+
63
+ // Said before a planned exit (restart, upgrade, shutdown): the call hears
64
+ // "I'm restarting" instead of an unexplained gap.
65
+ function notifyExiting(reason) {
66
+ exiting = true;
67
+ const l = link || connect();
68
+ return l ? l.notify("brain.exiting", { reason: String(reason || "restart") }) : false;
69
+ }
70
+
71
+ function configChanged(what) {
72
+ const l = link || connect();
73
+ return l ? l.notify("config.changed", { what }) : false;
74
+ }
75
+
76
+ function call(id, name, args) {
77
+ const l = connect();
78
+ if (!l) return Promise.reject(new Error("switchboard not connected"));
79
+ return l.request("call.job.fn", { id, name, args }, { timeoutMs: 0 });
80
+ }
81
+
82
+ function startJob({ id, adapterId, envelope, fields = {}, mirror = {}, resent = false } = {}) {
83
+ const base = findAdapter(adapterId);
84
+ if (!base || typeof base._emit !== "function") throw new Error(`no adapter ${adapterId}`);
85
+ const controller = new AbortController();
86
+ const job = { id, controller, mirror };
87
+ jobs.set(id, job);
88
+ const callAdapter = Object.create(base);
89
+ Object.assign(callAdapter, fields);
90
+ callAdapter.callTurn = { signal: controller.signal };
91
+ callAdapter.liveCallHistory = () => (job.mirror.history || []).map((entry) => ({ ...entry }));
92
+ callAdapter.liveCallCamera = () => {
93
+ const camera = job.mirror.camera;
94
+ if (!camera?.path) return null;
95
+ return { path: camera.path, ageMs: Math.max(0, Date.now() - (Number(camera.at) || Date.now())), receiving: camera.receiving !== false };
96
+ };
97
+ callAdapter.isCallTurnActive = () => !controller.signal.aborted && !!job.mirror.active;
98
+ callAdapter.cancelCallSpeech = () => { call(id, "cancelCallSpeech", []).catch(() => {}); };
99
+ callAdapter.onCallStage = (stage) => { call(id, "onCallStage", [stage]).catch(() => {}); };
100
+ callAdapter.onCallActivity = (event) => {
101
+ call(id, "onCallActivity", [{ type: event?.type, name: event?.name, detail: event?.detail }]).catch(() => {});
102
+ };
103
+ callAdapter.speakSentence = async (text) => {
104
+ try { return !!(await call(id, "speakSentence", [String(text ?? "")])); } catch (_) { return false; }
105
+ };
106
+ callAdapter.send = async (channelId, text) => {
107
+ try { return (await call(id, "send", [String(channelId), String(text ?? "")])) !== false; } catch (_) { return false; }
108
+ };
109
+ callAdapter.earlyCallAnswer = async (text) => {
110
+ try { return (await call(id, "earlyCallAnswer", [String(text ?? "")])) !== false; } catch (_) { return false; }
111
+ };
112
+ callAdapter.edit = async () => true;
113
+ callAdapter.typing = async () => {};
114
+ callAdapter.sendVoice = async (_channelId, voicePath) => {
115
+ console.warn(`Kazee call ignored file voice response ${voicePath}; streamed TTS is active`);
116
+ return true;
117
+ };
118
+ if (resent) console.log(`Switchboard call job ${id} asked again after a brain restart`);
119
+ base._emit("message", { ...envelope, adapter: callAdapter });
120
+ return callAdapter;
121
+ }
122
+
123
+ function carryOn({ adapterId, chatId, userId, prompt, threadId = null } = {}) {
124
+ const adapter = findAdapter(adapterId) || { id: adapterId, type: "kazee" };
125
+ const job = require("../../channels/kazee/call/manager").carryOnInChat({ adapter, chatId, userId, prompt, threadId });
126
+ return { id: job?.id || null };
127
+ }
128
+
129
+ function greetingContext({ adapterId, chatId, userId, threadId = null } = {}) {
130
+ const adapter = findAdapter(adapterId) || { id: adapterId, type: "kazee" };
131
+ return require("../../channels/kazee/call/manager").greetingContext({ adapter, chatId, userId, ...(threadId ? { threadId } : {}) });
132
+ }
133
+
134
+ // The brain's stand-in for a Kazee adapter's call manager: calls are the
135
+ // switchboard's, so the brain's socket ignores call events, stopping the
136
+ // brain never ends a call, and `open-claudia call` is passed across.
137
+ function callManagerProxy(adapter) {
138
+ return {
139
+ session: null,
140
+ switchboard: true,
141
+ wire() {},
142
+ async stop() {},
143
+ async outgoing(args = {}) {
144
+ const l = connect();
145
+ if (!l) throw new Error("switchboard not connected");
146
+ return l.request("call.outgoing", { adapterId: adapter.id, args }, { timeoutMs: 90000 });
147
+ },
148
+ };
149
+ }
150
+
151
+ function _resetForTests() {
152
+ goneHandled = true;
153
+ link?.close();
154
+ link = null;
155
+ options = {};
156
+ goneHandled = false;
157
+ exiting = false;
158
+ jobs.clear();
159
+ }
160
+
161
+ module.exports = { connect, hello, notifyExiting, configChanged, startJob, callManagerProxy, carryOn, greetingContext, jobs, _resetForTests };
@@ -0,0 +1,298 @@
1
+ // The live call room, held by the switchboard so a brain restart doesn't drop
2
+ // it. Each Kazee channel gets the same KazeeCallManager the bot always ran,
3
+ // on the switchboard's own chat-central socket. What the manager used to hand
4
+ // straight to the bot's router (a full-agent job) goes to the brain over the
5
+ // link instead. While the brain is away, new jobs wait, the call stays up and
6
+ // the caller hears why; when it is back, the waiting jobs go out.
7
+
8
+ const { KazeeAdapter } = require("../../channels/kazee/adapter");
9
+ const { KazeeCallManager, greetingContext } = require("../../channels/kazee/call/manager");
10
+
11
+ // Plain values of the per-job call adapter the brain needs as they are.
12
+ const JOB_FIELDS = ["callHistory", "quickVoice", "quickRelay", "liveCall", "suppressStreamPreview", "suppressChatVisibility", "liveCallThreadNote"];
13
+ // What the brain may call back on a job.
14
+ const JOB_FUNCTIONS = new Set(["speakSentence", "send", "earlyCallAnswer", "cancelCallSpeech", "onCallStage", "onCallActivity"]);
15
+
16
+ const LINES = {
17
+ restarting: "I'm restarting. Back in a few seconds.",
18
+ crashed: "Sorry, something went wrong on my side. Give me a few seconds.",
19
+ back: "I'm back.",
20
+ };
21
+
22
+ const DEFAULT_TIMING = {
23
+ // How often a job's live view (call so far, camera, still on the board)
24
+ // is re-checked and sent to the brain when it changed.
25
+ mirrorMs: 250,
26
+ // A finished job is kept this long: a carried-on answer can come late.
27
+ jobKeepMs: 30 * 60 * 1000,
28
+ greetingAskMs: 1500,
29
+ // A thread call reads the thread first (its title from the server).
30
+ threadGreetingAskMs: 3000,
31
+ };
32
+
33
+ function defaultLog(message) {
34
+ console.log(`${new Date().toISOString()} Switchboard: ${message}`);
35
+ }
36
+
37
+ function abortReason(signal) {
38
+ const reason = signal?.reason;
39
+ if (typeof reason === "string") return reason;
40
+ return String(reason?.message || reason?.name || "aborted");
41
+ }
42
+
43
+ // What the brain's prompt builder reads from a job while its run starts.
44
+ function mirrorOf(callAdapter, now = Date.now()) {
45
+ let history = [];
46
+ let camera = null;
47
+ let active = false;
48
+ try { history = callAdapter.liveCallHistory?.() || []; } catch (_) {}
49
+ try {
50
+ const still = callAdapter.liveCallCamera?.();
51
+ if (still?.path) camera = { path: still.path, at: now - (Number(still.ageMs) || 0), receiving: still.receiving !== false };
52
+ } catch (_) {}
53
+ try { active = !!callAdapter.isCallTurnActive?.(); } catch (_) {}
54
+ return { history, camera, active };
55
+ }
56
+
57
+ class CallHost {
58
+ constructor({ specs = [], AdapterImpl = KazeeAdapter, ManagerImpl = KazeeCallManager, managerOptions = {},
59
+ timing = {}, log = defaultLog } = {}) {
60
+ this.specs = specs;
61
+ this.AdapterImpl = AdapterImpl;
62
+ this.ManagerImpl = ManagerImpl;
63
+ this.managerOptions = managerOptions;
64
+ this.timing = { ...DEFAULT_TIMING, ...timing };
65
+ this.log = log;
66
+ this.adapters = [];
67
+ this.jobs = new Map();
68
+ this.link = null;
69
+ this.brainReady = false;
70
+ this.stopping = false;
71
+ this._seq = 0;
72
+ this._carryOns = [];
73
+ this._away = new Set();
74
+ this._told = new WeakSet();
75
+ this._idleListeners = [];
76
+ this._wasLive = false;
77
+ }
78
+
79
+ async start() {
80
+ for (const spec of this.specs) {
81
+ const adapter = new this.AdapterImpl({
82
+ id: spec.id, ...spec.opts,
83
+ callManager: (owner) => new this.ManagerImpl({
84
+ adapter: owner,
85
+ resolveGreetingContext: (target) => this._greetingContext(target),
86
+ ...this.managerOptions,
87
+ dispatchJob: (envelope, job) => this._dispatch(owner, envelope, job),
88
+ carryOn: (args) => this._carryOn(owner, args),
89
+ }),
90
+ });
91
+ this.adapters.push(adapter);
92
+ await adapter.startCalls();
93
+ }
94
+ this._pump = setInterval(() => this._tick(), this.timing.mirrorMs);
95
+ this._pump.unref?.();
96
+ }
97
+
98
+ onIdle(fn) { this._idleListeners.push(fn); }
99
+
100
+ liveCall() {
101
+ return this.adapters.some((adapter) => {
102
+ const manager = adapter._callManager;
103
+ return !!(manager?._pending || (manager?.session && !manager.session.closed));
104
+ });
105
+ }
106
+
107
+ _liveSessions() {
108
+ return this.adapters.map((adapter) => ({ manager: adapter._callManager, session: adapter._callManager?.session }))
109
+ .filter(({ session }) => session && !session.closed);
110
+ }
111
+
112
+ _say(manager, session, line) {
113
+ try { manager._rememberSpeech?.(session, "assistant", line); } catch (_) {}
114
+ Promise.resolve(session.speak(line)).catch(() => {});
115
+ }
116
+
117
+ // ── The brain ────────────────────────────────────────────────────
118
+
119
+ attachBrain(link) {
120
+ this.link = link;
121
+ this.brainReady = false;
122
+ link.handle("call.job.fn", (payload) => this._jobFn(payload));
123
+ link.handle("call.outgoing", (payload) => this._outgoing(payload));
124
+ link.on("brain.hello", (info) => this._brainHello(info));
125
+ link.on("brain.exiting", (payload) => this._brainLeaving(payload?.reason));
126
+ link.on("config.changed", (payload) => {
127
+ if (payload?.what === "voice") { try { require("../tts-voice")._resetCache(); } catch (_) {} }
128
+ });
129
+ }
130
+
131
+ // The brain said it is about to exit on purpose (restart, upgrade).
132
+ _brainLeaving(reason) {
133
+ this._leaving = String(reason || "restart");
134
+ if (this.stopping) return;
135
+ for (const { manager, session } of this._liveSessions()) {
136
+ if (this._told.has(session)) continue;
137
+ this._told.add(session);
138
+ this._away.add(session);
139
+ this._say(manager, session, LINES.restarting);
140
+ }
141
+ }
142
+
143
+ brainGone({ planned = false, reason = "" } = {}) {
144
+ this.link = null;
145
+ this.brainReady = false;
146
+ const leaving = this._leaving;
147
+ this._leaving = null;
148
+ if (this.stopping) return;
149
+ const live = this._liveSessions();
150
+ for (const { manager, session } of live) {
151
+ this._away.add(session);
152
+ if (this._told.has(session)) continue;
153
+ this._told.add(session);
154
+ this._say(manager, session, planned || leaving ? LINES.restarting : LINES.crashed);
155
+ }
156
+ // Jobs the brain had and never answered. After a planned exit they end
157
+ // (the exit was likely what they asked for); after a crash each is asked
158
+ // again once, when the brain is back.
159
+ for (const job of this.jobs.values()) {
160
+ if (!job.sent || job.answered || job.signal?.aborted) continue;
161
+ job.sent = false;
162
+ if (!planned && !leaving && !job.resent) { job.resent = true; continue; }
163
+ this._drop(job, "restart");
164
+ }
165
+ if (live.length) this.log(`brain gone (${planned || leaving ? "planned" : "unplanned"}: ${reason || leaving}); call kept`);
166
+ }
167
+
168
+ _brainHello(info = {}) {
169
+ this.brainReady = true;
170
+ const away = this._liveSessions().filter(({ session }) => this._away.has(session));
171
+ for (const { manager, session } of away) this._say(manager, session, LINES.back);
172
+ if (away.length) this.log(`brain back pid=${info.pid || "?"} v${info.version || "?"}; call went on`);
173
+ this._away.clear();
174
+ this._told = new WeakSet();
175
+ for (const job of this.jobs.values()) {
176
+ if (!job.sent && !job.answered && !job.signal?.aborted) this._send(job);
177
+ }
178
+ for (const carry of this._carryOns.splice(0)) this._sendCarryOn(carry);
179
+ }
180
+
181
+ // ── Jobs ─────────────────────────────────────────────────────────
182
+
183
+ _dispatch(adapter, envelope, { turn = null, session = null } = {}) {
184
+ const { adapter: callAdapter, ...rest } = envelope;
185
+ const signal = callAdapter?.callTurn?.signal || null;
186
+ const job = {
187
+ id: `${adapter.id}:${++this._seq}`, adapterId: adapter.id, manager: adapter._callManager,
188
+ callAdapter, envelope: rest, signal, turn, session,
189
+ sent: false, resent: false, answered: false, lastMirror: "", inactiveAt: 0,
190
+ };
191
+ this.jobs.set(job.id, job);
192
+ signal?.addEventListener("abort", () => {
193
+ if (job.sent) this.link?.notify("call.job.abort", { id: job.id, reason: abortReason(signal) });
194
+ }, { once: true });
195
+ if (!this._send(job)) this.log(`job ${job.id} waits for the brain`);
196
+ return job;
197
+ }
198
+
199
+ _send(job) {
200
+ if (!this.link || !this.brainReady) return false;
201
+ const fields = {};
202
+ for (const key of JOB_FIELDS) if (job.callAdapter[key] !== undefined) fields[key] = job.callAdapter[key];
203
+ const mirror = mirrorOf(job.callAdapter);
204
+ job.lastMirror = JSON.stringify(mirror);
205
+ job.sent = this.link.notify("call.job", {
206
+ id: job.id, adapterId: job.adapterId, envelope: job.envelope, fields, mirror, resent: job.resent,
207
+ });
208
+ return job.sent;
209
+ }
210
+
211
+ _drop(job, reason) {
212
+ if (job.turn && job.session && typeof job.manager?._dropJob === "function") {
213
+ try { job.manager._dropJob(job.session, job.turn, reason); return; } catch (_) {}
214
+ }
215
+ try { job.turn?.controller?.abort(reason); } catch (_) {}
216
+ }
217
+
218
+ async _jobFn({ id, name, args } = {}) {
219
+ const job = this.jobs.get(id);
220
+ if (!job) throw new Error("call job gone");
221
+ if (!JOB_FUNCTIONS.has(name) || typeof job.callAdapter[name] !== "function") throw new Error(`not a call job function: ${name}`);
222
+ if (name === "send") job.answered = true;
223
+ const value = await job.callAdapter[name](...(Array.isArray(args) ? args : []));
224
+ return value === undefined ? null : value;
225
+ }
226
+
227
+ _tick() {
228
+ const now = Date.now();
229
+ for (const job of this.jobs.values()) {
230
+ if (job.inactiveAt && now - job.inactiveAt > this.timing.jobKeepMs) {
231
+ this.jobs.delete(job.id);
232
+ this.link?.notify("call.job.forget", { id: job.id });
233
+ continue;
234
+ }
235
+ if (job.inactiveAt) continue;
236
+ const mirror = mirrorOf(job.callAdapter, now);
237
+ if (!mirror.active) job.inactiveAt = now;
238
+ if (!job.sent || !this.link) continue;
239
+ const text = JSON.stringify(mirror);
240
+ if (text === job.lastMirror) continue;
241
+ job.lastMirror = text;
242
+ this.link.notify("call.job.mirror", { id: job.id, mirror });
243
+ }
244
+ const live = this.liveCall();
245
+ if (this._wasLive && !live) for (const fn of this._idleListeners) { try { fn(); } catch (_) {} }
246
+ this._wasLive = live;
247
+ }
248
+
249
+ // ── Other things the call asks of the brain ──────────────────────
250
+
251
+ async _outgoing({ adapterId, args } = {}) {
252
+ const adapter = this.adapters.find((candidate) => candidate.id === adapterId) || this.adapters[0];
253
+ if (!adapter) throw new Error("No Kazee channel holds calls here");
254
+ return adapter._callManager.outgoing(args || {});
255
+ }
256
+
257
+ // The opening line reads the end of the chat. The brain knows which
258
+ // project the chat is on now; ask it, else read the files here.
259
+ async _greetingContext(target) {
260
+ if (this.link && this.brainReady) {
261
+ try {
262
+ return await this.link.request("call.greetingContext", {
263
+ adapterId: target.adapter?.id, chatId: String(target.chatId), userId: String(target.userId),
264
+ ...(target.threadId ? { threadId: String(target.threadId) } : {}),
265
+ }, { timeoutMs: target.threadId ? this.timing.threadGreetingAskMs : this.timing.greetingAskMs });
266
+ } catch (_) {}
267
+ }
268
+ return greetingContext(target);
269
+ }
270
+
271
+ _carryOn(adapter, { chatId, userId, prompt, threadId = null } = {}) {
272
+ const carry = { adapterId: adapter.id, chatId: String(chatId), userId: String(userId), prompt: String(prompt || ""), ...(threadId ? { threadId: String(threadId) } : {}) };
273
+ if (this.link && this.brainReady) this._sendCarryOn(carry);
274
+ else this._carryOns.push(carry);
275
+ }
276
+
277
+ _sendCarryOn(carry) {
278
+ const link = this.link;
279
+ if (!link || !this.brainReady) { this._carryOns.push(carry); return; }
280
+ link.request("call.carryOn", carry).catch((error) => {
281
+ this.log(`carry-on not handed over (${error.message}); kept for the brain`);
282
+ this._carryOns.push(carry);
283
+ });
284
+ }
285
+
286
+ // A full stop (pod going away, launchd stop): end calls properly so the
287
+ // caller's app hangs up now, not after chat-central's 20 s grace.
288
+ async stop(reason = "stop") {
289
+ this.stopping = true;
290
+ clearInterval(this._pump);
291
+ for (const adapter of this.adapters) {
292
+ try { await adapter._callManager.shutdown?.(`switchboard-${String(reason).toLowerCase().replace(/[^a-z0-9]+/g, "-")}`); } catch (_) {}
293
+ try { adapter.stopCalls?.(); } catch (_) {}
294
+ }
295
+ }
296
+ }
297
+
298
+ module.exports = { CallHost, LINES, JOB_FIELDS, JOB_FUNCTIONS, mirrorOf };