@inetafrica/open-claudia 3.5.33 → 3.5.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +65 -5
  2. package/bin/cli.js +10 -3
  3. package/bot.js +11 -0
  4. package/channels/kazee/adapter.js +220 -23
  5. package/channels/kazee/call/camera.js +233 -0
  6. package/channels/kazee/call/job-steps.js +81 -0
  7. package/channels/kazee/call/manager.js +1056 -27
  8. package/channels/kazee/call/miss-capture.js +82 -0
  9. package/channels/kazee/call/quick-voice.js +610 -0
  10. package/channels/kazee/call/session.js +787 -36
  11. package/channels/kazee/call/thread-context.js +100 -0
  12. package/channels/kazee/catch-up.js +275 -0
  13. package/core/actions.js +13 -4
  14. package/core/enforcer.js +1 -1
  15. package/core/handlers.js +14 -16
  16. package/core/identity.js +15 -1
  17. package/core/loopback.js +4 -1
  18. package/core/media.js +39 -1
  19. package/core/run-context.js +27 -3
  20. package/core/runner.js +70 -1
  21. package/core/state.js +6 -2
  22. package/core/switchboard/brain-link.js +161 -0
  23. package/core/switchboard/call-host.js +298 -0
  24. package/core/switchboard/handoff.js +80 -0
  25. package/core/switchboard/ipc.js +130 -0
  26. package/core/switchboard/supervisor.js +225 -0
  27. package/core/system-prompt.js +44 -4
  28. package/core/transcripts.js +39 -0
  29. package/core/tts-voice.js +62 -4
  30. package/core/turn-journal.js +23 -1
  31. package/package.json +2 -2
  32. package/test-call-speech.js +7 -0
  33. package/test-enforcer.js +13 -0
  34. package/test-fixtures/switchboard-fake-brain.js +20 -0
  35. package/test-kazee-call-manager.js +22 -4
  36. package/test-kazee-call-session.js +345 -8
  37. package/test-provider-compaction.js +8 -0
  38. package/test-provider-language.js +17 -5
  39. package/test-provider-prompt-parity.js +1 -0
  40. package/test-provider-runner.js +2 -0
  41. package/test-run-lock.js +24 -0
package/CHANGELOG.md CHANGED
@@ -1,12 +1,72 @@
1
1
  # Changelog
2
2
 
3
- ## 3.5.33
3
+ ## 3.5.58
4
4
 
5
- Quiet chats by default (Sumeet, 11 Oct 08:51 EAT, on Kazee unread counts: one voice note brought a "Heard:" echo, a progress message and "Recalled my notes" lines before the answer, each a new unread and often a push; 09:16 EAT "Yes" to turning them off by default on every bot). Built on 3.5.24, the fleet's version: the Browser canaries (3.5.25, 3.5.26, 3.5.28) and the call builds on Sumeet's own bot (3.5.27 and later) are not in it.
5
+ The call work from the owner's own bot (local builds 3.5.34-3.5.57), released for every bot (Sumeet, 13 Oct 11:53 EAT, canary pod first). Also carries 3.5.33's quiet chats and 3.5.47's guard fix.
6
6
 
7
- - **One message in, one answer out.** By default a turn now shows "typing" while it works and sends only its answer: no "Heard: …" echo of a voice note or audio file, no live progress message, and no "📖 Recalled my notes on …", "✏️ Updating my notes on …", new pack, entity, skill or "🧩 Reused" lines. Recall-failure alerts, /recall, /tooltrace, wake-up notices and limit warnings are unchanged.
8
- - **Each comes back with its own command** (`core/chatter.js`): `/heard`, `/notes` and `/progress` (on, off, or no argument for the current value and buttons), saved per person like /verbose. `/verbose` streams thinking into the progress message, so it turns the progress message on too: anyone with /verbose on keeps it until they turn /verbose off.
9
- - **Tests:** `test-quiet-defaults.js` (new, in `npm test`), `test-thread-lanes.js` (its live-preview check turns /progress on).
7
+ - **Quick voice on by default, on Opus 5.5.** A warm second voice runs the conversation: it greets, answers check-ins itself, hands real work to the full agent, says the full agent's answers, and decides whether new words add to, replace or ignore the work in flight. `KAZEE_CALL_QUICK_VOICE=off` brings back the full agent alone; `KAZEE_CALL_QUICK_MODEL` picks another model.
8
+ - **Calls in threads.** A call placed in a thread with the bot loads that thread's title, brief and last messages, opens on its topic, and carries work on in the thread after the goodbye.
9
+ - **Hearing and hanging up.** Interrupts on words, not sounds; a reply cut off by a sound is kept and said after; no asking twice; "are you still there?" after 10 s with no words, then goodbye; hangs up right after "bye" and carries promised work on in the chat; a soft line hum between lines, and a caller who mutes or holds is not taken for a dropped line.
10
+ - **Seeing.** A caller's camera, also when switched on mid-call, gives stills the full agent can look at.
11
+ - **Logs for every call:** each segment's loudness, every quick-voice decision, what was sent, and at the close how loud the caller came in (`Kazee call heard:`).
12
+ - **Switchboard (owner's Mac, `OC_SWITCHBOARD=on`):** calls survive a restart of the bot. Off by default.
13
+ - **/voice** lists only the speech server's own voices; a voice it can't make falls back instead of silencing a call.
14
+ - **Kazee:** messages that came in while the bot restarted are caught up.
15
+ - **Compaction:** the summary stage forks the live session, so a failed compaction never leaks its brief into the chat.
16
+ - **Guard (from 3.5.47):** credentials stay blocked; infrastructure and customer data follow the owner's channel rules and mandates.
17
+
18
+ ## 3.5.32
19
+
20
+ - **Call logs show every quick-voice decision:** jobs the quick voice hands to the full agent now log `route: JOB ... heard= line=` like SELF does (the 10:48 EAT call's "Let me just try changing a voice" and "I can't hear anything" left no route line).
21
+ - **/voice lists only voices the speech server can speak in.** The picker offered seven fixed voices, six of them hf:// clips the server (no voice cloning) could not render; on the 10:48 EAT call one silenced every line. It now asks the server (`GET /v1/voices`, speech-service b244cd6) for its voices and whether it can clone: buttons for those names, two to a row, "Clone from a clip" only when it can clone, and a typed or stale choice it can't make is refused with the list. If the server can't answer, the picker says so instead of guessing. Tests: `test-speech-voices.js` (new), `test-tts-voice.js` (now in `npm test`).
22
+ - A voice the speech server can't make (the 10:48 call went silent after /voice Expresso) is fixed on the speech server, not here: Sumeet, 11 Oct 11:18 EAT, "we build for the correct environment" (bots run on pods; the server serves them all).
23
+
24
+ ## 3.5.31
25
+
26
+ Fixes from the 10:10 EAT test call on 3.5.30 (7 minutes; every decision routed in 0.8-1.7 s, nothing dropped). Local build for the owner's own bot first; the fleet waits for his OK.
27
+
28
+ - **Lead-ins.** When an answer comes after the talk moved on, it opens with a few words naming its topic: "Back to the mail backup.", "On your voice question." (Sumeet on the call: after "I'll look into that too" the mail backup numbers came straight in, then "Yes, you can" for the voice question, with no sign of which was which.) The quick voice writes the lead-in while the line before it plays (0.8-1.7 s with Haiku 4.5); not ready in 1.5 s: "On your earlier question.". Words that already name their topic get none, and the full agent's call guidance now says to open each part of a reply that answers several questions with its topic.
29
+ - **Requests to do something always reach the full agent.** "Alright, change the voice. Try saying something" and then "What's going on? You're taking long" were answered by the quick voice alone (SELF), so nothing was done and the caller waited. The quick prompt now sends anything to be done, changed or tried, and "you're slow / did you do it?" with nothing running, to Claudia. Real Haiku bench, 31 utterances: the old prompt failed all 4 such cases (once claiming "Yeah, just tried the new voice"), the new one passes them; 28-30 of 31 overall vs 27-28. The lead-in rule rides in its own message: as a "no tag" rule in the system prompt it made a cold "Hello?" lose its tag.
30
+ - **A queued question starts as soon as the one before it is answered**, not once that answer has finished playing: the call's answer no longer holds the lane while it is spoken (a question asked during a long answer waited 27 s before its run began). Answers still play in order, and the next job's prompt hears the previous answer.
31
+ - **No "run failed" notes for stopped jobs.** A job the quick voice answered itself, or the caller replaced (and /stop), left "Provider process exited with code 143" in the transcript; the words are kept, the note is not.
32
+ - **Call logs name each decision:** `route:` lines now carry what was heard and the line said, and lead-ins are logged, so a call can be reviewed afterwards.
33
+ - **Tests:** `test-call-quick-voice.js` (lead-ins written once and spoken before held answers, the late fallback, topic-named words, send not waiting for playback, the new prompt rules), `test-call-stopped-transcript.js` (new, in `npm test`).
34
+
35
+ ## 3.5.30
36
+
37
+ The quick voice runs the conversation; the full agent works in the background (Sumeet, 11 Oct 09:44 EAT: "it essentially acts as the conversation… it doesn't need to get blocked by background work… bring it back and merge it with what we have, not necessarily replace"; 09:48: the small model decides whether to interrupt). A merge of the 4 Oct calls build into 3.5.29. Local build for the owner's own bot first; the fleet waits for his OK.
38
+
39
+ - **Every word no longer throws the last answer away.** Before, each new utterance aborted the running call turn, but its provider kept running (20-30 s, holding the call's lane, so the next request waited 9-12 s behind it) and its answer was then discarded: on the 09:39 call "That's fine" and "Is still there" lost two answers in a row.
40
+ - **The quick voice decides.** Each reply opens with a tag. With nothing running: SELF (a greeting, "are you there?", thanks, what we were on: it answers and the full agent's job is dropped) or JOB (anything else). While work runs: IGNORE (not meant for Claudia), ANSWER (a check-in or status, answered from the job board, the work carries on), ADD (another request, run after the current one) or REPLACE (a change of mind or "stop": only then is the work stopped). No tag in 3.5 s: ADD, so a guess never stops work. Measured on 22 utterances with Haiku 4.5: all routed right (it sometimes says SELF for ANSWER or ADD for JOB, which mean the same here), median 0.85 s.
41
+ - **A job board.** The runner now tells the call when the full agent starts on a job and each tool it uses (`onCallStage`, `onCallActivity`), turned into plain words ("checking email", "reading files"; the 4 Oct mapping). The quick voice gets the job, how long, its steps, what it has said, what waits after it, and what was answered earlier; "still on it" lines say real progress.
42
+ - **Nothing said is dropped.** A job's words are held while the caller talks and go out once the quick voice has answered (or straight away after noise); a finished answer still waiting is never retold by the quick voice. call-say no longer reports a held turn as stale.
43
+ - **Stopped means stopped.** A job dropped by REPLACE or SELF has its provider killed the way /stop does (named cancel flag, then the process tree; `stopCallRunWhenDropped`, ported from 4 Oct). A call that ends keeps its run, so "do X" said just before hanging up still gets done.
44
+ - **A queued job hears the one before it.** The call history goes into a job's prompt when the job starts, not when it was queued.
45
+ - **Tests:** `test-call-quick-voice.js` (each decision, held answers, the board, queued jobs, the slow-decision fallback), `test-call-run-stop.js` (new, in `npm test`), `test-kazee-call-manager.js` (held, not refused, while the caller talks).
46
+
47
+ ## 3.5.29
48
+
49
+ Calls I place open from the chat too (Sumeet, 11 Oct 09:26 EAT: "Just be calling me when you are ready to test things"). Built on 3.5.28 and the quiet-by-default chat change. A local build for the owner's own bot first; the fleet waits for his OK.
50
+
51
+ - **A greeting for outgoing calls.** When the bot rings someone, the quick voice writes the opening line while the phone rings, from the end of the chat and, when given, the reason for the call: `open-claudia call --about "testing barge-in"` opens with "Hey Sumeet, calling to test the barge-in feature. Got a minute?". The line is spoken as soon as they pick up; the call rings at once and never waits for it. A reason alone is enough when the chat is old; no chat and no reason, or a line not ready in 3.5 s: the fixed greeting. Measured 1.6-2.7 s per line with Haiku 4.5, well inside the ringing.
52
+ - **Tests:** `test-call-quick-voice.js` covers the outgoing note and line, and now fails if it ends early: the greeting deadline timer is unref'd, so a fake call waiting on it alone let node exit 0 before the file's later checks ran (true since 3.5.28).
53
+
54
+ ## 3.5.28
55
+
56
+ Calls open with where we left off, and never wait behind the chat (Sumeet, 11 Oct 09:00 EAT, on a walk: "rather than just a sort of static greeting… while the call's ringing… a very quick review of the conversation… 'what's up, still working on blah blah'"). A local build for the owner's own bot first; the fleet waits for his OK.
57
+
58
+ - **A greeting from the recent chat.** With the quick voice on, the moment a call comes in the quick voice reads the end of the call's chat (the last 6 messages, scrubbed, read from the transcript's tail without blocking) and writes the opening line: "Hey Sumeet, back to the Maya restart? What's up?". The call is answered once that line is ready, so the caller hears ringing rather than silence while it is written: measured 1.7-2.1 s from the call coming in with Haiku 4.5, about 1.3 s more ringing than before. Not ready in 3.5 s, nothing in the chat for 6 hours, or any failure: the fixed greeting, at once. The line also goes into the call's history, so the full agent knows how the call opened. The chat is read from the transcript chat turns actually write (the conversation's own project, `<hash>-kazee-<chat>.jsonl`), and wakeups and watches filed as the caller's turns are left out.
59
+ - **A call never waits behind the chat.** A call's turns used to queue in the lane of the chat they came from, so while a long turn ran there (09:00 EAT: building this greeting), the full agent never started on any of his call turns and only the quick voice spoke. Live-call turns now run in a lane of their own beside the chat's (`<chat>#call`), and that lane never waits for a free slot under `OC_MAX_PARALLEL_TURNS` or `_GLOBAL` (one call at a time, so at most one run over the caps). /stop in the chat no longer reaches a call; hanging up still cancels its work.
60
+ - **Tests:** `test-run-lock.js`, `test-provider-runner.js`, `test-call-greeting-context.js` (new, in `npm test`), `test-call-quick-voice.js`, `test-kazee-call-session.js`; the call tests no longer read a developer's own `.env` (a local `KAZEE_CALL_QUICK_VOICE=on` made one fail and another start a real CLI).
61
+
62
+ ## 3.5.27
63
+
64
+ Calls answer in about two seconds again, and say something while they work (Sumeet, 11 Oct 08:24 EAT on a walk, after a call where "hello" took 8.5 s and a 79 s lookup was never heard: "Yeah, let's give it a shot"). A canary build for the owner's own bot first. It builds on 3.5.24; the Browser canaries 3.5.25 and 3.5.26 are not in it yet.
65
+
66
+ - **A quick voice says a fitting line right away** (`channels/kazee/call/quick-voice.js`). A kept-warm small model, one process per call through the provider's own utility invocation, hears each utterance and says one short line ("Sure, let me check the call logs.", "Yep, I'm here.") within about a second, then a brief "still on it" line whenever the full agent leaves the caller in 8 s of silence, at most 3 per turn. It never answers: every utterance still goes to the full agent. The line is dropped if it is not ready in 3.5 s, if the full agent already spoke, if the caller is talking, or once a newer utterance took over. Off unless `KAZEE_CALL_QUICK_VOICE=on`; `KAZEE_CALL_QUICK_MODEL` (default `claude-haiku-4-5`), `_EFFORT` (`low`) and `_THINKING` (off) tune it. Measured on 23 call lines with traps ("Did you hear me?", "Are you sure?"): Haiku 4.5 finished its line in 0.8-1.1 s with thinking off (2-3 s with it on), Sonnet 5 in 1.7-1.9 s, and both kept every rule.
67
+ - **The full agent speaks first, then works.** The live-call guidance used to forbid "speculative progress", so a lookup went silent until it ended. Now the first action is a 3-12 word call-say ("Sure, let me check the call logs."), then a progress line about every ten seconds. Replayed on the four turns of the 11 Oct call, 3 times per effort level: the holding line came first 3 of 3 times at max, xhigh, high and medium (the old guidance: 1 of 3 at xhigh), simple replies unchanged. With the quick voice on, the agent skips its own acknowledgement and starts with substance.
68
+ - **A sound with no words no longer silences the answer.** Speech start mutes the running call turn in case it is a new ask; when the recogniser found no words (wind, footsteps, a cough) the turn stayed muted for good, its call-say got 409 and its final answer was never spoken. The session now reports a noise-only turn and the turn gets its voice back, unless someone is still talking or another segment is pending.
69
+ - **Tests:** `test-call-quick-voice.js` (new, in `npm test`), `test-kazee-call-manager.js`, `test-kazee-call-session.js`, `test-provider-prompt-parity.js`.
10
70
 
11
71
  ## 3.5.24
12
72
 
package/bin/cli.js CHANGED
@@ -16,6 +16,8 @@ function getBotMode() {
16
16
  }
17
17
 
18
18
  function startBot(args) {
19
+ // OC_SWITCHBOARD=on: this process holds calls and runs the bot as a child.
20
+ if (require(path.join(botDir, "core", "switchboard", "handoff")).maybeRunSwitchboard()) return;
19
21
  // Start web UI alongside bot if WEB_UI=true or --web flag
20
22
  if (process.env.WEB_UI === "true" || args.includes("--web")) {
21
23
  const { startWebServer } = require(path.join(botDir, "web.js"));
@@ -227,6 +229,8 @@ switch (command) {
227
229
  }
228
230
 
229
231
  case "web": {
232
+ // OC_SWITCHBOARD=on: this process holds calls and runs `web` as a child.
233
+ if (require(path.join(botDir, "core", "switchboard", "handoff")).maybeRunSwitchboard()) break;
230
234
  // Web UI only (for initial setup or config management)
231
235
  const { startWebServer: startWeb, isConfigured } = require(path.join(botDir, "web.js"));
232
236
  startWeb();
@@ -420,8 +424,11 @@ switch (command) {
420
424
  (async () => {
421
425
  const { postJson } = require("./loopback-client");
422
426
  try {
423
- const toUserId = String(args[1] || "").trim();
424
- const res = await postJson("call", toUserId ? { toUserId } : {});
427
+ const rest = args.slice(1);
428
+ const aboutAt = rest.indexOf("--about");
429
+ const reason = aboutAt >= 0 ? String(rest[aboutAt + 1] || "").trim() : "";
430
+ const toUserId = String(rest.find((arg, i) => !arg.startsWith("--") && rest[i - 1] !== "--about") || "").trim();
431
+ const res = await postJson("call", { ...(toUserId ? { toUserId } : {}), ...(reason ? { reason } : {}) });
425
432
  console.log(`Calling: room=${res.roomId} chat=${res.chatId} to=${res.toUserId}`);
426
433
  process.exit(0);
427
434
  } catch (e) {
@@ -624,7 +631,7 @@ Send tools (only work inside an active bot-spawned task):
624
631
  open-claudia tts "<text>" --out <path> Synthesize to a FILE without sending (voice-overs,
625
632
  narration; ext picks format; --voice <id> optional)
626
633
  open-claudia transcribe <file> Speech-to-text: print the transcript of an audio/video file
627
- open-claudia call [toUserId] Place a live voice call on the current channel (kazee; owner-only)
634
+ open-claudia call [toUserId] [--about "<why>"] Place a live voice call on the current channel (kazee; owner-only); --about tells the opening line why
628
635
  open-claudia card send <spec|-> Send a Kazee Card (decision, choice, rating, slider, report); answers wake this conversation
629
636
  open-claudia card update <id> ... Close/open a card you sent, add a note, or replace its spec (card example <kind> for specs)
630
637
  open-claudia decide "<q>" --option id="label" ... Queue a decision card for the owner (one at a time; decide list|cancel)
package/bot.js CHANGED
@@ -24,6 +24,13 @@ const {
24
24
  } = require("./core/config");
25
25
  ensureRuntimeDirectories();
26
26
 
27
+ // OC_SWITCHBOARD=on: this process becomes the switchboard (it holds the live
28
+ // call room) and runs the bot as its child, so a restart keeps calls up.
29
+ if (require("./core/switchboard/handoff").maybeRunSwitchboard()) return;
30
+ // The child end: open the link now, so a switchboard that dies takes this
31
+ // brain down with it (core/switchboard/brain-link.js).
32
+ require("./core/switchboard/brain-link").connect();
33
+
27
34
  // Refuse to double-boot: a second process on this CONFIG_DIR shares the same
28
35
  // channel tokens and 409-wars the running service (see core/single-instance).
29
36
  const { acquireSingleInstanceLock } = require("./core/single-instance");
@@ -122,6 +129,7 @@ function persist() {
122
129
 
123
130
  async function gracefulShutdown(signal) {
124
131
  console.log(`Received ${signal}, shutting down gracefully...`);
132
+ try { require("./core/switchboard/brain-link").notifyExiting(signal); } catch (e) {}
125
133
  try { stopScheduler(); } catch (e) {}
126
134
  // Save first: whoever sent SIGTERM kills the process soon after (bot pods
127
135
  // allow 15 s, launchd 20 s), and the drain below must not cost the save.
@@ -373,6 +381,9 @@ async function safeModeBoot() {
373
381
  console.error(`Adapter ${adapter.id} failed to start:`, e.message);
374
382
  }
375
383
  }
384
+ // Under a switchboard: ready for call jobs (a live call's waiting
385
+ // questions go out now and the caller hears "I'm back").
386
+ try { require("./core/switchboard/brain-link").hello(); } catch (e) { console.error("switchboard hello failed:", e.message); }
376
387
 
377
388
  // Turns cut short by the last restart resume (or are offered back), and
378
389
  // messages that were queued behind them are restored in order.
@@ -21,6 +21,7 @@ const { createKazeeSocket } = require("./socket");
21
21
  const { normalize } = require("./format");
22
22
  const { KazeeCallManager } = require("./call/manager");
23
23
  const kazeeState = require("./state");
24
+ const { KazeeIntake, catchUpEnabled, maxAgeMs: catchUpMaxAgeMs, SKIP_TYPES: CATCHUP_SKIP_TYPES } = require("./catch-up");
24
25
  const { createPassiveGate, quietCommand, CONTEXT_MESSAGES: PASSIVE_CONTEXT_MESSAGES, SNOOZE_MS: PASSIVE_SNOOZE_MS } = require("../../core/passive-replies");
25
26
 
26
27
  // Group catch-up context sizing: how many messages an engaged turn may carry,
@@ -39,6 +40,9 @@ const KAZEE_JOIN_ACK_TIMEOUT_MS = 5000;
39
40
  // many ids to remember: large enough to cover a burst, small enough to stay
40
41
  // trivial to scan.
41
42
  const INBOUND_DEDUP_MEMORY = 500;
43
+ // Restart catch-up (./catch-up.js): how long reading one chat's missed
44
+ // messages may take before that chat is left for the next connect.
45
+ const CATCHUP_FETCH_TIMEOUT_MS = 15000;
42
46
 
43
47
  const CONTEXT_MAX_MESSAGES = 40;
44
48
  const CONTEXT_BODY_LIMIT = 280;
@@ -90,6 +94,11 @@ function senderIdOf(m) {
90
94
  return String(m?.sender?._id || m?.sender?.id || m?.senderId || (typeof m?.sender === "string" ? m.sender : "") || "");
91
95
  }
92
96
 
97
+ function chatIdOf(m) {
98
+ const chat = m?.chat;
99
+ return String(m?.chatId || (typeof chat === "string" ? chat : (chat?._id || chat?.id)) || "");
100
+ }
101
+
93
102
  // How a new thread starts. "hybrid" (the default): the root, the replies so
94
103
  // far and the main-timeline messages just before the root. "fresh": the root
95
104
  // and the replies only. "fork" (Claude): the runner resumes the chat's main
@@ -186,8 +195,15 @@ function publicOnlyLookup(hostname, options, callback) {
186
195
  });
187
196
  }
188
197
 
198
+ function defaultCallManager(adapter) {
199
+ if (require("../../core/switchboard/handoff").isSwitchboardChild()) {
200
+ return require("../../core/switchboard/brain-link").callManagerProxy(adapter);
201
+ }
202
+ return new KazeeCallManager({ adapter });
203
+ }
204
+
189
205
  class KazeeAdapter {
190
- constructor({ id = "kazee", url, token, ownerUserId, botUserId, mediaHosts = "", passiveGate = null, spawnUtilityAgent = null }) {
206
+ constructor({ id = "kazee", url, token, ownerUserId, botUserId, mediaHosts = "", passiveGate = null, spawnUtilityAgent = null, callManager = null }) {
191
207
  this.id = id;
192
208
  this.type = "kazee";
193
209
  // KAZEE_URL is the root host (socket.io lives at /). REST routes are
@@ -219,7 +235,9 @@ class KazeeAdapter {
219
235
  // rootId -> time the bot was last found NOT in that thread (short cache).
220
236
  this._threadNotIn = new Map();
221
237
  this._typingThreadWarned = new Set();
222
- this._callManager = new KazeeCallManager({ adapter: this });
238
+ // Calls: this process's own manager, or (OC_SWITCHBOARD) a stand-in,
239
+ // because the switchboard holds the call room (core/switchboard).
240
+ this._callManager = callManager ? callManager(this) : defaultCallManager(this);
223
241
  // Groups set to "When they can help" (core/passive-replies.js); made on
224
242
  // first use, so a bot in no passive group never builds one.
225
243
  this._passive = passiveGate;
@@ -229,6 +247,11 @@ class KazeeAdapter {
229
247
  this._sentTags = new Map();
230
248
  this._pauseNext = new Map();
231
249
  this._spawnUtility = spawnUtilityAgent;
250
+ // Restart catch-up (./catch-up.js): what this bot has taken in, per chat.
251
+ this._intake = new KazeeIntake({ adapterId: this.id, botUserId: this.botUserId });
252
+ this._catchUpRun = null;
253
+ this._catchUpAgain = false;
254
+ this._unhookJournal = null;
232
255
  }
233
256
 
234
257
  _passiveGate() {
@@ -292,11 +315,15 @@ class KazeeAdapter {
292
315
  return () => this._listeners[event].delete(fn);
293
316
  }
294
317
 
318
+ // Settles when every handler has (never rejects): the restart catch-up
319
+ // counts a message as taken once the router is done with it.
295
320
  _emit(event, envelope) {
321
+ const pending = [];
296
322
  for (const fn of this._listeners[event] || []) {
297
- try { Promise.resolve(fn(envelope)).catch((e) => console.error(`kazee ${event} handler:`, e.message)); }
323
+ try { pending.push(Promise.resolve(fn(envelope)).catch((e) => console.error(`kazee ${event} handler:`, e.message))); }
298
324
  catch (e) { console.error(`kazee ${event} handler:`, e.message); }
299
325
  }
326
+ return Promise.all(pending);
300
327
  }
301
328
 
302
329
  async start() {
@@ -305,22 +332,18 @@ class KazeeAdapter {
305
332
  } else {
306
333
  console.error("Kazee adapter started without botUserId — set KAZEE_BOT_USER_ID. Slash commands and self-echo filtering will not work.");
307
334
  }
335
+ // 12 Oct: a message whose turn the turn journal holds is taken: the
336
+ // journal runs it again after a restart, so the catch-up must not.
337
+ if (!this._unhookJournal) {
338
+ this._unhookJournal = require("../../core/turn-journal").onHeld((held) => {
339
+ if (held.adapterId === this.id) this._takeInbound(held.channelId, held.messageId);
340
+ });
341
+ }
308
342
  this._socket = createKazeeSocket({
309
343
  url: this.url,
310
344
  token: this.token,
311
345
  userId: this.botUserId,
312
- onConnect: (s) => {
313
- console.log(`Kazee socket connected (${this.url})`);
314
- // A reconnect is a NEW socket server-side with an empty joinedRooms,
315
- // so anything we believed we had joined is stale.
316
- this._joinedRooms.clear();
317
- this._joinPending.clear();
318
- if (this.botUserId) {
319
- // Join the bot's personal room so the server can fan out
320
- // message:action presses directly to us.
321
- s.emit("user:join", { userId: this.botUserId });
322
- }
323
- },
346
+ onConnect: (s) => this._onSocketConnect(s),
324
347
  onDisconnect: (reason) => {
325
348
  console.log(`Kazee socket disconnected: ${reason}`);
326
349
  this._joinedRooms.clear();
@@ -331,10 +354,147 @@ class KazeeAdapter {
331
354
  this._wireSocket(this._socket);
332
355
  }
333
356
 
357
+ _onSocketConnect(s) {
358
+ console.log(`Kazee socket connected (${this.url})`);
359
+ // A reconnect is a NEW socket server-side with an empty joinedRooms,
360
+ // so anything we believed we had joined is stale.
361
+ this._joinedRooms.clear();
362
+ this._joinPending.clear();
363
+ if (this.botUserId) {
364
+ // Join the bot's personal room so the server can fan out
365
+ // message:action presses directly to us.
366
+ s.emit("user:join", { userId: this.botUserId });
367
+ }
368
+ // Messages that came while no socket was listening (a brain restart, a
369
+ // network drop): the server does not send them again.
370
+ this._startCatchUp();
371
+ }
372
+
334
373
  async stop() {
335
374
  await this._callManager.stop();
336
375
  try { this._socket?.disconnect?.(); } catch (e) {}
337
376
  this._socket = null;
377
+ try { this._unhookJournal?.(); } catch (e) {}
378
+ this._unhookJournal = null;
379
+ }
380
+
381
+ // One catch-up at a time; a connect while one runs asks for another pass
382
+ // after it (that pass finds what the first already took and skips it).
383
+ _startCatchUp() {
384
+ if (!catchUpEnabled() || !this.botUserId) return null;
385
+ if (this._catchUpRun) {
386
+ this._catchUpAgain = true;
387
+ return this._catchUpRun;
388
+ }
389
+ const run = (async () => {
390
+ try {
391
+ do {
392
+ this._catchUpAgain = false;
393
+ await this._catchUp();
394
+ } while (this._catchUpAgain);
395
+ } catch (e) {
396
+ console.error("Kazee catch-up failed:", e.message);
397
+ } finally {
398
+ if (this._catchUpRun === run) this._catchUpRun = null;
399
+ }
400
+ })();
401
+ this._catchUpRun = run;
402
+ return run;
403
+ }
404
+
405
+ // Restart catch-up (./catch-up.js): read each chat the bot has a record for
406
+ // from where it stopped taking messages in, and send what it never took
407
+ // through the live path (_handleInboundMessage: the same gate, media and
408
+ // thread handling, and the router's access rules), oldest first, across
409
+ // chats. Skipped: the bot's own messages, deleted ones, messages
410
+ // chat-central wrote itself (a call card), ones already taken or already
411
+ // being worked on in this process, and anything older than
412
+ // KAZEE_CATCHUP_MAX_AGE_MIN. A message that arrives live meanwhile is
413
+ // handled once: whichever copy comes second is a duplicate by id.
414
+ async _catchUp() {
415
+ const intake = this._intake;
416
+ const botId = String(this.botUserId);
417
+ const now = Date.now();
418
+ const maxAge = catchUpMaxAgeMs();
419
+ const due = [];
420
+ const read = [];
421
+ for (const chatId of intake.chats()) {
422
+ const sinceMs = intake.hold(chatId);
423
+ if (sinceMs == null) continue;
424
+ let messages;
425
+ try {
426
+ messages = await withTimeout(this._messagesAfter(chatId, new Date(sinceMs).toISOString()), CATCHUP_FETCH_TIMEOUT_MS, "Kazee catch-up read");
427
+ } catch (e) {
428
+ if (/not a participant|Chat not found/i.test(String(e.message || ""))) {
429
+ console.log(`Kazee catch-up: ${chatId} forgotten (the bot is no longer in it)`);
430
+ intake.forget(chatId);
431
+ continue;
432
+ }
433
+ console.error(`Kazee catch-up: ${chatId} read failed, trying again on the next connect: ${e.message}`);
434
+ continue;
435
+ }
436
+ read.push(chatId);
437
+ let stale = 0;
438
+ for (const m of messages) {
439
+ const id = String(m?._id || m?.id || "");
440
+ const at = Date.parse(m?.createdAt || "");
441
+ if (!id || !Number.isFinite(at) || at <= sinceMs) continue; // an edit or delete of an older message
442
+ if (chatIdOf(m) && chatIdOf(m) !== chatId) continue;
443
+ if (m.isDeleted || CATCHUP_SKIP_TYPES.has(m.type) || senderIdOf(m) === botId) continue;
444
+ if (intake.isTaken(chatId, id) || this._seenMessageIds.has(id)) continue;
445
+ if (now - at > maxAge) { stale++; continue; }
446
+ due.push({ chatId, id, at, msg: m });
447
+ }
448
+ if (stale) console.log(`Kazee catch-up: ${chatId} ${stale} message(s) older than ${Math.round(maxAge / 60000)} min left alone`);
449
+ }
450
+ due.sort((a, b) => a.at - b.at);
451
+ for (const { chatId, id, at, msg } of due) {
452
+ // Taken live (or by the journal) while the reads ran.
453
+ if (intake.isTaken(chatId, id) || this._seenMessageIds.has(id)) continue;
454
+ if (!intake.noteReplay(chatId, id, at)) {
455
+ console.log(`Kazee catch-up: ${chatId} ${id} left alone: replayed twice already and never finished`);
456
+ continue;
457
+ }
458
+ console.log(`Kazee catch-up: ${chatId} ${id} ${threadRootOf(msg) || "main"} age=${Math.round((Date.now() - at) / 1000)}s`);
459
+ try { await this._handleInboundMessage({ ...msg, chat: msg.chat || chatId }); }
460
+ catch (e) { console.error(`Kazee catch-up: ${chatId} ${id} failed:`, e.message); }
461
+ }
462
+ // Everything due is out (in flight, holding the cursor itself), so the
463
+ // chats read release their hold; a chat whose read failed keeps it.
464
+ for (const chatId of read) intake.release(chatId);
465
+ }
466
+
467
+ // Every message of a chat after `sinceIso`, thread replies included,
468
+ // oldest first (chat-central's since-sync, capped at 500 server-side). It
469
+ // also returns older messages edited or deleted since; the caller drops them.
470
+ async _messagesAfter(chatId, sinceIso) {
471
+ const route = `/messages/${encodeURIComponent(chatId)}/${encodeURIComponent(String(this.botUserId))}?since=${encodeURIComponent(sinceIso)}`;
472
+ const res = await this._request("GET", route);
473
+ const data = res?.data || res || {};
474
+ if (res?.error || data.error || res?.success === false) throw new Error(String(res?.error || data.error || "message read refused"));
475
+ return Array.isArray(data.messages) ? data.messages : [];
476
+ }
477
+
478
+ // The switchboard's use of this adapter: its own socket, carrying only the
479
+ // call events (chat messages stay with the brain's socket).
480
+ async startCalls() {
481
+ this._socket = createKazeeSocket({
482
+ url: this.url,
483
+ token: this.token,
484
+ userId: this.botUserId,
485
+ onConnect: (s) => {
486
+ console.log(`Switchboard: Kazee call socket connected (${this.url})`);
487
+ if (this.botUserId) s.emit("user:join", { userId: this.botUserId });
488
+ },
489
+ onDisconnect: (reason) => console.log(`Switchboard: Kazee call socket disconnected: ${reason}`),
490
+ onError: (err) => console.error("Switchboard: Kazee call socket error:", err.message),
491
+ });
492
+ this._callManager.wire(this._socket);
493
+ }
494
+
495
+ stopCalls() {
496
+ try { this._socket?.disconnect?.(); } catch (e) {}
497
+ this._socket = null;
338
498
  }
339
499
 
340
500
  _wireSocket(socket) {
@@ -646,21 +806,54 @@ class KazeeAdapter {
646
806
  }
647
807
  }
648
808
 
809
+ // Every chat message the bot gets: live off the socket, or replayed by the
810
+ // restart catch-up (_catchUp). 12 Oct: a message is "taken" (./catch-up.js)
811
+ // once the bot owns its outcome: when the router is done with it, or
812
+ // earlier, when the turn journal holds its turn (start()). Never merely on
813
+ // arrival: the 12 Oct voice note arrived and was dropped mid-transcription,
814
+ // before it was a turn the journal could resume.
649
815
  async _handleInboundMessage(msg) {
650
816
  if (!msg) return;
651
817
  if (this._isDuplicateInbound(msg)) return;
652
818
  const senderId = String(msg.sender?._id || msg.sender?.id || msg.senderId || "");
653
819
  if (this.botUserId && senderId === String(this.botUserId)) return; // ignore our own echoes
654
- const chatField = msg.chat;
655
- const channelId = String(
656
- msg.chatId
657
- || (typeof chatField === "string" ? chatField : (chatField?._id || chatField?.id))
658
- || ""
659
- );
820
+ const channelId = chatIdOf(msg);
660
821
  if (!channelId) {
661
822
  console.error("Kazee message:new missing chat id, ignoring:", JSON.stringify(msg).slice(0, 200));
662
823
  return;
663
824
  }
825
+ const messageId = String(msg._id || msg.id || "");
826
+ const tracked = !!messageId && catchUpEnabled();
827
+ if (tracked) {
828
+ try { this._intake.open(channelId, messageId, Date.parse(msg.createdAt || "")); }
829
+ catch (e) { console.error("Kazee catch-up: could not record a message:", e.message); }
830
+ }
831
+ // A slash command is taken as it goes out: /restart and /upgrade end this
832
+ // process before their dispatch settles, and one replayed after the
833
+ // restart would only restart it again.
834
+ if (tracked && String(msg.content || "").trim().startsWith("/")) this._takeInbound(channelId, messageId);
835
+ let routed = null;
836
+ try {
837
+ routed = await this._routeInbound(msg, channelId, senderId);
838
+ } finally {
839
+ if (tracked) {
840
+ const done = () => this._takeInbound(channelId, messageId);
841
+ if (routed && routed.emitted) routed.emitted.then(done, done);
842
+ else done();
843
+ }
844
+ }
845
+ }
846
+
847
+ // Bookkeeping only: it must never fail a dispatch.
848
+ _takeInbound(chatId, messageId) {
849
+ if (!catchUpEnabled()) return;
850
+ try { this._intake.taken(chatId, messageId); }
851
+ catch (e) { console.error("Kazee catch-up: could not record a taken message:", e.message); }
852
+ }
853
+
854
+ // The group gate, the envelope and the hand-off to the router. Returns
855
+ // { emitted } (settles when the router is done) when it went out.
856
+ async _routeInbound(msg, channelId, senderId) {
664
857
  const userId = senderId || channelId;
665
858
  const baseFrom = {
666
859
  id: userId,
@@ -847,7 +1040,7 @@ class KazeeAdapter {
847
1040
  }
848
1041
  }
849
1042
 
850
- this._emit("message", envelope);
1043
+ return { emitted: this._emit("message", envelope) };
851
1044
  }
852
1045
 
853
1046
  // Whether another bot's message answers one of this bot's tags: it replies
@@ -1017,7 +1210,11 @@ class KazeeAdapter {
1017
1210
 
1018
1211
  async _conversationContext(envelope) {
1019
1212
  if (!envelope || !this.botUserId) return null;
1020
- if (envelope.threadId) return this._threadContext(envelope);
1213
+ // A live call's turns carry their thread from the call's own note, built
1214
+ // once at call start (call/thread-context.js): a fetch per spoken turn
1215
+ // would cost seconds and move the thread's watermark past replies its
1216
+ // chat turns have not seen.
1217
+ if (envelope.threadId) return this.liveCall === true ? null : this._threadContext(envelope);
1021
1218
  if (envelope.surface !== "group") return null;
1022
1219
  const chatId = String(envelope.channelId || "");
1023
1220
  if (!chatId) return null;