@emqo/claudebridge 0.9.1 → 0.10.1

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/README.md CHANGED
@@ -38,6 +38,14 @@ Instead of hardcoded commands, ClaudeBridge injects a **skill document** into Cl
38
38
  - **Parallel Execution**: Multiple `claude` instances running simultaneously (`max_parallel` config)
39
39
  - **Observability**: `/status` command shows task queue, chain progress, and execution stats
40
40
 
41
+ ### v0.10.0: Dispatcher Architecture (Master-Worker Sessions)
42
+
43
+ - **Dispatcher (Master Session)**: Every user has a single dispatcher that receives all messages and routes them to the correct sub-session. Users never interact with sub-sessions directly
44
+ - **Intelligent Routing**: Fast path ($0) for 0-1 active sessions; Claude-powered classification with user memories + session summaries for 2+ sessions
45
+ - **Session Summaries**: Each sub-session maintains an auto-generated summary, giving the dispatcher context about what each conversation is doing
46
+ - **Memory-Aware Dispatch**: Dispatcher sees user memories + all active session summaries when classifying, enabling accurate routing even for ambiguous messages
47
+ - **Concurrent Sub-Sessions**: Multiple sub-sessions execute in parallel with per-session locks
48
+
41
49
  ## Quick Start
42
50
 
43
51
  ### Global Install (npm)
@@ -91,6 +99,11 @@ agent:
91
99
  max_memories: 50
92
100
  skill:
93
101
  enabled: true
102
+ session:
103
+ enabled: true
104
+ max_per_user: 3
105
+ idle_timeout_minutes: 30
106
+ dispatcher_budget: 0.05
94
107
 
95
108
  workspace:
96
109
  base_dir: "./workspaces"
@@ -231,11 +244,13 @@ src/
231
244
  ctl.ts claudebridge-ctl: memory/task/reminder/auto ops via SQLite
232
245
  webhook.ts HTTP server + GitHub webhooks + cron scheduler
233
246
  core/
234
- agent.ts Claude CLI subprocess spawner (runStream + runParallel)
247
+ agent.ts Claude CLI subprocess spawner + session summary sync
235
248
  config.ts YAML config with env fallback
236
249
  keys.ts Endpoint round-robin with cooldown
237
- lock.ts Per-user concurrency mutex (Redis or in-memory)
250
+ lock.ts Per-user/per-session concurrency mutex (Redis or in-memory)
238
251
  store.ts SQLite (WAL): sessions, usage, history, memories, tasks
252
+ router.ts Dispatcher: message routing with memories + session summaries
253
+ session.ts Sub-session lifecycle management
239
254
  permissions.ts Whitelist access control
240
255
  markdown.ts Markdown → Telegram MarkdownV2
241
256
  i18n.ts Internationalization (en/zh)
@@ -250,13 +265,16 @@ src/
250
265
  ### Data Flow
251
266
 
252
267
  ```
253
- User message → Adapter → Access check → Claude subprocess → Stream response back
254
-
255
- Reads skill doc → calls ctl via Bash
256
-
257
- SQLite: memories, tasks, reminders
258
-
259
- Bridge timers: auto-execute, remind, approve
268
+ User message → Adapter → Access check
269
+
270
+ Dispatcher (master session)
271
+ ├─ Fast path: 0-1 sessions → direct route ($0)
272
+ └─ Classify: 2+ sessions → Claude call (memories + summaries)
273
+
274
+ Sub-session execution (per-session lock)
275
+ ├─ Inject memories + skill doc → spawn claude CLI
276
+ ├─ Stream response back to adapter
277
+ └─ Post: save history, sync summary, auto-summarize to shared memory
260
278
  ```
261
279
 
262
280
  ## Prerequisites
@@ -305,6 +323,14 @@ MIT
305
323
  - **并行执行**:多个 `claude` 实例同时运行(`max_parallel` 配置)
306
324
  - **可观测性**:`/status` 命令显示任务队列、链路进度和执行统计
307
325
 
326
+ ### v0.10.0:Dispatcher 架构(主从会话)
327
+
328
+ - **Dispatcher(主会话)**:每个用户有一个 Dispatcher 接收所有消息并路由到正确的子会话,用户无需感知子会话的存在
329
+ - **智能路由**:0-1 个活跃会话走快速路径($0);2+ 个会话时 Claude 分类器携带用户记忆 + 会话摘要进行判断
330
+ - **会话摘要**:每个子会话自动生成摘要,让 Dispatcher 了解每个对话在做什么
331
+ - **记忆感知分发**:Dispatcher 分类时可见用户记忆 + 所有活跃会话摘要,即使模糊消息也能准确路由
332
+ - **并发子会话**:多个子会话并行执行,per-session 锁保证安全
333
+
308
334
  ## 快速开始
309
335
 
310
336
  ### 全局安装(npm)
@@ -37,7 +37,7 @@ agent:
37
37
  enabled: true # Enable multi-session (concurrent conversations per user)
38
38
  max_per_user: 3 # Max concurrent sub-sessions per user
39
39
  idle_timeout_minutes: 30 # Auto-close idle sub-sessions after this time
40
- classifier_budget: 0.05 # Max budget for routing classifier (Tier 3)
40
+ dispatcher_budget: 0.05 # Max budget for dispatcher classifier
41
41
  classifier_model: "" # Model for classifier (empty = use default)
42
42
 
43
43
  workspace:
@@ -11,6 +11,7 @@ export declare class DiscordAdapter implements Adapter {
11
11
  private reminderTimer?;
12
12
  private autoTimer?;
13
13
  private approvalTimer?;
14
+ private fileSendTimer?;
14
15
  private activeAutoTasks;
15
16
  private maxParallel;
16
17
  constructor(engine: AgentEngine, store: Store, config: DiscordConfig, locale?: string);
@@ -27,4 +28,5 @@ export declare class DiscordAdapter implements Adapter {
27
28
  private checkApprovals;
28
29
  private handleStatusCommand;
29
30
  private handleSessionsCommand;
31
+ private checkFileSends;
30
32
  }
@@ -1,5 +1,5 @@
1
1
  import { Client, GatewayIntentBits } from "discord.js";
2
- import { writeFileSync } from "fs";
2
+ import { writeFileSync, existsSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { chunkText } from "./base.js";
5
5
  import { reloadConfig } from "../core/config.js";
@@ -16,6 +16,7 @@ export class DiscordAdapter {
16
16
  reminderTimer;
17
17
  autoTimer;
18
18
  approvalTimer;
19
+ fileSendTimer;
19
20
  activeAutoTasks = 0;
20
21
  maxParallel = 1;
21
22
  constructor(engine, store, config, locale = "en") {
@@ -255,6 +256,7 @@ export class DiscordAdapter {
255
256
  this.reminderTimer = setInterval(() => this.checkReminders(), 30000);
256
257
  this.autoTimer = setInterval(() => this.processAutoTasks(), 60000);
257
258
  this.approvalTimer = setInterval(() => this.checkApprovals(), 15000);
259
+ this.fileSendTimer = setInterval(() => this.checkFileSends(), 5000);
258
260
  }
259
261
  stop() {
260
262
  if (this.reminderTimer)
@@ -263,6 +265,8 @@ export class DiscordAdapter {
263
265
  clearInterval(this.autoTimer);
264
266
  if (this.approvalTimer)
265
267
  clearInterval(this.approvalTimer);
268
+ if (this.fileSendTimer)
269
+ clearInterval(this.fileSendTimer);
266
270
  this.client.destroy();
267
271
  }
268
272
  async checkReminders() {
@@ -409,4 +413,31 @@ export class DiscordAdapter {
409
413
  });
410
414
  await msg.reply(`${t(this.locale, "sessions_list")}\n${lines.join("\n")}`);
411
415
  }
416
+ async checkFileSends() {
417
+ try {
418
+ const pending = this.store.getPendingFileSends("discord");
419
+ for (const f of pending) {
420
+ if (!existsSync(f.file_path)) {
421
+ this.store.markFileFailed(f.id);
422
+ continue;
423
+ }
424
+ try {
425
+ const ch = await this.client.channels.fetch(f.chat_id);
426
+ if (!ch?.isTextBased() || !("send" in ch)) {
427
+ this.store.markFileFailed(f.id);
428
+ continue;
429
+ }
430
+ await ch.send({ content: f.caption || undefined, files: [f.file_path] });
431
+ this.store.markFileSent(f.id);
432
+ }
433
+ catch (err) {
434
+ log.error("file send error", { id: f.id, error: err?.message });
435
+ this.store.markFileFailed(f.id);
436
+ }
437
+ }
438
+ }
439
+ catch (e) {
440
+ log.error("checkFileSends error", { error: e?.message });
441
+ }
442
+ }
412
443
  }
@@ -12,6 +12,7 @@ export declare class TelegramAdapter implements Adapter {
12
12
  private reminderTimer?;
13
13
  private autoTimer?;
14
14
  private approvalTimer?;
15
+ private fileSendTimer?;
15
16
  private activeAutoTasks;
16
17
  private maxParallel;
17
18
  private pages;
@@ -38,4 +39,5 @@ export declare class TelegramAdapter implements Adapter {
38
39
  private handleApprovalCallback;
39
40
  private handleStatusCommand;
40
41
  private handleSessionsCommand;
42
+ private checkFileSends;
41
43
  }
@@ -1,4 +1,6 @@
1
1
  import { chunkText } from "./base.js";
2
+ import { existsSync } from "fs";
3
+ import { basename, extname } from "path";
2
4
  import { reloadConfig } from "../core/config.js";
3
5
  import { toTelegramMarkdown } from "../core/markdown.js";
4
6
  import { t, getCommandDescriptions } from "../core/i18n.js";
@@ -15,6 +17,7 @@ export class TelegramAdapter {
15
17
  reminderTimer;
16
18
  autoTimer;
17
19
  approvalTimer;
20
+ fileSendTimer;
18
21
  activeAutoTasks = 0;
19
22
  maxParallel = 1;
20
23
  pages = new Map();
@@ -381,6 +384,7 @@ export class TelegramAdapter {
381
384
  this.reminderTimer = setInterval(() => this.checkReminders(), 30000);
382
385
  this.autoTimer = setInterval(() => this.processAutoTasks(), 60000);
383
386
  this.approvalTimer = setInterval(() => this.checkApprovals(), 15000);
387
+ this.fileSendTimer = setInterval(() => this.checkFileSends(), 5000);
384
388
  await this.registerCommands();
385
389
  let pollBackoff = 0;
386
390
  while (this.running) {
@@ -416,6 +420,8 @@ export class TelegramAdapter {
416
420
  clearInterval(this.autoTimer);
417
421
  if (this.approvalTimer)
418
422
  clearInterval(this.approvalTimer);
423
+ if (this.fileSendTimer)
424
+ clearInterval(this.fileSendTimer);
419
425
  }
420
426
  async registerCommands() {
421
427
  try {
@@ -625,4 +631,43 @@ export class TelegramAdapter {
625
631
  });
626
632
  await this.reply(chatId, `${t(this.locale, "sessions_list")}\n${lines.join("\n")}`);
627
633
  }
634
+ async checkFileSends() {
635
+ try {
636
+ const pending = this.store.getPendingFileSends("telegram");
637
+ for (const f of pending) {
638
+ if (!existsSync(f.file_path)) {
639
+ this.store.markFileFailed(f.id);
640
+ continue;
641
+ }
642
+ const chatId = Number(f.chat_id);
643
+ const ext = extname(f.file_path).toLowerCase();
644
+ const isPhoto = [".jpg", ".jpeg", ".png", ".gif"].includes(ext);
645
+ const method = isPhoto ? "sendPhoto" : "sendDocument";
646
+ const fieldName = isPhoto ? "photo" : "document";
647
+ try {
648
+ const form = new FormData();
649
+ form.append("chat_id", String(chatId));
650
+ const blob = new Blob([await import("fs").then(fs => fs.readFileSync(f.file_path))]);
651
+ form.append(fieldName, blob, basename(f.file_path));
652
+ if (f.caption)
653
+ form.append("caption", f.caption);
654
+ const res = await fetch(`${this.api}/${method}`, { method: "POST", body: form });
655
+ const json = await res.json();
656
+ if (json.ok)
657
+ this.store.markFileSent(f.id);
658
+ else {
659
+ log.error("file send API error", { id: f.id, desc: json.description });
660
+ this.store.markFileFailed(f.id);
661
+ }
662
+ }
663
+ catch (err) {
664
+ log.error("file send error", { id: f.id, error: err?.message });
665
+ this.store.markFileFailed(f.id);
666
+ }
667
+ }
668
+ }
669
+ catch (e) {
670
+ log.error("checkFileSends error", { error: e?.message });
671
+ }
672
+ }
628
673
  }
@@ -3,7 +3,7 @@ import { Store } from "./store.js";
3
3
  import { AccessControl } from "./permissions.js";
4
4
  import { EndpointRotator } from "./keys.js";
5
5
  import { SessionManager } from "./session.js";
6
- import { SessionRouter } from "./router.js";
6
+ import { Dispatcher } from "./router.js";
7
7
  export interface AgentResponse {
8
8
  text: string;
9
9
  sessionId: string;
@@ -19,7 +19,7 @@ export declare class AgentEngine {
19
19
  private lock;
20
20
  private rotator;
21
21
  private sessionMgr;
22
- private router;
22
+ private dispatcher;
23
23
  private sessionExpiryTimer?;
24
24
  access: AccessControl;
25
25
  constructor(config: Config, store: Store);
@@ -32,7 +32,7 @@ export declare class AgentEngine {
32
32
  getEndpointCount(): number;
33
33
  getMaxParallel(): number;
34
34
  getSessionManager(): SessionManager;
35
- getRouter(): SessionRouter;
35
+ getDispatcher(): Dispatcher;
36
36
  getWorkDir(userId: string): string;
37
37
  /** @deprecated Use isSessionLocked() for multi-session mode */
38
38
  isLocked(userId: string): boolean;
@@ -68,5 +68,6 @@ export declare class AgentEngine {
68
68
  private _execute;
69
69
  /** Parallel execution without session resume. Thin wrapper around _spawnAgent. */
70
70
  private _executeNoSession;
71
+ private _syncSessionSummary;
71
72
  private _autoSummarize;
72
73
  }
@@ -6,7 +6,7 @@ import { AccessControl } from "./permissions.js";
6
6
  import { EndpointRotator } from "./keys.js";
7
7
  import { generateSkillDoc } from "../skills/bridge.js";
8
8
  import { SessionManager } from "./session.js";
9
- import { SessionRouter } from "./router.js";
9
+ import { Dispatcher } from "./router.js";
10
10
  import { log as rootLog } from "./logger.js";
11
11
  import { getProvider } from "../providers/registry.js";
12
12
  const log = rootLog.child("agent");
@@ -16,7 +16,7 @@ export class AgentEngine {
16
16
  lock;
17
17
  rotator;
18
18
  sessionMgr;
19
- router;
19
+ dispatcher;
20
20
  sessionExpiryTimer;
21
21
  access;
22
22
  constructor(config, store) {
@@ -26,7 +26,7 @@ export class AgentEngine {
26
26
  this.access = new AccessControl(config.access.allowed_users, config.access.allowed_groups);
27
27
  this.rotator = new EndpointRotator(config.endpoints);
28
28
  this.sessionMgr = new SessionManager(store, config.agent.session);
29
- this.router = new SessionRouter(this.sessionMgr, this.rotator, config.agent.session);
29
+ this.dispatcher = new Dispatcher(this.sessionMgr, this.rotator, config.agent.session, store);
30
30
  // Periodic idle session expiry (every 5 min)
31
31
  this.sessionExpiryTimer = setInterval(() => {
32
32
  this.sessionMgr.expireIdle();
@@ -53,8 +53,8 @@ export class AgentEngine {
53
53
  getSessionManager() {
54
54
  return this.sessionMgr;
55
55
  }
56
- getRouter() {
57
- return this.router;
56
+ getDispatcher() {
57
+ return this.dispatcher;
58
58
  }
59
59
  getWorkDir(userId) {
60
60
  if (!this.config.workspace.isolation) {
@@ -80,8 +80,8 @@ export class AgentEngine {
80
80
  * Routes to the correct sub-session and executes concurrently.
81
81
  */
82
82
  async handleUserMessage(userId, prompt, platform, chatId, replyToMsgId, onChunk, overrideTimeoutMs) {
83
- // 1. Route
84
- const decision = await this.router.route(userId, platform, chatId, prompt, replyToMsgId);
83
+ // 1. Dispatch
84
+ const decision = await this.dispatcher.dispatch(userId, platform, chatId, prompt, replyToMsgId);
85
85
  // 2. Create or get sub-session
86
86
  let subSession;
87
87
  if (decision.action === "create") {
@@ -121,6 +121,8 @@ export class AgentEngine {
121
121
  // 6. Auto-summarize
122
122
  if (this.config.agent.memory?.auto_summary)
123
123
  this._autoSummarize(userId, prompt, res.text);
124
+ // 7. Sync sub-session summary for dispatcher context
125
+ this._syncSessionSummary(subSession, prompt, res.text);
124
126
  return { ...res, subSessionId: subSession.id, label: subSession.label };
125
127
  }
126
128
  /**
@@ -336,6 +338,47 @@ export class AgentEngine {
336
338
  logLabel: "parallel", verbose: false,
337
339
  });
338
340
  }
341
+ _syncSessionSummary(subSession, prompt, response) {
342
+ const ep = this.rotator.count
343
+ ? this.rotator.next()
344
+ : { name: "default", provider: "claude", model: "" };
345
+ const summaryPrompt = `Summarize this conversation exchange in 1-2 sentences for a dispatcher that routes messages. Focus on the topic/task being discussed.\n\nUser: ${prompt.slice(0, 300)}\nAssistant: ${response.slice(0, 500)}`;
346
+ const args = ["-p", summaryPrompt, "--output-format", "stream-json", "--max-turns", "1", "--max-budget-usd", "0.02"];
347
+ if (ep.model)
348
+ args.push("--model", ep.model);
349
+ const env = { ...process.env };
350
+ const child = spawn("claude", args, { env, stdio: ["pipe", "pipe", "pipe"] });
351
+ child.stdin.end();
352
+ const killTimer = setTimeout(() => { try {
353
+ child.kill("SIGTERM");
354
+ }
355
+ catch { } }, 30000);
356
+ let result = "";
357
+ let buffer = "";
358
+ child.stdout.on("data", (data) => {
359
+ buffer += data.toString();
360
+ const lines = buffer.split("\n");
361
+ buffer = lines.pop() || "";
362
+ for (const line of lines) {
363
+ if (!line.trim())
364
+ continue;
365
+ try {
366
+ const msg = JSON.parse(line);
367
+ if (msg.type === "result" && msg.result)
368
+ result = msg.result;
369
+ }
370
+ catch { }
371
+ }
372
+ });
373
+ child.on("close", () => {
374
+ clearTimeout(killTimer);
375
+ if (result && result.length > 0) {
376
+ this.sessionMgr.updateSummary(subSession.id, result.trim().slice(0, 200));
377
+ log.info("session summary synced", { sessionId: subSession.id.slice(0, 8) });
378
+ }
379
+ });
380
+ child.on("error", (err) => { log.warn("session summary error", { error: err.message }); });
381
+ }
339
382
  _autoSummarize(userId, prompt, response) {
340
383
  const ep = this.rotator.count
341
384
  ? this.rotator.next()
@@ -1,25 +1,29 @@
1
1
  import { SessionManager } from "./session.js";
2
2
  import { EndpointRotator } from "./keys.js";
3
3
  import { SessionConfig } from "./config.js";
4
+ import { Store } from "./store.js";
4
5
  export interface RouterDecision {
5
6
  action: "route" | "create";
6
7
  subSessionId?: string;
7
8
  label?: string;
8
9
  }
9
- export declare class SessionRouter {
10
+ export declare class Dispatcher {
10
11
  private sessionMgr;
11
12
  private rotator;
12
13
  private config;
13
- constructor(sessionMgr: SessionManager, rotator: EndpointRotator, config: SessionConfig);
14
+ private store;
15
+ constructor(sessionMgr: SessionManager, rotator: EndpointRotator, config: SessionConfig, store: Store);
14
16
  /**
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)
17
+ * Dispatch user message:
18
+ * Fast path: reply-to → direct route ($0)
19
+ * Fast path: 0 active → create ($0)
20
+ * Fast path: 1 active → route ($0)
21
+ * Classify: 2+ active → Claude classifier with memories + summaries
19
22
  */
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 */
23
+ dispatch(userId: string, platform: string, chatId: string, messageText: string, replyToMsgId?: string): Promise<RouterDecision>;
24
+ /** Classify with user memories + sub-session summaries for context */
22
25
  private _classify;
23
- /** Spawn claude CLI for single-turn classification (no tools, no session) */
26
+ /** Spawn claude CLI for single-turn classification */
24
27
  private _callClassifier;
25
28
  }
29
+ export { Dispatcher as SessionRouter };
@@ -1,23 +1,26 @@
1
1
  import { spawn } from "child_process";
2
2
  import { log as rootLog } from "./logger.js";
3
- const log = rootLog.child("router");
4
- export class SessionRouter {
3
+ const log = rootLog.child("dispatcher");
4
+ export class Dispatcher {
5
5
  sessionMgr;
6
6
  rotator;
7
7
  config;
8
- constructor(sessionMgr, rotator, config) {
8
+ store;
9
+ constructor(sessionMgr, rotator, config, store) {
9
10
  this.sessionMgr = sessionMgr;
10
11
  this.rotator = rotator;
11
12
  this.config = config;
13
+ this.store = store;
12
14
  }
13
15
  /**
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)
16
+ * Dispatch user message:
17
+ * Fast path: reply-to → direct route ($0)
18
+ * Fast path: 0 active → create ($0)
19
+ * Fast path: 1 active → route ($0)
20
+ * Classify: 2+ active → Claude classifier with memories + summaries
18
21
  */
19
- async route(userId, platform, chatId, messageText, replyToMsgId) {
20
- // Tier 1: reply-to routing
22
+ async dispatch(userId, platform, chatId, messageText, replyToMsgId) {
23
+ // Fast path: reply-to routing
21
24
  if (replyToMsgId) {
22
25
  const sessId = this.sessionMgr.getSessionByMessage(replyToMsgId, chatId);
23
26
  if (sessId) {
@@ -26,9 +29,8 @@ export class SessionRouter {
26
29
  return { action: "route", subSessionId: sessId };
27
30
  }
28
31
  }
29
- // reply-to pointed to closed/expired session — fall through to Tier 2/3
30
32
  }
31
- // Tier 2: 0-1 active sessions → direct
33
+ // Fast path: 0-1 active sessions
32
34
  const active = this.sessionMgr.getActive(userId, platform);
33
35
  if (active.length === 0) {
34
36
  return { action: "create", label: messageText.slice(0, 50) };
@@ -36,49 +38,67 @@ export class SessionRouter {
36
38
  if (active.length === 1) {
37
39
  return { action: "route", subSessionId: active[0].id };
38
40
  }
39
- // Tier 3: 2+ sessions Claude classifier
41
+ // 2+ sessions: classify with memories + summaries
40
42
  return await this._classify(userId, platform, messageText, active);
41
43
  }
42
- /** Single-turn Claude call to classify which session a message belongs to */
44
+ /** Classify with user memories + sub-session summaries for context */
43
45
  async _classify(userId, platform, text, sessions) {
44
46
  try {
47
+ // Gather context
48
+ const memories = this.store.getMemories(userId);
49
+ const summaries = this.sessionMgr.getSummaries(userId, platform);
45
50
  const sessionList = sessions
46
51
  .map(s => {
47
52
  const ago = Math.round((Date.now() - s.lastActiveAt) / 60000);
48
- return `[${s.id.slice(0, 8)}] "${s.label || "(no topic)"}" (${ago}min ago)`;
53
+ const sum = summaries.find(x => x.id === s.id);
54
+ const summaryText = sum?.summary ? ` | Summary: ${sum.summary}` : "";
55
+ return `[${s.id.slice(0, 8)}] "${s.label || "(no topic)"}" (${ago}min ago${summaryText})`;
49
56
  })
50
57
  .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.`;
58
+ const memoryBlock = memories.length
59
+ ? `\nUser context:\n${memories.slice(0, 10).map(m => `- ${m.content}`).join("\n")}\n`
60
+ : "";
61
+ const prompt = `You are a message dispatcher. Route the user's message to the correct conversation, or decide to create a new one, or handle a management request.
62
+ ${memoryBlock}
63
+ Active conversations:
64
+ ${sessionList}
65
+
66
+ User message: "${text.slice(0, 300)}"
67
+
68
+ Reply with ONLY one of:
69
+ - An 8-char session ID to route to
70
+ - "new" to create a new conversation
71
+
72
+ No explanation.`;
52
73
  const result = await this._callClassifier(prompt);
53
- const cleaned = result.trim().toLowerCase();
54
- if (cleaned === "new") {
74
+ const cleaned = result.trim();
75
+ if (cleaned.toLowerCase() === "new") {
55
76
  return { action: "create", label: text.slice(0, 50) };
56
77
  }
57
78
  // Match against active sessions (first 8 chars of ID)
58
- const match = sessions.find(s => s.id.slice(0, 8) === cleaned);
79
+ const match = sessions.find(s => s.id.slice(0, 8) === cleaned.toLowerCase());
59
80
  if (match) {
60
81
  return { action: "route", subSessionId: match.id };
61
82
  }
62
- // Fallback: if classifier returned something unexpected, route to most recently active
83
+ // Fallback: route to most recently active
63
84
  log.warn("classifier returned unexpected, falling back", { result: cleaned });
64
85
  return { action: "route", subSessionId: sessions[0].id };
65
86
  }
66
87
  catch (err) {
67
- // Classifier failed — fallback: create new session
68
88
  log.warn("classifier error, creating new session", { error: err.message });
69
89
  return { action: "create", label: text.slice(0, 50) };
70
90
  }
71
91
  }
72
- /** Spawn claude CLI for single-turn classification (no tools, no session) */
92
+ /** Spawn claude CLI for single-turn classification */
73
93
  _callClassifier(prompt) {
74
94
  return new Promise((resolve, reject) => {
95
+ const budget = this.config.dispatcher_budget ?? this.config.classifier_budget ?? 0.05;
75
96
  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));
97
+ if (budget)
98
+ args.push("--max-budget-usd", String(budget));
78
99
  if (this.config.classifier_model)
79
100
  args.push("--model", this.config.classifier_model);
80
101
  const env = { ...process.env };
81
- // Use the first available endpoint for the classifier model
82
102
  if (this.rotator.count) {
83
103
  const ep = this.rotator.next();
84
104
  if (!this.config.classifier_model && ep.model)
@@ -123,3 +143,5 @@ export class SessionRouter {
123
143
  });
124
144
  }
125
145
  }
146
+ // Backward compatibility alias
147
+ export { Dispatcher as SessionRouter };
@@ -16,7 +16,7 @@ declare const SessionConfigSchema: z.ZodObject<{
16
16
  enabled: z.ZodDefault<z.ZodBoolean>;
17
17
  max_per_user: z.ZodDefault<z.ZodNumber>;
18
18
  idle_timeout_minutes: z.ZodDefault<z.ZodNumber>;
19
- classifier_budget: z.ZodDefault<z.ZodNumber>;
19
+ dispatcher_budget: z.ZodDefault<z.ZodNumber>;
20
20
  classifier_model: z.ZodDefault<z.ZodString>;
21
21
  }, z.core.$strip>;
22
22
  declare const AgentConfigSchema: z.ZodObject<{
@@ -40,7 +40,7 @@ declare const AgentConfigSchema: z.ZodObject<{
40
40
  enabled: z.ZodDefault<z.ZodBoolean>;
41
41
  max_per_user: z.ZodDefault<z.ZodNumber>;
42
42
  idle_timeout_minutes: z.ZodDefault<z.ZodNumber>;
43
- classifier_budget: z.ZodDefault<z.ZodNumber>;
43
+ dispatcher_budget: z.ZodDefault<z.ZodNumber>;
44
44
  classifier_model: z.ZodDefault<z.ZodString>;
45
45
  }, z.core.$strip>>;
46
46
  }, z.core.$strip>;
@@ -108,7 +108,7 @@ export declare const ConfigSchema: z.ZodObject<{
108
108
  enabled: z.ZodDefault<z.ZodBoolean>;
109
109
  max_per_user: z.ZodDefault<z.ZodNumber>;
110
110
  idle_timeout_minutes: z.ZodDefault<z.ZodNumber>;
111
- classifier_budget: z.ZodDefault<z.ZodNumber>;
111
+ dispatcher_budget: z.ZodDefault<z.ZodNumber>;
112
112
  classifier_model: z.ZodDefault<z.ZodString>;
113
113
  }, z.core.$strip>>;
114
114
  }, z.core.$strip>>;
@@ -16,7 +16,7 @@ const SessionConfigSchema = z.object({
16
16
  enabled: z.boolean().default(true),
17
17
  max_per_user: z.number().int().positive().default(3),
18
18
  idle_timeout_minutes: z.number().positive().default(30),
19
- classifier_budget: z.number().nonnegative().default(0.05),
19
+ dispatcher_budget: z.number().nonnegative().default(0.05),
20
20
  classifier_model: z.string().default(""),
21
21
  });
22
22
  const AgentConfigSchema = z.object({
@@ -7,6 +7,7 @@ export interface SubSession {
7
7
  chatId: string;
8
8
  claudeSessionId: string | null;
9
9
  label: string;
10
+ summary: string;
10
11
  status: "active" | "idle" | "expired" | "closed";
11
12
  createdAt: number;
12
13
  lastActiveAt: number;
@@ -47,4 +48,13 @@ export declare class SessionManager {
47
48
  isUsable(session: SubSession): boolean;
48
49
  /** Get all sub-sessions for a user (all statuses) */
49
50
  getAll(userId: string): SubSession[];
51
+ /** Update the summary of a sub-session */
52
+ updateSummary(sessionId: string, summary: string): void;
53
+ /** Get summaries of active sub-sessions for dispatcher context */
54
+ getSummaries(userId: string, platform: string): {
55
+ id: string;
56
+ label: string;
57
+ summary: string;
58
+ lastActiveAt: number;
59
+ }[];
50
60
  }
@@ -10,6 +10,7 @@ function toSubSession(row) {
10
10
  chatId: row.chat_id,
11
11
  claudeSessionId: row.claude_session_id ?? null,
12
12
  label: row.label,
13
+ summary: row.summary ?? "",
13
14
  status: row.status,
14
15
  createdAt: row.created_at,
15
16
  lastActiveAt: row.last_active_at,
@@ -97,4 +98,14 @@ export class SessionManager {
97
98
  getAll(userId) {
98
99
  return this.store.getAllSubSessions(userId).map(toSubSession);
99
100
  }
101
+ /** Update the summary of a sub-session */
102
+ updateSummary(sessionId, summary) {
103
+ this.store.updateSubSessionSummary(sessionId, summary);
104
+ }
105
+ /** Get summaries of active sub-sessions for dispatcher context */
106
+ getSummaries(userId, platform) {
107
+ return this.store.getSubSessionSummaries(userId, platform).map(r => ({
108
+ id: r.id, label: r.label, summary: r.summary, lastActiveAt: r.last_active_at,
109
+ }));
110
+ }
100
111
  }
@@ -145,4 +145,22 @@ export declare class Store {
145
145
  message_count: number;
146
146
  total_cost: number;
147
147
  }[];
148
+ updateSubSessionSummary(id: string, summary: string): void;
149
+ getSubSessionSummaries(userId: string, platform: string): {
150
+ id: string;
151
+ label: string;
152
+ summary: string;
153
+ last_active_at: number;
154
+ }[];
155
+ addFileSend(userId: string, platform: string, chatId: string, filePath: string, caption: string): number;
156
+ getPendingFileSends(platform: string): {
157
+ id: number;
158
+ user_id: string;
159
+ platform: string;
160
+ chat_id: string;
161
+ file_path: string;
162
+ caption: string;
163
+ }[];
164
+ markFileSent(id: number): void;
165
+ markFileFailed(id: number): void;
148
166
  }
@@ -72,6 +72,17 @@ export class Store {
72
72
  message_count INTEGER NOT NULL DEFAULT 0,
73
73
  total_cost REAL NOT NULL DEFAULT 0
74
74
  );
75
+ CREATE TABLE IF NOT EXISTS file_sends (
76
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ user_id TEXT NOT NULL,
78
+ platform TEXT NOT NULL,
79
+ chat_id TEXT NOT NULL,
80
+ file_path TEXT NOT NULL,
81
+ caption TEXT NOT NULL DEFAULT '',
82
+ status TEXT NOT NULL DEFAULT 'pending',
83
+ created_at INTEGER NOT NULL
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_file_sends_status ON file_sends(platform, status);
75
86
  CREATE INDEX IF NOT EXISTS idx_subsess_user ON sub_sessions(user_id, platform, status);
76
87
  CREATE TABLE IF NOT EXISTS sub_session_messages (
77
88
  platform_msg_id TEXT NOT NULL,
@@ -81,7 +92,7 @@ export class Store {
81
92
  PRIMARY KEY (platform_msg_id, chat_id)
82
93
  );
83
94
  `);
84
- // Schema migration: add parent_id, result, and scheduled_at columns
95
+ // Schema migration: add parent_id, result, scheduled_at, and sub_session summary columns
85
96
  try {
86
97
  this.db.exec("ALTER TABLE tasks ADD COLUMN parent_id INTEGER");
87
98
  }
@@ -94,6 +105,10 @@ export class Store {
94
105
  this.db.exec("ALTER TABLE tasks ADD COLUMN scheduled_at INTEGER");
95
106
  }
96
107
  catch { }
108
+ try {
109
+ this.db.exec("ALTER TABLE sub_sessions ADD COLUMN summary TEXT DEFAULT ''");
110
+ }
111
+ catch { }
97
112
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id)");
98
113
  // Startup recovery: reset orphaned 'running' tasks back to 'auto' so they get re-executed
99
114
  const orphaned = this.db.prepare("SELECT id, description FROM tasks WHERE status = 'running'").all();
@@ -309,4 +324,24 @@ export class Store {
309
324
  getAllSubSessions(userId) {
310
325
  return this.db.prepare("SELECT * FROM sub_sessions WHERE user_id = ? ORDER BY last_active_at DESC").all(userId);
311
326
  }
327
+ updateSubSessionSummary(id, summary) {
328
+ this.db.prepare("UPDATE sub_sessions SET summary = ? WHERE id = ?").run(summary, id);
329
+ }
330
+ getSubSessionSummaries(userId, platform) {
331
+ return this.db.prepare("SELECT id, label, summary, last_active_at FROM sub_sessions WHERE user_id = ? AND platform = ? AND status IN ('active','idle') ORDER BY last_active_at DESC").all(userId, platform);
332
+ }
333
+ // --- file_sends ---
334
+ addFileSend(userId, platform, chatId, filePath, caption) {
335
+ const r = this.db.prepare("INSERT INTO file_sends (user_id, platform, chat_id, file_path, caption, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', ?)").run(userId, platform, chatId, filePath, caption, Date.now());
336
+ return Number(r.lastInsertRowid);
337
+ }
338
+ getPendingFileSends(platform) {
339
+ return this.db.prepare("SELECT id, user_id, platform, chat_id, file_path, caption FROM file_sends WHERE platform = ? AND status = 'pending'").all(platform);
340
+ }
341
+ markFileSent(id) {
342
+ this.db.prepare("UPDATE file_sends SET status = 'sent' WHERE id = ?").run(id);
343
+ }
344
+ markFileFailed(id) {
345
+ this.db.prepare("UPDATE file_sends SET status = 'failed' WHERE id = ?").run(id);
346
+ }
312
347
  }
package/dist/ctl.js CHANGED
@@ -166,6 +166,24 @@ else if (category === "auto") {
166
166
  fail("Usage: auto <add|add-approval|result|list|cancel|clear> ...");
167
167
  }
168
168
  }
169
+ else if (category === "file") {
170
+ if (action === "send") {
171
+ const [userId, platform, chatId, filePath, ...captionParts] = rest;
172
+ if (!userId || !platform || !chatId || !filePath)
173
+ fail("Usage: file send <user_id> <platform> <chat_id> <file_path> [caption...]");
174
+ const { resolve } = await import("path");
175
+ const { existsSync } = await import("fs");
176
+ const resolved = resolve(filePath);
177
+ if (!existsSync(resolved))
178
+ fail(`File not found: ${resolved}`);
179
+ const caption = captionParts.join(" ");
180
+ const r = db.prepare("INSERT INTO file_sends (user_id, platform, chat_id, file_path, caption, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', ?)").run(userId, platform, chatId, resolved, caption, Date.now());
181
+ output({ ok: true, id: Number(r.lastInsertRowid), message: `File queued for sending: ${resolved}` });
182
+ }
183
+ else {
184
+ fail("Usage: file <send> ...");
185
+ }
186
+ }
169
187
  else if (category === "session") {
170
188
  if (action === "list") {
171
189
  const [userId] = rest;
@@ -179,6 +197,6 @@ else if (category === "session") {
179
197
  }
180
198
  }
181
199
  else {
182
- fail("Usage: claudebridge-ctl <memory|task|reminder|auto|session> <action> [args...]");
200
+ fail("Usage: claudebridge-ctl <memory|task|reminder|auto|file|session> <action> [args...]");
183
201
  }
184
202
  db.close();
@@ -36,6 +36,12 @@ export function generateSkillDoc(ctx) {
36
36
  `- 取消自动任务: \`${ctl} auto cancel <任务ID>\``,
37
37
  `- 清除已完成任务: \`${ctl} auto clear ${ctx.userId}\``,
38
38
  ``,
39
+ `### 文件发送`,
40
+ `- 发送文件: \`${ctl} file send ${ctx.userId} ${ctx.platform} ${ctx.chatId} <文件路径> "说明"\``,
41
+ `- filePath 为 workspace 中的文件路径(相对或绝对)`,
42
+ `- 支持图片(jpg/png/gif)和文档(pdf/csv/txt/zip 等)`,
43
+ `- 用户要求生成文件时:先创建文件,再调用此命令发送`,
44
+ ``,
39
45
  `### 使用指南`,
40
46
  `- 用户要你记住某事 → 使用 memory add`,
41
47
  `- 用户问你记住了什么 → 使用 memory list`,
@@ -146,6 +152,12 @@ export function generateSkillDoc(ctx) {
146
152
  `- Cancel an auto task: \`${ctl} auto cancel <task_id>\``,
147
153
  `- Clear completed tasks: \`${ctl} auto clear ${ctx.userId}\``,
148
154
  ``,
155
+ `### File Sending`,
156
+ `- Send a file: \`${ctl} file send ${ctx.userId} ${ctx.platform} ${ctx.chatId} <filePath> "caption"\``,
157
+ `- filePath can be relative (to workspace) or absolute`,
158
+ `- Supports images (jpg/png/gif) and documents (pdf/csv/txt/zip etc.)`,
159
+ `- When user asks to generate a file: create it first, then call this command to send`,
160
+ ``,
149
161
  `### Guidelines`,
150
162
  `- User wants you to remember something → use memory add`,
151
163
  `- User asks what you remember → use memory list`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emqo/claudebridge",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "description": "Bridge claude CLI to chat platforms (Telegram, Discord) with scheduled auto-tasks, autonomous project management, HITL approval, conditional branching, webhook triggers, parallel execution, and observability",
5
5
  "main": "dist/index.js",
6
6
  "bin": {