@phuetz/code-buddy 1.3.2 → 1.5.0
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/README.md +23 -2
- package/dist/agent/execution/tool-selection-strategy.js +4 -1
- package/dist/channels/telegram/client.d.ts +15 -0
- package/dist/channels/telegram/client.js +76 -0
- package/dist/codebuddy/tools.js +15 -0
- package/dist/commands/handlers/channel-handlers.js +53 -23
- package/dist/input/text-to-speech.js +16 -1
- package/dist/voice/local-tts.d.ts +13 -0
- package/dist/voice/local-tts.js +103 -0
- package/dist/voice/local-whisper.d.ts +9 -0
- package/dist/voice/local-whisper.js +93 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<p align="center">
|
|
18
18
|
<a href="https://github.com/phuetz/code-buddy/stargazers"><img src="https://img.shields.io/github/stars/phuetz/code-buddy?style=flat-square&logo=github&color=feca57&label=Star" alt="GitHub stars"/></a>
|
|
19
19
|
<img src="https://img.shields.io/badge/Tests-27K%2B-00d26a?style=flat-square&logo=jest" alt="Tests"/>
|
|
20
|
-
<img src="https://img.shields.io/badge/v1.
|
|
20
|
+
<img src="https://img.shields.io/badge/v1.5.0-GA-blueviolet?style=flat-square" alt="Version 1.5.0 GA"/>
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
23
|
<br/>
|
|
@@ -101,13 +101,34 @@ An open-source, multi-provider AI coding agent with a terminal UI, an HTTP/WebSo
|
|
|
101
101
|
<img src="docs/screenshots/self-audit-bug-1.png" alt="Self-audit bug found" width="820"/>
|
|
102
102
|
</p>
|
|
103
103
|
|
|
104
|
+
**On your phone — chat with the same agent over Telegram.** Code Buddy runs as a messaging-channel bot, so the agent you use in the terminal is reachable from your pocket. Real, unedited captures (the bot is named *"Lisa"* here). The system prompt and tools **scale to each question** — light and instant for plain chat, escalating to load tools only when the request needs them (the same on-demand pattern as Codex / Claude):
|
|
105
|
+
|
|
106
|
+
<table>
|
|
107
|
+
<tr>
|
|
108
|
+
<td width="33%" align="center" valign="top">
|
|
109
|
+
<img src="docs/screenshots/telegram-companion-chat.jpg" alt="Telegram chat: instant greeting, the time, and tomorrow's live weather in Paris via web search" width="250"/><br/>
|
|
110
|
+
<sub><b>Chat + live tools, on demand</b><br/>"Bonjour" answers instantly; <i>"what time is it?"</i> and <i>"tomorrow's weather in Paris?"</i> pull the time and <b><code>web_search</code></b> tools — only when actually asked.</sub>
|
|
111
|
+
</td>
|
|
112
|
+
<td width="33%" align="center" valign="top">
|
|
113
|
+
<img src="docs/screenshots/telegram-companion-selfcode.jpg" alt="Telegram chat: the agent confirms it can read and inspect its own source code via view_file" width="250"/><br/>
|
|
114
|
+
<sub><b>Reads its own code</b><br/>Confirms it can inspect its own source (or any accessible file) via <code>view_file</code> — then introduces its recursive self-improvement →</sub>
|
|
115
|
+
</td>
|
|
116
|
+
<td width="33%" align="center" valign="top">
|
|
117
|
+
<img src="docs/screenshots/telegram-companion-recursive.jpg" alt="Telegram chat: the agent explains its recursive self-improvement — Manus-inspired lessons in RULE / PATTERN / CONTEXT categories, stored in .codebuddy/lessons.md" width="250"/><br/>
|
|
118
|
+
<sub><b>Improves itself across sessions</b><br/>The <code>lessons_*</code> loop (Manus-inspired): after each fix or success it extracts <b>RULE / PATTERN / CONTEXT</b> lessons, persisted to <code>.codebuddy/lessons.md</code> (project + global). <i>Accurate — matches its real source.</i></sub>
|
|
119
|
+
</td>
|
|
120
|
+
</tr>
|
|
121
|
+
</table>
|
|
122
|
+
|
|
123
|
+
🎙️ **And you can _talk_ to it.** Send a voice note and it replies by voice — speech-to-text (faster-whisper) and text-to-speech (Piper) both run **locally, `$0`**, mirroring your modality (voice in → voice out). Needs the local voice engines installed; it transparently degrades to a text reply otherwise.
|
|
124
|
+
|
|
104
125
|
More desktop demos (Fleet, Autonomy, Companion, …) and captures: [`cowork/readme.md`](cowork/readme.md#demo) · [`docs/screenshots/`](docs/screenshots/README.md).
|
|
105
126
|
|
|
106
127
|
---
|
|
107
128
|
|
|
108
129
|
## What's shipped
|
|
109
130
|
|
|
110
|
-
**1.
|
|
131
|
+
**1.5.0 GA — these aren't roadmap items.** The captures above are unedited, and the core runs today:
|
|
111
132
|
|
|
112
133
|
- ✅ **`$0` local coding agent** — a local Ollama model reasons on screen, then calls tools to do real work. *(the demos above)*
|
|
113
134
|
- ✅ **ChatGPT Plus/Pro → `gpt-5.5` at `$0`** — `buddy login`, flat-fee, no API key, no per-token metering.
|
|
@@ -39,7 +39,10 @@ const DEFAULT_CONFIG = {
|
|
|
39
39
|
// "agent proposes, human approves" path, so the model must always see it (a
|
|
40
40
|
// RAG-gated propose tool would rarely surface). `lessons_list` stays out
|
|
41
41
|
// (admin-style, not needed per-turn). Wakes the dormant feature in lessons-tracker.ts.
|
|
42
|
-
|
|
42
|
+
// `tool_search` is ALWAYS exposed so the model can discover & pull ANY tool
|
|
43
|
+
// on demand when the per-query TF-IDF subset missed it (progressive disclosure,
|
|
44
|
+
// like Codex/Claude). Without it, a tool outside the top-K is unreachable.
|
|
45
|
+
alwaysInclude: ['view_file', 'bash', 'search', 'str_replace_editor', 'web_search', 'remember', 'memory_propose', 'lessons_add', 'lessons_propose', 'lessons_search', 'tool_search'],
|
|
43
46
|
useAdaptiveThreshold: true,
|
|
44
47
|
enableCaching: true,
|
|
45
48
|
cacheTTLMs: 5 * 60 * 1000, // 5 minutes
|
|
@@ -137,6 +137,21 @@ export declare class TelegramChannel extends BaseChannel {
|
|
|
137
137
|
* Build inline keyboard from buttons
|
|
138
138
|
*/
|
|
139
139
|
private buildKeyboard;
|
|
140
|
+
/**
|
|
141
|
+
* Voice note → text. A Telegram voice/audio message arrives with no text but a
|
|
142
|
+
* voice attachment (file_id). Download it and transcribe it locally with
|
|
143
|
+
* faster-whisper (offline, $0) so the agent receives a normal text message —
|
|
144
|
+
* this is what lets you *talk* to the bot. No-op (and never throws) when there
|
|
145
|
+
* is already text, no audio attachment, or local Whisper isn't installed.
|
|
146
|
+
*/
|
|
147
|
+
private maybeTranscribeVoice;
|
|
148
|
+
/**
|
|
149
|
+
* Answer by VOICE: synthesize `text` locally (Piper → OGG/Opus) and send it as
|
|
150
|
+
* a Telegram voice note via multipart upload. Used to mirror the user's
|
|
151
|
+
* modality — when they send a voice note, the bot can reply with one too.
|
|
152
|
+
* Throws on failure so the caller keeps the text reply as the fallback.
|
|
153
|
+
*/
|
|
154
|
+
sendVoiceReply(channelId: string, text: string): Promise<void>;
|
|
140
155
|
/**
|
|
141
156
|
* Get file download URL
|
|
142
157
|
*/
|
|
@@ -401,6 +401,9 @@ export class TelegramChannel extends BaseChannel {
|
|
|
401
401
|
return;
|
|
402
402
|
}
|
|
403
403
|
const message = this.convertMessage(msg);
|
|
404
|
+
// Voice note → text: transcribe locally (faster-whisper, $0, offline) so you
|
|
405
|
+
// can TALK to the bot — the agent then sees a normal text message.
|
|
406
|
+
await this.maybeTranscribeVoice(message);
|
|
404
407
|
const parsed = this.parseCommand(message);
|
|
405
408
|
// Attach session key for session isolation
|
|
406
409
|
parsed.sessionKey = getSessionKey(parsed);
|
|
@@ -753,6 +756,79 @@ export class TelegramChannel extends BaseChannel {
|
|
|
753
756
|
}
|
|
754
757
|
return keyboard;
|
|
755
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Voice note → text. A Telegram voice/audio message arrives with no text but a
|
|
761
|
+
* voice attachment (file_id). Download it and transcribe it locally with
|
|
762
|
+
* faster-whisper (offline, $0) so the agent receives a normal text message —
|
|
763
|
+
* this is what lets you *talk* to the bot. No-op (and never throws) when there
|
|
764
|
+
* is already text, no audio attachment, or local Whisper isn't installed.
|
|
765
|
+
*/
|
|
766
|
+
async maybeTranscribeVoice(message) {
|
|
767
|
+
if (message.content && message.content.trim())
|
|
768
|
+
return;
|
|
769
|
+
const audio = message.attachments?.find((a) => a.type === 'voice' || a.type === 'audio');
|
|
770
|
+
if (!audio?.url)
|
|
771
|
+
return;
|
|
772
|
+
try {
|
|
773
|
+
const { localWhisperAvailable, transcribeFile } = await import('../../voice/local-whisper.js');
|
|
774
|
+
if (!localWhisperAvailable()) {
|
|
775
|
+
logger.warn('[telegram] voice note received but local Whisper is unavailable — install the ai-stack voice venv (faster-whisper) to enable speech');
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const fileUrl = await this.getFileUrl(audio.url);
|
|
779
|
+
const res = await fetch(fileUrl);
|
|
780
|
+
if (!res.ok)
|
|
781
|
+
throw new Error(`download HTTP ${res.status}`);
|
|
782
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
783
|
+
const os = await import('node:os');
|
|
784
|
+
const path = await import('node:path');
|
|
785
|
+
const fs = await import('node:fs/promises');
|
|
786
|
+
const tmp = path.join(os.tmpdir(), `cb-tg-voice-${message.id}.ogg`);
|
|
787
|
+
await fs.writeFile(tmp, bytes);
|
|
788
|
+
try {
|
|
789
|
+
const text = await transcribeFile(tmp, { language: process.env.CODEBUDDY_VOICE_LANG || 'fr' });
|
|
790
|
+
if (text && text.trim()) {
|
|
791
|
+
message.content = text.trim();
|
|
792
|
+
message.contentType = 'text';
|
|
793
|
+
logger.info(`[telegram] voice note transcribed → "${message.content.slice(0, 60)}…"`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
finally {
|
|
797
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
logger.warn(`[telegram] voice transcription failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Answer by VOICE: synthesize `text` locally (Piper → OGG/Opus) and send it as
|
|
806
|
+
* a Telegram voice note via multipart upload. Used to mirror the user's
|
|
807
|
+
* modality — when they send a voice note, the bot can reply with one too.
|
|
808
|
+
* Throws on failure so the caller keeps the text reply as the fallback.
|
|
809
|
+
*/
|
|
810
|
+
async sendVoiceReply(channelId, text) {
|
|
811
|
+
const { localTtsAvailable, synthesizeToOgg } = await import('../../voice/local-tts.js');
|
|
812
|
+
if (!localTtsAvailable())
|
|
813
|
+
throw new Error('local TTS (Piper) unavailable');
|
|
814
|
+
// Cap spoken length — a short reply is fine, avoid minutes of audio.
|
|
815
|
+
const spoken = text.length > 800 ? `${text.slice(0, 800)}…` : text;
|
|
816
|
+
const ogg = await synthesizeToOgg(spoken);
|
|
817
|
+
const fs = await import('node:fs/promises');
|
|
818
|
+
try {
|
|
819
|
+
const bytes = await fs.readFile(ogg);
|
|
820
|
+
const form = new FormData();
|
|
821
|
+
form.append('chat_id', channelId);
|
|
822
|
+
form.append('voice', new Blob([bytes], { type: 'audio/ogg' }), 'voice.ogg');
|
|
823
|
+
const url = `${TELEGRAM_API_BASE}/bot${this.telegramConfig.token}/sendVoice`;
|
|
824
|
+
const res = await fetch(url, { method: 'POST', body: form });
|
|
825
|
+
if (!res.ok)
|
|
826
|
+
throw new Error(`sendVoice HTTP ${res.status}`);
|
|
827
|
+
}
|
|
828
|
+
finally {
|
|
829
|
+
await fs.unlink(ogg).catch(() => undefined);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
756
832
|
/**
|
|
757
833
|
* Get file download URL
|
|
758
834
|
*/
|
package/dist/codebuddy/tools.js
CHANGED
|
@@ -399,6 +399,21 @@ export async function getAllCodeBuddyTools() {
|
|
|
399
399
|
? { ...t, function: { ...t.function, description: (t.function.description ?? '') + DEFER } }
|
|
400
400
|
: t);
|
|
401
401
|
}
|
|
402
|
+
// Populate the `tool_search` BM25 index so the model can DISCOVER any tool on
|
|
403
|
+
// demand (progressive disclosure, like Codex/Claude). It was never
|
|
404
|
+
// initialized, leaving tool_search blind ("No tools found"). Index the full
|
|
405
|
+
// set (built-in + MCP + plugin) so a tool outside the per-query TF-IDF subset
|
|
406
|
+
// is still reachable when the model calls tool_search.
|
|
407
|
+
try {
|
|
408
|
+
const { initToolSearchIndex } = await import('../tools/tool-search.js');
|
|
409
|
+
initToolSearchIndex(allTools.map((t) => ({
|
|
410
|
+
name: t.function.name,
|
|
411
|
+
description: t.function.description ?? '',
|
|
412
|
+
})));
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
// tool-search is optional — never block tool assembly.
|
|
416
|
+
}
|
|
402
417
|
return allTools;
|
|
403
418
|
}
|
|
404
419
|
// ============================================================================
|
|
@@ -327,13 +327,8 @@ export async function registerAIMessageHandler(manager) {
|
|
|
327
327
|
aiHandlerRegistered = true;
|
|
328
328
|
manager.onMessage(async (message, channel) => {
|
|
329
329
|
try {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
logger.warn('No API key for channel AI responses');
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
const { checkDMPairing, getDMPairing, getRouteAgentConfig } = await import('../../channels/core.js');
|
|
336
|
-
// 1. Check DM pairing first
|
|
330
|
+
// 1. DM pairing gate — unapproved senders get a code, then we stop.
|
|
331
|
+
const { checkDMPairing, getDMPairing } = await import('../../channels/core.js');
|
|
337
332
|
const pairingStatus = await checkDMPairing(message);
|
|
338
333
|
if (!pairingStatus.approved) {
|
|
339
334
|
if (pairingStatus.code) {
|
|
@@ -347,14 +342,35 @@ export async function registerAIMessageHandler(manager) {
|
|
|
347
342
|
}
|
|
348
343
|
return;
|
|
349
344
|
}
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
345
|
+
// Nothing to answer (e.g. a non-text message with no transcription).
|
|
346
|
+
if (!message.content || !message.content.trim()) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
// 2. Context-adaptive agent reply (« comme Claude »): the agent's own
|
|
350
|
+
// query-classifier + buildForQuery scale the system prompt to the
|
|
351
|
+
// request (a greeting → minimal ~800B prompt, NOT the 73KB legacy),
|
|
352
|
+
// and tools load on demand — RAG selects only the relevant ~15 and the
|
|
353
|
+
// `tool_search` meta-tool pulls more when actually needed. Bounded
|
|
354
|
+
// rounds keep a simple chat fast while a real task can still act.
|
|
355
|
+
const { resolveProviderFromEnv } = await import('../../fleet/peer-chat-client-factory.js');
|
|
356
|
+
const knownProviders = ['ollama', 'chatgpt', 'gemini', 'grok', 'anthropic'];
|
|
357
|
+
const preferredProvider = process.env.CODEBUDDY_PROVIDER && knownProviders.includes(process.env.CODEBUDDY_PROVIDER)
|
|
358
|
+
? process.env.CODEBUDDY_PROVIDER
|
|
359
|
+
: 'auto';
|
|
360
|
+
const resolved = resolveProviderFromEnv(preferredProvider);
|
|
361
|
+
if (!resolved) {
|
|
362
|
+
logger.warn('No LLM provider for channel chat — set CODEBUDDY_PROVIDER + a provider key/env');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const { getRouteAgentConfig } = await import('../../channels/core.js');
|
|
353
366
|
const { CodeBuddyAgent } = await import('../../agent/codebuddy-agent.js');
|
|
354
|
-
const
|
|
355
|
-
const
|
|
356
|
-
const agent = new CodeBuddyAgent(apiKey,
|
|
357
|
-
//
|
|
367
|
+
const agentConfig = getRouteAgentConfig(message);
|
|
368
|
+
const model = agentConfig.model || resolved.model;
|
|
369
|
+
const agent = new CodeBuddyAgent(resolved.apiKey || 'local', resolved.baseUrl, model, agentConfig.maxToolRounds ?? 6, // bounded (vs the 50-round default)
|
|
370
|
+
true, // useRAGToolSelection — relevant tools on demand, not all ~194
|
|
371
|
+
process.env.CODEBUDDY_CHANNEL_PROMPT_ID || 'auto', // minimal/adaptive prompt, not the 73KB legacy
|
|
372
|
+
process.cwd());
|
|
373
|
+
// Multi-turn: restore prior session history into the agent.
|
|
358
374
|
const sessionKey = message.sessionKey || 'default-global';
|
|
359
375
|
const sessionStore = agent.getSessionStore();
|
|
360
376
|
let session = await sessionStore.loadSession(sessionKey);
|
|
@@ -371,27 +387,38 @@ export async function registerAIMessageHandler(manager) {
|
|
|
371
387
|
await sessionStore.saveSession(session);
|
|
372
388
|
}
|
|
373
389
|
await sessionStore.resumeSession(sessionKey);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
const messages = activeSession.messages.map(m => ({
|
|
390
|
+
if (session.messages && session.messages.length > 0) {
|
|
391
|
+
const chatHistory = sessionStore.convertMessagesToChatEntries(session.messages);
|
|
392
|
+
const priorMessages = session.messages.map((m) => ({
|
|
378
393
|
role: m.type === 'user' ? 'user' : 'assistant',
|
|
379
|
-
content: m.content
|
|
394
|
+
content: m.content,
|
|
380
395
|
}));
|
|
381
396
|
const historyRestorer = agent;
|
|
382
397
|
historyRestorer.historyManager.setChatHistory(chatHistory);
|
|
383
|
-
historyRestorer.historyManager.setMessages(
|
|
398
|
+
historyRestorer.historyManager.setMessages(priorMessages);
|
|
384
399
|
}
|
|
385
|
-
// 5. Run agent turn
|
|
386
400
|
const entries = await agent.processUserMessage(message.content);
|
|
387
401
|
const lastEntry = entries[entries.length - 1];
|
|
388
402
|
const response = lastEntry ? String(lastEntry.content) : '';
|
|
389
|
-
// 6. Deliver reply
|
|
403
|
+
// 6. Deliver reply (text)
|
|
390
404
|
await channel.send({
|
|
391
405
|
channelId: message.channel.id,
|
|
392
406
|
content: response,
|
|
393
407
|
replyTo: message.id,
|
|
394
408
|
});
|
|
409
|
+
// 7. If the user SPOKE (voice note), answer by voice too — mirror the
|
|
410
|
+
// modality. Best-effort: the text reply already landed, so a TTS/upload
|
|
411
|
+
// failure (or a channel without voice support) is a no-op, never fatal.
|
|
412
|
+
const userSpoke = message.attachments?.some((a) => a.type === 'voice' || a.type === 'audio');
|
|
413
|
+
const voiceChannel = channel;
|
|
414
|
+
if (userSpoke && response.trim() && typeof voiceChannel.sendVoiceReply === 'function') {
|
|
415
|
+
try {
|
|
416
|
+
await voiceChannel.sendVoiceReply(message.channel.id, response);
|
|
417
|
+
}
|
|
418
|
+
catch (voiceErr) {
|
|
419
|
+
logger.warn(`Voice reply skipped: ${voiceErr instanceof Error ? voiceErr.message : String(voiceErr)}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
395
422
|
}
|
|
396
423
|
catch (err) {
|
|
397
424
|
logger.error('Channel AI response failed', { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -412,7 +439,10 @@ export async function instantiateChannel(config) {
|
|
|
412
439
|
switch (config.type) {
|
|
413
440
|
case 'telegram': {
|
|
414
441
|
const { TelegramChannel } = await import('../../channels/telegram/index.js');
|
|
415
|
-
|
|
442
|
+
// TelegramChannel reads `config.token` (client.ts) — pass `token`, not
|
|
443
|
+
// `botToken`, or it throws "Telegram bot token is required" and the
|
|
444
|
+
// channel never starts from channels.json / server intake.
|
|
445
|
+
return new TelegramChannel({ token: config.token || '', ...opts });
|
|
416
446
|
}
|
|
417
447
|
case 'discord': {
|
|
418
448
|
const { DiscordChannel } = await import('../../channels/discord/index.js');
|
|
@@ -272,8 +272,23 @@ export class TextToSpeechManager extends EventEmitter {
|
|
|
272
272
|
async speakWithPiper(text) {
|
|
273
273
|
return new Promise((resolve, reject) => {
|
|
274
274
|
const audioFile = path.join(this.tempDir, `tts_${Date.now()}.wav`);
|
|
275
|
+
// Resolve the Piper binary + voice model. Defaults to `piper` on PATH
|
|
276
|
+
// (no model), but honors the local-voice-stack convention shared with
|
|
277
|
+
// Cowork so a self-hosted Piper (e.g. ai-stack fr_FR-siwis-medium) works
|
|
278
|
+
// out of the box: COWORK_PIPER_BIN / COWORK_PIPER_VOICE (or the
|
|
279
|
+
// CODEBUDDY_* aliases, or a `.onnx` path set as the configured voice).
|
|
280
|
+
const piperBin = process.env.COWORK_PIPER_BIN || process.env.CODEBUDDY_PIPER_BIN || 'piper';
|
|
281
|
+
const voiceModel = process.env.COWORK_PIPER_VOICE ||
|
|
282
|
+
process.env.CODEBUDDY_PIPER_VOICE ||
|
|
283
|
+
(this.config.voice && this.config.voice.endsWith('.onnx')
|
|
284
|
+
? this.config.voice
|
|
285
|
+
: undefined);
|
|
286
|
+
const piperArgs = ['--output_file', audioFile];
|
|
287
|
+
if (voiceModel) {
|
|
288
|
+
piperArgs.push('--model', voiceModel);
|
|
289
|
+
}
|
|
275
290
|
// Piper reads from stdin and outputs to file
|
|
276
|
-
const piper = spawn(
|
|
291
|
+
const piper = spawn(piperBin, piperArgs);
|
|
277
292
|
piper.stdin?.write(text);
|
|
278
293
|
piper.stdin?.end();
|
|
279
294
|
piper.on('close', (code) => {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** True when a real Piper binary (not just the bare `piper` fallback) is resolvable. */
|
|
2
|
+
export declare function localTtsAvailable(): boolean;
|
|
3
|
+
export interface LocalTtsOptions {
|
|
4
|
+
/** ffmpeg binary (default: `ffmpeg` on PATH). */
|
|
5
|
+
ffmpeg?: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Synthesize `text` to an OGG/Opus file (Telegram voice-note format) and return
|
|
10
|
+
* its path. Caller is responsible for deleting the file. Throws if Piper or
|
|
11
|
+
* ffmpeg fail; callers should catch and fall back to a text-only reply.
|
|
12
|
+
*/
|
|
13
|
+
export declare function synthesizeToOgg(text: string, options?: LocalTtsOptions): Promise<string>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local text-to-speech → Telegram-ready voice note, fully offline / $0.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline: Piper (neural TTS) writes a WAV, then ffmpeg transcodes it to
|
|
5
|
+
* OGG/Opus (the format Telegram voice notes require). Returns the .ogg path.
|
|
6
|
+
*
|
|
7
|
+
* Resolution mirrors local-whisper.ts: explicit env wins, else the ai-stack
|
|
8
|
+
* install is auto-discovered, else we fall back to `piper` on PATH. Never
|
|
9
|
+
* throws on a missing engine — callers should treat a null return / rejection
|
|
10
|
+
* as "voice reply unavailable" and keep the text reply.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { unlink } from 'node:fs/promises';
|
|
15
|
+
import { homedir, tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
function resolvePiperBin() {
|
|
18
|
+
const candidates = [
|
|
19
|
+
process.env.COWORK_PIPER_BIN,
|
|
20
|
+
process.env.CODEBUDDY_PIPER_BIN,
|
|
21
|
+
join(homedir(), 'DEV/ai-stack/voice/piper/piper/piper'),
|
|
22
|
+
join(homedir(), 'ai-stack/voice/piper/piper/piper'),
|
|
23
|
+
].filter((c) => Boolean(c));
|
|
24
|
+
for (const c of candidates) {
|
|
25
|
+
if (existsSync(c))
|
|
26
|
+
return c;
|
|
27
|
+
}
|
|
28
|
+
return 'piper';
|
|
29
|
+
}
|
|
30
|
+
function resolvePiperVoice() {
|
|
31
|
+
const candidates = [
|
|
32
|
+
process.env.COWORK_PIPER_VOICE,
|
|
33
|
+
process.env.CODEBUDDY_PIPER_VOICE,
|
|
34
|
+
join(homedir(), 'DEV/ai-stack/voice/voices/fr_FR-siwis-medium.onnx'),
|
|
35
|
+
join(homedir(), 'ai-stack/voice/voices/fr_FR-siwis-medium.onnx'),
|
|
36
|
+
].filter((c) => Boolean(c));
|
|
37
|
+
for (const c of candidates) {
|
|
38
|
+
if (existsSync(c))
|
|
39
|
+
return c;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/** True when a real Piper binary (not just the bare `piper` fallback) is resolvable. */
|
|
44
|
+
export function localTtsAvailable() {
|
|
45
|
+
return (resolvePiperBin() !== 'piper' ||
|
|
46
|
+
Boolean(process.env.COWORK_PIPER_BIN) ||
|
|
47
|
+
Boolean(process.env.CODEBUDDY_PIPER_BIN));
|
|
48
|
+
}
|
|
49
|
+
function run(cmd, args, opts) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
52
|
+
let stderr = '';
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
child.kill('SIGKILL');
|
|
55
|
+
reject(new Error(`${cmd} timed out after ${opts.timeoutMs}ms`));
|
|
56
|
+
}, opts.timeoutMs);
|
|
57
|
+
child.stderr?.on('data', (d) => {
|
|
58
|
+
stderr += String(d);
|
|
59
|
+
});
|
|
60
|
+
child.on('error', (e) => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
reject(e);
|
|
63
|
+
});
|
|
64
|
+
child.on('close', (code) => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
if (code === 0)
|
|
67
|
+
resolve();
|
|
68
|
+
else
|
|
69
|
+
reject(new Error(`${cmd} exited ${code}: ${stderr.slice(-200)}`));
|
|
70
|
+
});
|
|
71
|
+
if (opts.stdin !== undefined) {
|
|
72
|
+
child.stdin?.write(opts.stdin);
|
|
73
|
+
child.stdin?.end();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Synthesize `text` to an OGG/Opus file (Telegram voice-note format) and return
|
|
79
|
+
* its path. Caller is responsible for deleting the file. Throws if Piper or
|
|
80
|
+
* ffmpeg fail; callers should catch and fall back to a text-only reply.
|
|
81
|
+
*/
|
|
82
|
+
export async function synthesizeToOgg(text, options = {}) {
|
|
83
|
+
const bin = resolvePiperBin();
|
|
84
|
+
const voice = resolvePiperVoice();
|
|
85
|
+
const ffmpeg = options.ffmpeg || 'ffmpeg';
|
|
86
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
87
|
+
const stamp = `${process.pid}-${Date.now()}`;
|
|
88
|
+
const wav = join(tmpdir(), `cb-tts-${stamp}.wav`);
|
|
89
|
+
const ogg = join(tmpdir(), `cb-tts-${stamp}.ogg`);
|
|
90
|
+
const piperArgs = ['--output_file', wav];
|
|
91
|
+
if (voice)
|
|
92
|
+
piperArgs.push('--model', voice);
|
|
93
|
+
try {
|
|
94
|
+
await run(bin, piperArgs, { stdin: text, timeoutMs });
|
|
95
|
+
// Telegram voice notes want OGG/Opus mono. 32 kbps is plenty for speech.
|
|
96
|
+
await run(ffmpeg, ['-y', '-loglevel', 'error', '-i', wav, '-ac', '1', '-c:a', 'libopus', '-b:a', '32k', ogg], { timeoutMs });
|
|
97
|
+
return ogg;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await unlink(wav).catch(() => undefined);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=local-tts.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface LocalWhisperOptions {
|
|
2
|
+
language?: string;
|
|
3
|
+
model?: string;
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
}
|
|
6
|
+
/** Transcribe an audio file (wav/ogg/mp3/webm…) to text via local faster-whisper. */
|
|
7
|
+
export declare function transcribeFile(audioPath: string, options?: LocalWhisperOptions): Promise<string>;
|
|
8
|
+
/** True if a local faster-whisper interpreter looks available. */
|
|
9
|
+
export declare function localWhisperAvailable(): boolean;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local speech-to-text via faster-whisper ($0, offline).
|
|
3
|
+
*
|
|
4
|
+
* Wraps a self-hosted faster-whisper venv (the same local voice stack Cowork
|
|
5
|
+
* uses — e.g. ~/DEV/ai-stack/voice/.venv) as a reusable `audioFile -> text`
|
|
6
|
+
* helper. One-shot per call (model loads in ~1-2s on int8 CPU), which is fine
|
|
7
|
+
* for occasional clips (Telegram voice notes, a CLI turn). No cloud, no key.
|
|
8
|
+
*
|
|
9
|
+
* Engine selection (first existing wins):
|
|
10
|
+
* CODEBUDDY_VOICE_PYTHON | COWORK_VOICE_PYTHON (explicit interpreter)
|
|
11
|
+
* ~/.codebuddy/voice/.venv/bin/python
|
|
12
|
+
* ~/DEV/ai-stack/voice/.venv/bin/python
|
|
13
|
+
* ~/ai-stack/voice/.venv/bin/python
|
|
14
|
+
* python3 (PATH fallback)
|
|
15
|
+
* Model: CODEBUDDY_WHISPER_MODEL | COWORK_WHISPER_MODEL | "base".
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
function resolvePython() {
|
|
22
|
+
const candidates = [
|
|
23
|
+
process.env.CODEBUDDY_VOICE_PYTHON,
|
|
24
|
+
process.env.COWORK_VOICE_PYTHON,
|
|
25
|
+
join(homedir(), '.codebuddy/voice/.venv/bin/python'),
|
|
26
|
+
join(homedir(), 'DEV/ai-stack/voice/.venv/bin/python'),
|
|
27
|
+
join(homedir(), 'ai-stack/voice/.venv/bin/python'),
|
|
28
|
+
].filter((c) => Boolean(c));
|
|
29
|
+
for (const c of candidates) {
|
|
30
|
+
if (existsSync(c))
|
|
31
|
+
return c;
|
|
32
|
+
}
|
|
33
|
+
return 'python3';
|
|
34
|
+
}
|
|
35
|
+
const WHISPER_SCRIPT = `
|
|
36
|
+
import sys, json
|
|
37
|
+
from faster_whisper import WhisperModel
|
|
38
|
+
audio, model, lang = sys.argv[1], sys.argv[2], (sys.argv[3] or None)
|
|
39
|
+
m = WhisperModel(model, device="cpu", compute_type="int8")
|
|
40
|
+
segs, _info = m.transcribe(audio, language=lang, vad_filter=True, beam_size=1)
|
|
41
|
+
print(json.dumps({"text": " ".join(s.text.strip() for s in segs).strip()}))
|
|
42
|
+
`;
|
|
43
|
+
/** Transcribe an audio file (wav/ogg/mp3/webm…) to text via local faster-whisper. */
|
|
44
|
+
export async function transcribeFile(audioPath, options = {}) {
|
|
45
|
+
const language = options.language ?? 'fr';
|
|
46
|
+
const model = options.model ||
|
|
47
|
+
process.env.CODEBUDDY_WHISPER_MODEL ||
|
|
48
|
+
process.env.COWORK_WHISPER_MODEL ||
|
|
49
|
+
'base';
|
|
50
|
+
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
51
|
+
if (!existsSync(audioPath)) {
|
|
52
|
+
throw new Error(`local-whisper: audio file not found: ${audioPath}`);
|
|
53
|
+
}
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const py = resolvePython();
|
|
56
|
+
const proc = spawn(py, ['-c', WHISPER_SCRIPT, audioPath, model, language], {
|
|
57
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
58
|
+
});
|
|
59
|
+
let stdout = '';
|
|
60
|
+
let stderr = '';
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
proc.kill('SIGKILL');
|
|
63
|
+
reject(new Error(`local-whisper: timed out after ${timeoutMs}ms`));
|
|
64
|
+
}, timeoutMs);
|
|
65
|
+
proc.stdout.on('data', (d) => (stdout += d.toString()));
|
|
66
|
+
proc.stderr.on('data', (d) => (stderr += d.toString()));
|
|
67
|
+
proc.on('error', (e) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
reject(new Error(`local-whisper: failed to spawn ${py}: ${e.message}`));
|
|
70
|
+
});
|
|
71
|
+
proc.on('close', (code) => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
if (code !== 0) {
|
|
74
|
+
reject(new Error(`local-whisper: exited ${code}: ${stderr.trim().slice(-300)}`));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}';
|
|
79
|
+
const parsed = JSON.parse(line);
|
|
80
|
+
resolve((parsed.text || '').trim());
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
reject(new Error(`local-whisper: bad output: ${String(e)} :: ${stdout.slice(-200)}`));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** True if a local faster-whisper interpreter looks available. */
|
|
89
|
+
export function localWhisperAvailable() {
|
|
90
|
+
const py = resolvePython();
|
|
91
|
+
return py !== 'python3' || Boolean(process.env.CODEBUDDY_VOICE_PYTHON);
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=local-whisper.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phuetz/code-buddy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Open-source multi-provider AI coding agent for the terminal, desktop, and HTTP. 15 LLM providers (Grok, Claude, ChatGPT, Gemini, Ollama, LM Studio, …) with ~110 tools, a peer-to-peer fleet, opt-in self-improvement, multi-channel messaging, and a skills system.",
|
|
5
5
|
"author": "Patrice Huetz <patrice.huetz@gmail.com>",
|
|
6
6
|
"repository": {
|