@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.
@@ -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, oggPath) {
343
+ async sendVoice(channelId, audioPath) {
334
344
  try {
335
- const id = this._registerMedia(oggPath, "audio/ogg", path.basename(oggPath));
336
- this._broadcast({ kind: "voice", messageId: this._mkId("v"), url: `/v1/media/${id}`, mime: "audio/ogg", ts: Date.now() });
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
- module.exports = { transcribeAudio, textToVoice, TTS_CMD };
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
- const voicePath = await textToVoice(finalText);
1204
- if (voicePath) await sendVoice(voicePath);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.47",
3
+ "version": "2.6.48",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {