@inetafrica/open-claudia 3.5.47 → 3.5.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +62 -10
  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/handlers.js +14 -16
  15. package/core/identity.js +15 -1
  16. package/core/loopback.js +4 -1
  17. package/core/media.js +39 -1
  18. package/core/run-context.js +27 -3
  19. package/core/runner.js +70 -1
  20. package/core/state.js +6 -2
  21. package/core/switchboard/brain-link.js +161 -0
  22. package/core/switchboard/call-host.js +298 -0
  23. package/core/switchboard/handoff.js +80 -0
  24. package/core/switchboard/ipc.js +130 -0
  25. package/core/switchboard/supervisor.js +225 -0
  26. package/core/system-prompt.js +44 -4
  27. package/core/transcripts.js +39 -0
  28. package/core/tts-voice.js +62 -4
  29. package/core/turn-journal.js +23 -1
  30. package/package.json +2 -2
  31. package/test-call-speech.js +7 -0
  32. package/test-fixtures/switchboard-fake-brain.js +20 -0
  33. package/test-kazee-call-manager.js +22 -4
  34. package/test-kazee-call-session.js +345 -8
  35. package/test-provider-compaction.js +8 -0
  36. package/test-provider-language.js +17 -9
  37. package/test-provider-prompt-parity.js +1 -0
  38. package/test-provider-runner.js +2 -0
  39. package/test-run-lock.js +24 -0
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+
3
+ // A call placed inside a chat thread works inside that thread (13 Oct 2026,
4
+ // Sumeet: "a call about CRM and Books" should start from the CRM thread's
5
+ // own context, not from the main chat). While the phone rings, the call
6
+ // gathers once: the thread's title, where it stood at its last compaction
7
+ // (the archived brief, core/runner.js archiveCompactionBrief) and its last
8
+ // few turns. The quick voice opens with it, and every full-agent turn on the
9
+ // call carries the same note (core/system-prompt.js voiceReplyGuidance), so
10
+ // neither has to look the thread up mid-call.
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ const THREAD_ENTRIES = 12;
16
+ const BRIEF_QUICK_CHARS = 1500;
17
+ const BRIEF_AGENT_CHARS = 6000;
18
+ const TITLE_MAX_MS = 1000;
19
+
20
+ function briefsDir() {
21
+ return path.join(require("../../../config-dir"), "briefs");
22
+ }
23
+
24
+ // The newest archived brief of one conversation: its files are named after
25
+ // the conversation's transcript (`<transcript basename>-<ISO stamp>.md`), so a
26
+ // thread's briefs never mix with its chat's or another thread's.
27
+ function newestBrief(transcriptPath, dir = briefsDir()) {
28
+ if (!transcriptPath) return null;
29
+ const prefix = `${path.basename(String(transcriptPath), ".jsonl")}-`;
30
+ let names = [];
31
+ try { names = fs.readdirSync(dir); } catch (_) { return null; }
32
+ // A stamp right after the prefix: another conversation whose name merely
33
+ // starts the same way (a longer id) never matches.
34
+ const stamped = names.filter((name) => name.startsWith(prefix) && name.endsWith(".md") && /^\d{4}-\d{2}-\d{2}T/.test(name.slice(prefix.length)));
35
+ if (!stamped.length) return null;
36
+ stamped.sort();
37
+ const file = path.join(dir, stamped[stamped.length - 1]);
38
+ try {
39
+ const stat = fs.statSync(file);
40
+ return { path: file, text: fs.readFileSync(file, "utf8"), at: stat.mtimeMs };
41
+ } catch (_) { return null; }
42
+ }
43
+
44
+ // The head of a brief, without its archive header, cut at a line end. Briefs
45
+ // lead with where things stand, so the head is the part a call needs.
46
+ function briefHead(text, max) {
47
+ const lines = String(text || "").split("\n");
48
+ while (lines.length && (/^# Compaction brief\b/.test(lines[0]) || /^Project: /.test(lines[0]) || !lines[0].trim())) lines.shift();
49
+ const body = lines.join("\n").trim();
50
+ if (body.length <= max) return body;
51
+ const cut = body.slice(0, max);
52
+ const end = cut.lastIndexOf("\n");
53
+ return `${(end > max / 2 ? cut.slice(0, end) : cut).trimEnd()}\n…`;
54
+ }
55
+
56
+ function ago(ms, now = Date.now()) {
57
+ const minutes = Math.max(0, Math.round((now - ms) / 60000));
58
+ if (minutes < 1) return "just now";
59
+ if (minutes < 120) return `${minutes} minutes ago`;
60
+ const hours = Math.round(minutes / 60);
61
+ if (hours < 48) return `${hours} hours ago`;
62
+ return `${Math.round(hours / 24)} days ago`;
63
+ }
64
+
65
+ async function withTimeout(promise, ms) {
66
+ let timer = null;
67
+ try {
68
+ return await Promise.race([promise, new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); timer.unref?.(); })]);
69
+ } finally { clearTimeout(timer); }
70
+ }
71
+
72
+ // The thread's shared title (chat-central), else the start of its first
73
+ // message. Best effort and short: a slow server leaves the call untitled.
74
+ async function threadTitle(adapter, chatId, threadId, maxMs = TITLE_MAX_MS) {
75
+ if (typeof adapter?.threadTitleSource !== "function") return "";
76
+ try {
77
+ const source = await withTimeout(Promise.resolve(adapter.threadTitleSource(String(chatId), String(threadId))), maxMs);
78
+ const title = String(source?.title || "").trim() || String(source?.rootText || "").trim().split("\n")[0].slice(0, 80);
79
+ return title;
80
+ } catch (_) { return ""; }
81
+ }
82
+
83
+ // What every full-agent turn on a thread call is told about the thread.
84
+ function agentNote({ title = "", brief = null, now = Date.now() } = {}) {
85
+ const named = title ? ` "${title}"` : "";
86
+ const parts = [
87
+ `Thread call: this call was placed inside the chat thread${named}. Work as you would in that thread: its topic is what the call is about, and anything you post goes there.`,
88
+ ];
89
+ if (brief?.text) {
90
+ parts.push(`Where the thread stood at its last summary (${ago(brief.at, now)}; the full brief is the file ${brief.path}):\n${briefHead(brief.text, BRIEF_AGENT_CHARS)}`);
91
+ } else {
92
+ parts.push("The thread has no summary yet; its recent turns are in your context.");
93
+ }
94
+ return parts.join("\n\n");
95
+ }
96
+
97
+ module.exports = {
98
+ THREAD_ENTRIES, BRIEF_QUICK_CHARS, BRIEF_AGENT_CHARS, TITLE_MAX_MS,
99
+ briefsDir, newestBrief, briefHead, ago, threadTitle, agentNote,
100
+ };
@@ -0,0 +1,275 @@
1
+ // Kazee restart catch-up: what the brain has taken in, per chat, so a message
2
+ // that came while it was down is handled after it reconnects (12 Oct 2026).
3
+ //
4
+ // 12 Oct 2026, 04:09:09.429Z: Sumeet's 0:52 voice note landed in a thread of
5
+ // his DM with the bot while the brain was stopping for the 3.5.50 upgrade. The
6
+ // old brain got it off the socket and started on it (the voice file was saved
7
+ // at 04:09:09.457Z), but it exited at .981Z, mid-transcription, before the
8
+ // message became a turn the turn journal could resume. The new brain started
9
+ // at 04:09:14Z and nothing ever looked again: socket.io does not replay events
10
+ // a client missed, so the note was never answered.
11
+ //
12
+ // Now each chat keeps, in <config>/kazee-catchup.json:
13
+ // - takenAt: the createdAt of the newest message taken in;
14
+ // - holdAt: the oldest message still in flight (got but not yet taken) at
15
+ // the last write, so a newer message finishing first cannot move the
16
+ // cursor past an older one that is still being worked on;
17
+ // - ids: the recently taken message ids (bounded), so nothing is handled
18
+ // twice; floor: the newest time the ids can no longer vouch for (the
19
+ // chat's first record, or an id dropped off the end of the list).
20
+ // "Taken" means the bot owns the message's outcome: the turn journal holds it
21
+ // (it runs it again after a restart itself), or the bot finished with it (a
22
+ // reply, a gate that stayed silent, a command). See KazeeAdapter.
23
+ // When the socket connects, every chat with a record is read from the server
24
+ // from that cursor on, and what was never taken goes through the live path
25
+ // again, oldest first. Never further back than KAZEE_CATCHUP_MAX_AGE_MIN (15):
26
+ // a long outage must not answer a pile of stale messages. A chat with no
27
+ // record replays nothing, so the first start after this ships replays nothing.
28
+ // KAZEE_CATCHUP=off switches it all off (nothing recorded, nothing replayed).
29
+
30
+ const path = require("path");
31
+ const { CONFIG_DIR } = require("../../core/config");
32
+ const { atomicWriteFileSync, readJsonWithFallback } = require("../../core/fsutil");
33
+
34
+ const DEFAULT_MAX_AGE_MIN = 15;
35
+ // Taken ids remembered per chat: well over 15 minutes of a busy chat.
36
+ const MAX_IDS = 300;
37
+ // Chats remembered (least recently active dropped first), and how many of them
38
+ // the catch-up reads on each connect (most recently active first).
39
+ const MAX_CHATS = 200;
40
+ const CATCHUP_CHATS = 50;
41
+ // Read from a little before the cursor: two messages a moment apart can reach
42
+ // the bot out of order. Anything already taken is skipped by id.
43
+ const SLACK_MS = 5000;
44
+ // A message replayed this often and still not taken may be what stops the
45
+ // brain; it is left alone (the turn journal's MAX_ATTEMPTS rule).
46
+ const MAX_REPLAYS = 2;
47
+ const MAX_REPLAY_NOTES = 50;
48
+ // Messages chat-central writes itself, not a person: never replayed. A call
49
+ // card ("call") follows every call, and a restart often follows a call (the
50
+ // switchboard picks up new code once the call ends); live, it only reaches the
51
+ // bot when the bot happens to sit in the chat's room.
52
+ const SKIP_TYPES = new Set(["call", "system", "notification", "deleted"]);
53
+
54
+ function setting(name) {
55
+ let value = process.env[name];
56
+ try { value = require("../../core/config").config?.[name] ?? value; } catch (_) {}
57
+ return value;
58
+ }
59
+
60
+ function catchUpEnabled() {
61
+ return !/^(off|false|0|no)$/i.test(String(setting("KAZEE_CATCHUP") ?? "on").trim());
62
+ }
63
+
64
+ function maxAgeMs() {
65
+ const n = Number(setting("KAZEE_CATCHUP_MAX_AGE_MIN"));
66
+ return (Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_AGE_MIN) * 60000;
67
+ }
68
+
69
+ function stateFileFor(adapterId) {
70
+ const id = String(adapterId || "kazee");
71
+ return path.join(CONFIG_DIR, id === "kazee" ? "kazee-catchup.json" : `kazee-catchup-${id.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
72
+ }
73
+
74
+ // Drop ids off the front of a chat's list while `drop` says so. The floor
75
+ // rises past each one: the list can no longer vouch it was taken.
76
+ function dropOldest(rec, drop) {
77
+ while (rec.ids.length && drop(rec.ids[0])) {
78
+ const [, droppedMs] = rec.ids.shift();
79
+ if (Number.isFinite(droppedMs) && droppedMs > (msOf(rec.floor) ?? -Infinity)) rec.floor = isoOf(droppedMs);
80
+ }
81
+ }
82
+
83
+ const msOf = (iso) => {
84
+ const t = Date.parse(iso || "");
85
+ return Number.isFinite(t) ? t : null;
86
+ };
87
+ const isoOf = (ms) => new Date(ms).toISOString();
88
+
89
+ class KazeeIntake {
90
+ constructor({ adapterId = "kazee", botUserId = null, file = null, now = Date.now } = {}) {
91
+ this.botUserId = botUserId ? String(botUserId) : "";
92
+ this.file = file || stateFileFor(adapterId);
93
+ this.now = now;
94
+ this._chats = null;
95
+ // chatId -> Map(messageId -> createdAt ms): got, not taken yet.
96
+ this._inflight = new Map();
97
+ // chatId -> cursor ms a catch-up has yet to read from (a running one, or
98
+ // the last process's in-flight messages, until caught up).
99
+ this._holds = new Map();
100
+ }
101
+
102
+ _load() {
103
+ if (this._chats) return this._chats;
104
+ const raw = readJsonWithFallback(this.file, null) || {};
105
+ // Another bot's record (a changed KAZEE_BOT_USER_ID) is not this bot's
106
+ // history: start again, replaying nothing.
107
+ const mine = raw.chats && typeof raw.chats === "object" && String(raw.botUserId || "") === this.botUserId;
108
+ this._chats = mine ? raw.chats : {};
109
+ // What the last process still had in flight holds each chat's cursor
110
+ // until this one has caught that chat up (release()): a write for another
111
+ // chat before then must not drop it.
112
+ for (const [chatId, rec] of Object.entries(this._chats)) {
113
+ const held = msOf(rec && rec.holdAt);
114
+ if (held != null) this._holds.set(chatId, held);
115
+ }
116
+ return this._chats;
117
+ }
118
+
119
+ _save() {
120
+ const chats = this._load();
121
+ // Ids older than twice the max age vouch for nothing a catch-up still
122
+ // reads (it never reads further back than the max age), so the file stays
123
+ // small however long the bot runs.
124
+ const keepFrom = this.now() - 2 * maxAgeMs();
125
+ for (const [chatId, rec] of Object.entries(chats)) {
126
+ const hold = this._holdMs(chatId);
127
+ if (hold == null) delete rec.holdAt;
128
+ else rec.holdAt = isoOf(hold);
129
+ if (Array.isArray(rec.ids)) dropOldest(rec, (pair) => rec.ids.length > MAX_IDS || !(pair[1] >= keepFrom));
130
+ }
131
+ const ids = Object.keys(chats);
132
+ if (ids.length > MAX_CHATS) {
133
+ ids.sort((a, b) => (Number(chats[a].at) || 0) - (Number(chats[b].at) || 0));
134
+ for (const old of ids.slice(0, ids.length - MAX_CHATS)) delete chats[old];
135
+ }
136
+ try { atomicWriteFileSync(this.file, JSON.stringify({ v: 1, botUserId: this.botUserId, chats })); }
137
+ catch (e) { console.error("kazee catch-up state save:", e.message); }
138
+ }
139
+
140
+ // The oldest message of this chat still in flight, or the point a catch-up
141
+ // has yet to read from, whichever is older; null when neither.
142
+ _holdMs(chatId) {
143
+ let hold = this._holds.has(chatId) ? this._holds.get(chatId) : null;
144
+ for (const ms of (this._inflight.get(chatId) || new Map()).values()) {
145
+ if (hold == null || ms < hold) hold = ms;
146
+ }
147
+ return hold;
148
+ }
149
+
150
+ record(chatId) {
151
+ return this._load()[String(chatId || "")] || null;
152
+ }
153
+
154
+ isTaken(chatId, messageId) {
155
+ const rec = this.record(chatId);
156
+ const id = String(messageId || "");
157
+ return !!(rec && id && (rec.ids || []).some((pair) => pair[0] === id));
158
+ }
159
+
160
+ // A message came in and is being worked on. A chat's first message makes
161
+ // its record (one write per new chat), so that even it is caught up if the
162
+ // brain stops before taking it.
163
+ open(chatId, messageId, createdAtMs) {
164
+ const chat = String(chatId || "");
165
+ const id = String(messageId || "");
166
+ if (!chat || !id) return;
167
+ const ms = Number.isFinite(createdAtMs) ? createdAtMs : this.now();
168
+ if (!this._inflight.has(chat)) this._inflight.set(chat, new Map());
169
+ this._inflight.get(chat).set(id, ms);
170
+ const chats = this._load();
171
+ if (!chats[chat]) {
172
+ chats[chat] = { takenAt: isoOf(ms - 1), floor: isoOf(ms - 1), ids: [], at: this.now() };
173
+ this._save();
174
+ }
175
+ }
176
+
177
+ // The bot owns this message's outcome now. Idempotent; a write only when
178
+ // something changed.
179
+ taken(chatId, messageId, createdAtMs = null) {
180
+ const chat = String(chatId || "");
181
+ const id = String(messageId || "");
182
+ if (!chat || !id) return;
183
+ const flying = this._inflight.get(chat);
184
+ const flyingMs = flying ? flying.get(id) : undefined;
185
+ if (flying) {
186
+ flying.delete(id);
187
+ if (!flying.size) this._inflight.delete(chat);
188
+ }
189
+ if (this.isTaken(chat, id)) return;
190
+ // Not seen in flight (a turn the journal resumed after a restart): the
191
+ // taking time stands in for its createdAt, which only ever keeps more.
192
+ const ms = Number.isFinite(createdAtMs) ? createdAtMs : (flyingMs !== undefined ? flyingMs : this.now());
193
+ const chats = this._load();
194
+ const rec = chats[chat] || (chats[chat] = { takenAt: isoOf(ms - 1), floor: isoOf(ms - 1), ids: [] });
195
+ rec.ids = Array.isArray(rec.ids) ? rec.ids : [];
196
+ rec.ids.push([id, ms]);
197
+ if (ms > (msOf(rec.takenAt) ?? -Infinity)) rec.takenAt = isoOf(ms);
198
+ if (rec.replays) delete rec.replays[id];
199
+ rec.at = this.now();
200
+ this._save();
201
+ }
202
+
203
+ // Chats to read on connect, most recently active first.
204
+ chats(limit = CATCHUP_CHATS) {
205
+ const chats = this._load();
206
+ return Object.keys(chats)
207
+ .sort((a, b) => (Number(chats[b].at) || 0) - (Number(chats[a].at) || 0))
208
+ .slice(0, limit);
209
+ }
210
+
211
+ // Start catching a chat up: where to read from (ms), held until release()
212
+ // so a live message taken meanwhile cannot move the saved cursor past what
213
+ // this catch-up has yet to read. null when the chat has no record.
214
+ hold(chatId) {
215
+ const chat = String(chatId || "");
216
+ const rec = this.record(chat);
217
+ if (!rec) return null;
218
+ const taken = msOf(rec.takenAt);
219
+ const held = this._holdMs(chat);
220
+ const cursor = Math.min(...[taken, held].filter((v) => v != null));
221
+ if (!Number.isFinite(cursor)) return null;
222
+ this._holds.set(chat, Math.min(cursor, this._holds.get(chat) ?? Infinity));
223
+ return Math.max(cursor - SLACK_MS, msOf(rec.floor) ?? -Infinity, this.now() - maxAgeMs());
224
+ }
225
+
226
+ // The chat was read and everything due went out: the hold ends. A failed
227
+ // read keeps it (the next connect tries again from the same point).
228
+ release(chatId) {
229
+ const chat = String(chatId || "");
230
+ if (!this._holds.delete(chat)) return;
231
+ const rec = this.record(chat);
232
+ const hold = this._holdMs(chat);
233
+ if (rec && (rec.holdAt || null) !== (hold == null ? null : isoOf(hold))) this._save();
234
+ }
235
+
236
+ // The bot left the chat (or it is gone): nothing more to catch up there.
237
+ forget(chatId) {
238
+ const chat = String(chatId || "");
239
+ const chats = this._load();
240
+ this._holds.delete(chat);
241
+ if (!chats[chat]) return;
242
+ delete chats[chat];
243
+ this._save();
244
+ }
245
+
246
+ // Count one more replay of a message; false when it was replayed
247
+ // MAX_REPLAYS times already (it is then taken, and left alone).
248
+ noteReplay(chatId, messageId, createdAtMs) {
249
+ const rec = this.record(chatId);
250
+ if (!rec) return true;
251
+ rec.replays = rec.replays && typeof rec.replays === "object" ? rec.replays : {};
252
+ const n = (Number(rec.replays[messageId]) || 0) + 1;
253
+ if (n > MAX_REPLAYS) {
254
+ this.taken(chatId, messageId, createdAtMs);
255
+ return false;
256
+ }
257
+ rec.replays[messageId] = n;
258
+ const notes = Object.keys(rec.replays);
259
+ for (const old of notes.slice(0, Math.max(0, notes.length - MAX_REPLAY_NOTES))) delete rec.replays[old];
260
+ this._save();
261
+ return true;
262
+ }
263
+ }
264
+
265
+ module.exports = {
266
+ KazeeIntake,
267
+ catchUpEnabled,
268
+ maxAgeMs,
269
+ stateFileFor,
270
+ SKIP_TYPES,
271
+ MAX_IDS,
272
+ MAX_REPLAYS,
273
+ SLACK_MS,
274
+ CATCHUP_CHATS,
275
+ };
package/core/actions.js CHANGED
@@ -493,7 +493,9 @@ async function handleAction(envelope) {
493
493
  if (!isChatOwner(envelope.channelId)) return send("Owner only — the voice is shared across this bot.");
494
494
  const ttsVoice = require("./tts-voice");
495
495
  const rest = d.slice(3);
496
+ const catalog = await require("./media").voiceCatalog();
496
497
  if (rest === "clone") {
498
+ if (catalog?.cloning === false) return send("🎙️ The voice server can't copy voices yet, so cloning from a clip isn't available.");
497
499
  state.pendingVoiceClone = { at: Date.now() };
498
500
  return send("🎙️ Send me a voice note now — 5–15s of clear speech and I'll clone it as your voice. Send /voice to cancel.");
499
501
  }
@@ -503,10 +505,17 @@ async function handleAction(envelope) {
503
505
  return send(`🎙️ Voice reset to default (${ttsVoice.getVoiceLabel()}).`);
504
506
  }
505
507
  if (rest.startsWith("p:")) {
506
- const preset = ttsVoice.presetByKey(rest.slice(2));
507
- if (!preset) return send("That voice button is stale. Open /voice again.");
508
- ttsVoice.setVoice(preset.id, preset.label);
509
- return send(`🎙️ Voice set: ${preset.label}`);
508
+ // A server voice name, or an older button's preset key.
509
+ const key = rest.slice(2);
510
+ const preset = catalog?.voices?.includes(key) ? null : ttsVoice.presetByKey(key);
511
+ const voice = preset ? preset.id : key;
512
+ if (!preset && !catalog) return send("No voice server is set up for this bot, so that voice can't be used.");
513
+ if (!preset && !catalog.voices.includes(key)) return send("That voice button is stale. Open /voice again.");
514
+ const refused = ttsVoice.checkChoice(catalog, voice);
515
+ if (refused) return send(refused);
516
+ const label = ttsVoice.voiceLabel(voice, catalog);
517
+ ttsVoice.setVoice(voice, label);
518
+ return send(`🎙️ Voice set: ${label}`);
510
519
  }
511
520
  return;
512
521
  }
package/core/handlers.js CHANGED
@@ -1468,9 +1468,11 @@ register({
1468
1468
  const ttsVoice = require("./tts-voice");
1469
1469
  const state = currentState();
1470
1470
  const arg = (tail || "").trim();
1471
+ const catalog = await require("./media").voiceCatalog();
1471
1472
  if (arg) {
1472
1473
  const low = arg.toLowerCase();
1473
1474
  if (low === "clone") {
1475
+ if (catalog?.cloning === false) return send("🎙️ The voice server can't copy voices yet, so cloning from a clip isn't available. Pick one of its voices with /voice.");
1474
1476
  laneFor(state).pendingVoiceClone = { at: Date.now() };
1475
1477
  return send("🎙️ Send me a voice note now — 5–15s of clear speech and I'll clone it as your voice. Send /voice to cancel.");
1476
1478
  }
@@ -1479,27 +1481,23 @@ register({
1479
1481
  ttsVoice.clearVoice();
1480
1482
  return send(`🎙️ Voice reset to default (${ttsVoice.getVoiceLabel()}).`);
1481
1483
  }
1484
+ // A server voice name, an older preset key, or a raw reference pocket-tts
1485
+ // understands (an hf:// path, an http(s) URL, a stored id) when it can clone.
1482
1486
  const preset = ttsVoice.presetByKey(low);
1483
- if (preset) {
1484
- ttsVoice.setVoice(preset.id, preset.label);
1485
- return send(`🎙️ Voice set: ${preset.label}`);
1486
- }
1487
- // Anything else is treated as a raw voice reference pocket-tts understands:
1488
- // a built-in shorthand, an hf:// path, an http(s) URL, or a stored id.
1489
- ttsVoice.setVoice(arg, null);
1490
- return send(`🎙️ Voice set: ${arg}`);
1487
+ const voice = catalog?.voices?.includes(low) ? low : preset ? preset.id : arg;
1488
+ const refused = ttsVoice.checkChoice(catalog, voice);
1489
+ if (refused) return send(refused);
1490
+ const label = ttsVoice.voiceLabel(voice, catalog);
1491
+ ttsVoice.setVoice(voice, label);
1492
+ return send(`🎙️ Voice set: ${label}`);
1491
1493
  }
1492
1494
  laneFor(state).pendingVoiceClone = null;
1493
- const rows = ttsVoice.presets().map((p) => [{ text: p.label, callback_data: `vc:p:${p.key}` }]);
1494
- rows.push([
1495
- { text: "🎙️ Clone from a clip", callback_data: "vc:clone" },
1496
- { text: "↩︎ Default", callback_data: "vc:default" },
1497
- ]);
1495
+ if (!catalog) return send(`🎙️ Voice: ${ttsVoice.getVoiceLabel()}\n\nNo voice server is set up for this bot, so there are no voices to pick from.`);
1498
1496
  send(
1499
1497
  `🎙️ Voice: ${ttsVoice.getVoiceLabel()}\n\n` +
1500
- "Pick a voice — it takes effect immediately, no restart. Or tap Clone from a clip (or send /voice clone) to use your own recorded voice.\n\n" +
1501
- "For any other voice: /voice hf://kyutai/tts-voices/<folder>/<clip>.wav",
1502
- { keyboard: { inline_keyboard: rows } },
1498
+ "Pick a voice — it takes effect immediately, no restart." +
1499
+ (catalog.cloning ? " Or tap Clone from a clip (or send /voice clone) to use your own recorded voice, or send /voice hf://kyutai/tts-voices/<folder>/<clip>.wav." : ""),
1500
+ { keyboard: { inline_keyboard: ttsVoice.pickerRows(catalog) } },
1503
1501
  );
1504
1502
  },
1505
1503
  });
package/core/identity.js CHANGED
@@ -40,8 +40,20 @@ function roomKeyFor(transport, channelId) {
40
40
  // Split a conversation key back into its parts. The channel id may itself
41
41
  // contain ':' (e.g. spaces "task:42"), so only the first ':' separates the
42
42
  // transport. Returns {transport, channelId, threadId|null, roomKey}.
43
- function parseConversationKey(key) {
43
+ // A live call's turns run in a lane of their own beside the chat's, so a call
44
+ // is never stuck behind work in that chat (11 Oct: the full agent never spoke
45
+ // on a call while a long turn ran in the same chat; only the quick voice did).
46
+ const CALL_LANE_SUFFIX = "#call";
47
+ function callLaneKey(key) {
44
48
  const k = String(key || "");
49
+ return k.endsWith(CALL_LANE_SUFFIX) ? k : `${k}${CALL_LANE_SUFFIX}`;
50
+ }
51
+ function isCallLaneKey(key) {
52
+ return String(key || "").endsWith(CALL_LANE_SUFFIX);
53
+ }
54
+
55
+ function parseConversationKey(key) {
56
+ const k = String(key || "").replace(/#call$/, "");
45
57
  const i = k.indexOf(THREAD_SEP);
46
58
  const room = i >= 0 ? k.slice(0, i) : k;
47
59
  const threadId = i >= 0 ? k.slice(i + THREAD_SEP.length) || null : null;
@@ -226,6 +238,8 @@ module.exports = {
226
238
  conversationKeyFor,
227
239
  roomKeyFor,
228
240
  parseConversationKey,
241
+ callLaneKey,
242
+ isCallLaneKey,
229
243
  isConfiguredOwnerChannel,
230
244
  ownerCanonical,
231
245
  channelIsOwner,
package/core/loopback.js CHANGED
@@ -518,7 +518,10 @@ async function handleJson(req, res, url, kind) {
518
518
  return reply(res, 501, { error: `adapter ${adapterId} does not support outgoing calls` });
519
519
  }
520
520
  try {
521
- const result = await manager.outgoing({ chatId: channelId, toUserId: payload.toUserId || undefined });
521
+ // Placed from a thread turn: the call belongs to that thread (the run's
522
+ // own thread, never a payload's claim; threadForCaller).
523
+ const threadId = threadForCaller(callerOf(payload), channelId, payload.threadId);
524
+ const result = await manager.outgoing({ chatId: channelId, toUserId: payload.toUserId || undefined, reason: typeof payload.reason === "string" ? payload.reason.slice(0, 300) : undefined, ...(threadId ? { threadId } : {}) });
522
525
  return reply(res, 200, { ok: true, ...result });
523
526
  } catch (e) {
524
527
  return reply(res, 500, { error: e.message });
package/core/media.js CHANGED
@@ -287,6 +287,44 @@ async function cloneVoiceFromClip(clipPath) {
287
287
  }
288
288
  }
289
289
 
290
+ // What the speech server can speak in: { default, voices: [names], cloning }.
291
+ // null when it can't say (unreachable, or an older server without the list).
292
+ async function speechVoices({ fetchImpl = fetch, timeoutMs = 8000 } = {}) {
293
+ const token = speechBearerToken();
294
+ if (!SPEECH_SERVICE_URL || !token) return null;
295
+ try {
296
+ const res = await fetchImpl(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/voices`, {
297
+ headers: { Authorization: `Bearer ${token}` },
298
+ signal: AbortSignal.timeout(timeoutMs),
299
+ });
300
+ if (!res.ok) return null;
301
+ const body = await res.json();
302
+ return body && Array.isArray(body.voices) ? body : null;
303
+ } catch (e) {
304
+ return null;
305
+ }
306
+ }
307
+
308
+ // The list /voice shows and checks against. The server's list is kept for ten
309
+ // minutes (a picker and its button tap are one fetch, not two). When the
310
+ // server can't be asked, the last list it gave, else its built-in voices, so
311
+ // the picker still offers voices it makes instead of nothing (12 Oct: the
312
+ // 11 Oct picker offered hf:// clips the server can't make, and a call went
313
+ // silent after one). null only when no speech server is configured.
314
+ const VOICE_LIST_TTL_MS = 10 * 60 * 1000;
315
+ let voiceList = null; // { at, catalog } from the server
316
+ async function voiceCatalog({ fetchImpl = fetch, now = Date.now() } = {}) {
317
+ if (!SPEECH_SERVICE_URL || !speechBearerToken()) return null;
318
+ if (voiceList && now - voiceList.at < VOICE_LIST_TTL_MS) return voiceList.catalog;
319
+ const live = await speechVoices({ fetchImpl, timeoutMs: 3000 });
320
+ if (live) {
321
+ voiceList = { at: now, catalog: live };
322
+ return live;
323
+ }
324
+ return voiceList ? voiceList.catalog : ttsVoice.builtinCatalog();
325
+ }
326
+ function _resetVoiceCatalog() { voiceList = null; }
327
+
290
328
  // True when at least one textToVoice route can produce audio. Mirrors the
291
329
  // pocket -> ElevenLabs -> say fallback chain; local Whisper is deliberately
292
330
  // irrelevant here (hosted pods speak via the gateway without it).
@@ -418,4 +456,4 @@ async function synthSentenceMp3(text) {
418
456
  }
419
457
  }
420
458
 
421
- module.exports = { transcribeAudio, transcribeAudioLocal, transcribeMedia, STT_TIMING, canTranscribe, textToVoice, textToVoiceFile, splitSentences, synthSentenceMp3, pocketToVoice, chunkForTTS, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
459
+ module.exports = { transcribeAudio, transcribeAudioLocal, transcribeMedia, STT_TIMING, canTranscribe, textToVoice, textToVoiceFile, splitSentences, synthSentenceMp3, pocketToVoice, chunkForTTS, cloneVoiceFromClip, speechBearerToken, speechVoices, voiceCatalog, _resetVoiceCatalog, canSpeak, TTS_CMD };
@@ -4,7 +4,7 @@ const crypto = require("crypto");
4
4
  const path = require("path");
5
5
  const { currentConversationKey, currentThreadId } = require("./context");
6
6
  const { resolveProviderSettings, peekLane } = require("./state");
7
- const { parseConversationKey } = require("./identity");
7
+ const { parseConversationKey, callLaneKey } = require("./identity");
8
8
 
9
9
  function cloneSerializable(value, label = "run context value") {
10
10
  if (value === undefined) return undefined;
@@ -57,6 +57,26 @@ function projectSnapshot(state, cwd, requestedProject) {
57
57
  // whole main context in a session of its own. Default "fresh": a new thread
58
58
  // starts a new session, seeded only with the thread's own messages. Codex has
59
59
  // no fork, so it always starts fresh.
60
+ // A live call answers in speech, so its turn may run a faster model and effort
61
+ // than the room's chat turns: KAZEE_CALL_MODEL / KAZEE_CALL_EFFORT (Claude
62
+ // only). Unset keeps the room's own settings; an unknown effort is ignored.
63
+ const CALL_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
64
+ function callSetting(name) {
65
+ let value = process.env[name];
66
+ try { value = require("./config").config?.[name] ?? value; } catch (_) {}
67
+ return String(value || "").trim();
68
+ }
69
+
70
+ function callProviderOverrides(provider) {
71
+ if (provider !== "claude") return {};
72
+ const overrides = {};
73
+ const model = callSetting("KAZEE_CALL_MODEL");
74
+ const effort = callSetting("KAZEE_CALL_EFFORT").toLowerCase();
75
+ if (model) overrides.model = model;
76
+ if (CALL_EFFORTS.has(effort)) overrides.effort = effort;
77
+ return overrides;
78
+ }
79
+
60
80
  function threadSeedMode() {
61
81
  let value = process.env.OC_THREAD_SEED;
62
82
  try { value = require("./config").config?.OC_THREAD_SEED ?? value; } catch (_) {}
@@ -127,8 +147,10 @@ function captureRunContext({
127
147
  const activeSessionId = state.activeSessions?.[project.name]?.[provider] || null;
128
148
  // The run's lane (its conversation: a thread is its own) and thread are
129
149
  // fixed here, at admission: queueing, /stop, preview, typing and delivery all
130
- // follow the lane the message arrived in, never a later one.
131
- const laneKey = String(opts.laneKey || currentConversationKey() || project.name || "Workspace");
150
+ // follow the lane the message arrived in, never a later one. A live call's
151
+ // turns get a lane of their own beside their chat's.
152
+ const conversationLane = String(opts.laneKey || currentConversationKey() || project.name || "Workspace");
153
+ const laneKey = liveCall ? callLaneKey(conversationLane) : conversationLane;
132
154
  const threadId = (store && store.threadId) || currentThreadId() || parseConversationKey(laneKey).threadId || null;
133
155
  const lane = peekLane(state, laneKey);
134
156
  const forkFromSessionId = threadForkSession(state, { provider, laneKey, threadId, activeSessionId, opts, liveCall });
@@ -154,6 +176,7 @@ function captureRunContext({
154
176
  // personal bucket, so non-room channels are unaffected.
155
177
  const providerSettings = cloneSerializable({
156
178
  ...(resolveProviderSettings(state, provider) || {}),
179
+ ...(liveCall ? callProviderOverrides(provider) : {}),
157
180
  ...(opts.providerSettings || {}),
158
181
  }, "provider settings");
159
182
  delete providerSettings.backend;
@@ -261,6 +284,7 @@ function bindRunInputs(runContext, inputs = {}) {
261
284
  }
262
285
 
263
286
  module.exports = {
287
+ projectSnapshot,
264
288
  captureRunContext,
265
289
  bindRunInputs,
266
290
  cloneSerializable,