@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
|
@@ -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 };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// OC_SWITCHBOARD=on splits the bot in two: a small switchboard process that
|
|
2
|
+
// holds what must not drop (stage 1: the live call room) and the brain (the
|
|
3
|
+
// rest of the bot), which runs as its child and can restart freely. The
|
|
4
|
+
// launchd plist and the pod CMD stay as they are: whichever entry point they
|
|
5
|
+
// run hands itself over here, and the child re-runs that same entry point.
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { execFileSync } = require("child_process");
|
|
10
|
+
|
|
11
|
+
const CHILD_ENV = "OC_SWITCHBOARD_CHILD";
|
|
12
|
+
|
|
13
|
+
function flagOn(value) {
|
|
14
|
+
return ["on", "1", "true", "yes"].includes(String(value || "").trim().toLowerCase());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function switchboardWanted(config = null) {
|
|
18
|
+
if (process.env.OC_SWITCHBOARD !== undefined) return flagOn(process.env.OC_SWITCHBOARD);
|
|
19
|
+
return flagOn(config?.OC_SWITCHBOARD);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The brain, started by a switchboard: it has the IPC channel to talk back on.
|
|
23
|
+
function isSwitchboardChild() {
|
|
24
|
+
return process.env[CHILD_ENV] === "1" && typeof process.send === "function";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Which install this is: a reinstall of the same version is a new stamp too.
|
|
28
|
+
function packageStamp(botDir) {
|
|
29
|
+
try {
|
|
30
|
+
const file = path.join(botDir, "package.json");
|
|
31
|
+
const version = JSON.parse(fs.readFileSync(file, "utf8")).version || "0";
|
|
32
|
+
return `${version}-${Math.floor(fs.statSync(file).mtimeMs)}`;
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The switchboard runs from its own copy of the package. An upgrade swaps the
|
|
39
|
+
// files on disk while a call may be live, and Node loads some modules only on
|
|
40
|
+
// first use, so a switchboard running in place could mix old and new code
|
|
41
|
+
// mid-call. On macOS the copy is an APFS clone: instant and costing no space.
|
|
42
|
+
// Elsewhere it runs in place (bot pods are replaced whole to upgrade).
|
|
43
|
+
function runtimeCopy({ botDir, configDir, platform = process.platform }) {
|
|
44
|
+
const stamp = packageStamp(botDir);
|
|
45
|
+
if (platform !== "darwin" || !stamp) return botDir;
|
|
46
|
+
const root = path.join(configDir, "switchboard-runtime");
|
|
47
|
+
const target = path.join(root, stamp);
|
|
48
|
+
try {
|
|
49
|
+
if (!fs.existsSync(path.join(target, "package.json"))) {
|
|
50
|
+
fs.mkdirSync(root, { recursive: true });
|
|
51
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
52
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
53
|
+
execFileSync("/bin/cp", ["-cR", botDir, tmp], { stdio: "ignore", timeout: 120000 });
|
|
54
|
+
// Settings live in the config dir; never keep a second copy of a stray one.
|
|
55
|
+
fs.rmSync(path.join(tmp, ".env"), { force: true });
|
|
56
|
+
fs.renameSync(tmp, target);
|
|
57
|
+
}
|
|
58
|
+
for (const old of fs.readdirSync(root)) {
|
|
59
|
+
if (old !== stamp) fs.rmSync(path.join(root, old), { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
return target;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(`Switchboard: running in place (copy failed: ${error.message})`);
|
|
64
|
+
return botDir;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// True when this process became the switchboard; the caller must then do
|
|
69
|
+
// nothing else (the brain runs in the child, from the live install).
|
|
70
|
+
function maybeRunSwitchboard() {
|
|
71
|
+
if (isSwitchboardChild()) return false;
|
|
72
|
+
const { config, CONFIG_DIR, BOT_DIR } = require("../config");
|
|
73
|
+
if (!switchboardWanted(config)) return false;
|
|
74
|
+
const runtime = runtimeCopy({ botDir: BOT_DIR, configDir: CONFIG_DIR });
|
|
75
|
+
require(path.join(runtime, "core", "switchboard", "supervisor"))
|
|
76
|
+
.runSwitchboard({ argv: process.argv.slice(1), configDir: CONFIG_DIR, botDir: BOT_DIR, runtimeDir: runtime });
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { CHILD_ENV, flagOn, switchboardWanted, isSwitchboardChild, maybeRunSwitchboard, packageStamp, runtimeCopy };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The switchboard and the brain talk over Node's child IPC channel (the brain
|
|
2
|
+
// is the switchboard's child, so the channel needs no socket file or token).
|
|
3
|
+
// A Link wraps one end: requests with replies, one-way notes, and a close
|
|
4
|
+
// that fails whatever is still waiting, so neither side hangs on a peer that
|
|
5
|
+
// went away mid-call.
|
|
6
|
+
|
|
7
|
+
const { EventEmitter } = require("events");
|
|
8
|
+
|
|
9
|
+
class Link extends EventEmitter {
|
|
10
|
+
constructor(channel, { label = "switchboard" } = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this.channel = channel;
|
|
13
|
+
this.label = label;
|
|
14
|
+
this.closed = false;
|
|
15
|
+
this._seq = 0;
|
|
16
|
+
this._pending = new Map();
|
|
17
|
+
this._handlers = new Map();
|
|
18
|
+
this._onMessage = (message) => this._receive(message);
|
|
19
|
+
this._onClose = () => this.close();
|
|
20
|
+
channel.on("message", this._onMessage);
|
|
21
|
+
channel.on("disconnect", this._onClose);
|
|
22
|
+
// A ChildProcess end also sees the child exit; harmless twice.
|
|
23
|
+
channel.on("exit", this._onClose);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
handle(type, fn) {
|
|
27
|
+
this._handlers.set(type, fn);
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
notify(type, payload) {
|
|
32
|
+
return this._send({ sb: 1, kind: "note", type, payload });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
request(type, payload, { timeoutMs = 30000 } = {}) {
|
|
36
|
+
if (this.closed) return Promise.reject(new Error(`${this.label} link closed`));
|
|
37
|
+
const id = ++this._seq;
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
40
|
+
this._pending.delete(id);
|
|
41
|
+
reject(new Error(`${this.label} ${type} timed out`));
|
|
42
|
+
}, timeoutMs) : null;
|
|
43
|
+
timer?.unref?.();
|
|
44
|
+
this._pending.set(id, { resolve, reject, timer });
|
|
45
|
+
if (!this._send({ sb: 1, kind: "req", id, type, payload })) {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
this._pending.delete(id);
|
|
48
|
+
reject(new Error(`${this.label} link closed`));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
close() {
|
|
54
|
+
if (this.closed) return;
|
|
55
|
+
this.closed = true;
|
|
56
|
+
this.channel.off?.("message", this._onMessage);
|
|
57
|
+
this.channel.off?.("disconnect", this._onClose);
|
|
58
|
+
this.channel.off?.("exit", this._onClose);
|
|
59
|
+
for (const { reject, timer } of this._pending.values()) {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
reject(new Error(`${this.label} link closed`));
|
|
62
|
+
}
|
|
63
|
+
this._pending.clear();
|
|
64
|
+
this.emit("close");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_send(message) {
|
|
68
|
+
if (this.closed || this.channel.connected === false) return false;
|
|
69
|
+
try {
|
|
70
|
+
// The callback swallows a channel that closed between the check and
|
|
71
|
+
// the write; close() above is what reports it.
|
|
72
|
+
this.channel.send(message, () => {});
|
|
73
|
+
return true;
|
|
74
|
+
} catch (_) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async _receive(message) {
|
|
80
|
+
if (!message || message.sb !== 1) return;
|
|
81
|
+
if (message.kind === "res") {
|
|
82
|
+
const waiting = this._pending.get(message.id);
|
|
83
|
+
if (!waiting) return;
|
|
84
|
+
this._pending.delete(message.id);
|
|
85
|
+
clearTimeout(waiting.timer);
|
|
86
|
+
if (message.ok) waiting.resolve(message.value);
|
|
87
|
+
else waiting.reject(new Error(message.error || `${this.label} request failed`));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (message.kind === "note") {
|
|
91
|
+
this.emit(message.type, message.payload);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (message.kind !== "req") return;
|
|
95
|
+
const handler = this._handlers.get(message.type);
|
|
96
|
+
try {
|
|
97
|
+
if (!handler) throw new Error(`no handler for ${message.type}`);
|
|
98
|
+
const value = await handler(message.payload);
|
|
99
|
+
this._send({ sb: 1, kind: "res", id: message.id, ok: true, value: value === undefined ? null : value });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
this._send({ sb: 1, kind: "res", id: message.id, ok: false, error: String(error?.message || error) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Two connected in-memory channel ends, for tests: messages are copied
|
|
107
|
+
// through JSON and delivered on a later tick, as the real channel does.
|
|
108
|
+
function channelPair() {
|
|
109
|
+
const a = new EventEmitter();
|
|
110
|
+
const b = new EventEmitter();
|
|
111
|
+
for (const [self, peer] of [[a, b], [b, a]]) {
|
|
112
|
+
self.connected = true;
|
|
113
|
+
self.send = (message, callback) => {
|
|
114
|
+
if (!self.connected) throw new Error("channel closed");
|
|
115
|
+
const copy = JSON.parse(JSON.stringify(message));
|
|
116
|
+
setImmediate(() => { if (peer.connected) peer.emit("message", copy); });
|
|
117
|
+
if (callback) setImmediate(callback);
|
|
118
|
+
return true;
|
|
119
|
+
};
|
|
120
|
+
self.disconnect = () => {
|
|
121
|
+
if (!self.connected) return;
|
|
122
|
+
self.connected = false;
|
|
123
|
+
peer.connected = false;
|
|
124
|
+
setImmediate(() => { self.emit("disconnect"); peer.emit("disconnect"); });
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return [a, b];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { Link, channelPair };
|