@phuetz/code-buddy 1.4.0 โ†’ 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 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.4.0-GA-blueviolet?style=flat-square" alt="Version 1.4.0 GA"/>
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/>
@@ -120,13 +120,15 @@ An open-source, multi-provider AI coding agent with a terminal UI, an HTTP/WebSo
120
120
  </tr>
121
121
  </table>
122
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
+
123
125
  More desktop demos (Fleet, Autonomy, Companion, โ€ฆ) and captures: [`cowork/readme.md`](cowork/readme.md#demo) ยท [`docs/screenshots/`](docs/screenshots/README.md).
124
126
 
125
127
  ---
126
128
 
127
129
  ## What's shipped
128
130
 
129
- **1.4.0 GA โ€” these aren't roadmap items.** The captures above are unedited, and the core runs today:
131
+ **1.5.0 GA โ€” these aren't roadmap items.** The captures above are unedited, and the core runs today:
130
132
 
131
133
  - โœ… **`$0` local coding agent** โ€” a local Ollama model reasons on screen, then calls tools to do real work. *(the demos above)*
132
134
  - โœ… **ChatGPT Plus/Pro โ†’ `gpt-5.5` at `$0`** โ€” `buddy login`, flat-fee, no API key, no per-token metering.
@@ -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
  */
@@ -400,12 +400,25 @@ export async function registerAIMessageHandler(manager) {
400
400
  const entries = await agent.processUserMessage(message.content);
401
401
  const lastEntry = entries[entries.length - 1];
402
402
  const response = lastEntry ? String(lastEntry.content) : '';
403
- // 6. Deliver reply
403
+ // 6. Deliver reply (text)
404
404
  await channel.send({
405
405
  channelId: message.channel.id,
406
406
  content: response,
407
407
  replyTo: message.id,
408
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
+ }
409
422
  }
410
423
  catch (err) {
411
424
  logger.error('Channel AI response failed', { error: err instanceof Error ? err.message : String(err) });
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phuetz/code-buddy",
3
- "version": "1.4.0",
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": {