@gonzih/cc-discord 0.2.38 → 0.2.40

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": {
@@ -65,21 +65,6 @@ export interface MetaAgentManager {
65
65
  /** Write a raw line to the stdin of the running session, if any. */
66
66
  sendToSession: (ns: string, line: string) => void;
67
67
  }
68
- /**
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.
77
- *
78
- * On process exit: remove from sessions map. Next message triggers a respawn.
79
- *
80
- * The 3-second poll loop drains any messages that arrived while a session was
81
- * starting up or temporarily unavailable.
82
- */
83
68
  export declare function createMetaAgentManager(): MetaAgentManager;
84
69
  /**
85
70
  * Migrate the old cc-agent meta input key to the new cc-discord key.
@@ -293,7 +293,7 @@ export function spawnSession(ns, message, token, wire) {
293
293
  * Stdout is wired to Redis via wireStdoutToRedis.
294
294
  * Returns the ChildProcess.
295
295
  */
296
- function spawnPersistentSession(ns, token, wire, onExit) {
296
+ function spawnPersistentSession(ns, token, wire, onExit, onOutput) {
297
297
  const wsPath = workspacePath(ns);
298
298
  const claudeBin = resolveClaude();
299
299
  const env = buildEnv(token);
@@ -312,6 +312,9 @@ function spawnPersistentSession(ns, token, wire, onExit) {
312
312
  ], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
313
313
  proc.stdin.setDefaultEncoding("utf8");
314
314
  wireStdoutToRedis(proc, ns, wire);
315
+ if (onOutput) {
316
+ proc.stdout.on("data", onOutput);
317
+ }
315
318
  proc.on("exit", (code) => {
316
319
  console.log(`[meta-agent-manager] persistent session exited (ns=${ns}, code=${code})`);
317
320
  onExit();
@@ -337,15 +340,22 @@ function spawnPersistentSession(ns, token, wire, onExit) {
337
340
  * The 3-second poll loop drains any messages that arrived while a session was
338
341
  * starting up or temporarily unavailable.
339
342
  */
343
+ /** Kill a session after this many ms of no stdout (or no new stdin). */
344
+ const SESSION_INACTIVITY_MS = 5 * 60_000; // 5 minutes
340
345
  export function createMetaAgentManager() {
341
346
  let pollInterval = null;
347
+ let watchdogInterval = null;
342
348
  /** One persistent ChildProcess per namespace. */
343
349
  const sessions = new Map();
344
350
  /** Namespaces currently being set up (workspace clone / first spawn). */
345
351
  const startingUp = new Set();
352
+ /** Wire reference needed for watchdog notifications. Set in startPolling. */
353
+ let wireRef = null;
346
354
  /**
347
355
  * Write a line to the stdin of the persistent session for ns.
348
356
  * Silently no-ops if no session exists.
357
+ * Resets the inactivity timer so the watchdog doesn't kill a session
358
+ * that just received a message but hasn't responded yet.
349
359
  */
350
360
  function writeToStdin(ns, line) {
351
361
  const session = sessions.get(ns);
@@ -354,11 +364,54 @@ export function createMetaAgentManager() {
354
364
  try {
355
365
  // Interactive mode: plain text line, Claude reads it as user input
356
366
  session.proc.stdin.write(`${line}\n`);
367
+ // Reset inactivity timer — the session now has SESSION_INACTIVITY_MS to respond
368
+ session.lastOutputAt = Date.now();
357
369
  }
358
370
  catch (err) {
359
371
  console.warn(`[meta-agent-manager] stdin write failed (ns=${ns}):`, err.message);
360
372
  }
361
373
  }
374
+ /**
375
+ * Watchdog: scan all sessions every 60s. If any session has been silent
376
+ * (no stdout and no new stdin) for SESSION_INACTIVITY_MS, kill it and
377
+ * notify Discord so the user can see why the session went quiet.
378
+ */
379
+ function startWatchdog() {
380
+ if (watchdogInterval)
381
+ return;
382
+ watchdogInterval = setInterval(() => {
383
+ if (!wireRef)
384
+ return;
385
+ const now = Date.now();
386
+ for (const [ns, session] of sessions) {
387
+ const idleMs = now - session.lastOutputAt;
388
+ if (idleMs < SESSION_INACTIVITY_MS)
389
+ continue;
390
+ const idleMin = Math.round(idleMs / 60_000);
391
+ console.warn(`[meta-agent-manager] watchdog: ns=${ns} idle ${idleMin}m, killing stuck session`);
392
+ // Notify Discord before killing so the user knows what happened
393
+ const alertMsg = {
394
+ id: crypto.randomUUID(),
395
+ source: "claude",
396
+ role: "assistant",
397
+ content: `⚠️ Session for **${ns}** was idle for ${idleMin} minutes (likely stuck on a tool call). Killed and removed — send any message to restart.`,
398
+ timestamp: new Date().toISOString(),
399
+ chatId: 0,
400
+ };
401
+ wireRef.discord.publishOutgoing(ns, alertMsg).catch((err) => {
402
+ console.warn(`[meta-agent-manager] watchdog publishOutgoing failed (ns=${ns}):`, err.message);
403
+ });
404
+ try {
405
+ session.proc.stdin.end();
406
+ session.proc.kill("SIGTERM");
407
+ }
408
+ catch {
409
+ // ignore kill errors
410
+ }
411
+ sessions.delete(ns);
412
+ }
413
+ }, 60_000);
414
+ }
362
415
  /**
363
416
  * Drain all pending messages from the Redis input queue into the session's stdin.
364
417
  */
@@ -405,12 +458,19 @@ export function createMetaAgentManager() {
405
458
  const wsPath = workspacePath(ns);
406
459
  await ensureWorkspace(ns, repoUrl);
407
460
  injectMcp(ns, wsPath, token);
461
+ // Placeholder — filled in after proc is known so the onOutput closure can update it
462
+ let session;
408
463
  const proc = spawnPersistentSession(ns, token, wire, () => {
409
464
  // On exit: remove from map so next message triggers a respawn
410
465
  sessions.delete(ns);
411
466
  console.log(`[meta-agent-manager] session removed from map (ns=${ns})`);
467
+ }, () => {
468
+ // Reset inactivity timer on any stdout data
469
+ if (session)
470
+ session.lastOutputAt = Date.now();
412
471
  });
413
- sessions.set(ns, { proc, ns });
472
+ session = { proc, ns, lastOutputAt: Date.now() };
473
+ sessions.set(ns, session);
414
474
  // Drain any messages that arrived before this session started
415
475
  await drainQueue(ns, wire);
416
476
  // Write the triggering message last (after queue drain, in order)
@@ -443,6 +503,8 @@ export function createMetaAgentManager() {
443
503
  startPolling(wire, getNamespaces, instanceId) {
444
504
  if (pollInterval)
445
505
  return; // already running
506
+ wireRef = wire;
507
+ startWatchdog();
446
508
  pollInterval = setInterval(() => {
447
509
  const namespaces = getNamespaces();
448
510
  if (namespaces.length === 0)
@@ -499,6 +561,10 @@ export function createMetaAgentManager() {
499
561
  pollInterval = null;
500
562
  console.log("[meta-agent-manager] polling stopped");
501
563
  }
564
+ if (watchdogInterval) {
565
+ clearInterval(watchdogInterval);
566
+ watchdogInterval = null;
567
+ }
502
568
  // Kill all active sessions
503
569
  for (const [ns, session] of sessions) {
504
570
  try {
package/dist/notifier.js CHANGED
@@ -279,11 +279,6 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
279
279
  if (!buf || !buf.text.trim())
280
280
  return;
281
281
  const text = `← [${ns}] ` + stripAnsi(buf.text.trim());
282
- if (text.length < 30) {
283
- buf.text = "";
284
- buf.timer = null;
285
- return;
286
- }
287
282
  buf.text = "";
288
283
  buf.timer = null;
289
284
  // During an active loop, route meta-agent output to the thread rather than main channel
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.38",
3
+ "version": "0.2.40",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {