@inetafrica/open-claudia 3.1.10 → 3.1.12

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/core/actions.js CHANGED
@@ -416,6 +416,27 @@ async function handleAction(envelope) {
416
416
  await send(`Tooling mode: ${m}${m === "relaxed" ? " ⚠️ deny-gate is log-only; ad-hoc scripting allowed" : " — tool-first enforced"}`);
417
417
  return;
418
418
  }
419
+ if (d.startsWith("vc:")) {
420
+ if (!isChatOwner(envelope.channelId)) return send("Owner only — the voice is shared across this bot.");
421
+ const ttsVoice = require("./tts-voice");
422
+ const rest = d.slice(3);
423
+ if (rest === "clone") {
424
+ state.pendingVoiceClone = { at: Date.now() };
425
+ 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.");
426
+ }
427
+ state.pendingVoiceClone = null;
428
+ if (rest === "default") {
429
+ ttsVoice.clearVoice();
430
+ return send(`🎙️ Voice reset to default (${ttsVoice.getVoiceLabel()}).`);
431
+ }
432
+ if (rest.startsWith("p:")) {
433
+ const preset = ttsVoice.presetByKey(rest.slice(2));
434
+ if (!preset) return send("That voice button is stale. Open /voice again.");
435
+ ttsVoice.setVoice(preset.id, preset.label);
436
+ return send(`🎙️ Voice set: ${preset.label}`);
437
+ }
438
+ return;
439
+ }
419
440
  if (d.startsWith("apr:")) {
420
441
  // Approval buttons. Owner-only: the exact payload was shown in the prompt;
421
442
  // decide() is idempotent so double-taps can't flip it. Two shapes share
package/core/handlers.js CHANGED
@@ -1322,6 +1322,49 @@ register({
1322
1322
  },
1323
1323
  });
1324
1324
 
1325
+ register({
1326
+ name: "voice", description: "Pick the Pocket TTS voice, or clone your own", args: "[<preset>|<hf://…>|clone|default]", ownerOnly: true,
1327
+ handler: async (env, { tail }) => {
1328
+ if (!authorized(env)) return;
1329
+ const ttsVoice = require("./tts-voice");
1330
+ const state = currentState();
1331
+ const arg = (tail || "").trim();
1332
+ if (arg) {
1333
+ const low = arg.toLowerCase();
1334
+ if (low === "clone") {
1335
+ state.pendingVoiceClone = { at: Date.now() };
1336
+ 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.");
1337
+ }
1338
+ state.pendingVoiceClone = null;
1339
+ if (low === "default" || low === "reset") {
1340
+ ttsVoice.clearVoice();
1341
+ return send(`🎙️ Voice reset to default (${ttsVoice.getVoiceLabel()}).`);
1342
+ }
1343
+ const preset = ttsVoice.presetByKey(low);
1344
+ if (preset) {
1345
+ ttsVoice.setVoice(preset.id, preset.label);
1346
+ return send(`🎙️ Voice set: ${preset.label}`);
1347
+ }
1348
+ // Anything else is treated as a raw voice reference pocket-tts understands:
1349
+ // a built-in shorthand, an hf:// path, an http(s) URL, or a stored id.
1350
+ ttsVoice.setVoice(arg, null);
1351
+ return send(`🎙️ Voice set: ${arg}`);
1352
+ }
1353
+ state.pendingVoiceClone = null;
1354
+ const rows = ttsVoice.presets().map((p) => [{ text: p.label, callback_data: `vc:p:${p.key}` }]);
1355
+ rows.push([
1356
+ { text: "🎙️ Clone from a clip", callback_data: "vc:clone" },
1357
+ { text: "↩︎ Default", callback_data: "vc:default" },
1358
+ ]);
1359
+ send(
1360
+ `🎙️ Voice: ${ttsVoice.getVoiceLabel()}\n\n` +
1361
+ "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" +
1362
+ "For any other voice: /voice hf://kyutai/tts-voices/<folder>/<clip>.wav",
1363
+ { keyboard: { inline_keyboard: rows } },
1364
+ );
1365
+ },
1366
+ });
1367
+
1325
1368
  register({
1326
1369
  name: "budget", description: "Set max spend for next task", args: "[$N]",
1327
1370
  handler: async (env, { tail }) => {
package/core/media.js CHANGED
@@ -4,7 +4,8 @@
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
6
  const { execSync } = require("child_process");
7
- const { WHISPER_CLI, WHISPER_MODEL, FFMPEG, TEMP_DIR, ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL, SPEECH_SERVICE_URL, SPEECH_API_KEY, AGENTSPACE_POD_TOKEN, POCKET_TTS_VOICE, TTS_PROVIDER } = require("./config");
7
+ const { WHISPER_CLI, WHISPER_MODEL, FFMPEG, TEMP_DIR, ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL, SPEECH_SERVICE_URL, SPEECH_API_KEY, AGENTSPACE_POD_TOKEN, TTS_PROVIDER } = require("./config");
8
+ const ttsVoice = require("./tts-voice");
8
9
 
9
10
  const TTS_CMD = process.platform === "darwin" ? "say" : null;
10
11
 
@@ -108,7 +109,7 @@ async function pocketToVoice(clean) {
108
109
  const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/audio/speech`, {
109
110
  method: "POST",
110
111
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
111
- body: JSON.stringify({ input: clean, voice: POCKET_TTS_VOICE, response_format: "wav" }),
112
+ body: JSON.stringify({ input: clean, voice: ttsVoice.getVoice(), response_format: "wav" }),
112
113
  signal: AbortSignal.timeout(60000),
113
114
  });
114
115
  if (!res.ok) {
@@ -129,6 +130,52 @@ async function pocketToVoice(clean) {
129
130
  }
130
131
  }
131
132
 
133
+ // Upload a reference clip to the speech gateway for voice cloning. The gateway
134
+ // stores the clip on the TTS pod's model-cache volume and returns a voice id
135
+ // usable as the `voice` field on later /v1/audio/speech calls; pocket-tts clones
136
+ // from it. Returns the id string, or null on any failure.
137
+ async function cloneVoiceFromClip(clipPath) {
138
+ const token = speechBearerToken();
139
+ if (!SPEECH_SERVICE_URL || !token || !clipPath) return null;
140
+ // Telegram voice notes arrive as ogg/opus, which pocket-tts can't decode as a
141
+ // reference prompt — transcode to 24kHz mono wav (the model's native rate)
142
+ // first. Fall back to the raw clip only if ffmpeg is unavailable.
143
+ let wavPath = null;
144
+ let uploadPath = clipPath;
145
+ if (FFMPEG) {
146
+ try {
147
+ wavPath = path.join(TEMP_DIR, `voice-clone-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.wav`);
148
+ execSync(`"${FFMPEG}" -i "${clipPath}" -ar 24000 -ac 1 -y "${wavPath}" 2>/dev/null`, { timeout: 30000 });
149
+ uploadPath = wavPath;
150
+ } catch (e) {
151
+ console.error("Voice clone transcode error:", e.message);
152
+ wavPath = null;
153
+ uploadPath = clipPath;
154
+ }
155
+ }
156
+ try {
157
+ const body = new FormData();
158
+ body.append("file", new Blob([fs.readFileSync(uploadPath)]), path.basename(uploadPath));
159
+ const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/voices`, {
160
+ method: "POST",
161
+ headers: { Authorization: `Bearer ${token}` },
162
+ body,
163
+ signal: AbortSignal.timeout(120000),
164
+ });
165
+ if (!res.ok) {
166
+ console.error(`Voice clone upload failed: HTTP ${res.status}`);
167
+ return null;
168
+ }
169
+ const payload = await res.json().catch(() => ({}));
170
+ return String(payload.voice_id || payload.id || "").trim() || null;
171
+ } catch (e) {
172
+ console.error("Voice clone error:", e.message);
173
+ return null;
174
+ } finally {
175
+ if (wavPath) { try { fs.unlinkSync(wavPath); } catch (e) {} }
176
+ }
177
+ }
178
+
132
179
  // True when at least one textToVoice route can produce audio. Mirrors the
133
180
  // pocket -> ElevenLabs -> say fallback chain; local Whisper is deliberately
134
181
  // irrelevant here (hosted pods speak via the gateway without it).
@@ -203,4 +250,4 @@ async function synthSentenceMp3(text) {
203
250
  }
204
251
  }
205
252
 
206
- module.exports = { transcribeAudio, transcribeAudioLocal, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, speechBearerToken, canSpeak, TTS_CMD };
253
+ module.exports = { transcribeAudio, transcribeAudioLocal, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
package/core/router.js CHANGED
@@ -13,7 +13,7 @@ const introFlow = require("./intro-flow");
13
13
  const { send, deleteMessage } = require("./io");
14
14
  const { currentState, saveState, autoLinkOwnerChannel } = require("./state");
15
15
  const { admitRunContext, runAgent } = require("./runner");
16
- const { transcribeAudio } = require("./media");
16
+ const { transcribeAudio, cloneVoiceFromClip } = require("./media");
17
17
  const { vault } = require("./vault-store");
18
18
  const { isOnboarded, handleOnboarding } = require("./onboarding");
19
19
  const {
@@ -185,6 +185,13 @@ async function handleVoice(envelope) {
185
185
  if (media.size && media.size > MAX_VOICE_SIZE) {
186
186
  return send(`Voice note too large (${Math.round(media.size / 1024 / 1024)}MB). Max: ${MAX_VOICE_SIZE / 1024 / 1024}MB`);
187
187
  }
188
+ // /voice clone armed this channel: the next clip is a cloning sample, not a
189
+ // prompt. Consume the flag (fresh only) before the normal transcribe path.
190
+ if (state.pendingVoiceClone) {
191
+ const fresh = Date.now() - state.pendingVoiceClone.at < 5 * 60 * 1000;
192
+ state.pendingVoiceClone = null;
193
+ if (fresh) return captureVoiceClone(envelope, media);
194
+ }
188
195
  const runContext = admitRunContext("", state.currentSession.dir, envelope.messageId, {
189
196
  purpose: "foreground",
190
197
  attachments: [{ type: "voice", mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
@@ -216,6 +223,11 @@ async function handleAudio(envelope) {
216
223
  try {
217
224
  const media = envelope.media?.[0];
218
225
  if (!media) return;
226
+ if (state.pendingVoiceClone) {
227
+ const fresh = Date.now() - state.pendingVoiceClone.at < 5 * 60 * 1000;
228
+ state.pendingVoiceClone = null;
229
+ if (fresh) return captureVoiceClone(envelope, media);
230
+ }
219
231
  const runContext = admitRunContext("", state.currentSession.dir, envelope.messageId, {
220
232
  purpose: "foreground",
221
233
  attachments: [{ type: "audio", mimeType: media.mimeType || null, fileName: media.fileName || null }],
@@ -233,6 +245,26 @@ async function handleAudio(envelope) {
233
245
  } catch (err) { await send(`Audio failed: ${err.message}`); }
234
246
  }
235
247
 
248
+ // A clip the owner sent right after /voice clone. Upload it to the speech
249
+ // gateway (which stores it on the TTS pod and returns a voice id), then set that
250
+ // id as the active voice. Never transcribed or sent to the agent.
251
+ async function captureVoiceClone(envelope, media) {
252
+ try {
253
+ envelope.adapter.typing(envelope.channelId).catch(() => {});
254
+ await send("🎙️ Got it — cloning your voice…");
255
+ const clipPath = await envelope.adapter.downloadMedia(media);
256
+ const voiceId = await cloneVoiceFromClip(clipPath);
257
+ try { fs.unlinkSync(clipPath); } catch (e) {}
258
+ if (!voiceId) {
259
+ return send("Couldn't process that clip for cloning. Send a clear 5–15s recording and try /voice clone again.");
260
+ }
261
+ require("./tts-voice").setVoice(voiceId, "your cloned voice");
262
+ return send("✅ Cloned. I'll speak in that voice from now on — change it anytime with /voice.");
263
+ } catch (err) {
264
+ await send(`Voice clone failed: ${err.message}`);
265
+ }
266
+ }
267
+
236
268
  async function handlePhoto(envelope) {
237
269
  if (isDuplicate(envelope)) return;
238
270
  const state = currentState();
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+
3
+ // Live-read, persisted Pocket TTS voice selection.
4
+ //
5
+ // The active voice overrides the POCKET_TTS_VOICE env default and is read at
6
+ // synth time (media.js), so /voice takes effect immediately with no restart.
7
+ // The choice survives restarts via a small JSON file in the bot config dir;
8
+ // env stays the deploy-time default/floor and is used only when no override is
9
+ // set. A cloned-clip voice is just another voice string (a server-issued id).
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const { CONFIG_DIR, POCKET_TTS_VOICE } = require("./config");
14
+ const { atomicWriteFileSync } = require("./fsutil");
15
+
16
+ const STORE = path.join(CONFIG_DIR, "tts-voice.json");
17
+
18
+ // Curated shortlist for the /voice picker. `id` is exactly what pocket-tts's
19
+ // get_state_for_audio_prompt resolves: the built-in "alba" shorthand or an
20
+ // hf:// reference into the kyutai/tts-voices repo. Every path here was verified
21
+ // against that repo's file tree, so the buttons always resolve.
22
+ const PRESETS = [
23
+ { key: "alba", label: "Alba (default)", id: "alba" },
24
+ { key: "announcer", label: "Alba · announcer", id: "hf://kyutai/tts-voices/alba-mackenna/announcer.wav" },
25
+ { key: "merchant", label: "Alba · merchant", id: "hf://kyutai/tts-voices/alba-mackenna/merchant.wav" },
26
+ { key: "expresso", label: "Expresso", id: "hf://kyutai/tts-voices/expresso/ex01-ex02_default_001_channel1_168s.wav" },
27
+ { key: "enunciated", label: "Expresso · clear", id: "hf://kyutai/tts-voices/expresso/ex01-ex02_enunciated_001_channel1_432s.wav" },
28
+ { key: "vctk-p225", label: "VCTK p225", id: "hf://kyutai/tts-voices/vctk/p225_023.wav" },
29
+ { key: "vctk-p226", label: "VCTK p226", id: "hf://kyutai/tts-voices/vctk/p226_023.wav" },
30
+ ];
31
+
32
+ // undefined = not loaded yet; null = loaded, no override (use env default);
33
+ // { voice, label } = an active override.
34
+ let cache;
35
+
36
+ function defaultVoice() {
37
+ return (POCKET_TTS_VOICE && String(POCKET_TTS_VOICE).trim()) || "alba";
38
+ }
39
+
40
+ function load() {
41
+ if (cache !== undefined) return cache;
42
+ try {
43
+ const raw = JSON.parse(fs.readFileSync(STORE, "utf-8"));
44
+ if (raw && typeof raw.voice === "string" && raw.voice.trim()) {
45
+ cache = { voice: raw.voice.trim(), label: typeof raw.label === "string" && raw.label ? raw.label : null };
46
+ return cache;
47
+ }
48
+ } catch (e) { /* no store yet, or malformed — fall back to default */ }
49
+ cache = null;
50
+ return cache;
51
+ }
52
+
53
+ // The active voice string handed to the speech gateway.
54
+ function getVoice() {
55
+ const s = load();
56
+ return (s && s.voice) || defaultVoice();
57
+ }
58
+
59
+ // Human-friendly label for the active voice (preset label, custom note, or id).
60
+ function getVoiceLabel() {
61
+ const s = load();
62
+ if (s) return s.label || s.voice;
63
+ const dv = defaultVoice();
64
+ const p = PRESETS.find((x) => x.id === dv || x.key === dv);
65
+ return p ? p.label : dv;
66
+ }
67
+
68
+ // True when the active voice is an explicit override (not the env default).
69
+ function isOverridden() {
70
+ return !!load();
71
+ }
72
+
73
+ function setVoice(voice, label = null) {
74
+ const v = String(voice || "").trim();
75
+ if (!v) throw new Error("voice is required");
76
+ cache = { voice: v, label: label ? String(label).trim() : null };
77
+ atomicWriteFileSync(STORE, JSON.stringify(cache, null, 2), { backup: false, mode: 0o600 });
78
+ return cache;
79
+ }
80
+
81
+ // Drop the override and fall back to the env/config default.
82
+ function clearVoice() {
83
+ cache = null;
84
+ try { fs.unlinkSync(STORE); } catch (e) { /* already absent */ }
85
+ }
86
+
87
+ function presets() { return PRESETS.slice(); }
88
+ function presetByKey(key) { return PRESETS.find((p) => p.key === String(key || "").toLowerCase()) || null; }
89
+
90
+ // Test seam: forget the in-memory cache so the next read re-consults disk.
91
+ function _resetCache() { cache = undefined; }
92
+
93
+ module.exports = {
94
+ getVoice, getVoiceLabel, isOverridden,
95
+ setVoice, clearVoice,
96
+ presets, presetByKey, defaultVoice,
97
+ STORE, _resetCache,
98
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.1.10",
3
+ "version": "3.1.12",
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": {
@@ -195,7 +195,16 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
195
195
  // bot process advertised voice as enabled. Synthesis moves into the credentialed
196
196
  // bot process behind a loopback "say" kind; the CLI now sends only text; the R8
197
197
  // external-contact egress guard is preserved. Plus this version-pin bump.
198
- assert.strictEqual(pkg.version, "3.1.10", "release version must remain unchanged without explicit approval");
198
+ // 3.1.11 was cut for the /voice picker + voice cloning feature but its build
199
+ // failed on this very version pin (still 3.1.10): the package.json bump landed
200
+ // without the matching pin update, so the docker stage was skipped and no image
201
+ // was published. Never retag a failed version — re-cut as +1.
202
+ // 3.1.12 approved by Sumeet 2026-07-21: re-cut of 3.1.11. Owner-gated /voice
203
+ // preset picker over the Pocket TTS catalog (live-read, persists across restart,
204
+ // Custom hf:// escape hatch) plus voice cloning — send a clip and it's transcoded
205
+ // (ogg->24kHz mono wav) and registered via the speech gateway /v1/voices as the
206
+ // active stored:<digest> voice. Plus this version-pin bump.
207
+ assert.strictEqual(pkg.version, "3.1.12", "release version must remain unchanged without explicit approval");
199
208
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
200
209
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
201
210
  }