@gonzih/cc-discord 0.2.37 → 0.2.39

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/dist/bot.js CHANGED
@@ -911,8 +911,15 @@ export class CcDiscordBot {
911
911
  this.lastActiveChannelId = channelId;
912
912
  switch (interaction.commandName) {
913
913
  case "reset": {
914
+ // Kill local DM session if any
914
915
  this.killSession(channelId);
915
- await interaction.reply("Session reset. Send a message to start.");
916
+ // Kill persistent meta-agent session if this is a routed channel.
917
+ // Unlike /clear, we do NOT delete the JSONL — context is preserved.
918
+ // Next message will respawn with --continue, picking up the same history.
919
+ const resetNs = this.resolveNamespaceForChannel(channelId);
920
+ this.metaAgentManager.killSession(resetNs);
921
+ await interaction.reply(`Session process killed for \`${resetNs}\`. Next message respawns with existing context intact.\n` +
922
+ `Use \`/clear\` to also wipe conversation history.`);
916
923
  break;
917
924
  }
918
925
  case "costs": {
@@ -4,12 +4,13 @@
4
4
  * Flow per namespace:
5
5
  * 1. ensureWorkspace: git clone repo to ~/cc-discord-workspace/{ns}
6
6
  * 2. injectMcp: write .mcp.json so the claude subprocess has MCP tool access
7
- * 3. ensureSession: add message to per-namespace queue, start processQueue
8
- * 4. processQueue: spawn `claude --continue -p` (stdin+EOF) per message, one at a time
9
- * 5. Context is maintained via --continue (JSONL session file in workspace)
7
+ * 3. ensureSession: spawn one persistent `claude --continue` process per namespace
8
+ * (no -p flag messages go via stdin)
9
+ * 4. On new message: write "${message}\n" to stdin of the running process
10
+ * 5. On process exit: remove from sessions map; next message respawns
10
11
  *
11
- * Spawn-per-message with --continue maintains full conversation history.
12
- * Messages queue up and process sequentially per namespace.
12
+ * The polling loop now just drains any queued Redis messages into stdin.
13
+ * No more per-message process spawn Claude receives messages in sequence.
13
14
  */
14
15
  import type { createCcWire } from "@gonzih/cc-wire";
15
16
  type Wire = ReturnType<typeof createCcWire>;
@@ -43,9 +44,12 @@ export declare function injectMcp(ns: string, wsPath: string, token: string): vo
43
44
  export declare const metaStreamChannel: (ns: string) => string;
44
45
  export declare const metaLogKey: (ns: string) => string;
45
46
  /**
46
- * Spawn `claude --continue -p` with message written to stdin, then close stdin (EOF).
47
- * Claude responds and exits. Context is maintained via --continue (JSONL session file).
47
+ * Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
48
+ * namespace workspace. Pipes stdout line-by-line wire.discord.publishOutgoing.
49
+ * Also streams each chunk to Redis: PUBLISH cca:meta:{ns}:stream and LPUSH cca:meta:{ns}:log.
48
50
  * Returns a Promise that resolves when the process exits.
51
+ *
52
+ * Kept for backwards-compat and direct integration-test usage.
49
53
  */
50
54
  export declare function spawnSession(ns: string, message: string, token: string, wire: Wire): Promise<void>;
51
55
  export interface MetaAgentManager {
@@ -56,20 +60,25 @@ export interface MetaAgentManager {
56
60
  repoUrl: string;
57
61
  }>, instanceId?: string) => void;
58
62
  stop: () => void;
59
- /** Clear the message queue for a namespace (e.g. on /clear). */
63
+ /** Kill and remove the persistent session for a namespace (e.g. on /clear). */
60
64
  killSession: (ns: string) => void;
61
- /** Queue a message for a namespace. */
65
+ /** Write a raw line to the stdin of the running session, if any. */
62
66
  sendToSession: (ns: string, line: string) => void;
63
67
  }
64
68
  /**
65
- * Create a MetaAgentManager that processes Discord messages via spawn-per-message.
69
+ * Create a MetaAgentManager that maintains one persistent Claude process per namespace.
70
+ *
71
+ * On first message for a namespace:
72
+ * 1. ensureWorkspace + injectMcp
73
+ * 2. Drain any pending Redis queue entries to stdin
74
+ * 3. Write the new message to stdin
75
+ *
76
+ * On subsequent messages: write directly to stdin of the running process.
66
77
  *
67
- * Each message spawns `claude --continue -p` with the message written to stdin,
68
- * then stdin is closed (EOF) so Claude processes and exits. --continue maintains
69
- * conversation history via the JSONL session file in the workspace.
78
+ * On process exit: remove from sessions map. Next message triggers a respawn.
70
79
  *
71
- * Messages queue per namespace and process sequentially no concurrent spawns
72
- * for the same namespace.
80
+ * The 3-second poll loop drains any messages that arrived while a session was
81
+ * starting up or temporarily unavailable.
73
82
  */
74
83
  export declare function createMetaAgentManager(): MetaAgentManager;
75
84
  /**
@@ -4,12 +4,13 @@
4
4
  * Flow per namespace:
5
5
  * 1. ensureWorkspace: git clone repo to ~/cc-discord-workspace/{ns}
6
6
  * 2. injectMcp: write .mcp.json so the claude subprocess has MCP tool access
7
- * 3. ensureSession: add message to per-namespace queue, start processQueue
8
- * 4. processQueue: spawn `claude --continue -p` (stdin+EOF) per message, one at a time
9
- * 5. Context is maintained via --continue (JSONL session file in workspace)
7
+ * 3. ensureSession: spawn one persistent `claude --continue` process per namespace
8
+ * (no -p flag messages go via stdin)
9
+ * 4. On new message: write "${message}\n" to stdin of the running process
10
+ * 5. On process exit: remove from sessions map; next message respawns
10
11
  *
11
- * Spawn-per-message with --continue maintains full conversation history.
12
- * Messages queue up and process sequentially per namespace.
12
+ * The polling loop now just drains any queued Redis messages into stdin.
13
+ * No more per-message process spawn Claude receives messages in sequence.
13
14
  */
14
15
  import { spawn, execSync } from "child_process";
15
16
  import { existsSync, mkdirSync, writeFileSync } from "fs";
@@ -211,7 +212,19 @@ function wireStdoutToRedis(proc, ns, wire) {
211
212
  result: resultText,
212
213
  is_error: parsed.is_error ?? false,
213
214
  };
214
- // Do not re-publish result text — assistant event already published it
215
+ if (resultText) {
216
+ const msg = {
217
+ id: crypto.randomUUID(),
218
+ source: "claude",
219
+ role: "assistant",
220
+ content: resultText,
221
+ timestamp: new Date().toISOString(),
222
+ chatId: 0,
223
+ };
224
+ wire.discord.publishOutgoing(ns, msg).catch((err) => {
225
+ console.warn(`[meta-agent-manager] publishOutgoing (result) failed (ns=${ns}):`, err.message);
226
+ });
227
+ }
215
228
  }
216
229
  else {
217
230
  structuredEvent = parsed;
@@ -244,9 +257,12 @@ function wireStdoutToRedis(proc, ns, wire) {
244
257
  });
245
258
  }
246
259
  /**
247
- * Spawn `claude --continue -p` with message written to stdin, then close stdin (EOF).
248
- * Claude responds and exits. Context is maintained via --continue (JSONL session file).
260
+ * Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
261
+ * namespace workspace. Pipes stdout line-by-line wire.discord.publishOutgoing.
262
+ * Also streams each chunk to Redis: PUBLISH cca:meta:{ns}:stream and LPUSH cca:meta:{ns}:log.
249
263
  * Returns a Promise that resolves when the process exits.
264
+ *
265
+ * Kept for backwards-compat and direct integration-test usage.
250
266
  */
251
267
  export function spawnSession(ns, message, token, wire) {
252
268
  return new Promise((resolve, reject) => {
@@ -255,15 +271,11 @@ export function spawnSession(ns, message, token, wire) {
255
271
  const env = buildEnv(token);
256
272
  const proc = spawn(claudeBin, [
257
273
  "--continue",
258
- "-p",
274
+ "-p", message,
259
275
  "--output-format", "stream-json",
260
276
  "--verbose",
261
277
  "--dangerously-skip-permissions",
262
- ], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
263
- // Write message to stdin, then close (EOF triggers Claude to process and respond)
264
- proc.stdin.write(message + "\n", () => {
265
- proc.stdin.end();
266
- });
278
+ ], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
267
279
  wireStdoutToRedis(proc, ns, wire);
268
280
  proc.on("exit", (code) => {
269
281
  console.log(`[meta-agent-manager] session exited (ns=${ns}, code=${code})`);
@@ -276,75 +288,149 @@ export function spawnSession(ns, message, token, wire) {
276
288
  });
277
289
  }
278
290
  /**
279
- * Create a MetaAgentManager that processes Discord messages via spawn-per-message.
291
+ * Spawn a persistent `claude --continue` process for a namespace.
292
+ * No -p flag — messages are delivered via stdin.
293
+ * Stdout is wired to Redis via wireStdoutToRedis.
294
+ * Returns the ChildProcess.
295
+ */
296
+ function spawnPersistentSession(ns, token, wire, onExit) {
297
+ const wsPath = workspacePath(ns);
298
+ const claudeBin = resolveClaude();
299
+ const env = buildEnv(token);
300
+ console.log(`[meta-agent-manager] spawning persistent session (ns=${ns})`);
301
+ // DO NOT ADD -p OR --input-format stream-json HERE.
302
+ // With -p + --input-format stream-json, Claude buffers all stdin until EOF before responding.
303
+ // Since the stdin pipe is never closed (it stays open for subsequent messages), Claude
304
+ // waits forever and never produces output. Interactive mode (no -p) reads one line at a
305
+ // time and responds immediately. Verified by direct test: `echo msg | claude --continue
306
+ // --output-format stream-json ...` responds correctly; the -p variant does not.
307
+ const proc = spawn(claudeBin, [
308
+ "--continue",
309
+ "--output-format", "stream-json",
310
+ "--verbose",
311
+ "--dangerously-skip-permissions",
312
+ ], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
313
+ proc.stdin.setDefaultEncoding("utf8");
314
+ wireStdoutToRedis(proc, ns, wire);
315
+ proc.on("exit", (code) => {
316
+ console.log(`[meta-agent-manager] persistent session exited (ns=${ns}, code=${code})`);
317
+ onExit();
318
+ });
319
+ proc.on("error", (err) => {
320
+ console.error(`[meta-agent-manager] persistent session spawn error (ns=${ns}):`, err.message);
321
+ onExit();
322
+ });
323
+ return proc;
324
+ }
325
+ /**
326
+ * Create a MetaAgentManager that maintains one persistent Claude process per namespace.
327
+ *
328
+ * On first message for a namespace:
329
+ * 1. ensureWorkspace + injectMcp
330
+ * 2. Drain any pending Redis queue entries to stdin
331
+ * 3. Write the new message to stdin
332
+ *
333
+ * On subsequent messages: write directly to stdin of the running process.
280
334
  *
281
- * Each message spawns `claude --continue -p` with the message written to stdin,
282
- * then stdin is closed (EOF) so Claude processes and exits. --continue maintains
283
- * conversation history via the JSONL session file in the workspace.
335
+ * On process exit: remove from sessions map. Next message triggers a respawn.
284
336
  *
285
- * Messages queue per namespace and process sequentially no concurrent spawns
286
- * for the same namespace.
337
+ * The 3-second poll loop drains any messages that arrived while a session was
338
+ * starting up or temporarily unavailable.
287
339
  */
288
340
  export function createMetaAgentManager() {
289
341
  let pollInterval = null;
290
- // Per-namespace message queue (local, drained by processQueue)
291
- const queues = new Map();
292
- // Namespaces currently running a spawnSession (blocks concurrent spawns)
293
- const processing = new Set();
294
- // Cached session metadata (token, wire, repoUrl) per namespace
295
- const sessionMeta = new Map();
342
+ /** One persistent ChildProcess per namespace. */
343
+ const sessions = new Map();
344
+ /** Namespaces currently being set up (workspace clone / first spawn). */
345
+ const startingUp = new Set();
296
346
  /**
297
- * Drain the local queue for a namespace, spawning one session per message.
298
- * No-ops if already processing or queue is empty.
347
+ * Write a line to the stdin of the persistent session for ns.
348
+ * Silently no-ops if no session exists.
299
349
  */
300
- async function processQueue(ns) {
301
- if (processing.has(ns))
302
- return;
303
- const queue = queues.get(ns);
304
- if (!queue || queue.length === 0)
305
- return;
306
- const meta = sessionMeta.get(ns);
307
- if (!meta)
350
+ function writeToStdin(ns, line) {
351
+ const session = sessions.get(ns);
352
+ if (!session)
308
353
  return;
309
- processing.add(ns);
310
- console.log(`[meta-agent-manager] processQueue started (ns=${ns})`);
311
354
  try {
312
- while (true) {
313
- const q = queues.get(ns);
314
- if (!q || q.length === 0)
315
- break;
316
- const message = q.shift();
317
- const { token, wire } = sessionMeta.get(ns) ?? meta;
318
- console.log(`[meta-agent-manager] spawning session for message (ns=${ns})`);
319
- try {
320
- await spawnSession(ns, message, token, wire);
321
- }
322
- catch (err) {
323
- console.error(`[meta-agent-manager] spawnSession error (ns=${ns}):`, err.message);
324
- }
325
- }
355
+ // Interactive mode: plain text line, Claude reads it as user input
356
+ session.proc.stdin.write(`${line}\n`);
326
357
  }
327
- finally {
328
- processing.delete(ns);
329
- console.log(`[meta-agent-manager] processQueue done (ns=${ns})`);
358
+ catch (err) {
359
+ console.warn(`[meta-agent-manager] stdin write failed (ns=${ns}):`, err.message);
360
+ }
361
+ }
362
+ /**
363
+ * Drain all pending messages from the Redis input queue into the session's stdin.
364
+ */
365
+ async function drainQueue(ns, wire) {
366
+ const inputKey = discordMetaInputKey(ns);
367
+ for (;;) {
368
+ let raw;
369
+ try {
370
+ raw = await wire._redis.lpop(inputKey);
371
+ }
372
+ catch {
373
+ break;
374
+ }
375
+ if (!raw)
376
+ break;
377
+ let content;
378
+ try {
379
+ content = JSON.parse(raw).content ?? raw;
380
+ }
381
+ catch {
382
+ content = raw;
383
+ }
384
+ writeToStdin(ns, content);
330
385
  }
331
386
  }
332
387
  /**
333
- * Ensure workspace + MCP are ready, add message to queue, start processing.
388
+ * Ensure a persistent session exists for ns.
389
+ * If one already exists, returns immediately.
390
+ * If not, spawns one, drains any queued messages, then writes `firstMessage` if provided.
334
391
  */
335
392
  async function ensureSession(ns, repoUrl, token, wire, firstMessage) {
336
- sessionMeta.set(ns, { token, wire, repoUrl });
337
- const wsPath = workspacePath(ns);
338
- await ensureWorkspace(ns, repoUrl);
339
- injectMcp(ns, wsPath, token);
340
- if (!queues.has(ns))
341
- queues.set(ns, []);
342
- if (firstMessage !== undefined) {
343
- queues.get(ns).push(firstMessage);
393
+ if (sessions.has(ns)) {
394
+ if (firstMessage !== undefined)
395
+ writeToStdin(ns, firstMessage);
396
+ return;
397
+ }
398
+ if (startingUp.has(ns)) {
399
+ // Already spinning up — queue the message so drainQueue picks it up
400
+ return;
401
+ }
402
+ startingUp.add(ns);
403
+ try {
404
+ // Ensure workspace exists
405
+ const wsPath = workspacePath(ns);
406
+ await ensureWorkspace(ns, repoUrl);
407
+ injectMcp(ns, wsPath, token);
408
+ const proc = spawnPersistentSession(ns, token, wire, () => {
409
+ // On exit: remove from map so next message triggers a respawn
410
+ sessions.delete(ns);
411
+ console.log(`[meta-agent-manager] session removed from map (ns=${ns})`);
412
+ });
413
+ sessions.set(ns, { proc, ns });
414
+ // Drain any messages that arrived before this session started
415
+ await drainQueue(ns, wire);
416
+ // Write the triggering message last (after queue drain, in order)
417
+ if (firstMessage !== undefined)
418
+ writeToStdin(ns, firstMessage);
419
+ await wire.discord.setStatus(ns, {
420
+ namespace: ns,
421
+ status: "running",
422
+ isTyping: true,
423
+ turnCount: 0,
424
+ updatedAt: new Date().toISOString(),
425
+ });
426
+ }
427
+ catch (err) {
428
+ console.error(`[meta-agent-manager] ensureSession failed (ns=${ns}):`, err.message);
429
+ sessions.delete(ns);
430
+ }
431
+ finally {
432
+ startingUp.delete(ns);
344
433
  }
345
- processQueue(ns).catch((err) => {
346
- console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
347
- });
348
434
  }
349
435
  return {
350
436
  async ensureWorkspace(ns, repoUrl) {
@@ -356,7 +442,7 @@ export function createMetaAgentManager() {
356
442
  },
357
443
  startPolling(wire, getNamespaces, instanceId) {
358
444
  if (pollInterval)
359
- return;
445
+ return; // already running
360
446
  pollInterval = setInterval(() => {
361
447
  const namespaces = getNamespaces();
362
448
  if (namespaces.length === 0)
@@ -371,21 +457,25 @@ export function createMetaAgentManager() {
371
457
  }
372
458
  }).catch(() => { });
373
459
  }
374
- // Skip if already processing
375
- if (processing.has(ns))
460
+ // If session exists, drain any queued messages
461
+ if (sessions.has(ns)) {
462
+ drainQueue(ns, wire).catch((err) => {
463
+ console.warn(`[meta-agent-manager] drainQueue error (ns=${ns}):`, err.message);
464
+ });
376
465
  continue;
377
- // Check Redis queue for pending messages
466
+ }
467
+ // Check if anything is queued for this namespace
378
468
  const inputKey = discordMetaInputKey(ns);
379
469
  wire._redis.llen(inputKey).then(async (queueLen) => {
380
470
  if (queueLen === 0)
381
471
  return;
472
+ // Resolve token
382
473
  let token;
383
474
  try {
384
475
  token = await wire.token.getMaster();
385
476
  }
386
477
  catch {
387
- token = sessionMeta.get(ns)?.token
388
- ?? process.env.CLAUDE_CODE_OAUTH_TOKEN
478
+ token = process.env.CLAUDE_CODE_OAUTH_TOKEN
389
479
  ?? process.env.CLAUDE_CODE_TOKEN
390
480
  ?? process.env.ANTHROPIC_API_KEY
391
481
  ?? "";
@@ -394,31 +484,8 @@ export function createMetaAgentManager() {
394
484
  console.warn(`[meta-agent-manager] no token available, skipping session for ns=${ns}`);
395
485
  return;
396
486
  }
397
- // Drain Redis queue into local queue
398
- if (!queues.has(ns))
399
- queues.set(ns, []);
400
- for (;;) {
401
- const raw = await wire._redis.lpop(inputKey).catch(() => null);
402
- if (!raw)
403
- break;
404
- let content;
405
- try {
406
- content = JSON.parse(raw).content ?? raw;
407
- }
408
- catch {
409
- content = raw;
410
- }
411
- queues.get(ns).push(content);
412
- }
413
- if (!sessionMeta.has(ns)) {
414
- sessionMeta.set(ns, { token, wire, repoUrl });
415
- }
416
- const wsPath = workspacePath(ns);
417
- await ensureWorkspace(ns, repoUrl);
418
- injectMcp(ns, wsPath, token);
419
- processQueue(ns).catch((err) => {
420
- console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
421
- });
487
+ // ensureSession will drain the queue itself
488
+ await ensureSession(ns, repoUrl, token, wire);
422
489
  }).catch((err) => {
423
490
  console.warn(`[meta-agent-manager] llen error (ns=${ns}):`, err.message);
424
491
  });
@@ -432,20 +499,35 @@ export function createMetaAgentManager() {
432
499
  pollInterval = null;
433
500
  console.log("[meta-agent-manager] polling stopped");
434
501
  }
435
- queues.clear();
502
+ // Kill all active sessions
503
+ for (const [ns, session] of sessions) {
504
+ try {
505
+ session.proc.stdin.end();
506
+ session.proc.kill();
507
+ }
508
+ catch {
509
+ // ignore errors during shutdown
510
+ }
511
+ sessions.delete(ns);
512
+ console.log(`[meta-agent-manager] killed session on stop (ns=${ns})`);
513
+ }
436
514
  },
437
515
  killSession(ns) {
438
- // Clear local queue — any in-flight spawn will complete naturally
439
- queues.delete(ns);
440
- console.log(`[meta-agent-manager] killed session queue (ns=${ns})`);
516
+ const session = sessions.get(ns);
517
+ if (!session)
518
+ return;
519
+ try {
520
+ session.proc.stdin.end();
521
+ session.proc.kill();
522
+ }
523
+ catch {
524
+ // ignore
525
+ }
526
+ sessions.delete(ns);
527
+ console.log(`[meta-agent-manager] killed session (ns=${ns})`);
441
528
  },
442
529
  sendToSession(ns, line) {
443
- if (!queues.has(ns))
444
- queues.set(ns, []);
445
- queues.get(ns).push(line);
446
- processQueue(ns).catch((err) => {
447
- console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
448
- });
530
+ writeToStdin(ns, line);
449
531
  },
450
532
  };
451
533
  }
package/dist/voice.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Voice message transcription via whisper.cpp.
3
- * Flow: Telegram OGG → ffmpeg convert to 16kHz WAV → whisper-cpp → text
3
+ * Flow: Discord voice memo (OGG/M4A) → ffmpeg convert to 16kHz WAV → whisper-cpp → text
4
4
  */
5
5
  /**
6
- * Transcribe a voice message from a Telegram file URL.
6
+ * Transcribe a voice message from a Discord CDN URL.
7
+ * Accepts OGG, M4A, MP3, or any audio format ffmpeg can decode.
7
8
  * Returns the transcribed text, or throws if whisper/ffmpeg not available.
8
9
  */
9
10
  export declare function transcribeVoice(fileUrl: string): Promise<string>;
package/dist/voice.js CHANGED
@@ -1,40 +1,40 @@
1
1
  /**
2
2
  * Voice message transcription via whisper.cpp.
3
- * Flow: Telegram OGG → ffmpeg convert to 16kHz WAV → whisper-cpp → text
3
+ * Flow: Discord voice memo (OGG/M4A) → ffmpeg convert to 16kHz WAV → whisper-cpp → text
4
4
  */
5
5
  import { execFile } from "child_process";
6
6
  import { promisify } from "util";
7
- import { existsSync } from "fs";
8
- import { unlink, readFile } from "fs/promises";
7
+ import { existsSync, createWriteStream } from "fs";
8
+ import { unlink, readFile, access } from "fs/promises";
9
9
  import { tmpdir } from "os";
10
10
  import { join } from "path";
11
11
  import https from "https";
12
12
  import http from "http";
13
- import { createWriteStream } from "fs";
14
13
  const execFileAsync = promisify(execFile);
15
- // Whisper model small.en is fast and accurate enough for commands
16
- // Falls back to base.en if small not found
14
+ // Env var overrides allow test injection: WHISPER_BIN, FFMPEG_BIN, WHISPER_MODEL
17
15
  const WHISPER_MODELS = [
16
+ process.env.WHISPER_MODEL,
18
17
  "/opt/homebrew/share/whisper-cpp/ggml-small.en.bin",
19
18
  "/opt/homebrew/share/whisper-cpp/ggml-small.bin",
20
19
  "/opt/homebrew/share/whisper-cpp/ggml-base.en.bin",
21
20
  "/opt/homebrew/share/whisper-cpp/ggml-base.bin",
22
- // user-local
23
21
  `${process.env.HOME}/.local/share/whisper-cpp/ggml-small.en.bin`,
24
22
  `${process.env.HOME}/.local/share/whisper-cpp/ggml-base.en.bin`,
25
- ];
23
+ ].filter(Boolean);
26
24
  const WHISPER_BIN_CANDIDATES = [
27
- "/opt/homebrew/bin/whisper-cli", // whisper-cpp brew formula installs as whisper-cli
25
+ process.env.WHISPER_BIN,
26
+ "/opt/homebrew/bin/whisper-cli",
28
27
  "/opt/homebrew/bin/whisper-cpp",
29
28
  "/usr/local/bin/whisper-cli",
30
29
  "/usr/local/bin/whisper-cpp",
31
30
  "/opt/homebrew/bin/whisper",
32
- ];
31
+ ].filter(Boolean);
33
32
  const FFMPEG_CANDIDATES = [
33
+ process.env.FFMPEG_BIN,
34
34
  "/opt/homebrew/bin/ffmpeg",
35
35
  "/usr/local/bin/ffmpeg",
36
36
  "/usr/bin/ffmpeg",
37
- ];
37
+ ].filter(Boolean);
38
38
  function findBin(candidates) {
39
39
  for (const p of candidates) {
40
40
  if (existsSync(p))
@@ -49,15 +49,34 @@ function findModel() {
49
49
  }
50
50
  return null;
51
51
  }
52
- function downloadFile(url, dest) {
52
+ /**
53
+ * Download a file from a URL to dest.
54
+ * Supports file:// (copies local file), and https/http with redirect following.
55
+ */
56
+ async function downloadFile(url, dest, redirectsLeft = 5) {
57
+ if (url.startsWith("file://")) {
58
+ const { copyFile } = await import("fs/promises");
59
+ await copyFile(url.slice("file://".length), dest);
60
+ return;
61
+ }
53
62
  return new Promise((resolve, reject) => {
54
- const file = createWriteStream(dest);
63
+ if (redirectsLeft <= 0) {
64
+ reject(new Error(`Too many redirects downloading ${url}`));
65
+ return;
66
+ }
55
67
  const getter = url.startsWith("https") ? https : http;
56
68
  getter.get(url, (res) => {
69
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
70
+ res.resume();
71
+ downloadFile(res.headers.location, dest, redirectsLeft - 1).then(resolve).catch(reject);
72
+ return;
73
+ }
57
74
  if (res.statusCode !== 200) {
75
+ res.resume();
58
76
  reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
59
77
  return;
60
78
  }
79
+ const file = createWriteStream(dest);
61
80
  res.pipe(file);
62
81
  file.on("finish", () => file.close(() => resolve()));
63
82
  file.on("error", reject);
@@ -65,7 +84,8 @@ function downloadFile(url, dest) {
65
84
  });
66
85
  }
67
86
  /**
68
- * Transcribe a voice message from a Telegram file URL.
87
+ * Transcribe a voice message from a Discord CDN URL.
88
+ * Accepts OGG, M4A, MP3, or any audio format ffmpeg can decode.
69
89
  * Returns the transcribed text, or throws if whisper/ffmpeg not available.
70
90
  */
71
91
  export async function transcribeVoice(fileUrl) {
@@ -78,58 +98,82 @@ export async function transcribeVoice(fileUrl) {
78
98
  const model = findModel();
79
99
  if (!model)
80
100
  throw new Error("No whisper model found — run: whisper-cpp-download-ggml-model small.en");
81
- const tmp = join(tmpdir(), `cc-tg-voice-${Date.now()}`);
82
- const oggPath = `${tmp}.ogg`;
101
+ const tmp = join(tmpdir(), `cc-discord-voice-${Date.now()}`);
102
+ // Preserve original extension so ffmpeg auto-detects format
103
+ const urlPath = new URL(fileUrl).pathname;
104
+ const ext = urlPath.includes(".") ? "." + urlPath.split(".").pop().split("?")[0] : ".ogg";
105
+ const audioPath = `${tmp}${ext}`;
83
106
  const wavPath = `${tmp}.wav`;
84
107
  try {
85
- // 1. Download OGG from Telegram
86
- await downloadFile(fileUrl, oggPath);
87
- // 2. Convert OGG 16kHz mono WAV (whisper requirement)
88
- await execFileAsync(ffmpegBin, [
89
- "-y", "-i", oggPath,
90
- "-ar", "16000",
91
- "-ac", "1",
92
- "-c:a", "pcm_s16le",
93
- wavPath,
94
- ]);
108
+ // 1. Download audio from Discord CDN (follows redirects)
109
+ await downloadFile(fileUrl, audioPath);
110
+ // 2. Convert to 16kHz mono WAV (whisper requirement)
111
+ try {
112
+ await execFileAsync(ffmpegBin, [
113
+ "-y", "-i", audioPath,
114
+ "-ar", "16000",
115
+ "-ac", "1",
116
+ "-c:a", "pcm_s16le",
117
+ wavPath,
118
+ ]);
119
+ }
120
+ catch (err) {
121
+ const msg = err instanceof Error ? err.message : String(err);
122
+ throw new Error(`ffmpeg conversion failed: ${msg}`);
123
+ }
95
124
  // 3. Run whisper-cpp
96
- // --output-txt writes to ${wavPath}.txt (NOT stdout)
97
- // -l auto fails with .en models — detect and use -l en instead
125
+ // -l auto fails with .en models — use -l en for those
98
126
  const isEnModel = model.includes(".en.");
99
127
  const langArgs = isEnModel ? ["-l", "en"] : ["-l", "auto"];
128
+ let whisperStderr = "";
100
129
  try {
101
- await execFileAsync(whisperBin, [
130
+ const result = await execFileAsync(whisperBin, [
102
131
  "-m", model,
103
132
  "-f", wavPath,
104
133
  "--no-timestamps",
105
134
  ...langArgs,
106
- "--output-txt", // writes to wavPath + ".txt"
135
+ "--output-txt",
107
136
  ]);
137
+ whisperStderr = result.stderr ?? "";
108
138
  }
109
139
  catch (err) {
110
- const msg = err instanceof Error ? err.message : String(err);
111
- throw new Error(`whisper-cpp failed: ${msg}`);
140
+ const e = err;
141
+ whisperStderr = e.stderr ?? "";
142
+ const msg = e.message ?? String(err);
143
+ throw new Error(`whisper-cpp failed: ${msg}\nstderr: ${whisperStderr}`);
112
144
  }
113
- // Read the output file whisper-cpp wrote
114
- const txtPath = `${wavPath}.txt`;
145
+ // 4. Find output file whisper-cpp versions differ on suffix:
146
+ // some write {wavPath}.txt, others write {baseName}.txt (strip .wav)
147
+ const txtPathFull = `${wavPath}.txt`; // /tmp/foo.wav.txt
148
+ const txtPathStripped = wavPath.replace(/\.wav$/, ".txt"); // /tmp/foo.txt
115
149
  let raw = "";
116
- try {
117
- raw = await readFile(txtPath, "utf-8");
150
+ for (const candidate of [txtPathFull, txtPathStripped]) {
151
+ try {
152
+ await access(candidate);
153
+ raw = await readFile(candidate, "utf-8");
154
+ await unlink(candidate).catch(() => { });
155
+ break;
156
+ }
157
+ catch {
158
+ // try next
159
+ }
160
+ }
161
+ if (!raw && !whisperStderr) {
162
+ throw new Error("whisper-cpp ran but produced no output file (tried .wav.txt and .txt)");
118
163
  }
119
- catch {
120
- throw new Error("whisper-cpp ran but produced no output file");
164
+ // Use stdout text if file wasn't found but whisper printed to stderr (some versions do)
165
+ if (!raw) {
166
+ raw = whisperStderr;
121
167
  }
122
168
  const text = raw
123
169
  .replace(/\[BLANK_AUDIO\]/gi, "")
124
- .replace(/\[.*?\]/g, "") // remove timestamp artifacts
170
+ .replace(/\[.*?\]/g, "")
125
171
  .trim();
126
172
  return text || "[empty transcription]";
127
173
  }
128
174
  finally {
129
- // Cleanup temp files
130
- await unlink(oggPath).catch(() => { });
175
+ await unlink(audioPath).catch(() => { });
131
176
  await unlink(wavPath).catch(() => { });
132
- await unlink(`${wavPath}.txt`).catch(() => { });
133
177
  }
134
178
  }
135
179
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.37",
3
+ "version": "0.2.39",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {