@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.
- package/CHANGELOG.md +62 -10
- package/bin/cli.js +10 -3
- package/bot.js +11 -0
- package/channels/kazee/adapter.js +220 -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/handlers.js +14 -16
- package/core/identity.js +15 -1
- package/core/loopback.js +4 -1
- package/core/media.js +39 -1
- 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 +44 -4
- package/core/transcripts.js +39 -0
- package/core/tts-voice.js +62 -4
- package/core/turn-journal.js +23 -1
- package/package.json +2 -2
- package/test-call-speech.js +7 -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 +17 -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/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 };
|
|
@@ -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 };
|