@emqo/claudebridge 0.7.0 → 0.9.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/config.yaml.example +12 -3
- package/dist/adapters/base.d.ts +1 -0
- package/dist/adapters/discord.d.ts +4 -0
- package/dist/adapters/discord.js +99 -23
- package/dist/adapters/telegram.d.ts +4 -0
- package/dist/adapters/telegram.js +138 -60
- package/dist/core/agent.d.ts +37 -0
- package/dist/core/agent.js +251 -232
- package/dist/core/config.d.ts +2 -70
- package/dist/core/config.js +9 -38
- package/dist/core/i18n.js +12 -4
- package/dist/core/keys.d.ts +2 -10
- package/dist/core/keys.js +7 -22
- package/dist/core/lock.d.ts +8 -4
- package/dist/core/lock.js +28 -17
- package/dist/core/logger.d.ts +11 -0
- package/dist/core/logger.js +24 -0
- package/dist/core/router.d.ts +25 -0
- package/dist/core/router.js +125 -0
- package/dist/core/schema.d.ts +166 -0
- package/dist/core/schema.js +85 -0
- package/dist/core/session.d.ts +50 -0
- package/dist/core/session.js +100 -0
- package/dist/core/store.d.ts +52 -15
- package/dist/core/store.js +105 -19
- package/dist/ctl.js +32 -30
- package/dist/index.js +42 -13
- package/dist/providers/base.d.ts +26 -0
- package/dist/providers/base.js +1 -0
- package/dist/providers/claude.d.ts +9 -0
- package/dist/providers/claude.js +53 -0
- package/dist/providers/codex.d.ts +9 -0
- package/dist/providers/codex.js +35 -0
- package/dist/providers/registry.d.ts +2 -0
- package/dist/providers/registry.js +12 -0
- package/dist/skills/bridge.d.ts +2 -0
- package/dist/skills/bridge.js +2 -0
- package/dist/webhook.js +7 -5
- package/package.json +8 -4
package/dist/core/config.d.ts
CHANGED
|
@@ -1,73 +1,5 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
enabled: boolean;
|
|
5
|
-
auto_summary: boolean;
|
|
6
|
-
max_memories: number;
|
|
7
|
-
}
|
|
8
|
-
export interface SkillConfig {
|
|
9
|
-
enabled: boolean;
|
|
10
|
-
}
|
|
11
|
-
export interface AgentConfig {
|
|
12
|
-
allowed_tools: string[];
|
|
13
|
-
permission_mode: string;
|
|
14
|
-
max_turns: number;
|
|
15
|
-
max_budget_usd: number;
|
|
16
|
-
system_prompt: string;
|
|
17
|
-
cwd: string;
|
|
18
|
-
timeout_seconds: number;
|
|
19
|
-
max_parallel: number;
|
|
20
|
-
memory: MemoryConfig;
|
|
21
|
-
skill: SkillConfig;
|
|
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 WebhookConfig {
|
|
46
|
-
enabled: boolean;
|
|
47
|
-
port: number;
|
|
48
|
-
token: string;
|
|
49
|
-
github_secret: string;
|
|
50
|
-
}
|
|
51
|
-
export interface CronEntry {
|
|
52
|
-
schedule_minutes: number;
|
|
53
|
-
user_id: string;
|
|
54
|
-
platform: string;
|
|
55
|
-
chat_id: string;
|
|
56
|
-
description: string;
|
|
57
|
-
}
|
|
58
|
-
export interface Config {
|
|
59
|
-
endpoints: Endpoint[];
|
|
60
|
-
agent: AgentConfig;
|
|
61
|
-
workspace: WorkspaceConfig;
|
|
62
|
-
access: AccessConfig;
|
|
63
|
-
redis: RedisConfig;
|
|
64
|
-
locale: string;
|
|
65
|
-
platforms: {
|
|
66
|
-
telegram: TelegramConfig;
|
|
67
|
-
discord: DiscordConfig;
|
|
68
|
-
};
|
|
69
|
-
webhook: WebhookConfig;
|
|
70
|
-
cron: CronEntry[];
|
|
71
|
-
}
|
|
2
|
+
export type { Config, Endpoint, AgentConfig, MemoryConfig, SkillConfig, SessionConfig, WorkspaceConfig, AccessConfig, TelegramConfig, DiscordConfig, RedisConfig, WebhookConfig, CronEntry, } from "./schema.js";
|
|
3
|
+
import type { Config } from "./schema.js";
|
|
72
4
|
export declare function loadConfig(path?: string): Config;
|
|
73
5
|
export declare function reloadConfig(): Config;
|
package/dist/core/config.js
CHANGED
|
@@ -1,50 +1,21 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
import { parse } from "yaml";
|
|
3
3
|
import "dotenv/config";
|
|
4
|
+
import { ConfigSchema } from "./schema.js";
|
|
4
5
|
let _configPath = "config.yaml";
|
|
5
6
|
export function loadConfig(path) {
|
|
6
7
|
if (path)
|
|
7
8
|
_configPath = path;
|
|
8
|
-
const raw = parse(readFileSync(_configPath, "utf-8"));
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
timeout_seconds: raw.agent?.timeout_seconds ?? 300,
|
|
14
|
-
max_parallel: raw.agent?.max_parallel ?? 1,
|
|
15
|
-
memory: { enabled: true, auto_summary: true, max_memories: 50, ...raw.agent?.memory },
|
|
16
|
-
skill: { enabled: true, ...raw.agent?.skill },
|
|
17
|
-
},
|
|
18
|
-
workspace: raw.workspace,
|
|
19
|
-
access: raw.access || { allowed_users: [], allowed_groups: [] },
|
|
20
|
-
redis: raw.redis || { enabled: false, url: "" },
|
|
21
|
-
locale: raw.locale || "en",
|
|
22
|
-
platforms: raw.platforms,
|
|
23
|
-
webhook: { enabled: false, port: 3100, token: "", github_secret: "", ...raw.webhook },
|
|
24
|
-
cron: raw.cron || [],
|
|
25
|
-
};
|
|
26
|
-
// defaults for each endpoint
|
|
27
|
-
for (const ep of c.endpoints) {
|
|
28
|
-
ep.name = ep.name || "default";
|
|
29
|
-
ep.base_url = ep.base_url || "";
|
|
30
|
-
ep.api_key = ep.api_key || "";
|
|
31
|
-
ep.model = ep.model || "";
|
|
32
|
-
}
|
|
33
|
-
// env fallback: single endpoint from env vars
|
|
34
|
-
if (!c.endpoints.length && process.env.ANTHROPIC_API_KEY) {
|
|
35
|
-
c.endpoints.push({
|
|
36
|
-
name: "env-default",
|
|
37
|
-
base_url: process.env.ANTHROPIC_BASE_URL || "",
|
|
38
|
-
api_key: process.env.ANTHROPIC_API_KEY,
|
|
39
|
-
model: process.env.ANTHROPIC_MODEL || "",
|
|
40
|
-
});
|
|
9
|
+
const raw = parse(readFileSync(_configPath, "utf-8")) || {};
|
|
10
|
+
const result = ConfigSchema.safeParse(raw);
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
const issues = result.error.issues.map(i => ` ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
13
|
+
throw new Error(`Config validation failed:\n${issues}`);
|
|
41
14
|
}
|
|
15
|
+
const c = result.data;
|
|
42
16
|
c.redis.url = c.redis.url || process.env.REDIS_URL || "";
|
|
43
|
-
c.platforms.telegram.token =
|
|
44
|
-
|
|
45
|
-
c.platforms.discord = c.platforms.discord || { enabled: false, token: "", chunk_size: 1900 };
|
|
46
|
-
c.platforms.discord.token =
|
|
47
|
-
c.platforms.discord.token || process.env.DISCORD_BOT_TOKEN || "";
|
|
17
|
+
c.platforms.telegram.token = c.platforms.telegram.token || process.env.TELEGRAM_BOT_TOKEN || "";
|
|
18
|
+
c.platforms.discord.token = c.platforms.discord.token || process.env.DISCORD_BOT_TOKEN || "";
|
|
48
19
|
return c;
|
|
49
20
|
}
|
|
50
21
|
export function reloadConfig() {
|
package/dist/core/i18n.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const messages = {
|
|
2
2
|
en: {
|
|
3
|
-
help: "ClaudeBridge ready.\n\nManagement commands:\n/new - clear session\n/usage - your stats\n/allusage - all stats\n/history - recent chats\n/model - endpoints info\n/status - auto task status\n/reload - reload config\n/help - show this help\n\nJust chat naturally to manage memories, tasks, reminders, and more — Claude handles it all.",
|
|
3
|
+
help: "ClaudeBridge ready.\n\nManagement commands:\n/new - clear session\n/usage - your stats\n/allusage - all stats\n/history - recent chats\n/model - endpoints info\n/status - auto task status\n/sessions - active sessions\n/reload - reload config\n/help - show this help\n\nJust chat naturally to manage memories, tasks, reminders, and more — Claude handles it all.",
|
|
4
4
|
session_cleared: "Session cleared.",
|
|
5
5
|
no_usage: "No usage data.",
|
|
6
6
|
no_history: "No history.",
|
|
@@ -23,9 +23,13 @@ const messages = {
|
|
|
23
23
|
no_auto_tasks: "No auto tasks found.",
|
|
24
24
|
status_report: "Auto Task Status:",
|
|
25
25
|
chain_progress: "Chain #{id} progress: {done}/{total} done{cost}",
|
|
26
|
+
sessions_list: "Active sessions:",
|
|
27
|
+
no_sessions: "No active sessions.",
|
|
28
|
+
session_created: "New session created: {label}",
|
|
29
|
+
session_limit: "Session limit reached. Oldest idle session closed.",
|
|
26
30
|
},
|
|
27
31
|
zh: {
|
|
28
|
-
help: "ClaudeBridge 就绪。\n\n管理命令:\n/new - 清除会话\n/usage - 你的用量\n/allusage - 所有用量\n/history - 最近对话\n/model - 端点信息\n/status - 自动任务状态\n/reload - 重载配置\n/help - 显示帮助\n\n直接对话即可管理记忆、任务、提醒等 — Claude 会自动处理。",
|
|
32
|
+
help: "ClaudeBridge 就绪。\n\n管理命令:\n/new - 清除会话\n/usage - 你的用量\n/allusage - 所有用量\n/history - 最近对话\n/model - 端点信息\n/status - 自动任务状态\n/sessions - 活跃会话\n/reload - 重载配置\n/help - 显示帮助\n\n直接对话即可管理记忆、任务、提醒等 — Claude 会自动处理。",
|
|
29
33
|
session_cleared: "会话已清除。",
|
|
30
34
|
no_usage: "暂无用量数据。",
|
|
31
35
|
no_history: "暂无历史记录。",
|
|
@@ -48,18 +52,22 @@ const messages = {
|
|
|
48
52
|
no_auto_tasks: "暂无自动任务。",
|
|
49
53
|
status_report: "自动任务状态:",
|
|
50
54
|
chain_progress: "任务链 #{id} 进度:{done}/{total} 完成{cost}",
|
|
55
|
+
sessions_list: "活跃会话:",
|
|
56
|
+
no_sessions: "暂无活跃会话。",
|
|
57
|
+
session_created: "新会话已创建:{label}",
|
|
58
|
+
session_limit: "会话数已达上限,已关闭最旧的空闲会话。",
|
|
51
59
|
},
|
|
52
60
|
};
|
|
53
61
|
const commandDescriptions = {
|
|
54
62
|
en: {
|
|
55
63
|
new: "Clear session", usage: "Your usage stats", allusage: "All users usage",
|
|
56
64
|
history: "Recent conversations", model: "Current model/endpoints", status: "Auto task status",
|
|
57
|
-
reload: "Reload config", help: "Show all commands",
|
|
65
|
+
sessions: "Active sessions", reload: "Reload config", help: "Show all commands",
|
|
58
66
|
},
|
|
59
67
|
zh: {
|
|
60
68
|
new: "清除会话", usage: "你的用量", allusage: "所有用量",
|
|
61
69
|
history: "最近对话", model: "端点信息", status: "自动任务状态",
|
|
62
|
-
reload: "重载配置", help: "显示帮助",
|
|
70
|
+
sessions: "活跃会话", reload: "重载配置", help: "显示帮助",
|
|
63
71
|
},
|
|
64
72
|
};
|
|
65
73
|
export function t(locale, key, vars) {
|
package/dist/core/keys.d.ts
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
base_url: string;
|
|
4
|
-
api_key: string;
|
|
5
|
-
model: string;
|
|
6
|
-
}
|
|
7
|
-
/** Round-robin endpoint rotation with cooldown on failure */
|
|
1
|
+
import type { Endpoint } from "./config.js";
|
|
2
|
+
/** Simple round-robin endpoint selector — CLI handles its own auth */
|
|
8
3
|
export declare class EndpointRotator {
|
|
9
4
|
private endpoints;
|
|
10
5
|
private index;
|
|
11
|
-
private cooldowns;
|
|
12
|
-
private cooldownMs;
|
|
13
6
|
constructor(endpoints: Endpoint[]);
|
|
14
7
|
next(): Endpoint;
|
|
15
|
-
markFailed(ep: Endpoint): void;
|
|
16
8
|
get count(): number;
|
|
17
9
|
list(): {
|
|
18
10
|
name: string;
|
package/dist/core/keys.js
CHANGED
|
@@ -1,31 +1,17 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Simple round-robin endpoint selector — CLI handles its own auth */
|
|
2
2
|
export class EndpointRotator {
|
|
3
3
|
endpoints;
|
|
4
4
|
index = 0;
|
|
5
|
-
cooldowns = new Map();
|
|
6
|
-
cooldownMs = 60_000;
|
|
7
5
|
constructor(endpoints) {
|
|
8
|
-
this.endpoints = endpoints
|
|
6
|
+
this.endpoints = endpoints;
|
|
9
7
|
}
|
|
10
8
|
next() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if ((this.cooldowns.get(idx) || 0) <= now) {
|
|
16
|
-
this.index = (idx + 1) % len;
|
|
17
|
-
return this.endpoints[idx];
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
const idx = this.index;
|
|
21
|
-
this.index = (idx + 1) % len;
|
|
9
|
+
if (!this.endpoints.length)
|
|
10
|
+
throw new Error("No endpoints configured");
|
|
11
|
+
const idx = this.index % this.endpoints.length;
|
|
12
|
+
this.index = (idx + 1) % this.endpoints.length;
|
|
22
13
|
return this.endpoints[idx];
|
|
23
14
|
}
|
|
24
|
-
markFailed(ep) {
|
|
25
|
-
const idx = this.endpoints.indexOf(ep);
|
|
26
|
-
if (idx >= 0)
|
|
27
|
-
this.cooldowns.set(idx, Date.now() + this.cooldownMs);
|
|
28
|
-
}
|
|
29
15
|
get count() {
|
|
30
16
|
return this.endpoints.length;
|
|
31
17
|
}
|
|
@@ -33,8 +19,7 @@ export class EndpointRotator {
|
|
|
33
19
|
return this.endpoints.map(e => ({ name: e.name, model: e.model }));
|
|
34
20
|
}
|
|
35
21
|
reload(endpoints) {
|
|
36
|
-
this.endpoints = endpoints
|
|
22
|
+
this.endpoints = endpoints;
|
|
37
23
|
this.index = 0;
|
|
38
|
-
this.cooldowns.clear();
|
|
39
24
|
}
|
|
40
25
|
}
|
package/dist/core/lock.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
/** Per-
|
|
2
|
-
|
|
1
|
+
/** Per-session lock with Redis backend, memory fallback.
|
|
2
|
+
* Renamed from UserLock to SessionLock — key is now subSessionId, not userId.
|
|
3
|
+
* Multiple sub-sessions for the same user can run concurrently. */
|
|
4
|
+
export declare class SessionLock {
|
|
3
5
|
private memLocks;
|
|
4
6
|
private redis;
|
|
5
7
|
private prefix;
|
|
6
8
|
private ttl;
|
|
7
9
|
constructor(redisUrl?: string);
|
|
8
|
-
acquire(
|
|
9
|
-
isLocked(
|
|
10
|
+
acquire(sessionId: string): Promise<() => void>;
|
|
11
|
+
isLocked(sessionId: string): boolean;
|
|
12
|
+
/** Return which of the given keys are currently locked (memory backend only) */
|
|
13
|
+
isAnyLocked(keys: string[]): string[];
|
|
10
14
|
private _acquireMem;
|
|
11
15
|
private _acquireRedis;
|
|
12
16
|
}
|
package/dist/core/lock.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import Redis from "ioredis";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { log as rootLog } from "./logger.js";
|
|
3
|
+
const log = rootLog.child("lock");
|
|
4
|
+
/** Per-session lock with Redis backend, memory fallback.
|
|
5
|
+
* Renamed from UserLock to SessionLock — key is now subSessionId, not userId.
|
|
6
|
+
* Multiple sub-sessions for the same user can run concurrently. */
|
|
7
|
+
export class SessionLock {
|
|
4
8
|
memLocks = new Map();
|
|
5
9
|
redis = null;
|
|
6
|
-
prefix = "claudebridge:lock:";
|
|
10
|
+
prefix = "claudebridge:lock:session:";
|
|
7
11
|
ttl = 300; // 5 min max lock
|
|
8
12
|
constructor(redisUrl) {
|
|
9
13
|
if (redisUrl) {
|
|
10
14
|
try {
|
|
11
15
|
this.redis = new Redis(redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
|
|
12
16
|
this.redis.connect().catch(() => {
|
|
13
|
-
|
|
17
|
+
log.warn("Redis unavailable, falling back to memory");
|
|
14
18
|
this.redis = null;
|
|
15
19
|
});
|
|
16
20
|
}
|
|
@@ -19,35 +23,42 @@ export class UserLock {
|
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
}
|
|
22
|
-
async acquire(
|
|
26
|
+
async acquire(sessionId) {
|
|
23
27
|
if (this.redis)
|
|
24
|
-
return this._acquireRedis(
|
|
25
|
-
return this._acquireMem(
|
|
28
|
+
return this._acquireRedis(sessionId);
|
|
29
|
+
return this._acquireMem(sessionId);
|
|
26
30
|
}
|
|
27
|
-
isLocked(
|
|
31
|
+
isLocked(sessionId) {
|
|
28
32
|
if (this.redis)
|
|
29
33
|
return false; // can't sync-check redis, rely on acquire
|
|
30
|
-
return this.memLocks.has(
|
|
34
|
+
return this.memLocks.has(sessionId);
|
|
31
35
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
/** Return which of the given keys are currently locked (memory backend only) */
|
|
37
|
+
isAnyLocked(keys) {
|
|
38
|
+
return keys.filter(k => this.memLocks.has(k));
|
|
39
|
+
}
|
|
40
|
+
async _acquireMem(sessionId) {
|
|
41
|
+
while (this.memLocks.has(sessionId)) {
|
|
42
|
+
await this.memLocks.get(sessionId);
|
|
35
43
|
}
|
|
36
44
|
let release;
|
|
37
45
|
const p = new Promise((r) => (release = r));
|
|
38
|
-
this.memLocks.set(
|
|
46
|
+
this.memLocks.set(sessionId, p);
|
|
39
47
|
return () => {
|
|
40
|
-
this.memLocks.delete(
|
|
48
|
+
this.memLocks.delete(sessionId);
|
|
41
49
|
release();
|
|
42
50
|
};
|
|
43
51
|
}
|
|
44
|
-
async _acquireRedis(
|
|
45
|
-
const key = this.prefix +
|
|
46
|
-
//
|
|
52
|
+
async _acquireRedis(sessionId) {
|
|
53
|
+
const key = this.prefix + sessionId;
|
|
54
|
+
const maxWait = this.ttl * 1000 + 5000; // TTL + 5s grace
|
|
55
|
+
const start = Date.now();
|
|
47
56
|
while (true) {
|
|
48
57
|
const ok = await this.redis.set(key, "1", "EX", this.ttl, "NX");
|
|
49
58
|
if (ok)
|
|
50
59
|
break;
|
|
60
|
+
if (Date.now() - start > maxWait)
|
|
61
|
+
throw new Error(`Lock timeout for session ${sessionId}`);
|
|
51
62
|
await new Promise((r) => setTimeout(r, 500));
|
|
52
63
|
}
|
|
53
64
|
return async () => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
2
|
+
export declare function setLogLevel(level: LogLevel): void;
|
|
3
|
+
export declare function getLogLevel(): LogLevel;
|
|
4
|
+
export interface Logger {
|
|
5
|
+
debug(msg: string, extra?: Record<string, unknown>): void;
|
|
6
|
+
info(msg: string, extra?: Record<string, unknown>): void;
|
|
7
|
+
warn(msg: string, extra?: Record<string, unknown>): void;
|
|
8
|
+
error(msg: string, extra?: Record<string, unknown>): void;
|
|
9
|
+
child(module: string): Logger;
|
|
10
|
+
}
|
|
11
|
+
export declare const log: Logger;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
2
|
+
let globalLevel = "info";
|
|
3
|
+
export function setLogLevel(level) { globalLevel = level; }
|
|
4
|
+
export function getLogLevel() { return globalLevel; }
|
|
5
|
+
function emit(level, module, msg, extra) {
|
|
6
|
+
if (LEVELS[level] < LEVELS[globalLevel])
|
|
7
|
+
return;
|
|
8
|
+
const entry = { ts: new Date().toISOString(), level, module, msg, pid: process.pid, ...extra };
|
|
9
|
+
const line = JSON.stringify(entry);
|
|
10
|
+
if (level === "warn" || level === "error")
|
|
11
|
+
process.stderr.write(line + "\n");
|
|
12
|
+
else
|
|
13
|
+
process.stdout.write(line + "\n");
|
|
14
|
+
}
|
|
15
|
+
function createLogger(module) {
|
|
16
|
+
return {
|
|
17
|
+
debug: (msg, extra) => emit("debug", module, msg, extra),
|
|
18
|
+
info: (msg, extra) => emit("info", module, msg, extra),
|
|
19
|
+
warn: (msg, extra) => emit("warn", module, msg, extra),
|
|
20
|
+
error: (msg, extra) => emit("error", module, msg, extra),
|
|
21
|
+
child: (sub) => createLogger(`${module}:${sub}`),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export const log = createLogger("bridge");
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SessionManager } from "./session.js";
|
|
2
|
+
import { EndpointRotator } from "./keys.js";
|
|
3
|
+
import { SessionConfig } from "./config.js";
|
|
4
|
+
export interface RouterDecision {
|
|
5
|
+
action: "route" | "create";
|
|
6
|
+
subSessionId?: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class SessionRouter {
|
|
10
|
+
private sessionMgr;
|
|
11
|
+
private rotator;
|
|
12
|
+
private config;
|
|
13
|
+
constructor(sessionMgr: SessionManager, rotator: EndpointRotator, config: SessionConfig);
|
|
14
|
+
/**
|
|
15
|
+
* 3-tier routing:
|
|
16
|
+
* Tier 1: reply-to → direct route ($0)
|
|
17
|
+
* Tier 2: 0-1 active sessions → bypass ($0)
|
|
18
|
+
* Tier 3: 2+ active sessions → Claude classifier (~$0.002)
|
|
19
|
+
*/
|
|
20
|
+
route(userId: string, platform: string, chatId: string, messageText: string, replyToMsgId?: string): Promise<RouterDecision>;
|
|
21
|
+
/** Single-turn Claude call to classify which session a message belongs to */
|
|
22
|
+
private _classify;
|
|
23
|
+
/** Spawn claude CLI for single-turn classification (no tools, no session) */
|
|
24
|
+
private _callClassifier;
|
|
25
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { log as rootLog } from "./logger.js";
|
|
3
|
+
const log = rootLog.child("router");
|
|
4
|
+
export class SessionRouter {
|
|
5
|
+
sessionMgr;
|
|
6
|
+
rotator;
|
|
7
|
+
config;
|
|
8
|
+
constructor(sessionMgr, rotator, config) {
|
|
9
|
+
this.sessionMgr = sessionMgr;
|
|
10
|
+
this.rotator = rotator;
|
|
11
|
+
this.config = config;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 3-tier routing:
|
|
15
|
+
* Tier 1: reply-to → direct route ($0)
|
|
16
|
+
* Tier 2: 0-1 active sessions → bypass ($0)
|
|
17
|
+
* Tier 3: 2+ active sessions → Claude classifier (~$0.002)
|
|
18
|
+
*/
|
|
19
|
+
async route(userId, platform, chatId, messageText, replyToMsgId) {
|
|
20
|
+
// Tier 1: reply-to routing
|
|
21
|
+
if (replyToMsgId) {
|
|
22
|
+
const sessId = this.sessionMgr.getSessionByMessage(replyToMsgId, chatId);
|
|
23
|
+
if (sessId) {
|
|
24
|
+
const sess = this.sessionMgr.get(sessId);
|
|
25
|
+
if (sess && this.sessionMgr.isUsable(sess)) {
|
|
26
|
+
return { action: "route", subSessionId: sessId };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// reply-to pointed to closed/expired session — fall through to Tier 2/3
|
|
30
|
+
}
|
|
31
|
+
// Tier 2: 0-1 active sessions → direct
|
|
32
|
+
const active = this.sessionMgr.getActive(userId, platform);
|
|
33
|
+
if (active.length === 0) {
|
|
34
|
+
return { action: "create", label: messageText.slice(0, 50) };
|
|
35
|
+
}
|
|
36
|
+
if (active.length === 1) {
|
|
37
|
+
return { action: "route", subSessionId: active[0].id };
|
|
38
|
+
}
|
|
39
|
+
// Tier 3: 2+ sessions → Claude classifier
|
|
40
|
+
return await this._classify(userId, platform, messageText, active);
|
|
41
|
+
}
|
|
42
|
+
/** Single-turn Claude call to classify which session a message belongs to */
|
|
43
|
+
async _classify(userId, platform, text, sessions) {
|
|
44
|
+
try {
|
|
45
|
+
const sessionList = sessions
|
|
46
|
+
.map(s => {
|
|
47
|
+
const ago = Math.round((Date.now() - s.lastActiveAt) / 60000);
|
|
48
|
+
return `[${s.id.slice(0, 8)}] "${s.label || "(no topic)"}" (${ago}min ago)`;
|
|
49
|
+
})
|
|
50
|
+
.join("\n");
|
|
51
|
+
const prompt = `You are a message router. Active conversations:\n${sessionList}\n\nUser message: "${text.slice(0, 200)}"\n\nReply with ONLY the 8-char session ID to route to, or "new" for a new conversation. No explanation.`;
|
|
52
|
+
const result = await this._callClassifier(prompt);
|
|
53
|
+
const cleaned = result.trim().toLowerCase();
|
|
54
|
+
if (cleaned === "new") {
|
|
55
|
+
return { action: "create", label: text.slice(0, 50) };
|
|
56
|
+
}
|
|
57
|
+
// Match against active sessions (first 8 chars of ID)
|
|
58
|
+
const match = sessions.find(s => s.id.slice(0, 8) === cleaned);
|
|
59
|
+
if (match) {
|
|
60
|
+
return { action: "route", subSessionId: match.id };
|
|
61
|
+
}
|
|
62
|
+
// Fallback: if classifier returned something unexpected, route to most recently active
|
|
63
|
+
log.warn("classifier returned unexpected, falling back", { result: cleaned });
|
|
64
|
+
return { action: "route", subSessionId: sessions[0].id };
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
// Classifier failed — fallback: create new session
|
|
68
|
+
log.warn("classifier error, creating new session", { error: err.message });
|
|
69
|
+
return { action: "create", label: text.slice(0, 50) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Spawn claude CLI for single-turn classification (no tools, no session) */
|
|
73
|
+
_callClassifier(prompt) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const args = ["-p", prompt, "--output-format", "stream-json", "--max-turns", "1"];
|
|
76
|
+
if (this.config.classifier_budget)
|
|
77
|
+
args.push("--max-budget-usd", String(this.config.classifier_budget));
|
|
78
|
+
if (this.config.classifier_model)
|
|
79
|
+
args.push("--model", this.config.classifier_model);
|
|
80
|
+
const env = { ...process.env };
|
|
81
|
+
// Use the first available endpoint for the classifier model
|
|
82
|
+
if (this.rotator.count) {
|
|
83
|
+
const ep = this.rotator.next();
|
|
84
|
+
if (!this.config.classifier_model && ep.model)
|
|
85
|
+
args.push("--model", ep.model);
|
|
86
|
+
}
|
|
87
|
+
const child = spawn("claude", args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
88
|
+
child.stdin.end();
|
|
89
|
+
const timer = setTimeout(() => { try {
|
|
90
|
+
child.kill("SIGTERM");
|
|
91
|
+
}
|
|
92
|
+
catch { } }, 15000);
|
|
93
|
+
let result = "";
|
|
94
|
+
let buffer = "";
|
|
95
|
+
child.stdout.on("data", (data) => {
|
|
96
|
+
buffer += data.toString();
|
|
97
|
+
const lines = buffer.split("\n");
|
|
98
|
+
buffer = lines.pop() || "";
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
if (!line.trim())
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
const msg = JSON.parse(line);
|
|
104
|
+
if (msg.type === "result" && msg.result)
|
|
105
|
+
result = msg.result;
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
let stderr = "";
|
|
111
|
+
child.stderr.on("data", (data) => { stderr += data.toString(); });
|
|
112
|
+
child.on("close", (code) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
if (result)
|
|
115
|
+
resolve(result);
|
|
116
|
+
else
|
|
117
|
+
reject(new Error(`classifier exited ${code}: ${stderr.slice(0, 200)}`));
|
|
118
|
+
});
|
|
119
|
+
child.on("error", (err) => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
reject(err);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|