@emqo/claudebridge 0.1.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.
@@ -0,0 +1,218 @@
1
+ import { spawn } from "child_process";
2
+ import { mkdirSync } from "fs";
3
+ import { join } from "path";
4
+ import { UserLock } from "./lock.js";
5
+ import { AccessControl } from "./permissions.js";
6
+ import { EndpointRotator } from "./keys.js";
7
+ export class AgentEngine {
8
+ config;
9
+ store;
10
+ lock;
11
+ rotator;
12
+ access;
13
+ constructor(config, store) {
14
+ this.config = config;
15
+ this.store = store;
16
+ this.lock = new UserLock(config.redis.enabled ? config.redis.url : undefined);
17
+ this.access = new AccessControl(config.access.allowed_users, config.access.allowed_groups);
18
+ this.rotator = new EndpointRotator(config.endpoints);
19
+ }
20
+ reloadConfig(config) {
21
+ this.config = config;
22
+ this.access.reload(config.access.allowed_users, config.access.allowed_groups);
23
+ this.rotator.reload(config.endpoints);
24
+ }
25
+ getEndpoints() {
26
+ return this.rotator.list();
27
+ }
28
+ getRotator() {
29
+ return this.rotator;
30
+ }
31
+ getIntentConfig() {
32
+ return this.config.agent.intent;
33
+ }
34
+ getEndpointCount() {
35
+ return this.rotator.count;
36
+ }
37
+ getWorkDir(userId) {
38
+ if (!this.config.workspace.isolation) {
39
+ return this.config.agent.cwd || process.cwd();
40
+ }
41
+ const dir = join(this.config.workspace.base_dir, userId);
42
+ mkdirSync(dir, { recursive: true });
43
+ return dir;
44
+ }
45
+ isLocked(userId) {
46
+ return this.lock.isLocked(userId);
47
+ }
48
+ async runStream(userId, prompt, platform, onChunk) {
49
+ const release = await this.lock.acquire(userId);
50
+ try {
51
+ this.store.addHistory(userId, platform, "user", prompt);
52
+ const memories = this.config.agent.memory?.enabled ? this.store.getMemories(userId) : [];
53
+ const memoryPrompt = memories.length ? memories.map(m => `- ${m.content}`).join("\n") : "";
54
+ const res = await this._executeWithRetry(userId, prompt, platform, onChunk, memoryPrompt);
55
+ this.store.addHistory(userId, platform, "assistant", res.text);
56
+ this.store.recordUsage(userId, platform, res.cost || 0);
57
+ if (this.config.agent.memory?.auto_summary)
58
+ this._autoSummarize(userId, prompt, res.text);
59
+ return res;
60
+ }
61
+ finally {
62
+ release();
63
+ }
64
+ }
65
+ async _executeWithRetry(userId, prompt, platform, onChunk, memoryPrompt) {
66
+ const maxRetries = Math.min(this.rotator.count, 3);
67
+ let lastErr;
68
+ for (let i = 0; i < maxRetries; i++) {
69
+ const ep = this.rotator.next();
70
+ try {
71
+ return await this._execute(userId, prompt, platform, ep, onChunk, memoryPrompt);
72
+ }
73
+ catch (err) {
74
+ lastErr = err;
75
+ const msg = String(err?.message || "");
76
+ if (msg.includes("429") || msg.includes("401") || msg.includes("529")) {
77
+ console.warn(`[agent] endpoint ${ep.name} failed, rotating`);
78
+ this.rotator.markFailed(ep);
79
+ continue;
80
+ }
81
+ throw err;
82
+ }
83
+ }
84
+ throw lastErr;
85
+ }
86
+ _execute(userId, prompt, platform, ep, onChunk, memoryPrompt) {
87
+ return new Promise((resolve, reject) => {
88
+ const sessionId = this.store.getSession(userId) || "";
89
+ const cwd = this.getWorkDir(userId);
90
+ const args = ["-p", prompt, "--verbose", "--output-format", "stream-json", "--permission-mode", this.config.agent.permission_mode || "acceptEdits"];
91
+ if (ep.model)
92
+ args.push("--model", ep.model);
93
+ if (sessionId)
94
+ args.push("-r", sessionId);
95
+ if (this.config.agent.system_prompt)
96
+ args.push("--system-prompt", this.config.agent.system_prompt);
97
+ if (memoryPrompt)
98
+ args.push("--append-system-prompt", `User memories:\n${memoryPrompt}`);
99
+ if (this.config.agent.allowed_tools?.length)
100
+ args.push("--allowed-tools", this.config.agent.allowed_tools.join(","));
101
+ if (this.config.agent.max_turns)
102
+ args.push("--max-turns", String(this.config.agent.max_turns));
103
+ if (this.config.agent.max_budget_usd)
104
+ args.push("--max-budget-usd", String(this.config.agent.max_budget_usd));
105
+ const env = { ...process.env };
106
+ env.ANTHROPIC_API_KEY = ep.api_key;
107
+ if (ep.base_url)
108
+ env.ANTHROPIC_BASE_URL = ep.base_url;
109
+ const child = spawn("claude", args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
110
+ child.stdin.end();
111
+ console.log(`[agent] spawned claude pid=${child.pid} cwd=${cwd} args=${args.join(" ")}`);
112
+ const timeoutMs = (this.config.agent.timeout_seconds || 300) * 1000;
113
+ const timer = setTimeout(() => { try {
114
+ child.kill("SIGTERM");
115
+ }
116
+ catch { } }, timeoutMs);
117
+ let fullText = "";
118
+ let newSessionId = sessionId;
119
+ let cost = 0;
120
+ let buffer = "";
121
+ child.stdout.on("data", (data) => {
122
+ const chunk = data.toString();
123
+ console.log(`[agent] stdout chunk: ${chunk.slice(0, 100)}`);
124
+ buffer += chunk;
125
+ const lines = buffer.split("\n");
126
+ buffer = lines.pop() || "";
127
+ for (const line of lines) {
128
+ if (!line.trim())
129
+ continue;
130
+ try {
131
+ const msg = JSON.parse(line);
132
+ if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
133
+ newSessionId = msg.session_id;
134
+ }
135
+ if (msg.type === "assistant" && msg.message?.content) {
136
+ for (const block of msg.message.content) {
137
+ if (block.type === "text" && block.text) {
138
+ fullText += block.text + "\n";
139
+ if (onChunk)
140
+ onChunk(block.text, fullText);
141
+ }
142
+ }
143
+ }
144
+ if (msg.type === "result") {
145
+ if (msg.result)
146
+ fullText = msg.result;
147
+ if (msg.total_cost_usd)
148
+ cost = msg.total_cost_usd;
149
+ }
150
+ }
151
+ catch { }
152
+ }
153
+ });
154
+ let stderr = "";
155
+ child.stderr.on("data", (data) => {
156
+ const s = data.toString();
157
+ stderr += s;
158
+ console.log(`[agent] stderr: ${s.slice(0, 200)}`);
159
+ });
160
+ child.on("close", (code, signal) => {
161
+ clearTimeout(timer);
162
+ console.log(`[agent] claude exited code=${code} signal=${signal} fullText=${fullText.length}chars stderr=${stderr.slice(0, 200)}`);
163
+ if (signal === "SIGTERM") {
164
+ if (newSessionId)
165
+ this.store.setSession(userId, newSessionId, platform);
166
+ resolve({ text: fullText.trim() || "(timed out)", sessionId: newSessionId, cost });
167
+ return;
168
+ }
169
+ if (newSessionId)
170
+ this.store.setSession(userId, newSessionId, platform);
171
+ if (code === 0 || fullText.trim()) {
172
+ resolve({ text: fullText.trim() || "(no response)", sessionId: newSessionId, cost });
173
+ }
174
+ else {
175
+ reject(new Error(`claude exited ${code}: ${stderr.slice(0, 500)}`));
176
+ }
177
+ });
178
+ child.on("error", reject);
179
+ });
180
+ }
181
+ _autoSummarize(userId, prompt, response) {
182
+ const ep = this.rotator.next();
183
+ const env = { ...process.env };
184
+ env.ANTHROPIC_API_KEY = ep.api_key;
185
+ if (ep.base_url)
186
+ env.ANTHROPIC_BASE_URL = ep.base_url;
187
+ const summaryPrompt = `Extract 1-3 key facts worth remembering about the user from this exchange. Output only bullet points, no preamble. If nothing worth remembering, output "NONE".\n\nUser: ${prompt.slice(0, 500)}\nAssistant: ${response.slice(0, 1000)}`;
188
+ const args = ["-p", summaryPrompt, "--output-format", "stream-json", "--max-turns", "1", "--max-budget-usd", "0.05"];
189
+ if (ep.model)
190
+ args.push("--model", ep.model);
191
+ const child = spawn("claude", args, { env, stdio: ["pipe", "pipe", "pipe"] });
192
+ child.stdin.end();
193
+ let result = "";
194
+ let buffer = "";
195
+ child.stdout.on("data", (data) => {
196
+ buffer += data.toString();
197
+ const lines = buffer.split("\n");
198
+ buffer = lines.pop() || "";
199
+ for (const line of lines) {
200
+ if (!line.trim())
201
+ continue;
202
+ try {
203
+ const msg = JSON.parse(line);
204
+ if (msg.type === "result" && msg.result)
205
+ result = msg.result;
206
+ }
207
+ catch { }
208
+ }
209
+ });
210
+ child.on("close", () => {
211
+ if (result && !result.includes("NONE")) {
212
+ this.store.addMemory(userId, result.trim(), "auto");
213
+ this.store.trimMemories(userId, this.config.agent.memory?.max_memories || 50);
214
+ console.log(`[agent] auto-summary saved for ${userId}`);
215
+ }
216
+ });
217
+ }
218
+ }
@@ -0,0 +1,58 @@
1
+ import "dotenv/config";
2
+ import { Endpoint } from "./keys.js";
3
+ export interface MemoryConfig {
4
+ enabled: boolean;
5
+ auto_summary: boolean;
6
+ max_memories: number;
7
+ }
8
+ export interface IntentConfig {
9
+ enabled: boolean;
10
+ use_claude_fallback: boolean;
11
+ }
12
+ export interface AgentConfig {
13
+ allowed_tools: string[];
14
+ permission_mode: string;
15
+ max_turns: number;
16
+ max_budget_usd: number;
17
+ system_prompt: string;
18
+ cwd: string;
19
+ timeout_seconds: number;
20
+ memory: MemoryConfig;
21
+ intent: IntentConfig;
22
+ }
23
+ export interface WorkspaceConfig {
24
+ base_dir: string;
25
+ isolation: boolean;
26
+ }
27
+ export interface AccessConfig {
28
+ allowed_users: string[];
29
+ allowed_groups: string[];
30
+ }
31
+ export interface TelegramConfig {
32
+ enabled: boolean;
33
+ token: string;
34
+ chunk_size: number;
35
+ }
36
+ export interface DiscordConfig {
37
+ enabled: boolean;
38
+ token: string;
39
+ chunk_size: number;
40
+ }
41
+ export interface RedisConfig {
42
+ enabled: boolean;
43
+ url: string;
44
+ }
45
+ export interface Config {
46
+ endpoints: Endpoint[];
47
+ agent: AgentConfig;
48
+ workspace: WorkspaceConfig;
49
+ access: AccessConfig;
50
+ redis: RedisConfig;
51
+ locale: string;
52
+ platforms: {
53
+ telegram: TelegramConfig;
54
+ discord: DiscordConfig;
55
+ };
56
+ }
57
+ export declare function loadConfig(path?: string): Config;
58
+ export declare function reloadConfig(): Config;
@@ -0,0 +1,49 @@
1
+ import { readFileSync } from "fs";
2
+ import { parse } from "yaml";
3
+ import "dotenv/config";
4
+ let _configPath = "config.yaml";
5
+ export function loadConfig(path) {
6
+ if (path)
7
+ _configPath = path;
8
+ const raw = parse(readFileSync(_configPath, "utf-8"));
9
+ const c = {
10
+ endpoints: raw.endpoints || [],
11
+ agent: {
12
+ ...raw.agent,
13
+ timeout_seconds: raw.agent?.timeout_seconds ?? 300,
14
+ memory: { enabled: true, auto_summary: true, max_memories: 50, ...raw.agent?.memory },
15
+ intent: { enabled: true, use_claude_fallback: true, ...raw.agent?.intent },
16
+ },
17
+ workspace: raw.workspace,
18
+ access: raw.access || { allowed_users: [], allowed_groups: [] },
19
+ redis: raw.redis || { enabled: false, url: "" },
20
+ locale: raw.locale || "en",
21
+ platforms: raw.platforms,
22
+ };
23
+ // defaults for each endpoint
24
+ for (const ep of c.endpoints) {
25
+ ep.name = ep.name || "default";
26
+ ep.base_url = ep.base_url || "";
27
+ ep.api_key = ep.api_key || "";
28
+ ep.model = ep.model || "";
29
+ }
30
+ // env fallback: single endpoint from env vars
31
+ if (!c.endpoints.length && process.env.ANTHROPIC_API_KEY) {
32
+ c.endpoints.push({
33
+ name: "env-default",
34
+ base_url: process.env.ANTHROPIC_BASE_URL || "",
35
+ api_key: process.env.ANTHROPIC_API_KEY,
36
+ model: process.env.ANTHROPIC_MODEL || "",
37
+ });
38
+ }
39
+ c.redis.url = c.redis.url || process.env.REDIS_URL || "";
40
+ c.platforms.telegram.token =
41
+ c.platforms.telegram.token || process.env.TELEGRAM_BOT_TOKEN || "";
42
+ c.platforms.discord = c.platforms.discord || { enabled: false, token: "", chunk_size: 1900 };
43
+ c.platforms.discord.token =
44
+ c.platforms.discord.token || process.env.DISCORD_BOT_TOKEN || "";
45
+ return c;
46
+ }
47
+ export function reloadConfig() {
48
+ return loadConfig();
49
+ }
@@ -0,0 +1,5 @@
1
+ export declare function t(locale: string, key: string, vars?: Record<string, string | number>): string;
2
+ export declare function getCommandDescriptions(locale: string): {
3
+ command: string;
4
+ description: string;
5
+ }[];
@@ -0,0 +1,102 @@
1
+ const messages = {
2
+ en: {
3
+ help: "ClaudeBridge ready.\n\nCommands:\n/new - clear session\n/usage - your stats\n/allusage - all stats\n/history - recent chats\n/model - endpoints info\n/reload - reload config\n/remember <text> - save a memory\n/memories - list memories\n/forget - clear all memories\n/task <desc> - add a task\n/tasks - list pending tasks\n/done <id> - complete a task\n/remind <minutes>m <desc> - set reminder\n/auto <desc> - queue auto task\n/autotasks - list auto tasks\n/cancelauto <id> - cancel auto task\n\nSend text or files to chat. Unknown /commands are forwarded to Claude.",
4
+ session_cleared: "Session cleared.",
5
+ no_usage: "No usage data.",
6
+ no_history: "No history.",
7
+ config_reloaded: "Config reloaded.",
8
+ reload_failed: "Reload failed: ",
9
+ memory_saved: "✅ Memory saved.",
10
+ no_memories: "No memories.",
11
+ memories_cleared: "✅ All memories cleared.",
12
+ task_added: "✅ Task #{id} added.",
13
+ no_tasks: "No pending tasks.",
14
+ task_done: "✅ Task #{id} done.",
15
+ task_not_found: "Task #{id} not found.",
16
+ reminder_set: "✅ Reminder #{id} set for {mins}m.",
17
+ auto_queued: "🤖 Auto task #{id} queued. Will execute when idle.",
18
+ no_auto_tasks: "No auto tasks.",
19
+ auto_cancelled: "✅ Auto task #{id} cancelled.",
20
+ auto_starting: "🤖 Auto task #{id} starting:\n{desc}",
21
+ auto_done: "✅ Auto task #{id} done (cost: ${cost}):",
22
+ auto_failed: "❌ Auto task #{id} failed: {err}",
23
+ thinking: "⏳ Thinking...",
24
+ still_processing: "⏳ Still processing...",
25
+ upload_failed: "Upload failed: ",
26
+ reminder_notify: "⏰ Reminder: {desc}",
27
+ usage_remember: "Usage: /remember <text>",
28
+ usage_task: "Usage: /task <description>",
29
+ usage_done: "Usage: /done <task_id>",
30
+ usage_remind: "Usage: /remind <minutes>m <description>",
31
+ usage_auto: "Usage: /auto <task description>",
32
+ usage_cancelauto: "Usage: /cancelauto <task_id>",
33
+ intent_reminder_set: "✅ Reminder detected: in {mins}m — {desc} (#{id})",
34
+ intent_task_added: "✅ Task detected #{id}: {desc}",
35
+ intent_memory_saved: "✅ Remembered: {desc}",
36
+ },
37
+ zh: {
38
+ help: "ClaudeBridge 就绪。\n\n命令:\n/new - 清除会话\n/usage - 你的用量\n/allusage - 所有用量\n/history - 最近对话\n/model - 端点信息\n/reload - 重载配置\n/remember <文本> - 保存记忆\n/memories - 查看记忆\n/forget - 清除所有记忆\n/task <描述> - 添加任务\n/tasks - 查看待办\n/done <id> - 完成任务\n/remind <分钟>m <描述> - 设置提醒\n/auto <描述> - 排队自动任务\n/autotasks - 查看自动任务\n/cancelauto <id> - 取消自动任务\n\n发送文字或文件即可对话。未知 / 命令会转发给 Claude。",
39
+ session_cleared: "会话已清除。",
40
+ no_usage: "暂无用量数据。",
41
+ no_history: "暂无历史记录。",
42
+ config_reloaded: "配置已重载。",
43
+ reload_failed: "重载失败:",
44
+ memory_saved: "✅ 记忆已保存。",
45
+ no_memories: "暂无记忆。",
46
+ memories_cleared: "✅ 所有记忆已清除。",
47
+ task_added: "✅ 任务 #{id} 已添加。",
48
+ no_tasks: "暂无待办任务。",
49
+ task_done: "✅ 任务 #{id} 已完成。",
50
+ task_not_found: "任务 #{id} 未找到。",
51
+ reminder_set: "✅ 提醒 #{id} 已设置,{mins}分钟后触发。",
52
+ auto_queued: "🤖 自动任务 #{id} 已排队,空闲时执行。",
53
+ no_auto_tasks: "暂无自动任务。",
54
+ auto_cancelled: "✅ 自动任务 #{id} 已取消。",
55
+ auto_starting: "🤖 自动任务 #{id} 开始执行:\n{desc}",
56
+ auto_done: "✅ 自动任务 #{id} 完成(花费:${cost}):",
57
+ auto_failed: "❌ 自动任务 #{id} 失败:{err}",
58
+ thinking: "⏳ 思考中...",
59
+ still_processing: "⏳ 仍在处理...",
60
+ upload_failed: "上传失败:",
61
+ reminder_notify: "⏰ 提醒:{desc}",
62
+ usage_remember: "用法:/remember <文本>",
63
+ usage_task: "用法:/task <描述>",
64
+ usage_done: "用法:/done <任务ID>",
65
+ usage_remind: "用法:/remind <分钟>m <描述>",
66
+ usage_auto: "用法:/auto <任务描述>",
67
+ usage_cancelauto: "用法:/cancelauto <任务ID>",
68
+ intent_reminder_set: "✅ 已识别提醒:{mins}分钟后 — {desc} (#{id})",
69
+ intent_task_added: "✅ 已识别任务 #{id}:{desc}",
70
+ intent_memory_saved: "✅ 已识别并记住:{desc}",
71
+ },
72
+ };
73
+ const commandDescriptions = {
74
+ en: {
75
+ new: "Clear session", usage: "Your usage stats", allusage: "All users usage",
76
+ history: "Recent conversations", model: "Current model/endpoints", reload: "Reload config",
77
+ remember: "Save a memory", memories: "List your memories", forget: "Clear all memories",
78
+ task: "Add a task", tasks: "List pending tasks", done: "Complete a task",
79
+ remind: "Set a timed reminder", auto: "Queue auto task (runs when idle)",
80
+ autotasks: "List auto tasks", cancelauto: "Cancel an auto task", help: "Show all commands",
81
+ },
82
+ zh: {
83
+ new: "清除会话", usage: "你的用量", allusage: "所有用量",
84
+ history: "最近对话", model: "端点信息", reload: "重载配置",
85
+ remember: "保存记忆", memories: "查看记忆", forget: "清除所有记忆",
86
+ task: "添加任务", tasks: "查看待办", done: "完成任务",
87
+ remind: "设置提醒", auto: "排队自动任务(空闲执行)",
88
+ autotasks: "查看自动任务", cancelauto: "取消自动任务", help: "显示所有命令",
89
+ },
90
+ };
91
+ export function t(locale, key, vars) {
92
+ const lang = messages[locale] ? locale : "en";
93
+ let msg = messages[lang][key] ?? messages.en[key] ?? key;
94
+ if (vars)
95
+ for (const [k, v] of Object.entries(vars))
96
+ msg = msg.replaceAll(`{${k}}`, String(v));
97
+ return msg;
98
+ }
99
+ export function getCommandDescriptions(locale) {
100
+ const lang = commandDescriptions[locale] ? locale : "en";
101
+ return Object.entries(commandDescriptions[lang]).map(([command, description]) => ({ command, description }));
102
+ }
@@ -0,0 +1,10 @@
1
+ import { IntentConfig } from "./config.js";
2
+ import { EndpointRotator } from "./keys.js";
3
+ export interface IntentResult {
4
+ type: "reminder" | "task" | "memory" | "forget" | "clear_session" | "none";
5
+ description?: string;
6
+ minutes?: number;
7
+ }
8
+ export declare function regexDetect(text: string): IntentResult;
9
+ export declare function claudeDetect(text: string, rotator: EndpointRotator): Promise<IntentResult>;
10
+ export declare function detectIntent(text: string, rotator: EndpointRotator, config?: IntentConfig): Promise<IntentResult>;
@@ -0,0 +1,84 @@
1
+ import { spawn } from "child_process";
2
+ const patterns = [
3
+ [/(?:提醒我?|remind\s*me)\s*(\d+)\s*(?:分钟|min(?:ute)?s?|m)\s*(?:后|later)?\s*(.+)/i,
4
+ m => ({ type: "reminder", minutes: +m[1], description: m[2].trim() })],
5
+ [/^(?:添加|加个?|创建|add|create)\s*(?:一个)?(?:任务|task)[::\s]*(.+)/i,
6
+ m => ({ type: "task", description: m[1].trim() })],
7
+ [/^(?:记住|记下|remember)\s*(?:that|this)?[::\s]*(.+)/i,
8
+ m => ({ type: "memory", description: m[1].trim() })],
9
+ [/^(?:忘记所有|清除记忆|forget\s*all|clear\s*memo)/i,
10
+ () => ({ type: "forget" })],
11
+ [/^(?:新会话|新对话|new\s*session|clear\s*session)/i,
12
+ () => ({ type: "clear_session" })],
13
+ ];
14
+ export function regexDetect(text) {
15
+ for (const [re, fn] of patterns) {
16
+ const m = text.match(re);
17
+ if (m)
18
+ return fn(m);
19
+ }
20
+ return { type: "none" };
21
+ }
22
+ export function claudeDetect(text, rotator) {
23
+ return new Promise(resolve => {
24
+ const timeout = setTimeout(() => resolve({ type: "none" }), 15000);
25
+ const ep = rotator.next();
26
+ const env = { ...process.env };
27
+ env.ANTHROPIC_API_KEY = ep.api_key;
28
+ if (ep.base_url)
29
+ env.ANTHROPIC_BASE_URL = ep.base_url;
30
+ const prompt = `Classify the user's intent. Output ONLY one JSON line, no other text:
31
+ {"type":"reminder","minutes":5,"description":"check server"}
32
+ {"type":"task","description":"buy milk"}
33
+ {"type":"memory","description":"I like TypeScript"}
34
+ {"type":"none"}
35
+
36
+ User: ${text.slice(0, 500)}`;
37
+ const args = ["-p", prompt, "--output-format", "stream-json", "--max-turns", "1", "--max-budget-usd", "0.005"];
38
+ if (ep.model)
39
+ args.push("--model", ep.model);
40
+ const child = spawn("claude", args, { env, stdio: ["pipe", "pipe", "pipe"] });
41
+ child.stdin.end();
42
+ let result = "";
43
+ let buffer = "";
44
+ child.stdout.on("data", (d) => {
45
+ buffer += d.toString();
46
+ const lines = buffer.split("\n");
47
+ buffer = lines.pop() || "";
48
+ for (const line of lines) {
49
+ if (!line.trim())
50
+ continue;
51
+ try {
52
+ const msg = JSON.parse(line);
53
+ if (msg.type === "result" && msg.result)
54
+ result = msg.result;
55
+ }
56
+ catch { }
57
+ }
58
+ });
59
+ child.on("close", () => {
60
+ clearTimeout(timeout);
61
+ try {
62
+ const m = result.match(/\{[^}]+\}/);
63
+ if (m) {
64
+ const obj = JSON.parse(m[0]);
65
+ if (obj.type && obj.type !== "none") {
66
+ resolve(obj);
67
+ return;
68
+ }
69
+ }
70
+ }
71
+ catch { }
72
+ resolve({ type: "none" });
73
+ });
74
+ child.on("error", () => { clearTimeout(timeout); resolve({ type: "none" }); });
75
+ });
76
+ }
77
+ export async function detectIntent(text, rotator, config) {
78
+ const r = regexDetect(text);
79
+ if (r.type !== "none")
80
+ return r;
81
+ if (config?.use_claude_fallback !== false)
82
+ return claudeDetect(text, rotator);
83
+ return { type: "none" };
84
+ }
@@ -0,0 +1,22 @@
1
+ export interface Endpoint {
2
+ name: string;
3
+ base_url: string;
4
+ api_key: string;
5
+ model: string;
6
+ }
7
+ /** Round-robin endpoint rotation with cooldown on failure */
8
+ export declare class EndpointRotator {
9
+ private endpoints;
10
+ private index;
11
+ private cooldowns;
12
+ private cooldownMs;
13
+ constructor(endpoints: Endpoint[]);
14
+ next(): Endpoint;
15
+ markFailed(ep: Endpoint): void;
16
+ get count(): number;
17
+ list(): {
18
+ name: string;
19
+ model: string;
20
+ }[];
21
+ reload(endpoints: Endpoint[]): void;
22
+ }
@@ -0,0 +1,42 @@
1
+ /** Round-robin endpoint rotation with cooldown on failure */
2
+ export class EndpointRotator {
3
+ endpoints;
4
+ index = 0;
5
+ cooldowns = new Map();
6
+ cooldownMs = 60_000;
7
+ constructor(endpoints) {
8
+ this.endpoints = endpoints.filter(e => e.api_key);
9
+ if (!this.endpoints.length)
10
+ throw new Error("No endpoints configured");
11
+ }
12
+ next() {
13
+ const now = Date.now();
14
+ const len = this.endpoints.length;
15
+ for (let i = 0; i < len; i++) {
16
+ const idx = (this.index + i) % len;
17
+ if ((this.cooldowns.get(idx) || 0) <= now) {
18
+ this.index = (idx + 1) % len;
19
+ return this.endpoints[idx];
20
+ }
21
+ }
22
+ const idx = this.index;
23
+ this.index = (idx + 1) % len;
24
+ return this.endpoints[idx];
25
+ }
26
+ markFailed(ep) {
27
+ const idx = this.endpoints.indexOf(ep);
28
+ if (idx >= 0)
29
+ this.cooldowns.set(idx, Date.now() + this.cooldownMs);
30
+ }
31
+ get count() {
32
+ return this.endpoints.length;
33
+ }
34
+ list() {
35
+ return this.endpoints.map(e => ({ name: e.name, model: e.model }));
36
+ }
37
+ reload(endpoints) {
38
+ this.endpoints = endpoints.filter(e => e.api_key);
39
+ this.index = 0;
40
+ this.cooldowns.clear();
41
+ }
42
+ }
@@ -0,0 +1,12 @@
1
+ /** Per-user lock with Redis backend, memory fallback */
2
+ export declare class UserLock {
3
+ private memLocks;
4
+ private redis;
5
+ private prefix;
6
+ private ttl;
7
+ constructor(redisUrl?: string);
8
+ acquire(userId: string): Promise<() => void>;
9
+ isLocked(userId: string): boolean;
10
+ private _acquireMem;
11
+ private _acquireRedis;
12
+ }
@@ -0,0 +1,57 @@
1
+ import Redis from "ioredis";
2
+ /** Per-user lock with Redis backend, memory fallback */
3
+ export class UserLock {
4
+ memLocks = new Map();
5
+ redis = null;
6
+ prefix = "claudebridge:lock:";
7
+ ttl = 300; // 5 min max lock
8
+ constructor(redisUrl) {
9
+ if (redisUrl) {
10
+ try {
11
+ this.redis = new Redis(redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
12
+ this.redis.connect().catch(() => {
13
+ console.warn("[lock] Redis unavailable, falling back to memory");
14
+ this.redis = null;
15
+ });
16
+ }
17
+ catch {
18
+ this.redis = null;
19
+ }
20
+ }
21
+ }
22
+ async acquire(userId) {
23
+ if (this.redis)
24
+ return this._acquireRedis(userId);
25
+ return this._acquireMem(userId);
26
+ }
27
+ isLocked(userId) {
28
+ if (this.redis)
29
+ return false; // can't sync-check redis, rely on acquire
30
+ return this.memLocks.has(userId);
31
+ }
32
+ async _acquireMem(userId) {
33
+ while (this.memLocks.has(userId)) {
34
+ await this.memLocks.get(userId);
35
+ }
36
+ let release;
37
+ const p = new Promise((r) => (release = r));
38
+ this.memLocks.set(userId, p);
39
+ return () => {
40
+ this.memLocks.delete(userId);
41
+ release();
42
+ };
43
+ }
44
+ async _acquireRedis(userId) {
45
+ const key = this.prefix + userId;
46
+ // spin until acquired
47
+ while (true) {
48
+ const ok = await this.redis.set(key, "1", "EX", this.ttl, "NX");
49
+ if (ok)
50
+ break;
51
+ await new Promise((r) => setTimeout(r, 500));
52
+ }
53
+ return async () => {
54
+ await this.redis.del(key).catch(() => { });
55
+ };
56
+ }
57
+ }
@@ -0,0 +1,7 @@
1
+ /** Escape text for Telegram MarkdownV2 */
2
+ export declare function escapeMarkdownV2(text: string): string;
3
+ /**
4
+ * Convert standard markdown to Telegram MarkdownV2.
5
+ * Handles code blocks (preserve as-is), inline code, bold, italic, links.
6
+ */
7
+ export declare function toTelegramMarkdown(text: string): string;