@inetafrica/open-claudia 3.5.47 → 3.5.59
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.
- package/CHANGELOG.md +70 -10
- package/bin/cli.js +10 -3
- package/bot.js +11 -0
- package/channels/kazee/adapter.js +244 -23
- package/channels/kazee/call/camera.js +233 -0
- package/channels/kazee/call/job-steps.js +81 -0
- package/channels/kazee/call/manager.js +1056 -27
- package/channels/kazee/call/miss-capture.js +82 -0
- package/channels/kazee/call/quick-voice.js +610 -0
- package/channels/kazee/call/session.js +787 -36
- package/channels/kazee/call/thread-context.js +100 -0
- package/channels/kazee/catch-up.js +275 -0
- package/core/actions.js +13 -4
- package/core/enforcer.js +3 -0
- package/core/handlers.js +14 -16
- package/core/identity.js +15 -1
- package/core/loopback.js +4 -1
- package/core/media.js +39 -1
- package/core/relationship.js +41 -18
- package/core/run-context.js +27 -3
- package/core/runner.js +70 -1
- package/core/state.js +6 -2
- package/core/switchboard/brain-link.js +161 -0
- package/core/switchboard/call-host.js +298 -0
- package/core/switchboard/handoff.js +80 -0
- package/core/switchboard/ipc.js +130 -0
- package/core/switchboard/supervisor.js +225 -0
- package/core/system-prompt.js +64 -12
- package/core/transcripts.js +39 -0
- package/core/tts-voice.js +62 -4
- package/core/turn-journal.js +23 -1
- package/core/utility-agent.js +5 -2
- package/package.json +2 -2
- package/test-call-speech.js +7 -0
- package/test-external-rule-scopes.js +24 -0
- package/test-fixtures/switchboard-fake-brain.js +20 -0
- package/test-kazee-call-manager.js +22 -4
- package/test-kazee-call-session.js +345 -8
- package/test-provider-compaction.js +8 -0
- package/test-provider-language.js +19 -9
- package/test-provider-prompt-parity.js +1 -0
- package/test-provider-runner.js +2 -0
- package/test-run-lock.js +24 -0
package/core/run-context.js
CHANGED
|
@@ -4,7 +4,7 @@ const crypto = require("crypto");
|
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { currentConversationKey, currentThreadId } = require("./context");
|
|
6
6
|
const { resolveProviderSettings, peekLane } = require("./state");
|
|
7
|
-
const { parseConversationKey } = require("./identity");
|
|
7
|
+
const { parseConversationKey, callLaneKey } = require("./identity");
|
|
8
8
|
|
|
9
9
|
function cloneSerializable(value, label = "run context value") {
|
|
10
10
|
if (value === undefined) return undefined;
|
|
@@ -57,6 +57,26 @@ function projectSnapshot(state, cwd, requestedProject) {
|
|
|
57
57
|
// whole main context in a session of its own. Default "fresh": a new thread
|
|
58
58
|
// starts a new session, seeded only with the thread's own messages. Codex has
|
|
59
59
|
// no fork, so it always starts fresh.
|
|
60
|
+
// A live call answers in speech, so its turn may run a faster model and effort
|
|
61
|
+
// than the room's chat turns: KAZEE_CALL_MODEL / KAZEE_CALL_EFFORT (Claude
|
|
62
|
+
// only). Unset keeps the room's own settings; an unknown effort is ignored.
|
|
63
|
+
const CALL_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
64
|
+
function callSetting(name) {
|
|
65
|
+
let value = process.env[name];
|
|
66
|
+
try { value = require("./config").config?.[name] ?? value; } catch (_) {}
|
|
67
|
+
return String(value || "").trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function callProviderOverrides(provider) {
|
|
71
|
+
if (provider !== "claude") return {};
|
|
72
|
+
const overrides = {};
|
|
73
|
+
const model = callSetting("KAZEE_CALL_MODEL");
|
|
74
|
+
const effort = callSetting("KAZEE_CALL_EFFORT").toLowerCase();
|
|
75
|
+
if (model) overrides.model = model;
|
|
76
|
+
if (CALL_EFFORTS.has(effort)) overrides.effort = effort;
|
|
77
|
+
return overrides;
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
function threadSeedMode() {
|
|
61
81
|
let value = process.env.OC_THREAD_SEED;
|
|
62
82
|
try { value = require("./config").config?.OC_THREAD_SEED ?? value; } catch (_) {}
|
|
@@ -127,8 +147,10 @@ function captureRunContext({
|
|
|
127
147
|
const activeSessionId = state.activeSessions?.[project.name]?.[provider] || null;
|
|
128
148
|
// The run's lane (its conversation: a thread is its own) and thread are
|
|
129
149
|
// fixed here, at admission: queueing, /stop, preview, typing and delivery all
|
|
130
|
-
// follow the lane the message arrived in, never a later one.
|
|
131
|
-
|
|
150
|
+
// follow the lane the message arrived in, never a later one. A live call's
|
|
151
|
+
// turns get a lane of their own beside their chat's.
|
|
152
|
+
const conversationLane = String(opts.laneKey || currentConversationKey() || project.name || "Workspace");
|
|
153
|
+
const laneKey = liveCall ? callLaneKey(conversationLane) : conversationLane;
|
|
132
154
|
const threadId = (store && store.threadId) || currentThreadId() || parseConversationKey(laneKey).threadId || null;
|
|
133
155
|
const lane = peekLane(state, laneKey);
|
|
134
156
|
const forkFromSessionId = threadForkSession(state, { provider, laneKey, threadId, activeSessionId, opts, liveCall });
|
|
@@ -154,6 +176,7 @@ function captureRunContext({
|
|
|
154
176
|
// personal bucket, so non-room channels are unaffected.
|
|
155
177
|
const providerSettings = cloneSerializable({
|
|
156
178
|
...(resolveProviderSettings(state, provider) || {}),
|
|
179
|
+
...(liveCall ? callProviderOverrides(provider) : {}),
|
|
157
180
|
...(opts.providerSettings || {}),
|
|
158
181
|
}, "provider settings");
|
|
159
182
|
delete providerSettings.backend;
|
|
@@ -261,6 +284,7 @@ function bindRunInputs(runContext, inputs = {}) {
|
|
|
261
284
|
}
|
|
262
285
|
|
|
263
286
|
module.exports = {
|
|
287
|
+
projectSnapshot,
|
|
264
288
|
captureRunContext,
|
|
265
289
|
bindRunInputs,
|
|
266
290
|
cloneSerializable,
|
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
|
-
|
|
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 };
|