@inetafrica/open-claudia 3.2.0 → 3.2.1

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.2.1 — Speech as a general capability, sockets that survive rejection, and calls that greet back
4
+
5
+ - **A rejected handshake no longer strands a bot forever.** socket.io-client permanently stops reconnecting when the *server* rejects a handshake (auth middleware `next(new Error())`): `connect_error` fires with `socket.active === false` and the manager never dials again. During the 2026-08-08 outage chat-central booted before Central's MongoDB and rejected every handshake for a few minutes — terminally stranding the whole bot fleet, pods "Running" but deaf until manually restarted. New `core/socket-reconnect.js` `wireTerminalReconnect()` watches for exactly that terminal state and drives a manual `connect()` with 2s→30s capped backoff; transport-level failures (where the manager retries on its own, `socket.active === true`) are left alone. A deliberate client-side `disconnect()` is intercepted — a wedged socket emits no event for it — and cancels retries until `connect()` re-arms them. Wired into both the kazee and spaces sockets; `test-socket-reconnect.js` joins the pretest chain.
6
+ - **The bot can now place calls, both directions get a greeting, and the media leg works behind NAT.** Call-platform batch in the kazee channel, live-tested end to end on 2026-08-08:
7
+ - ICE servers now come from chat-central's `GET /webrtc/config` instead of a hardcoded STUN list, filtered to `stun:`/`turn:` URLs without `transport=tcp` (werift's ICE is UDP-only), falling back to defaults if the fetch fails. This fixes the zero-RTP calls where STUN alone couldn't punch through (diagnosed 2026-07-30).
8
+ - `outgoing({ chatId, toUserId })` places a bot-initiated call: the caller side owns room lifecycle, so the session creates the Janus room itself (mirroring the web client's params, tolerating 427 already-exists) and destroys it on close; `call:invite` goes out with a 60s ring timer — FCM's TTL is 60s and chat-central keeps ringing calls fresh for 90s, while the mobile app auto-ends its ring UI at 30s.
9
+ - That 30s device auto-end fires `call:end`, which previously tore down the room while a human was still walking to the phone — a late answer (44s in the live test) then joined a destroyed room and hung on "Connecting". `ended()` now ignores end events while the call is still ringing; the ring timer and `call:rejected` own that phase.
10
+ - Callers heard silence until they spoke first and read the call as dead. The session now speaks a greeting exactly once, when the first remote feed comes up — both directions ("Hey, it's Claudia…").
11
+ - Double-subscribe race fixed: the join response and the async publishers event both list the same feed, and while `attach()` was in flight the second frame passed the `has()` check and subscribed the feed twice, orphaning a PeerConnection (diagnosed 2026-07-30). The slot is now reserved before the first await.
12
+ - `call:accepted`/`call:rejected` are handled, and both call-test harnesses grew coverage (+45/+47 lines) for the outgoing flow, ring timeout, ignore-while-ringing, and greeting.
13
+ - **Received videos and mobile uploads no longer save as `.bin`.** Telegram messages without a fileName (videos especially) and Kazee Media docs from mobile uploads (which often carry no fileName at all) fell through to the `.bin` fallback, so an mp4 landed on disk unplayable. Extension derivation is now tiered: fileName ext → transport hint (Telegram's `file_path`, which carries the real extension; Kazee's blob-URL pathname) → mimeType map (16 common types) → per-media-type default (video→.mp4, audio→.m4a/.ogg) → `.bin` only as the true last resort. Kazee documents that keep their original name get the derived extension appended when the name has none.
14
+ - **TTS and STT become first-class agent capabilities, not just the voice-note reply path.** Fleet agents asked to produce a voice-over or transcribe a sent recording had nothing to reach for: they probed `open-claudia say --help` (which would have *spoken* "--help"), then tried direct gateway/vendor API calls and concluded "the key is missing" — correctly, because the provider subprocess is credential-stripped by design. Two new loopback verbs run in the credentialed bot process: `tts` (`open-claudia tts "<text>" --out <path> [--voice <id>]` — synthesize to a FILE without any chat send; extension picks the container, .ogg native / .mp3 / .wav transcoded; voice override reaches the gateway for cloned voices) and `transcribe` (`open-claudia transcribe <file>` — prints the transcript of any audio OR video file; `transcribeMedia` ffmpeg-normalizes to 16kHz mono wav first, which extracts video audio tracks and shrinks the gateway upload, then falls through the existing gateway → local-Whisper chain). No guardrail on `tts` because the artifact stays on local disk — the send verbs that could ship it carry their own R8 guard. A new "Speech services (TTS / STT)" system-prompt block — gated on real capability (`canSpeak()` / new `canTranscribe()`) — documents both plus `say`, and tells agents explicitly to never call the gateway or vendor APIs directly. `say`/`tts`/`transcribe` all answer `--help` now instead of synthesizing the flag. Regression chain: `test-speech-loopback.js` pins the loopback contract (empty text 400, no route 503, synth failure 500, relative/missing transcribe paths 400, empty transcript is a valid 200, auth 401) and the media mechanics (copy vs transcode by extension, mkdir -p, voice override in the gateway body, temp-wav cleanup, prompt-block presence). Verified live: a real gateway round-trip synthesized a sentence to mp3 and transcribed it back byte-identical.
15
+
3
16
  ## v3.2.0 — The employee batch: memory hygiene at write time, prospective memory, earned trust, and learning from the owner's judgment calls
4
17
 
5
18
  Three approved plans land together — memory adoption A1-A4 (from the TencentDB-Agent-Memory review), codegraph Phase 1, and employee capabilities C1-C6 — plus the Kazee group speaker-identity fix. Two rules hold throughout: **report-only where trust is involved** (nothing here grants, acts, or loosens an approval rule by itself), and **no new standing context cost** (everything injects only when already-matched content fires, or lands in the nightly dream summary).
package/bin/cli.js CHANGED
@@ -278,6 +278,11 @@ switch (command) {
278
278
  }
279
279
 
280
280
  case "say": {
281
+ // Agents probe with --help before speaking; don't synthesize the flag.
282
+ if (args[1] === "--help" || args[1] === "-h") {
283
+ console.log('Usage: open-claudia say "<text>"\nSynthesize the text (TTS) and send it as a voice note to the current chat.\nTo synthesize to a file instead (voice-overs), use: open-claudia tts "<text>" --out <path>');
284
+ process.exit(0);
285
+ }
281
286
  const text = args.slice(1).join(" ").trim();
282
287
  if (!text) {
283
288
  console.error('Usage: open-claudia say "<text>"');
@@ -299,6 +304,90 @@ switch (command) {
299
304
  break;
300
305
  }
301
306
 
307
+ case "tts": {
308
+ if (args[1] === "--help" || args[1] === "-h") {
309
+ console.log('Usage: open-claudia tts "<text>" [--out <path>.ogg|.mp3|.wav] [--voice <id>]\nSynthesize speech to a FILE without sending it to chat (voice-overs, narration).\nThe extension picks the container; prints the final path. Omit --out for a temp .ogg.');
310
+ process.exit(0);
311
+ }
312
+ const rest = args.slice(1);
313
+ let out = null;
314
+ let voice = null;
315
+ const textParts = [];
316
+ for (let i = 0; i < rest.length; i++) {
317
+ if (rest[i] === "--out") out = rest[++i];
318
+ else if (rest[i] === "--voice") voice = rest[++i];
319
+ else textParts.push(rest[i]);
320
+ }
321
+ const text = textParts.join(" ").trim();
322
+ if (!text) {
323
+ console.error('Usage: open-claudia tts "<text>" [--out <path>.ogg|.mp3|.wav] [--voice <id>]');
324
+ process.exit(2);
325
+ }
326
+ (async () => {
327
+ // Like `say`, synthesis happens in the credentialed bot process — this CLI
328
+ // has no speech token. The bot writes the file and returns its path.
329
+ const { postJson } = require("./loopback-client");
330
+ try {
331
+ const res = await postJson("tts", {
332
+ text,
333
+ out: out ? path.resolve(out) : undefined,
334
+ voice: voice || undefined,
335
+ });
336
+ console.log(res.path);
337
+ process.exit(0);
338
+ } catch (e) {
339
+ console.error(e.message);
340
+ process.exit(1);
341
+ }
342
+ })();
343
+ break;
344
+ }
345
+
346
+ case "transcribe": {
347
+ if (args[1] === "--help" || args[1] === "-h") {
348
+ console.log("Usage: open-claudia transcribe <audio-or-video-file>\nSpeech-to-text: prints the transcript. Accepts audio (ogg/mp3/m4a/wav) and video\n(mp4/mkv — the audio track is extracted automatically).");
349
+ process.exit(0);
350
+ }
351
+ const filePath = String(args[1] || "").trim();
352
+ if (!filePath) {
353
+ console.error("Usage: open-claudia transcribe <audio-or-video-file>");
354
+ process.exit(2);
355
+ }
356
+ const abs = path.resolve(filePath);
357
+ if (!fs.existsSync(abs)) {
358
+ console.error(`File not found: ${abs}`);
359
+ process.exit(2);
360
+ }
361
+ (async () => {
362
+ const { postJson } = require("./loopback-client");
363
+ try {
364
+ const res = await postJson("transcribe", { path: abs });
365
+ console.log(res.text || "");
366
+ process.exit(0);
367
+ } catch (e) {
368
+ console.error(e.message);
369
+ process.exit(1);
370
+ }
371
+ })();
372
+ break;
373
+ }
374
+
375
+ case "call": {
376
+ (async () => {
377
+ const { postJson } = require("./loopback-client");
378
+ try {
379
+ const toUserId = String(args[1] || "").trim();
380
+ const res = await postJson("call", toUserId ? { toUserId } : {});
381
+ console.log(`Calling: room=${res.roomId} chat=${res.chatId} to=${res.toUserId}`);
382
+ process.exit(0);
383
+ } catch (e) {
384
+ console.error(e.message);
385
+ process.exit(1);
386
+ }
387
+ })();
388
+ break;
389
+ }
390
+
302
391
  case "tw":
303
392
  case "transcript-window": {
304
393
  require("./transcript-window").run(args.slice(1));
@@ -474,6 +563,10 @@ Send tools (only work inside an active bot-spawned task):
474
563
  open-claudia send-photo <path> [caption]
475
564
  open-claudia send-voice <path>
476
565
  open-claudia say "<text>" Synthesize the text (TTS) and send it as a voice note
566
+ open-claudia tts "<text>" --out <path> Synthesize to a FILE without sending (voice-overs,
567
+ narration; ext picks format; --voice <id> optional)
568
+ open-claudia transcribe <file> Speech-to-text: print the transcript of an audio/video file
569
+ open-claudia call [toUserId] Place a live voice call on the current channel (kazee; owner-only)
477
570
 
478
571
  Memory tools:
479
572
  open-claudia transcript-search <query> Ranked FTS5 search over indexed transcripts
@@ -23,6 +23,28 @@ const CONTEXT_MAX_MESSAGES = 40;
23
23
  const CONTEXT_BODY_LIMIT = 280;
24
24
  const COLD_START_WINDOW = 15;
25
25
 
26
+ // Kazee Media docs from mobile uploads often carry no fileName, so the saved
27
+ // extension must come from the mimeType (or the blob URL) — otherwise every
28
+ // video/document lands on disk as ".bin".
29
+ const MIME_EXT = {
30
+ "video/mp4": ".mp4",
31
+ "video/quicktime": ".mov",
32
+ "video/webm": ".webm",
33
+ "video/x-matroska": ".mkv",
34
+ "audio/mpeg": ".mp3",
35
+ "audio/mp4": ".m4a",
36
+ "audio/x-m4a": ".m4a",
37
+ "audio/aac": ".aac",
38
+ "audio/ogg": ".ogg",
39
+ "audio/wav": ".wav",
40
+ "image/jpeg": ".jpg",
41
+ "image/png": ".png",
42
+ "image/gif": ".gif",
43
+ "image/webp": ".webp",
44
+ "application/pdf": ".pdf",
45
+ "application/zip": ".zip",
46
+ };
47
+
26
48
  class KazeeAdapter {
27
49
  constructor({ id = "kazee", url, token, ownerUserId, botUserId }) {
28
50
  this.id = id;
@@ -600,10 +622,22 @@ class KazeeAdapter {
600
622
  async downloadMedia(media) {
601
623
  if (!media || !media.fileId) return null;
602
624
  const url = media.fileId; // Kazee passes the cloud URL through fileId.
603
- const ext = path.extname(media.fileName || "") || (media.type === "voice" ? ".ogg" : media.type === "photo" ? ".jpg" : ".bin");
625
+ let urlExt = "";
626
+ try { urlExt = path.extname(new URL(url).pathname); } catch (e) {}
627
+ const mime = (media.mimeType || "").toLowerCase().split(";")[0].trim();
628
+ const ext = path.extname(media.fileName || "")
629
+ || urlExt
630
+ || MIME_EXT[mime]
631
+ || (media.type === "voice" ? ".ogg"
632
+ : media.type === "photo" ? ".jpg"
633
+ : media.type === "video" ? ".mp4"
634
+ : media.type === "audio" ? ".m4a"
635
+ : ".bin");
604
636
  const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
605
- const localPath = media.type === "document" && media.fileName
606
- ? path.join(baseDir, media.fileName)
637
+ const keepName = media.type === "document" && media.fileName;
638
+ const nameSuffix = keepName && !path.extname(media.fileName) && ext !== ".bin" ? ext : "";
639
+ const localPath = keepName
640
+ ? path.join(baseDir, media.fileName + nameSuffix)
607
641
  : path.join(baseDir, `kz-${Date.now()}${ext}`);
608
642
  await this._downloadUrl(url, localPath);
609
643
  return localPath;
@@ -8,6 +8,16 @@ const DEFAULT_ICE_SERVERS = [
8
8
  { urls: "stun:coturn.kazee.africa:3478" },
9
9
  { urls: "stun:stun.l.google.com:19302" },
10
10
  ];
11
+ // FCM push TTL is 60s and chat-central treats ringing logs as fresh for 90s.
12
+ // Must comfortably outlast the mobile client's own 30s ring timeout: a callee
13
+ // device auto-ends at 30s, the human then answers late, and chat-central
14
+ // revives the CallLog — the room has to still exist when that answer lands
15
+ // (first live outbound test 2026-08-08: end at 30s, real answer at 44s).
16
+ const RING_TIMEOUT_MS = 60_000;
17
+ const OUTGOING_GREETING = "Hey, it's Claudia. Can you hear me okay?";
18
+ // Inbound must greet too: callers get silence until they speak first, which
19
+ // reads as a dead call (live test 2026-08-08 — media was up, caller hung up).
20
+ const INCOMING_GREETING = "Hey, it's Claudia — you're through. Can you hear me?";
11
21
 
12
22
  class KazeeCallManager {
13
23
  constructor({ adapter, SessionImpl = KazeeCallSession, janusUrl = process.env.KAZEE_JANUS_URL || DEFAULT_JANUS_URL,
@@ -17,12 +27,45 @@ class KazeeCallManager {
17
27
  this.janusUrl = janusUrl;
18
28
  this.iceServers = iceServers;
19
29
  this.session = null;
30
+ this._ringTimer = null;
20
31
  }
21
32
 
22
33
  wire(socket) {
23
34
  socket.on("call:incoming", (payload) => this.incoming(payload));
24
35
  socket.on("call:ended", (payload) => this.ended(payload));
25
- socket.on("call:rejected", (payload) => this.ended(payload));
36
+ socket.on("call:rejected", (payload) => this.rejected(payload));
37
+ socket.on("call:accepted", (payload) => this.accepted(payload));
38
+ socket.on("call:ringing", (payload) => {
39
+ if (this.session) console.log(`Kazee call ringing: chat=${String(payload?.chatId || "")}`);
40
+ });
41
+ }
42
+
43
+ // Janus signalling always worked; the media leg never did — the hardcoded
44
+ // default is STUN-only, and behind NAT only a TURN relay yields a workable
45
+ // candidate pair (zero RTP ever arrived, 2026-07-30). chat-central mints
46
+ // TURN credentials at /webrtc/config — the same endpoint the web and mobile
47
+ // clients use. werift's ICE stack is UDP-only, so tcp/tls variants are
48
+ // filtered out before they can break candidate gathering.
49
+ async _fetchIceServers() {
50
+ if (typeof this.adapter?._request !== "function") return this.iceServers;
51
+ try {
52
+ const res = await this.adapter._request("GET", "/webrtc/config");
53
+ const fetched = Array.isArray(res?.config?.iceServers) ? res.config.iceServers : [];
54
+ const usable = fetched.filter((server) => {
55
+ const url = String(Array.isArray(server.urls) ? server.urls[0] : server.urls || "");
56
+ if (url.startsWith("stun:")) return true;
57
+ return url.startsWith("turn:") && !url.includes("transport=tcp");
58
+ });
59
+ const turns = usable.filter((s) => String(Array.isArray(s.urls) ? s.urls[0] : s.urls).startsWith("turn:")).length;
60
+ if (turns > 0) {
61
+ console.log(`Kazee call ICE config: ${usable.length} servers (${turns} TURN) from /webrtc/config`);
62
+ return usable;
63
+ }
64
+ console.warn("Kazee call ICE config: /webrtc/config returned no usable TURN entry, using defaults");
65
+ } catch (error) {
66
+ console.error(`Kazee call ICE config fetch failed, using defaults: ${error.message}`);
67
+ }
68
+ return this.iceServers;
26
69
  }
27
70
 
28
71
  async incoming(payload = {}) {
@@ -44,9 +87,10 @@ class KazeeCallManager {
44
87
  if (callType !== "audio") return reject("unsupported_media");
45
88
  if (this.session) return reject("busy");
46
89
 
90
+ const iceServers = await this._fetchIceServers();
47
91
  const session = new this.SessionImpl({
48
92
  roomId, chatId, callerId, botUserId: this.adapter.botUserId,
49
- janusUrl: this.janusUrl, iceServers: this.iceServers,
93
+ janusUrl: this.janusUrl, iceServers, greeting: INCOMING_GREETING,
50
94
  onTranscript: (text, meta) => this._transcript(text, payload, meta),
51
95
  onEnded: () => { if (this.session === session) this.session = null; },
52
96
  });
@@ -65,6 +109,70 @@ class KazeeCallManager {
65
109
  }
66
110
  }
67
111
 
112
+ // Bot-initiated call. Mirrors what a human caller's client does: create the
113
+ // Janus room, publish into it, then emit call:invite so chat-central rings
114
+ // the callee (socket + FCM/VoIP push). The callee's publisher appearing in
115
+ // the room is what completes the media leg — the existing auto-subscribe in
116
+ // the session handles that, so acceptance needs no extra media work here.
117
+ async outgoing({ chatId, toUserId } = {}) {
118
+ const target = String(toUserId || this.adapter.ownerUserId || "");
119
+ const chat = String(chatId || "");
120
+ if (!target || !chat) throw new Error("missing chatId/toUserId");
121
+ if (this.session) throw new Error("busy: a call session is already active");
122
+ if (!this.adapter._socket?.connected) throw new Error("kazee socket is not connected");
123
+ const roomId = Math.floor(Math.random() * 1e9);
124
+ const iceServers = await this._fetchIceServers();
125
+ // _transcript() reads chatId/fromUserId/roomId off the inbound invite
126
+ // payload; for outbound the remote human is the "from" side.
127
+ const remote = { chatId: chat, fromUserId: target, roomId, fromUserName: "" };
128
+ const session = new this.SessionImpl({
129
+ roomId, chatId: chat, callerId: target, botUserId: this.adapter.botUserId,
130
+ janusUrl: this.janusUrl, iceServers, asCaller: true, greeting: OUTGOING_GREETING,
131
+ onTranscript: (text, meta) => this._transcript(text, remote, meta),
132
+ onEnded: () => {
133
+ this._clearRingTimer();
134
+ if (this.session === session) this.session = null;
135
+ },
136
+ });
137
+ this.session = session;
138
+ try {
139
+ await session.start();
140
+ } catch (error) {
141
+ this.session = null;
142
+ await session.close("connect-failed").catch(() => {});
143
+ throw error;
144
+ }
145
+ console.log(`Kazee call outgoing: room=${roomId} chat=${chat} to=${target}`);
146
+ this.adapter._socket.emit("call:invite", { toUserId: target, callType: "audio", chatId: chat, roomId });
147
+ this._ringTimer = setTimeout(() => {
148
+ this._ringTimer = null;
149
+ this._ringTimeout(target, chat, roomId).catch((error) => console.error("Kazee call ring timeout:", error.message));
150
+ }, RING_TIMEOUT_MS);
151
+ if (typeof this._ringTimer.unref === "function") this._ringTimer.unref();
152
+ return { roomId, chatId: chat, toUserId: target };
153
+ }
154
+
155
+ accepted(payload = {}) {
156
+ if (!this.session || !this._ringTimer) return;
157
+ console.log(`Kazee call answered: room=${this.session.roomId} chat=${String(payload.chatId || "")}`);
158
+ this._clearRingTimer();
159
+ }
160
+
161
+ async _ringTimeout(toUserId, chatId, roomId) {
162
+ const session = this.session;
163
+ if (!session || session.roomId !== roomId) return;
164
+ console.log(`Kazee call unanswered: room=${roomId} to=${toUserId}`);
165
+ // Caller hanging up while ringing: chat-central finalizes the CallLog as
166
+ // missed and dismisses the callee's ringing UI/push.
167
+ this.adapter._socket?.emit("call:end", { toUserId, chatId });
168
+ this.session = null;
169
+ await session.close("no-answer").catch(() => {});
170
+ }
171
+
172
+ _clearRingTimer() {
173
+ if (this._ringTimer) { clearTimeout(this._ringTimer); this._ringTimer = null; }
174
+ }
175
+
68
176
  _transcript(text, incoming, meta = {}) {
69
177
  const session = this.session;
70
178
  if (!session || !text) return;
@@ -106,11 +214,29 @@ class KazeeCallManager {
106
214
  async ended(payload = {}) {
107
215
  if (!this.session) return;
108
216
  if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
217
+ // While our outbound ring is live, a call:ended is usually a callee DEVICE
218
+ // giving up (mobile auto-ends its ring at 30s), not the human declining.
219
+ // chat-central revives the CallLog if they answer late, so the caller must
220
+ // keep the room alive until its own ring timeout — tearing down here left
221
+ // the late answer joining a destroyed room, stuck on "Connecting" (2026-08-08).
222
+ // A deliberate decline arrives as call:rejected and still closes.
223
+ if (this._ringTimer) {
224
+ console.log(`Kazee call ignoring ended-while-ringing: room=${this.session.roomId}`);
225
+ return;
226
+ }
109
227
  const session = this.session;
110
228
  this.session = null;
111
229
  await session.close("remote-ended");
112
230
  }
113
231
 
232
+ async rejected(payload = {}) {
233
+ if (!this.session) return;
234
+ if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
235
+ const session = this.session;
236
+ this.session = null;
237
+ await session.close("remote-rejected");
238
+ }
239
+
114
240
  async stop() {
115
241
  if (!this.session) return;
116
242
  const session = this.session;
@@ -55,12 +55,16 @@ const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
55
55
 
56
56
  class KazeeCallSession {
57
57
  constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
58
- maxCallMs, idleMs, synthesize = synthesizePcm,
58
+ maxCallMs, idleMs, synthesize = synthesizePcm, asCaller = false, greeting = null,
59
59
  JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
60
60
  this.roomId = Number(roomId);
61
61
  this.chatId = String(chatId);
62
62
  this.callerId = String(callerId);
63
63
  this.botUserId = String(botUserId);
64
+ this.asCaller = !!asCaller;
65
+ this.greeting = greeting ? String(greeting) : null;
66
+ this._greeted = false;
67
+ this._createdRoom = false;
64
68
  this.janus = new JanusClientImpl({ url: janusUrl });
65
69
  this.PeerConnectionImpl = PeerConnectionImpl;
66
70
  this.SpeechStreamImpl = SpeechStreamImpl;
@@ -97,6 +101,21 @@ class KazeeCallSession {
97
101
  await this.janus.connect();
98
102
  this.publisherHandle = await this.janus.attach();
99
103
  this.janus.onHandle(this.publisherHandle, (frame) => this._onPublisherEvent(frame));
104
+ if (this.asCaller) {
105
+ // Outbound: the caller side owns room lifecycle, mirroring the web
106
+ // client's create params (audio-only, so no videocodec).
107
+ const created = await this.janus.message(this.publisherHandle, {
108
+ request: "create", room: this.roomId, publishers: 6, audiocodec: "opus",
109
+ description: `Call room ${this.roomId}`, permanent: false,
110
+ });
111
+ const cdata = created?.plugindata?.data || {};
112
+ const exists = Number(cdata.error_code) === 427 || /already exists/i.test(String(cdata.error || ""));
113
+ if (cdata.videoroom !== "created" && !exists) {
114
+ throw new Error(cdata.error || "Janus room create failed");
115
+ }
116
+ this._createdRoom = true;
117
+ clog(`Kazee call room created: room=${this.roomId}`);
118
+ }
100
119
  // The caller creates the Janus room during its own call setup, which can
101
120
  // land a beat after the invite reaches us — and a bot answers faster than
102
121
  // any human. Poll join through transient 426 "No such room" like the web
@@ -163,31 +182,47 @@ class KazeeCallSession {
163
182
  }
164
183
 
165
184
  async _subscribe(feedId, display) {
185
+ if (this.subscribers.has(feedId)) return;
186
+ // Reserve the slot before the first await: the join response and the async
187
+ // publishers event both list the same feed, and while attach() is in
188
+ // flight the second frame passes the caller's has() check and subscribes
189
+ // this feed twice, orphaning a PeerConnection (diagnosed 2026-07-30).
190
+ this.subscribers.set(feedId, null);
166
191
  clog(`Kazee call subscribing: feed=${feedId} display=${String(display || "")}`);
167
- const handle = await this.janus.attach();
168
- const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
169
- pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
170
- const sub = {
171
- handle,
172
- pc,
173
- display: String(display || ""),
174
- // Opus decoders are stateful, so each feed needs its own.
175
- codec: new OpusAudioCodec(),
176
- vad: new TurnDetector({ thresholdDb: BASE_THRESHOLD_DB }),
177
- stt: new this.SpeechStreamImpl({ onTranscript: (text) => this._onFeedTranscript(feedId, text) }),
178
- prebuffer: [],
179
- };
180
- this.subscribers.set(feedId, sub);
181
- pc.onTrack.subscribe((track) => {
182
- if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
183
- });
184
- this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
185
- const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
186
- await this._consumeSubscriberFrame(feedId, attached);
187
- await sub.stt.connect().then(
188
- () => clog(`Kazee call STT connected: feed=${feedId}`),
189
- (error) => console.error("Kazee call STT connect:", error.message),
190
- );
192
+ try {
193
+ const handle = await this.janus.attach();
194
+ const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
195
+ pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
196
+ const sub = {
197
+ handle,
198
+ pc,
199
+ display: String(display || ""),
200
+ // Opus decoders are stateful, so each feed needs its own.
201
+ codec: new OpusAudioCodec(),
202
+ vad: new TurnDetector({ thresholdDb: BASE_THRESHOLD_DB }),
203
+ stt: new this.SpeechStreamImpl({ onTranscript: (text) => this._onFeedTranscript(feedId, text) }),
204
+ prebuffer: [],
205
+ };
206
+ this.subscribers.set(feedId, sub);
207
+ pc.onTrack.subscribe((track) => {
208
+ if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
209
+ });
210
+ this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
211
+ const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
212
+ await this._consumeSubscriberFrame(feedId, attached);
213
+ await sub.stt.connect().then(
214
+ () => clog(`Kazee call STT connected: feed=${feedId}`),
215
+ (error) => console.error("Kazee call STT connect:", error.message),
216
+ );
217
+ // First remote feed up = both media legs established; greet exactly once.
218
+ if (this.greeting && !this._greeted) {
219
+ this._greeted = true;
220
+ this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
221
+ }
222
+ } catch (error) {
223
+ if (this.subscribers.get(feedId) === null) this.subscribers.delete(feedId);
224
+ throw error;
225
+ }
191
226
  }
192
227
 
193
228
  async _onSubscriberEvent(feedId, frame) {
@@ -403,6 +438,9 @@ class KazeeCallSession {
403
438
  this._stopThinking();
404
439
  this.cancelSpeech();
405
440
  for (const feed of [...this.subscribers.keys()]) await this._removeSubscriber(feed);
441
+ if (this._createdRoom) {
442
+ try { await this.janus.message(this.publisherHandle, { request: "destroy", room: this.roomId }); } catch (_) {}
443
+ }
406
444
  try { await this.publisher?.pc?.close(); } catch (_) {}
407
445
  try { await this.janus.detach(this.publisherHandle); } catch (_) {}
408
446
  try { await this.janus.close(); } catch (_) {}
@@ -5,6 +5,8 @@ let io;
5
5
  try { io = require("socket.io-client").io; }
6
6
  catch (e) { io = null; }
7
7
 
8
+ const { wireTerminalReconnect } = require("../../core/socket-reconnect");
9
+
8
10
  function createKazeeSocket({ url, token, userId, onConnect, onDisconnect, onError }) {
9
11
  if (!io) {
10
12
  throw new Error("socket.io-client is not installed. Run `npm install` to install dependencies.");
@@ -22,6 +24,7 @@ function createKazeeSocket({ url, token, userId, onConnect, onDisconnect, onErro
22
24
  socket.on("connect", () => { if (onConnect) onConnect(socket); });
23
25
  socket.on("disconnect", (reason) => { if (onDisconnect) onDisconnect(reason); });
24
26
  socket.on("connect_error", (err) => { if (onError) onError(err); });
27
+ wireTerminalReconnect(socket, { label: `kazee socket (${url})` });
25
28
  return socket;
26
29
  }
27
30
 
@@ -14,6 +14,8 @@ let io;
14
14
  try { io = require("socket.io-client").io; }
15
15
  catch (e) { io = null; }
16
16
 
17
+ const { wireTerminalReconnect } = require("../../core/socket-reconnect");
18
+
17
19
  function createSpacesSocket({ url, token, onNotification, onConnect, onDisconnect, onError }) {
18
20
  if (!io) {
19
21
  throw new Error("socket.io-client is not installed. Run `npm install` to install dependencies.");
@@ -34,6 +36,7 @@ function createSpacesSocket({ url, token, onNotification, onConnect, onDisconnec
34
36
  catch (e) { console.error("spaces notification handler:", e.message); }
35
37
  }
36
38
  });
39
+ wireTerminalReconnect(socket, { label: `spaces socket (${url})` });
37
40
  return socket;
38
41
  }
39
42
 
@@ -672,11 +672,15 @@ class TelegramAdapter {
672
672
  if (!media || !media.fileId) return null;
673
673
  const file = await this.bot.getFile(media.fileId);
674
674
  const fileUrl = `https://api.telegram.org/file/bot${this.token}/${file.file_path}`;
675
- const ext = media.fileName ? path.extname(media.fileName) || ".bin"
676
- : media.type === "voice" ? ".ogg"
677
- : media.type === "audio" ? path.extname(media.fileName || "") || ".ogg"
678
- : media.type === "photo" ? ".jpg"
679
- : ".bin";
675
+ // Telegram's file_path carries the real extension even when the message
676
+ // has no fileName (e.g. videos), so prefer it over the .bin fallback.
677
+ const ext = path.extname(media.fileName || "")
678
+ || path.extname(file.file_path || "")
679
+ || (media.type === "voice" ? ".ogg"
680
+ : media.type === "audio" ? ".ogg"
681
+ : media.type === "photo" ? ".jpg"
682
+ : media.type === "video" ? ".mp4"
683
+ : ".bin");
680
684
  const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
681
685
  const localPath = media.type === "document" && media.fileName
682
686
  ? path.join(baseDir, media.fileName)
package/core/loopback.js CHANGED
@@ -82,7 +82,7 @@ const JSON_KINDS = new Set([
82
82
  "relay-send", "recent-fetch", "audit-tail",
83
83
  "auth-list", "auth-revoke",
84
84
  "approval-request",
85
- "say",
85
+ "say", "call", "tts", "transcribe",
86
86
  ]);
87
87
 
88
88
  function callerIsOwner(payload) {
@@ -200,6 +200,56 @@ async function handleJson(req, res, url, kind) {
200
200
  return reply(res, ok ? 200 : 500, { ok });
201
201
  }
202
202
 
203
+ if (kind === "tts") {
204
+ // Synthesis-to-FILE: no chat egress, so no guardrail — the artifact stays on
205
+ // local disk until some send verb (with its own guard) ships it. Runs here
206
+ // because only the bot process holds the speech token.
207
+ const text = String(payload.text || "").trim();
208
+ if (!text) return reply(res, 400, { error: "missing text" });
209
+ try {
210
+ const { textToVoiceFile } = require("./media");
211
+ const outPath = await textToVoiceFile(text, {
212
+ voice: payload.voice ? String(payload.voice) : undefined,
213
+ out: payload.out ? String(payload.out) : undefined,
214
+ });
215
+ if (!outPath) return reply(res, 503, { error: "no TTS route available" });
216
+ return reply(res, 200, { ok: true, path: outPath });
217
+ } catch (e) {
218
+ return reply(res, 500, { error: `synthesis failed: ${e.message}` });
219
+ }
220
+ }
221
+
222
+ if (kind === "transcribe") {
223
+ const filePath = String(payload.path || "").trim();
224
+ if (!filePath) return reply(res, 400, { error: "missing path" });
225
+ if (!path.isAbsolute(filePath)) return reply(res, 400, { error: "path must be absolute (the bot process has its own cwd)" });
226
+ if (!fs.existsSync(filePath)) return reply(res, 400, { error: `file not found: ${filePath}` });
227
+ try {
228
+ const { transcribeMedia } = require("./media");
229
+ const text = await transcribeMedia(filePath);
230
+ if (text == null) return reply(res, 503, { error: "no STT route available" });
231
+ return reply(res, 200, { ok: true, text });
232
+ } catch (e) {
233
+ return reply(res, 500, { error: `transcription failed: ${e.message}` });
234
+ }
235
+ }
236
+
237
+ if (kind === "call") {
238
+ // Placing a live voice call is owner-only: it rings a phone on the bot's
239
+ // behalf, so nothing guarded/external may trigger it.
240
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "only the owner can place a call" });
241
+ const manager = adapter._callManager;
242
+ if (!manager || typeof manager.outgoing !== "function") {
243
+ return reply(res, 501, { error: `adapter ${adapterId} does not support outgoing calls` });
244
+ }
245
+ try {
246
+ const result = await manager.outgoing({ chatId: channelId, toUserId: payload.toUserId || undefined });
247
+ return reply(res, 200, { ok: true, ...result });
248
+ } catch (e) {
249
+ return reply(res, 500, { error: e.message });
250
+ }
251
+ }
252
+
203
253
  if (kind === "schedule-wakeup") {
204
254
  const fireAt = Number(payload.fireAt);
205
255
  if (!Number.isFinite(fireAt) || fireAt <= 0) return reply(res, 400, { error: "missing/invalid fireAt" });
package/core/media.js CHANGED
@@ -101,7 +101,7 @@ async function elevenLabsToVoice(clean) {
101
101
 
102
102
  // Pocket TTS through the Agent Space speech gateway. The gateway returns WAV;
103
103
  // Telegram and voice transports receive a compact Ogg/Opus file.
104
- async function pocketToVoice(clean) {
104
+ async function pocketToVoice(clean, voiceOverride) {
105
105
  const token = speechBearerToken();
106
106
  if (TTS_PROVIDER === "elevenlabs" || !token || !SPEECH_SERVICE_URL || !FFMPEG) return null;
107
107
  let wavPath = null;
@@ -109,7 +109,7 @@ async function pocketToVoice(clean) {
109
109
  const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/audio/speech`, {
110
110
  method: "POST",
111
111
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
112
- body: JSON.stringify({ input: clean, voice: ttsVoice.getVoice(), response_format: "wav" }),
112
+ body: JSON.stringify({ input: clean, voice: voiceOverride || ttsVoice.getVoice(), response_format: "wav" }),
113
113
  signal: AbortSignal.timeout(60000),
114
114
  });
115
115
  if (!res.ok) {
@@ -186,16 +186,69 @@ function canSpeak() {
186
186
  }
187
187
 
188
188
  // Natural voice via ElevenLabs, falling back to macOS `say` only on no-key/error.
189
- async function textToVoice(text) {
189
+ // opts.voice overrides the gateway voice id (e.g. a cloned voice) for the pocket
190
+ // route only — ElevenLabs/say fallbacks keep their configured voices.
191
+ async function textToVoice(text, opts = {}) {
190
192
  const clean = cleanForTTS(text);
191
193
  if (!clean) return null;
192
- const pocket = await pocketToVoice(clean);
194
+ const pocket = await pocketToVoice(clean, opts.voice);
193
195
  if (pocket) return pocket;
194
196
  const eleven = await elevenLabsToVoice(clean);
195
197
  if (eleven) return eleven;
196
198
  return sayToVoice(clean);
197
199
  }
198
200
 
201
+ // Synthesize to a caller-chosen FILE (voice-overs, video narration) instead of a
202
+ // chat send. `out` picks the container by extension (.ogg is native; anything
203
+ // else transcodes). Returns the final path, or null when no TTS route exists.
204
+ async function textToVoiceFile(text, { voice, out } = {}) {
205
+ const oggPath = await textToVoice(text, { voice });
206
+ if (!oggPath) return null;
207
+ if (!out || path.resolve(out) === path.resolve(oggPath)) return oggPath;
208
+ fs.mkdirSync(path.dirname(out), { recursive: true });
209
+ if (path.extname(out).toLowerCase() === path.extname(oggPath).toLowerCase()) {
210
+ fs.copyFileSync(oggPath, out);
211
+ } else {
212
+ if (!FFMPEG) throw new Error(`ffmpeg unavailable to convert to ${path.extname(out) || "the requested format"}`);
213
+ execSync(`"${FFMPEG}" -i "${oggPath}" -y "${out}" 2>/dev/null`, { timeout: 60000 });
214
+ }
215
+ try { fs.unlinkSync(oggPath); } catch (e) {}
216
+ return out;
217
+ }
218
+
219
+ // True when at least one transcribeAudio route can produce text.
220
+ function canTranscribe() {
221
+ if (SPEECH_SERVICE_URL && speechBearerToken()) return true;
222
+ return Boolean(WHISPER_CLI && FFMPEG);
223
+ }
224
+
225
+ // Transcribe ANY audio/video file (mp3, m4a, wav, ogg, mp4, mkv...): normalize
226
+ // to 16kHz mono wav first when ffmpeg is available — that extracts the audio
227
+ // track from video containers and shrinks the gateway upload. Returns the
228
+ // transcript string, or null when no STT route exists.
229
+ async function transcribeMedia(filePath) {
230
+ if (!canTranscribe()) return null;
231
+ let wavPath = null;
232
+ let sendPath = filePath;
233
+ if (FFMPEG && !/\.wav$/i.test(filePath)) {
234
+ try {
235
+ wavPath = path.join(TEMP_DIR, `stt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.wav`);
236
+ execSync(`"${FFMPEG}" -i "${filePath}" -vn -ar 16000 -ac 1 -y "${wavPath}" 2>/dev/null`, { timeout: 300000 });
237
+ sendPath = wavPath;
238
+ } catch (e) {
239
+ console.error("STT transcode error:", e.message);
240
+ try { if (wavPath) fs.unlinkSync(wavPath); } catch (_) {}
241
+ wavPath = null;
242
+ sendPath = filePath;
243
+ }
244
+ }
245
+ try {
246
+ return await transcribeAudio(sendPath);
247
+ } finally {
248
+ if (wavPath) { try { fs.unlinkSync(wavPath); } catch (e) {} }
249
+ }
250
+ }
251
+
199
252
  // Split a reply into sentence-sized chunks for streamed TTS. Tiny fragments are
200
253
  // merged into the next chunk so we don't fire a TTS call per "Hi." and end up
201
254
  // with choppy playback. Returns cleaned, non-empty chunks.
@@ -250,4 +303,4 @@ async function synthSentenceMp3(text) {
250
303
  }
251
304
  }
252
305
 
253
- module.exports = { transcribeAudio, transcribeAudioLocal, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
306
+ module.exports = { transcribeAudio, transcribeAudioLocal, transcribeMedia, canTranscribe, textToVoice, textToVoiceFile, splitSentences, synthSentenceMp3, pocketToVoice, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+
3
+ // socket.io-client permanently stops reconnecting when the server REJECTS a
4
+ // handshake (auth middleware next(new Error())): connect_error fires with
5
+ // socket.active === false and the manager never dials again. During the
6
+ // 2026-08-08 outage chat-central booted before Central's MongoDB and rejected
7
+ // every handshake for a few minutes, terminally stranding the whole bot fleet.
8
+ // This wires a manual connect() retry with capped backoff so a rejected
9
+ // handshake is never terminal. A deliberate client-side disconnect() cancels
10
+ // any pending retry and stays final until connect() is called again.
11
+
12
+ const DEFAULT_INITIAL_DELAY_MS = 2000;
13
+ const DEFAULT_MAX_DELAY_MS = 30000;
14
+
15
+ function wireTerminalReconnect(socket, opts = {}) {
16
+ const label = opts.label || "socket";
17
+ const log = opts.log || ((msg) => console.error(msg));
18
+ const initialDelay = opts.initialDelayMs || DEFAULT_INITIAL_DELAY_MS;
19
+ const maxDelay = opts.maxDelayMs || DEFAULT_MAX_DELAY_MS;
20
+
21
+ let attempts = 0;
22
+ let timer = null;
23
+ let stopped = false;
24
+
25
+ const cancel = () => {
26
+ if (timer) { clearTimeout(timer); timer = null; }
27
+ };
28
+
29
+ const schedule = (why) => {
30
+ if (stopped || timer) return;
31
+ const delay = Math.min(initialDelay * 2 ** attempts, maxDelay);
32
+ attempts += 1;
33
+ log(`${label}: ${why} — socket.io will not retry on its own; manual connect() in ${Math.ceil(delay / 1000)}s (attempt ${attempts})`);
34
+ timer = setTimeout(() => {
35
+ timer = null;
36
+ if (stopped || socket.connected || socket.active) return;
37
+ try { socket.connect(); }
38
+ catch (e) { schedule(`reconnect attempt threw (${e.message})`); }
39
+ }, delay);
40
+ if (typeof timer.unref === "function") timer.unref();
41
+ };
42
+
43
+ socket.on("connect", () => { attempts = 0; cancel(); });
44
+ socket.on("connect_error", (err) => {
45
+ if (socket.active) return; // transport failure: the manager auto-retries
46
+ schedule(`handshake rejected (${err && err.message ? err.message : err})`);
47
+ });
48
+ socket.on("disconnect", (reason) => {
49
+ if (reason === "io client disconnect") { cancel(); return; }
50
+ if (socket.active) return; // the manager auto-reconnects
51
+ schedule(`disconnected (${reason})`);
52
+ });
53
+
54
+ // disconnect() while wedged emits no event, so intercept the call itself;
55
+ // connect() re-arms retries for the adapter-restart path.
56
+ const origDisconnect = socket.disconnect;
57
+ socket.disconnect = function (...args) {
58
+ stopped = true;
59
+ cancel();
60
+ return origDisconnect.apply(this, args);
61
+ };
62
+ const origConnect = socket.connect;
63
+ socket.connect = function (...args) {
64
+ stopped = false;
65
+ return origConnect.apply(this, args);
66
+ };
67
+
68
+ return socket;
69
+ }
70
+
71
+ module.exports = { wireTerminalReconnect, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS };
@@ -4,7 +4,7 @@
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
6
  const { SOUL_FILE, CRONS_FILE, VAULT_FILE, FILES_DIR, BOT_DIR, config } = require("./config");
7
- const { canSpeak } = require("./media");
7
+ const { canSpeak, canTranscribe } = require("./media");
8
8
  const CONFIG_DIR = require("../config-dir");
9
9
  const { currentState, getProjectKey, getProviderSession } = require("./state");
10
10
  const { currentAdapter, currentChannelId, currentUserId, currentSpeakerName } = require("./context");
@@ -202,6 +202,7 @@ function buildCoreInstructionsUncached() {
202
202
  // Gate on actual TTS capability, not local Whisper: hosted pods hear and
203
203
  // speak through the speech gateway with no whisper binary installed.
204
204
  const hasVoice = canSpeak();
205
+ const hasEars = canTranscribe();
205
206
  return `
206
207
  ${soul}
207
208
  ${buildPersonaBlock()}
@@ -235,6 +236,13 @@ Reply normally in your final answer. To send a file, image, or voice clip back t
235
236
  - \`open-claudia send-voice <path>\` — ogg/opus voice note
236
237
  - \`open-claudia say "<text>"\` — synthesize the text and send it as a voice note (use this when asked for a voice reply)
237
238
  Never print or embed bot tokens in prompts, commands, logs, or messages.
239
+ ${hasVoice || hasEars ? `
240
+ ## Speech services (TTS / STT)
241
+ Text-to-speech and speech-to-text are general capabilities, not just the voice-note reply path. Synthesis and transcription run inside the credentialed bot process — your own shell holds NO speech token or vendor key, so never call the speech gateway or TTS/STT vendor APIs directly and never conclude "the key is missing"; use these commands (they work from inside any bot-spawned task):
242
+ ${hasVoice ? `- \`open-claudia tts "<text>" --out <path>.mp3\` — synthesize narration to a FILE without sending it to chat: video voice-overs, explainer narration, IVR prompts, any audio artifact. The extension picks the container (.ogg native, .mp3/.wav transcoded); prints the final path. \`--voice <id>\` selects a specific gateway voice (e.g. a cloned voice).
243
+ - \`open-claudia say "<text>"\` — synthesize AND send as a voice note to the current chat (listed under Delivery above).` : ""}
244
+ ${hasEars ? `- \`open-claudia transcribe <file>\` — speech-to-text: prints the transcript of an audio OR video file (voice notes, meeting recordings, mp3/m4a/wav/ogg, mp4/mkv — the audio track is extracted automatically). Use it whenever the user sends a recording and wants its contents.` : ""}
245
+ For long narration, synthesize per-paragraph with \`tts\` and assemble with ffmpeg.` : ""}
238
246
 
239
247
  ## Background work
240
248
  You can persist work across turns and wake yourself up later. These survive bot restarts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "test:kazee-call": "node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
11
11
  "setup": "node setup.js",
12
12
  "start": "node bot.js",
13
- "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
13
+ "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-socket-reconnect.js && node test-speech-loopback.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
14
14
  "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js"
15
15
  },
16
16
  "files": [
@@ -487,8 +487,12 @@ async function main() {
487
487
  throw new Error("FAKE_AGENT_KIND must be claude or codex");
488
488
  }
489
489
  if (handleCompatibilityCommand()) return;
490
- captureInvocation();
490
+ // Arm signal handlers BEFORE writing the boot capture: the capture is the
491
+ // parent's green light to send SIGTERM, and on a loaded machine the child
492
+ // can be preempted between these calls — a TERM then hits node's default
493
+ // disposition and kills the process before the signal capture ever writes.
491
494
  installSignalControls();
495
+ captureInvocation();
492
496
  spawnDescendantFixture();
493
497
  const stdin = await readStdin();
494
498
  captureStdin(stdin);
@@ -11,7 +11,7 @@ class FakeSession {
11
11
  }
12
12
  function fixture() {
13
13
  const events = [], inbound = [];
14
- const socket = { on() {}, emit(name, payload) { events.push({ name, payload }); } };
14
+ const socket = { connected: true, on() {}, emit(name, payload) { events.push({ name, payload }); } };
15
15
  const adapter = { ownerUserId: "owner-1", botUserId: "bot-1", _socket: socket, _emit(name, payload) { inbound.push({ name, payload }); } };
16
16
  return { events, inbound, manager: new KazeeCallManager({ adapter, SessionImpl: FakeSession }) };
17
17
  }
@@ -36,5 +36,48 @@ function fixture() {
36
36
  assert.strictEqual(f.manager.session.spoken, "Your agenda is clear.");
37
37
  await f.manager.ended({ roomId: 12 });
38
38
  assert.strictEqual(f.manager.session, null);
39
+
40
+ // Outbound: session starts in caller mode, invite goes out, answer clears the ring timer.
41
+ f = fixture();
42
+ const res = await f.manager.outgoing({ chatId: "chat-2" });
43
+ assert.strictEqual(res.toUserId, "owner-1");
44
+ const outSession = f.manager.session;
45
+ assert.strictEqual(outSession.options.asCaller, true);
46
+ assert.ok(outSession.options.greeting);
47
+ assert.strictEqual(outSession.started, true);
48
+ const invite = f.events.find((e) => e.name === "call:invite");
49
+ assert.deepStrictEqual(invite.payload, { toUserId: "owner-1", callType: "audio", chatId: "chat-2", roomId: res.roomId });
50
+ assert.ok(f.manager._ringTimer);
51
+ // A callee device auto-ending its ring must NOT tear down the outbound call —
52
+ // chat-central revives the CallLog if the human answers late.
53
+ await f.manager.ended({ chatId: "chat-2" });
54
+ assert.ok(f.manager.session, "ended-while-ringing is ignored");
55
+ f.manager.accepted({ chatId: "chat-2" });
56
+ assert.strictEqual(f.manager._ringTimer, null);
57
+ // Outbound transcripts dispatch with the remote human as the "from" side.
58
+ outSession.options.onTranscript("Hello there");
59
+ assert.strictEqual(f.inbound[0].payload.channelId, "chat-2");
60
+ assert.strictEqual(f.inbound[0].payload.userId, "owner-1");
61
+ assert.strictEqual(f.inbound[0].payload.raw.ephemeralAudio, true);
62
+ await assert.rejects(() => f.manager.outgoing({ chatId: "chat-2" }), /busy/);
63
+ await f.manager.ended({ roomId: res.roomId });
64
+ assert.strictEqual(f.manager.session, null);
65
+
66
+ // A deliberate decline (call:rejected) still tears down mid-ring.
67
+ f = fixture();
68
+ await f.manager.outgoing({ chatId: "chat-4" });
69
+ assert.ok(f.manager._ringTimer);
70
+ await f.manager.rejected({ chatId: "chat-4" });
71
+ assert.strictEqual(f.manager.session, null, "reject-while-ringing closes the call");
72
+ assert.strictEqual(f.manager._ringTimer, null);
73
+
74
+ // Unanswered: ring timeout tells the server to finalize as missed and hangs up.
75
+ f = fixture();
76
+ const res2 = await f.manager.outgoing({ chatId: "chat-3", toUserId: "owner-1" });
77
+ f.manager._clearRingTimer();
78
+ await f.manager._ringTimeout("owner-1", "chat-3", res2.roomId);
79
+ const end = f.events.find((e) => e.name === "call:end");
80
+ assert.deepStrictEqual(end.payload, { toUserId: "owner-1", chatId: "chat-3" });
81
+ assert.strictEqual(f.manager.session, null);
39
82
  console.log("kazee call manager: ok");
40
83
  })().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -41,12 +41,12 @@ class FakeSpeechStream {
41
41
  close() { this.closedStream = true; }
42
42
  }
43
43
 
44
- function makeSession({ synthesize, maxCallMs, idleMs } = {}) {
44
+ function makeSession({ synthesize, maxCallMs, idleMs, asCaller, greeting } = {}) {
45
45
  const ended = [];
46
46
  const transcripts = [];
47
47
  const session = new KazeeCallSession({
48
48
  roomId: 7, chatId: "chat", callerId: "owner", botUserId: "bot", janusUrl: "ws://test",
49
- maxCallMs, idleMs,
49
+ maxCallMs, idleMs, asCaller, greeting,
50
50
  synthesize: synthesize || (async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 2), sampleRate: 48000 })),
51
51
  onTranscript: (text, meta) => transcripts.push({ text, meta }),
52
52
  onEnded: (reason) => ended.push(reason),
@@ -228,6 +228,47 @@ async function testJoinGivesUpAfterRetries() {
228
228
  await session.close();
229
229
  }
230
230
 
231
+ async function testCallerModeRoomLifecycle() {
232
+ const { session } = makeSession({ asCaller: true, greeting: "hi there" });
233
+ const messages = [];
234
+ session.janus.message = async (_handle, body) => {
235
+ messages.push(body);
236
+ if (body?.request === "create") return { plugindata: { data: { videoroom: "created", room: 7 } } };
237
+ return {};
238
+ };
239
+ await session.start();
240
+ assert.strictEqual(messages[0].request, "create", "caller creates the room before joining");
241
+ assert.strictEqual(messages[0].room, 7);
242
+ assert.strictEqual(messages[0].audiocodec, "opus");
243
+ assert.strictEqual(messages[1].request, "join");
244
+ assert.strictEqual(session._createdRoom, true);
245
+ const spoken = [];
246
+ session.speak = async (text) => { spoken.push(text); return true; };
247
+ await session._subscribe(101, "sumeet@kazee");
248
+ await session._subscribe(102, "second@kazee");
249
+ assert.deepStrictEqual(spoken, ["hi there"], "greeting speaks exactly once, on first remote feed");
250
+ await session.close();
251
+ const destroy = messages.find((m) => m?.request === "destroy");
252
+ assert.ok(destroy && destroy.room === 7, "created room is destroyed on close");
253
+ }
254
+
255
+ async function testCallerModeRoomExistsAndFailure() {
256
+ const { session } = makeSession({ asCaller: true });
257
+ session.janus.message = async (_handle, body) => body?.request === "create"
258
+ ? { plugindata: { data: { videoroom: "event", error_code: 427, error: "Room 7 already exists" } } }
259
+ : {};
260
+ await session.start();
261
+ assert.strictEqual(session._createdRoom, true, "already-exists (427) counts as created");
262
+ await session.close();
263
+
264
+ const bad = makeSession({ asCaller: true }).session;
265
+ bad.janus.message = async (_handle, body) => body?.request === "create"
266
+ ? { plugindata: { data: { videoroom: "event", error_code: 499, error: "boom" } } }
267
+ : {};
268
+ await assert.rejects(() => bad.start(), /boom/, "hard create failure fails the call");
269
+ await bad.close();
270
+ }
271
+
231
272
  async function testThinkingCues() {
232
273
  const { session } = makeSession({
233
274
  synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 3), sampleRate: 48000 }),
@@ -300,6 +341,8 @@ async function testCloseResolvesSpeech() {
300
341
  await testMaxDuration();
301
342
  await testJoinRetriesNoSuchRoom();
302
343
  await testJoinGivesUpAfterRetries();
344
+ await testCallerModeRoomLifecycle();
345
+ await testCallerModeRoomExistsAndFailure();
303
346
  await testThinkingCues();
304
347
  await testCloseResolvesSpeech();
305
348
  console.log("kazee call session: ok");
@@ -346,7 +346,12 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
346
346
  // into a new version work through till the end and deploy out a new version"):
347
347
  // the employee batch — memory adoption A1-A4, the codegraph reusable tool,
348
348
  // employee capabilities C1-C6, and the Kazee group speaker-identity fix.
349
- assert.strictEqual(pkg.version, "3.2.0", "release version must remain unchanged without explicit approval");
349
+ // 3.2.1 approved by Sumeet 2026-08-08 ("Push it", answering "Your go on the
350
+ // v3.2.1 cut → commit, tag, CI image, fleet roll"): terminal socket-reconnect
351
+ // fix from the chat-central outage, the call-platform batch (outgoing calls,
352
+ // greetings, TURN via /webrtc/config, ring-phase teardown fixes), the
353
+ // received-media .bin extension fix, and the tts/transcribe agent verbs.
354
+ assert.strictEqual(pkg.version, "3.2.1", "release version must remain unchanged without explicit approval");
350
355
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
351
356
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
352
357
  }