@gonzih/cc-discord 0.1.0 → 0.1.2

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/router.js DELETED
@@ -1,193 +0,0 @@
1
- /**
2
- * Hashtag meta-agent routing.
3
- *
4
- * Parses #tag or #org/repo tokens from Telegram messages and routes them to
5
- * the appropriate cc-agent meta-agent instead of the local Claude session.
6
- *
7
- * Tag formats:
8
- * #repo-name → namespace=repo-name, repo=https://github.com/{DEFAULT_GITHUB_ORG}/repo-name (null if DEFAULT_GITHUB_ORG unset)
9
- * #org/repo → namespace=repo, repo=https://github.com/org/repo
10
- */
11
- import { execSync } from "child_process";
12
- import { metaAgentStatusKey, metaKey, metaInputKey } from "@gonzih/cc-wire";
13
- /**
14
- * Parse the first #tag or #org/repo token from a message.
15
- * Returns null when no routing tag is present, or when the short #repo format is used
16
- * without DEFAULT_GITHUB_ORG being set (operators must configure this explicitly).
17
- *
18
- * Examples:
19
- * "#cc-agent fix the bug" → null if DEFAULT_GITHUB_ORG unset; { namespace: "cc-agent", repoUrl: "…/{org}/cc-agent", … } if set
20
- * "#gonzih/of-stack deploy it" → { namespace: "of-stack", repoUrl: "…/gonzih/of-stack", … }
21
- * "#org/repo do something" → { namespace: "repo", repoUrl: "…/org/repo", … }
22
- * "please help #of-stack with this" → null if DEFAULT_GITHUB_ORG unset
23
- */
24
- export function parseRoutingTag(text) {
25
- const defaultOrg = process.env.DEFAULT_GITHUB_ORG;
26
- // Match #word or #org/repo — each segment: starts with alphanumeric, allows ._- inside
27
- const match = text.match(/#([a-zA-Z0-9][a-zA-Z0-9._-]*)(?:\/([a-zA-Z0-9][a-zA-Z0-9._-]*))?/);
28
- if (!match)
29
- return null;
30
- const fullMatch = match[0]; // e.g. "#gonzih/of-stack"
31
- const part1 = match[1]; // org-or-repo
32
- const part2 = match[2]; // repo (only present in #org/repo format)
33
- let namespace;
34
- let repoUrl;
35
- if (part2) {
36
- // #org/repo format
37
- namespace = part2;
38
- repoUrl = `https://github.com/${part1}/${part2}`;
39
- }
40
- else {
41
- // #repo format — requires DEFAULT_GITHUB_ORG; return null if unset
42
- if (!defaultOrg)
43
- return null;
44
- namespace = part1;
45
- repoUrl = `https://github.com/${defaultOrg}/${part1}`;
46
- }
47
- // Strip the matched tag token and collapse whitespace
48
- const strippedMessage = text.replace(fullMatch, "").replace(/\s+/g, " ").trim();
49
- return { namespace, repoUrl, strippedMessage };
50
- }
51
- /**
52
- * Ensure a meta-agent for the given namespace is running.
53
- *
54
- * Steps:
55
- * 1. Check Redis for readiness via two keys (see below) — return early if already ready.
56
- * 2. Verify the GitHub repo exists; create it (public) if not.
57
- * 3. Call the start_meta_agent MCP tool via callTool.
58
- * 4. Poll both Redis keys every 1s until ready or META_AGENT_TIMEOUT_MS expires.
59
- *
60
- * Two Redis keys are checked:
61
- * cca:meta-agent:status:{namespace} — live-status key written by writeLiveStatus()
62
- * (only populated after the first message is processed by messageMetaAgent)
63
- * cca:meta:{namespace} — state key written by startMetaAgent() directly via saveState()
64
- * (populated as soon as the workspace is created, with status:"idle")
65
- *
66
- * Bug context: start_meta_agent writes cca:meta:{namespace} but NOT cca:meta-agent:status:{namespace}.
67
- * Polling only the status key caused a 10s timeout on every cold start.
68
- *
69
- * Throws on failure (repo creation error, tool call failure, or timeout).
70
- */
71
- export async function ensureMetaAgent(namespace, repoUrl, callTool, redis) {
72
- const timeoutMs = parseInt(process.env.META_AGENT_TIMEOUT_MS ?? "10000", 10);
73
- const statusKey = metaAgentStatusKey(namespace);
74
- // State key written by startMetaAgent() directly — the source of truth for workspace existence.
75
- const stateKey = metaKey(namespace);
76
- console.log(`[router] ensureMetaAgent namespace=${namespace}`);
77
- // Fast path: check live-status key (written by messageMetaAgent after first message)
78
- const statusRaw = await redis.get(statusKey);
79
- if (statusRaw) {
80
- try {
81
- const status = JSON.parse(statusRaw);
82
- if (status.status === "running" || status.status === "idle") {
83
- console.log(`[router] meta-agent ${namespace} is already ready (status=${status.status})`);
84
- return;
85
- }
86
- }
87
- catch {
88
- // Corrupt status value — fall through
89
- }
90
- }
91
- // Fast path: also check state key (written by startMetaAgent, persists 30 days).
92
- // Presence of this key means the workspace was already created — no need to re-run start_meta_agent.
93
- const stateRaw = await redis.get(stateKey);
94
- if (stateRaw) {
95
- try {
96
- const state = JSON.parse(stateRaw);
97
- if (state.status === "idle" || state.status === "running") {
98
- console.log(`[router] meta-agent ${namespace} workspace exists (state.status=${state.status})`);
99
- return;
100
- }
101
- }
102
- catch {
103
- // Corrupt state — fall through and re-initialize
104
- }
105
- }
106
- // Derive "org/repo" from the full URL for gh CLI calls
107
- const orgRepo = repoUrl.replace(/^https:\/\/github\.com\//, "");
108
- // Verify / create the GitHub repo
109
- try {
110
- execSync(`gh repo view ${orgRepo}`, { stdio: "ignore" });
111
- }
112
- catch {
113
- // Repo not found — create it
114
- try {
115
- execSync(`gh repo create ${orgRepo} --public --description "Meta-agent workspace for ${namespace}"`, { stdio: "pipe" });
116
- console.log(`[router] created repo ${orgRepo} for namespace=${namespace}`);
117
- }
118
- catch (createErr) {
119
- throw new Error(`Failed to create repo ${orgRepo}: ${createErr.message}`);
120
- }
121
- }
122
- // Start the meta-agent via MCP (clones workspace if needed, writes cca:meta:{namespace})
123
- const result = await callTool("start_meta_agent", { namespace, repo_url: repoUrl });
124
- if (result === null) {
125
- throw new Error(`start_meta_agent returned null — tool may not be available in cc-agent`);
126
- }
127
- // Check for explicit failure payload (e.g. git clone error)
128
- try {
129
- const parsed = JSON.parse(result);
130
- if (parsed.ok === false) {
131
- throw new Error(`start_meta_agent failed: ${parsed.error ?? "unknown error"}`);
132
- }
133
- }
134
- catch (jsonErr) {
135
- if (!(jsonErr instanceof SyntaxError))
136
- throw jsonErr;
137
- // Non-JSON result (e.g. plain "ok") — not an error, continue to poll
138
- }
139
- // Poll until ready. Check both keys:
140
- // - statusKey: written by writeLiveStatus() during messageMetaAgent (may not exist yet on cold start)
141
- // - stateKey: written by startMetaAgent() above — will appear within 1s of the tool call returning
142
- const deadline = Date.now() + timeoutMs;
143
- while (Date.now() < deadline) {
144
- await new Promise((resolve) => setTimeout(resolve, 1000));
145
- const raw = await redis.get(statusKey);
146
- if (raw) {
147
- try {
148
- const s = JSON.parse(raw);
149
- console.log(`[router] waiting for meta-agent ${namespace} — status key: ${s.status}`);
150
- if (s.status === "running" || s.status === "idle")
151
- return;
152
- }
153
- catch {
154
- // ignore parse errors, keep polling
155
- }
156
- }
157
- // Also check state key — startMetaAgent writes this synchronously before responding
158
- const state = await redis.get(stateKey);
159
- if (state) {
160
- try {
161
- const s = JSON.parse(state);
162
- console.log(`[router] waiting for meta-agent ${namespace} — state key: ${s.status}`);
163
- if (s.status === "idle" || s.status === "running")
164
- return;
165
- }
166
- catch {
167
- // ignore parse errors, keep polling
168
- }
169
- }
170
- else {
171
- console.log(`[router] waiting for meta-agent ${namespace} — neither key present yet`);
172
- }
173
- }
174
- throw new Error(`Meta-agent for ${namespace} did not become ready within ${timeoutMs}ms`);
175
- }
176
- /**
177
- * Route a message to a running meta-agent via Redis RPUSH.
178
- * The cc-agent polls cca:meta:{namespace}:input every 3s (up to 3s delivery latency).
179
- *
180
- * No-op when strippedMessage is empty (user sent only the tag token).
181
- */
182
- export async function routeToMetaAgent(namespace, strippedMessage, redis) {
183
- if (!strippedMessage)
184
- return;
185
- const entry = JSON.stringify({
186
- id: crypto.randomUUID(),
187
- content: strippedMessage,
188
- timestamp: new Date().toISOString(),
189
- });
190
- // FIFO — cc-agent reads via LPOP
191
- await redis.rpush(metaInputKey(namespace), entry);
192
- console.log(`[router] routed message to meta-agent namespace=${namespace}`);
193
- }
package/dist/tokens.d.ts DELETED
@@ -1,24 +0,0 @@
1
- /**
2
- * OAuth token pool management.
3
- *
4
- * Supports CLAUDE_CODE_OAUTH_TOKENS (comma-separated list of tokens).
5
- * Falls back to CLAUDE_CODE_OAUTH_TOKEN for single-token / backwards compat.
6
- *
7
- * cc-tg token pool rotates independently from cc-agent's pool. No coordination between them.
8
- */
9
- /**
10
- * Load tokens from env vars. Called on startup; also re-callable in tests.
11
- * Priority: CLAUDE_CODE_OAUTH_TOKENS > CLAUDE_CODE_OAUTH_TOKEN > (empty)
12
- */
13
- export declare function loadTokens(): string[];
14
- /** Returns the current active token, or empty string if none configured. */
15
- export declare function getCurrentToken(): string;
16
- /**
17
- * Advance to the next token (wraps around).
18
- * Returns the new current token.
19
- */
20
- export declare function rotateToken(): string;
21
- /** Zero-based index of the current token. */
22
- export declare function getTokenIndex(): number;
23
- /** Total number of tokens in the pool. */
24
- export declare function getTokenCount(): number;
package/dist/tokens.js DELETED
@@ -1,58 +0,0 @@
1
- /**
2
- * OAuth token pool management.
3
- *
4
- * Supports CLAUDE_CODE_OAUTH_TOKENS (comma-separated list of tokens).
5
- * Falls back to CLAUDE_CODE_OAUTH_TOKEN for single-token / backwards compat.
6
- *
7
- * cc-tg token pool rotates independently from cc-agent's pool. No coordination between them.
8
- */
9
- let tokens = [];
10
- let currentIndex = 0;
11
- let initialized = false;
12
- /**
13
- * Load tokens from env vars. Called on startup; also re-callable in tests.
14
- * Priority: CLAUDE_CODE_OAUTH_TOKENS > CLAUDE_CODE_OAUTH_TOKEN > (empty)
15
- */
16
- export function loadTokens() {
17
- const multi = process.env.CLAUDE_CODE_OAUTH_TOKENS;
18
- if (multi) {
19
- tokens = multi.split(",").map((t) => t.trim()).filter(Boolean);
20
- }
21
- else {
22
- const single = process.env.CLAUDE_CODE_OAUTH_TOKEN;
23
- tokens = single ? [single] : [];
24
- }
25
- currentIndex = 0;
26
- initialized = true;
27
- return tokens;
28
- }
29
- function ensureInitialized() {
30
- if (!initialized)
31
- loadTokens();
32
- }
33
- /** Returns the current active token, or empty string if none configured. */
34
- export function getCurrentToken() {
35
- ensureInitialized();
36
- return tokens[currentIndex] ?? "";
37
- }
38
- /**
39
- * Advance to the next token (wraps around).
40
- * Returns the new current token.
41
- */
42
- export function rotateToken() {
43
- ensureInitialized();
44
- if (tokens.length === 0)
45
- return "";
46
- currentIndex = (currentIndex + 1) % tokens.length;
47
- return tokens[currentIndex];
48
- }
49
- /** Zero-based index of the current token. */
50
- export function getTokenIndex() {
51
- ensureInitialized();
52
- return currentIndex;
53
- }
54
- /** Total number of tokens in the pool. */
55
- export function getTokenCount() {
56
- ensureInitialized();
57
- return tokens.length;
58
- }
package/dist/voice.d.ts DELETED
@@ -1,13 +0,0 @@
1
- /**
2
- * Voice message transcription via whisper.cpp.
3
- * Flow: Telegram OGG → ffmpeg convert to 16kHz WAV → whisper-cpp → text
4
- */
5
- /**
6
- * Transcribe a voice message from a Telegram file URL.
7
- * Returns the transcribed text, or throws if whisper/ffmpeg not available.
8
- */
9
- export declare function transcribeVoice(fileUrl: string): Promise<string>;
10
- /**
11
- * Check if voice transcription is available on this system.
12
- */
13
- export declare function isVoiceAvailable(): boolean;
package/dist/voice.js DELETED
@@ -1,142 +0,0 @@
1
- /**
2
- * Voice message transcription via whisper.cpp.
3
- * Flow: Telegram OGG → ffmpeg convert to 16kHz WAV → whisper-cpp → text
4
- */
5
- import { execFile } from "child_process";
6
- import { promisify } from "util";
7
- import { existsSync } from "fs";
8
- import { unlink, readFile } from "fs/promises";
9
- import { tmpdir } from "os";
10
- import { join } from "path";
11
- import https from "https";
12
- import http from "http";
13
- import { createWriteStream } from "fs";
14
- 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
17
- const WHISPER_MODELS = [
18
- "/opt/homebrew/share/whisper-cpp/ggml-small.en.bin",
19
- "/opt/homebrew/share/whisper-cpp/ggml-small.bin",
20
- "/opt/homebrew/share/whisper-cpp/ggml-base.en.bin",
21
- "/opt/homebrew/share/whisper-cpp/ggml-base.bin",
22
- // user-local
23
- `${process.env.HOME}/.local/share/whisper-cpp/ggml-small.en.bin`,
24
- `${process.env.HOME}/.local/share/whisper-cpp/ggml-base.en.bin`,
25
- ];
26
- const WHISPER_BIN_CANDIDATES = [
27
- "/opt/homebrew/bin/whisper-cli", // whisper-cpp brew formula installs as whisper-cli
28
- "/opt/homebrew/bin/whisper-cpp",
29
- "/usr/local/bin/whisper-cli",
30
- "/usr/local/bin/whisper-cpp",
31
- "/opt/homebrew/bin/whisper",
32
- ];
33
- const FFMPEG_CANDIDATES = [
34
- "/opt/homebrew/bin/ffmpeg",
35
- "/usr/local/bin/ffmpeg",
36
- "/usr/bin/ffmpeg",
37
- ];
38
- function findBin(candidates) {
39
- for (const p of candidates) {
40
- if (existsSync(p))
41
- return p;
42
- }
43
- return null;
44
- }
45
- function findModel() {
46
- for (const p of WHISPER_MODELS) {
47
- if (existsSync(p))
48
- return p;
49
- }
50
- return null;
51
- }
52
- function downloadFile(url, dest) {
53
- return new Promise((resolve, reject) => {
54
- const file = createWriteStream(dest);
55
- const getter = url.startsWith("https") ? https : http;
56
- getter.get(url, (res) => {
57
- if (res.statusCode !== 200) {
58
- reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
59
- return;
60
- }
61
- res.pipe(file);
62
- file.on("finish", () => file.close(() => resolve()));
63
- file.on("error", reject);
64
- }).on("error", reject);
65
- });
66
- }
67
- /**
68
- * Transcribe a voice message from a Telegram file URL.
69
- * Returns the transcribed text, or throws if whisper/ffmpeg not available.
70
- */
71
- export async function transcribeVoice(fileUrl) {
72
- const whisperBin = findBin(WHISPER_BIN_CANDIDATES);
73
- if (!whisperBin)
74
- throw new Error("whisper-cpp not found — install with: brew install whisper-cpp");
75
- const ffmpegBin = findBin(FFMPEG_CANDIDATES);
76
- if (!ffmpegBin)
77
- throw new Error("ffmpeg not found — install with: brew install ffmpeg");
78
- const model = findModel();
79
- if (!model)
80
- 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`;
83
- const wavPath = `${tmp}.wav`;
84
- 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
- ]);
95
- // 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
98
- const isEnModel = model.includes(".en.");
99
- const langArgs = isEnModel ? ["-l", "en"] : ["-l", "auto"];
100
- try {
101
- await execFileAsync(whisperBin, [
102
- "-m", model,
103
- "-f", wavPath,
104
- "--no-timestamps",
105
- ...langArgs,
106
- "--output-txt", // writes to wavPath + ".txt"
107
- ]);
108
- }
109
- catch (err) {
110
- const msg = err instanceof Error ? err.message : String(err);
111
- throw new Error(`whisper-cpp failed: ${msg}`);
112
- }
113
- // Read the output file whisper-cpp wrote
114
- const txtPath = `${wavPath}.txt`;
115
- let raw = "";
116
- try {
117
- raw = await readFile(txtPath, "utf-8");
118
- }
119
- catch {
120
- throw new Error("whisper-cpp ran but produced no output file");
121
- }
122
- const text = raw
123
- .replace(/\[BLANK_AUDIO\]/gi, "")
124
- .replace(/\[.*?\]/g, "") // remove timestamp artifacts
125
- .trim();
126
- return text || "[empty transcription]";
127
- }
128
- finally {
129
- // Cleanup temp files
130
- await unlink(oggPath).catch(() => { });
131
- await unlink(wavPath).catch(() => { });
132
- await unlink(`${wavPath}.txt`).catch(() => { });
133
- }
134
- }
135
- /**
136
- * Check if voice transcription is available on this system.
137
- */
138
- export function isVoiceAvailable() {
139
- return (findBin(WHISPER_BIN_CANDIDATES) !== null &&
140
- findBin(FFMPEG_CANDIDATES) !== null &&
141
- findModel() !== null);
142
- }