@inetafrica/open-claudia 2.6.47 → 2.6.48
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/channels/voice/adapter.js +21 -4
- package/channels/voice/manage.js +285 -0
- package/core/io.js +11 -1
- package/core/media.js +53 -1
- package/core/runner.js +16 -4
- package/package.json +1 -1
|
@@ -127,6 +127,16 @@ class VoiceAdapter {
|
|
|
127
127
|
});
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// Management API — REST surface over the core stores (tasks, jobs, packs,
|
|
131
|
+
// entities, people, lessons, ideas) so the app is a full Open Claudia
|
|
132
|
+
// frontend, not just a voice channel.
|
|
133
|
+
if (pathname.startsWith("/v1/manage/")) {
|
|
134
|
+
return require("./manage").handle(req, res, url, {
|
|
135
|
+
json: (code, payload) => this._json(res, code, payload),
|
|
136
|
+
readBody: (cb) => this._readBody(req, res, cb),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
130
140
|
if (req.method === "POST" && pathname === "/v1/messages/text") {
|
|
131
141
|
return this._readBody(req, res, (buf) => this._onText(buf, res));
|
|
132
142
|
}
|
|
@@ -330,10 +340,11 @@ class VoiceAdapter {
|
|
|
330
340
|
this._broadcast({ kind: "delete", messageId, ts: Date.now() });
|
|
331
341
|
}
|
|
332
342
|
|
|
333
|
-
async sendVoice(channelId,
|
|
343
|
+
async sendVoice(channelId, audioPath) {
|
|
334
344
|
try {
|
|
335
|
-
const
|
|
336
|
-
|
|
345
|
+
const mime = this._guessMime(audioPath);
|
|
346
|
+
const id = this._registerMedia(audioPath, mime, path.basename(audioPath));
|
|
347
|
+
this._broadcast({ kind: "voice", messageId: this._mkId("v"), url: `/v1/media/${id}`, mime, ts: Date.now() });
|
|
337
348
|
return true;
|
|
338
349
|
} catch (e) {
|
|
339
350
|
console.error("voice sendVoice error:", e.message);
|
|
@@ -341,6 +352,12 @@ class VoiceAdapter {
|
|
|
341
352
|
}
|
|
342
353
|
}
|
|
343
354
|
|
|
355
|
+
// Marks the end of a streamed multi-clip spoken reply so the client can stop
|
|
356
|
+
// queueing and re-arm the mic for the next hands-free turn.
|
|
357
|
+
async sendVoiceEnd() {
|
|
358
|
+
this._broadcast({ kind: "voice-end", ts: Date.now() });
|
|
359
|
+
}
|
|
360
|
+
|
|
344
361
|
async sendPhoto(channelId, filePath, caption) { return this.sendFile(channelId, filePath, caption); }
|
|
345
362
|
|
|
346
363
|
async sendFile(channelId, filePath, caption) {
|
|
@@ -442,7 +459,7 @@ class VoiceAdapter {
|
|
|
442
459
|
_cors(res, code) {
|
|
443
460
|
res.writeHead(code, {
|
|
444
461
|
"Access-Control-Allow-Origin": "*",
|
|
445
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
462
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
446
463
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
447
464
|
});
|
|
448
465
|
res.end();
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Management API for the voice/companion app — a thin REST surface over the
|
|
2
|
+
// same core stores the CLI uses (tasks, jobs, packs, entities, people,
|
|
3
|
+
// lessons, ideas). Lets the Expo client act as a full Open Claudia frontend:
|
|
4
|
+
// browse and manage work across every channel, not just the voice one.
|
|
5
|
+
//
|
|
6
|
+
// Mounted by the voice adapter under /v1/manage/*. Auth is already enforced
|
|
7
|
+
// by the adapter before we get here (same bearer token as the rest of the
|
|
8
|
+
// bridge). All routes return JSON.
|
|
9
|
+
//
|
|
10
|
+
// Scope note: tasks are stored per channel (tasks/<adapter>-<channelId>.json),
|
|
11
|
+
// so the owner's real backlog lives on the Telegram channel, not voice. We
|
|
12
|
+
// aggregate across every task file so the app shows the whole picture. The
|
|
13
|
+
// file stem is an opaque "key"; since tasks.filePathFor re-applies the same
|
|
14
|
+
// safe() (idempotent on already-safe strings) and just concatenates with a
|
|
15
|
+
// single "-", splitting the key at the first "-" round-trips to the same file
|
|
16
|
+
// regardless of where the boundary truly was.
|
|
17
|
+
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const tasks = require("../../core/tasks");
|
|
21
|
+
const jobs = require("../../core/jobs");
|
|
22
|
+
const packs = require("../../core/packs");
|
|
23
|
+
const entities = require("../../core/entities");
|
|
24
|
+
const people = require("../../core/people");
|
|
25
|
+
const lessons = require("../../core/lessons");
|
|
26
|
+
const ideas = require("../../core/ideas");
|
|
27
|
+
const { TASKS_DIR, DEFAULT_CLAUDE_MODEL } = require("../../core/config");
|
|
28
|
+
|
|
29
|
+
let PKG_VERSION = "unknown";
|
|
30
|
+
try { PKG_VERSION = require("../../package.json").version || "unknown"; } catch (e) {}
|
|
31
|
+
|
|
32
|
+
// ── task channel keys ──────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function taskFileKeys() {
|
|
35
|
+
let entries = [];
|
|
36
|
+
try { entries = fs.readdirSync(TASKS_DIR); } catch (e) { return []; }
|
|
37
|
+
return entries
|
|
38
|
+
.filter((f) => f.endsWith(".json") && !f.endsWith(".tmp") && !f.startsWith("."))
|
|
39
|
+
.map((f) => f.slice(0, -5));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function keyToAC(key) {
|
|
43
|
+
const i = String(key).indexOf("-");
|
|
44
|
+
if (i < 0) return { adapter: key, channelId: "" };
|
|
45
|
+
return { adapter: key.slice(0, i), channelId: key.slice(i + 1) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Friendlier channel label for the UI than the raw file stem.
|
|
49
|
+
function channelLabel(key) {
|
|
50
|
+
const { adapter } = keyToAC(key);
|
|
51
|
+
const map = { telegram: "Telegram", kazee: "Kazee", voice: "Voice" };
|
|
52
|
+
return map[adapter] || adapter || key;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isOpen(t) { return t.status === "pending" || t.status === "in_progress"; }
|
|
56
|
+
|
|
57
|
+
// ── responses ──────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function overview() {
|
|
60
|
+
const keys = taskFileKeys();
|
|
61
|
+
let openTasks = 0, inProgress = 0;
|
|
62
|
+
for (const key of keys) {
|
|
63
|
+
const { adapter, channelId } = keyToAC(key);
|
|
64
|
+
for (const t of tasks.load(adapter, channelId)) {
|
|
65
|
+
if (isOpen(t)) openTasks++;
|
|
66
|
+
if (t.status === "in_progress") inProgress++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const allJobs = jobs.listAll();
|
|
70
|
+
return {
|
|
71
|
+
version: PKG_VERSION,
|
|
72
|
+
model: DEFAULT_CLAUDE_MODEL,
|
|
73
|
+
counts: {
|
|
74
|
+
tasksOpen: openTasks,
|
|
75
|
+
tasksInProgress: inProgress,
|
|
76
|
+
taskChannels: keys.length,
|
|
77
|
+
crons: allJobs.filter((j) => j.kind === "cron").length,
|
|
78
|
+
wakeups: allJobs.filter((j) => j.kind === "wakeup").length,
|
|
79
|
+
packs: safeLen(() => packs.listPacks()),
|
|
80
|
+
entities: safeLen(() => entities.listEntities()),
|
|
81
|
+
people: safeLen(() => people.list()),
|
|
82
|
+
lessons: safeLen(() => lessons.listLessons()),
|
|
83
|
+
ideas: safeLen(() => ideas.listIdeas()),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeLen(fn) { try { return (fn() || []).length; } catch (e) { return 0; } }
|
|
89
|
+
|
|
90
|
+
// Aggregated task tree across every channel, grouped per channel and sorted
|
|
91
|
+
// so the busiest channel (most open tasks) comes first.
|
|
92
|
+
function allTasks() {
|
|
93
|
+
const groups = [];
|
|
94
|
+
for (const key of taskFileKeys()) {
|
|
95
|
+
const { adapter, channelId } = keyToAC(key);
|
|
96
|
+
const tree = tasks.tree(adapter, channelId);
|
|
97
|
+
const open = tree.filter((r) => isOpen(r) || (r.children || []).some(isOpen)).length;
|
|
98
|
+
if (tree.length === 0) continue;
|
|
99
|
+
groups.push({ key, label: channelLabel(key), adapter, channelId, open, total: tree.length, tasks: tree });
|
|
100
|
+
}
|
|
101
|
+
groups.sort((a, b) => b.open - a.open || b.total - a.total);
|
|
102
|
+
return { groups };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function allJobsView() {
|
|
106
|
+
const list = jobs.listAll().map((j) => ({
|
|
107
|
+
id: j.id,
|
|
108
|
+
kind: j.kind,
|
|
109
|
+
label: j.label || "",
|
|
110
|
+
prompt: j.prompt || "",
|
|
111
|
+
adapterType: j.adapterType || j.adapter || "",
|
|
112
|
+
channelId: j.channelId || "",
|
|
113
|
+
project: j.project || null,
|
|
114
|
+
source: j.source || "agent",
|
|
115
|
+
schedule: j.schedule || null,
|
|
116
|
+
fireAt: j.fireAt || null,
|
|
117
|
+
lastFireAt: j.lastFireAt || null,
|
|
118
|
+
lastFireOk: j.lastFireOk === undefined ? null : j.lastFireOk,
|
|
119
|
+
createdAt: j.createdAt || null,
|
|
120
|
+
}));
|
|
121
|
+
// Wakeups by soonest fire, then crons.
|
|
122
|
+
list.sort((a, b) => {
|
|
123
|
+
if (a.kind !== b.kind) return a.kind === "wakeup" ? -1 : 1;
|
|
124
|
+
if (a.kind === "wakeup") return (a.fireAt || 0) - (b.fireAt || 0);
|
|
125
|
+
return String(a.label).localeCompare(String(b.label));
|
|
126
|
+
});
|
|
127
|
+
return { jobs: list };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function packSummaries() {
|
|
131
|
+
return {
|
|
132
|
+
packs: (packs.listPacks() || []).map((p) => ({
|
|
133
|
+
dir: p.dir, name: p.name, description: p.description, tags: p.tags,
|
|
134
|
+
parent: p.parent, skill: p.skill, kind: p.kind, updated: p.updated,
|
|
135
|
+
usage_count: p.usage_count, archived: !!p.archived,
|
|
136
|
+
})),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function packDetail(dir) {
|
|
141
|
+
const p = packs.readPack(dir);
|
|
142
|
+
if (!p) return null;
|
|
143
|
+
return p; // includes sections {Stance, Procedure, State, Journal}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function entitySummaries() {
|
|
147
|
+
return {
|
|
148
|
+
entities: (entities.listEntities() || []).map((e) => ({
|
|
149
|
+
slug: e.slug, name: e.name, type: e.type, description: e.description,
|
|
150
|
+
aliases: e.aliases, updated: e.updated, last_seen: e.last_seen,
|
|
151
|
+
})),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function entityDetail(slug) {
|
|
156
|
+
const e = entities.readEntity(slug);
|
|
157
|
+
if (!e) return null;
|
|
158
|
+
// readEntity stores sections as parseSections(body) → { sections: {...} }.
|
|
159
|
+
const sections = e.sections && e.sections.sections ? e.sections.sections : e.sections;
|
|
160
|
+
return { ...e, sections };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function peopleView() {
|
|
164
|
+
return {
|
|
165
|
+
people: (people.list() || []).map((p) => ({
|
|
166
|
+
id: p.id, name: p.name, isOwner: !!p.isOwner, bio: p.bio || null,
|
|
167
|
+
handles: (p.handles || []).map((h) => ({ adapter: h.adapter, channelId: h.channelId })),
|
|
168
|
+
primaryChannel: p.primaryChannel || null,
|
|
169
|
+
notes: (p.notes || []).map((n) => (typeof n === "string" ? { text: n } : { text: n.text || "", at: n.at || n.createdAt || null })),
|
|
170
|
+
})),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function lessonsView() {
|
|
175
|
+
return { lessons: (lessons.listLessons() || []).map((l) => ({ id: l.id, text: l.text, src: l.src || "", origin: l.origin || "", created: l.created || "", reinforced: l.reinforced || 0 })) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ideasView() {
|
|
179
|
+
return { ideas: (ideas.listIdeas() || []).map((i) => ({ id: i.id, text: i.text, created: i.created || "" })) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── router ─────────────────────────────────────────────────────────
|
|
183
|
+
// helpers: { json(code, payload), readBody(cb) } supplied by the adapter so
|
|
184
|
+
// we don't reach into its privates.
|
|
185
|
+
|
|
186
|
+
function handle(req, res, url, helpers) {
|
|
187
|
+
const { json, readBody } = helpers;
|
|
188
|
+
const method = req.method;
|
|
189
|
+
const sub = url.pathname.slice("/v1/manage/".length).replace(/\/+$/, "");
|
|
190
|
+
const parts = sub.split("/").filter(Boolean);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
// GET reads
|
|
194
|
+
if (method === "GET") {
|
|
195
|
+
if (sub === "overview") return json(200, { ok: true, ...overview() });
|
|
196
|
+
if (sub === "tasks") return json(200, { ok: true, ...allTasks() });
|
|
197
|
+
if (sub === "jobs") return json(200, { ok: true, ...allJobsView() });
|
|
198
|
+
if (sub === "packs") return json(200, { ok: true, ...packSummaries() });
|
|
199
|
+
if (parts[0] === "packs" && parts[1]) {
|
|
200
|
+
const d = packDetail(decodeURIComponent(parts.slice(1).join("/")));
|
|
201
|
+
return d ? json(200, { ok: true, pack: d }) : json(404, { ok: false, error: "pack not found" });
|
|
202
|
+
}
|
|
203
|
+
if (sub === "entities") return json(200, { ok: true, ...entitySummaries() });
|
|
204
|
+
if (parts[0] === "entities" && parts[1]) {
|
|
205
|
+
const e = entityDetail(decodeURIComponent(parts.slice(1).join("/")));
|
|
206
|
+
return e ? json(200, { ok: true, entity: e }) : json(404, { ok: false, error: "entity not found" });
|
|
207
|
+
}
|
|
208
|
+
if (sub === "people") return json(200, { ok: true, ...peopleView() });
|
|
209
|
+
if (sub === "lessons") return json(200, { ok: true, ...lessonsView() });
|
|
210
|
+
if (sub === "ideas") return json(200, { ok: true, ...ideasView() });
|
|
211
|
+
return json(404, { ok: false, error: "unknown manage route" });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// POST /tasks — add a task (or subtask)
|
|
215
|
+
if (method === "POST" && sub === "tasks") {
|
|
216
|
+
return readBody((buf) => {
|
|
217
|
+
let body = {};
|
|
218
|
+
try { body = JSON.parse(buf.toString("utf-8") || "{}"); } catch (e) {}
|
|
219
|
+
const { adapter, channelId } = resolveTaskTarget(body);
|
|
220
|
+
const content = String(body.content || "").trim();
|
|
221
|
+
if (!content) return json(400, { ok: false, error: "content required" });
|
|
222
|
+
try {
|
|
223
|
+
const t = tasks.add(adapter, channelId, content, {
|
|
224
|
+
parentId: body.parentId || null,
|
|
225
|
+
description: body.description || null,
|
|
226
|
+
});
|
|
227
|
+
return json(201, { ok: true, task: t });
|
|
228
|
+
} catch (e) { return json(400, { ok: false, error: e.message }); }
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// PATCH /tasks/:id — { key|adapter+channelId, action: start|done|remove|pending }
|
|
233
|
+
if (method === "PATCH" && parts[0] === "tasks" && parts[1]) {
|
|
234
|
+
const id = parts[1];
|
|
235
|
+
return readBody((buf) => {
|
|
236
|
+
let body = {};
|
|
237
|
+
try { body = JSON.parse(buf.toString("utf-8") || "{}"); } catch (e) {}
|
|
238
|
+
const { adapter, channelId } = resolveTaskTarget(body);
|
|
239
|
+
const action = String(body.action || "").toLowerCase();
|
|
240
|
+
try {
|
|
241
|
+
if (action === "start") {
|
|
242
|
+
const t = tasks.update(adapter, channelId, id, { status: "in_progress" });
|
|
243
|
+
return t ? json(200, { ok: true, task: t }) : json(404, { ok: false, error: "task not found" });
|
|
244
|
+
}
|
|
245
|
+
if (action === "pending") {
|
|
246
|
+
const t = tasks.update(adapter, channelId, id, { status: "pending" });
|
|
247
|
+
return t ? json(200, { ok: true, task: t }) : json(404, { ok: false, error: "task not found" });
|
|
248
|
+
}
|
|
249
|
+
if (action === "done") {
|
|
250
|
+
const r = tasks.complete(adapter, channelId, id);
|
|
251
|
+
if (!r) return json(404, { ok: false, error: "task not found" });
|
|
252
|
+
if (r.blocked) return json(409, { ok: false, error: "plan has open subtasks", openChildren: r.openChildren.map((c) => c.content) });
|
|
253
|
+
return json(200, { ok: true, removedCount: r.removedCount });
|
|
254
|
+
}
|
|
255
|
+
if (action === "remove") {
|
|
256
|
+
const r = tasks.remove(adapter, channelId, id);
|
|
257
|
+
return r ? json(200, { ok: true, alsoRemoved: r.alsoRemoved }) : json(404, { ok: false, error: "task not found" });
|
|
258
|
+
}
|
|
259
|
+
return json(400, { ok: false, error: "unknown action" });
|
|
260
|
+
} catch (e) { return json(400, { ok: false, error: e.message }); }
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// DELETE /jobs/:id — cancel a wakeup or cron
|
|
265
|
+
if (method === "DELETE" && parts[0] === "jobs" && parts[1]) {
|
|
266
|
+
const removed = jobs.remove(parts[1]);
|
|
267
|
+
return removed ? json(200, { ok: true, removed: { id: removed.id, kind: removed.kind, label: removed.label } }) : json(404, { ok: false, error: "job not found" });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return json(404, { ok: false, error: "unknown manage route" });
|
|
271
|
+
} catch (e) {
|
|
272
|
+
return json(500, { ok: false, error: e.message });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A task mutation needs the exact (adapter, channelId). The client passes the
|
|
277
|
+
// channel "key" (file stem) it got from GET /tasks; fall back to explicit
|
|
278
|
+
// adapter+channelId, then to the voice channel as a last resort.
|
|
279
|
+
function resolveTaskTarget(body) {
|
|
280
|
+
if (body && body.key) return keyToAC(body.key);
|
|
281
|
+
if (body && body.adapter) return { adapter: body.adapter, channelId: body.channelId || "" };
|
|
282
|
+
return { adapter: "voice", channelId: "voice-owner" };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { handle };
|
package/core/io.js
CHANGED
|
@@ -35,6 +35,16 @@ async function sendVoice(oggPath) {
|
|
|
35
35
|
return adapter.sendVoice(channelId, oggPath);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Signal the end of a streamed multi-clip spoken reply. Only the voice channel
|
|
39
|
+
// implements it (so the client knows the reply is complete and can re-arm the
|
|
40
|
+
// mic); other adapters simply have no such method and this is a no-op.
|
|
41
|
+
async function sendVoiceEnd() {
|
|
42
|
+
const adapter = currentAdapter();
|
|
43
|
+
const channelId = currentChannelId();
|
|
44
|
+
if (!adapter || !channelId || typeof adapter.sendVoiceEnd !== "function") return;
|
|
45
|
+
return adapter.sendVoiceEnd(channelId);
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
async function sendFile(filePath, caption) {
|
|
39
49
|
const adapter = currentAdapter();
|
|
40
50
|
const channelId = currentChannelId();
|
|
@@ -56,4 +66,4 @@ function splitMessage(text, maxLen = 4000) {
|
|
|
56
66
|
return chunks;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
module.exports = { send, editMessage, deleteMessage, sendVoice, sendFile, typing, splitMessage };
|
|
69
|
+
module.exports = { send, editMessage, deleteMessage, sendVoice, sendVoiceEnd, sendFile, typing, splitMessage };
|
package/core/media.js
CHANGED
|
@@ -79,4 +79,56 @@ async function textToVoice(text) {
|
|
|
79
79
|
return sayToVoice(clean);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
// Split a reply into sentence-sized chunks for streamed TTS. Tiny fragments are
|
|
83
|
+
// merged into the next chunk so we don't fire a TTS call per "Hi." and end up
|
|
84
|
+
// with choppy playback. Returns cleaned, non-empty chunks.
|
|
85
|
+
function splitSentences(text, minLen = 30) {
|
|
86
|
+
const clean = cleanForTTS(text);
|
|
87
|
+
if (!clean) return [];
|
|
88
|
+
const raw = clean.match(/[^.!?]+[.!?]+|\S[^.!?]*$/g) || [clean];
|
|
89
|
+
const out = [];
|
|
90
|
+
let buf = "";
|
|
91
|
+
for (const piece of raw) {
|
|
92
|
+
buf = (buf ? `${buf} ${piece.trim()}` : piece.trim()).trim();
|
|
93
|
+
if (buf.length >= minLen) { out.push(buf); buf = ""; }
|
|
94
|
+
}
|
|
95
|
+
if (buf) {
|
|
96
|
+
if (out.length) out[out.length - 1] = `${out[out.length - 1]} ${buf}`.trim();
|
|
97
|
+
else out.push(buf);
|
|
98
|
+
}
|
|
99
|
+
return out.filter(Boolean);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Synthesize one already-short chunk to a directly-playable mp3 (no transcode).
|
|
103
|
+
// Used by the voice channel's streamed replies. Falls back to the ogg path
|
|
104
|
+
// (textToVoice) on no-key/error so callers always get a playable file or null.
|
|
105
|
+
async function synthSentenceMp3(text) {
|
|
106
|
+
const clean = cleanForTTS(text);
|
|
107
|
+
if (!clean) return null;
|
|
108
|
+
if (!ELEVENLABS_API_KEY) return sayToVoice(clean); // ogg fallback
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${ELEVENLABS_VOICE_ID}`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "xi-api-key": ELEVENLABS_API_KEY, "Content-Type": "application/json" },
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
text: clean,
|
|
115
|
+
model_id: ELEVENLABS_MODEL,
|
|
116
|
+
voice_settings: { stability: 0.5, similarity_boost: 0.85, style: 0.5, use_speaker_boost: true },
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
const body = await res.text().catch(() => "");
|
|
121
|
+
console.error(`ElevenLabs TTS failed: ${res.status} ${body}`.slice(0, 300));
|
|
122
|
+
return sayToVoice(clean);
|
|
123
|
+
}
|
|
124
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
125
|
+
const mp3Path = path.join(TEMP_DIR, `tts-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.mp3`);
|
|
126
|
+
fs.writeFileSync(mp3Path, buf);
|
|
127
|
+
return mp3Path;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.error("ElevenLabs TTS error:", e.message);
|
|
130
|
+
return sayToVoice(clean);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { transcribeAudio, textToVoice, splitSentences, synthSentenceMp3, TTS_CMD };
|
package/core/runner.js
CHANGED
|
@@ -15,8 +15,8 @@ const { currentState, saveState, recordSession, userOwnsClaudeSession, resetSess
|
|
|
15
15
|
const { chatContext, currentChannelId, currentAdapter } = require("./context");
|
|
16
16
|
const { buildSystemPrompt, promptWithDynamicContext } = require("./system-prompt");
|
|
17
17
|
const { redactSensitive } = require("./redact");
|
|
18
|
-
const { send, editMessage, sendVoice, splitMessage } = require("./io");
|
|
19
|
-
const { textToVoice } = require("./media");
|
|
18
|
+
const { send, editMessage, sendVoice, sendVoiceEnd, splitMessage } = require("./io");
|
|
19
|
+
const { textToVoice, splitSentences, synthSentenceMp3 } = require("./media");
|
|
20
20
|
const { killProcessTree } = require("./process-tree");
|
|
21
21
|
const {
|
|
22
22
|
appendProjectTranscript, transcriptProjectInfo,
|
|
@@ -1200,8 +1200,20 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1200
1200
|
// input is unwanted noise, so gate it to the voice channel.
|
|
1201
1201
|
const { currentTransport } = require("./context");
|
|
1202
1202
|
if (currentTransport() === "voice") {
|
|
1203
|
-
|
|
1204
|
-
|
|
1203
|
+
// Stream the spoken reply sentence-by-sentence so the first audio
|
|
1204
|
+
// plays while the rest still synthesizes — far lower time-to-first-
|
|
1205
|
+
// sound than waiting for one TTS pass over the whole reply.
|
|
1206
|
+
const sentences = splitSentences(finalText);
|
|
1207
|
+
let spokeAny = false;
|
|
1208
|
+
for (const sentence of sentences) {
|
|
1209
|
+
const clip = await synthSentenceMp3(sentence);
|
|
1210
|
+
if (clip) { spokeAny = true; await sendVoice(clip); }
|
|
1211
|
+
}
|
|
1212
|
+
if (!spokeAny) {
|
|
1213
|
+
const voicePath = await textToVoice(finalText);
|
|
1214
|
+
if (voicePath) await sendVoice(voicePath);
|
|
1215
|
+
}
|
|
1216
|
+
await sendVoiceEnd();
|
|
1205
1217
|
}
|
|
1206
1218
|
}
|
|
1207
1219
|
} catch (e) {
|
package/package.json
CHANGED