evolclaw 2.3.0 → 2.5.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.
@@ -9,17 +9,22 @@ export class SessionManager {
9
9
  db;
10
10
  eventBus;
11
11
  ownerResolver;
12
+ adminResolver;
12
13
  fileAdapters = new Map();
13
- constructor(dbPath = resolvePaths().db, eventBus, ownerResolver) {
14
+ constructor(dbPath = resolvePaths().db, eventBus, ownerResolver, adminResolver) {
14
15
  ensureDir(path.dirname(dbPath));
15
16
  this.db = new DatabaseSync(dbPath);
16
17
  this.eventBus = eventBus;
17
18
  this.ownerResolver = ownerResolver;
19
+ this.adminResolver = adminResolver;
18
20
  this.initDatabase();
19
21
  }
20
22
  setOwnerResolver(resolver) {
21
23
  this.ownerResolver = resolver;
22
24
  }
25
+ setAdminResolver(resolver) {
26
+ this.adminResolver = resolver;
27
+ }
23
28
  registerFileAdapter(adapter) {
24
29
  this.fileAdapters.set(adapter.agentId, adapter);
25
30
  logger.debug(`[SessionManager] Registered file adapter: ${adapter.agentId}`);
@@ -62,8 +67,11 @@ export class SessionManager {
62
67
  resolveIdentity(channel, userId) {
63
68
  if (!userId)
64
69
  return { role: 'anonymous', mode: 'interactive' };
65
- const isOwner = this.ownerResolver?.(channel, userId) ?? false;
66
- return { role: isOwner ? 'owner' : 'guest', mode: 'interactive' };
70
+ if (this.ownerResolver?.(channel, userId))
71
+ return { role: 'owner', mode: 'interactive' };
72
+ if (this.adminResolver?.(channel, userId))
73
+ return { role: 'admin', mode: 'interactive' };
74
+ return { role: 'guest', mode: 'interactive' };
67
75
  }
68
76
  /** 更新 session 的 identity(owner 绑定后调用) */
69
77
  async updateIdentity(sessionId, identity) {
@@ -546,6 +554,10 @@ export class SessionManager {
546
554
  sets.push('metadata = ?');
547
555
  values.push(updates.metadata ? JSON.stringify(updates.metadata) : null);
548
556
  }
557
+ if ('agentSessionId' in updates) {
558
+ sets.push('claude_session_id = ?');
559
+ values.push(updates.agentSessionId ?? null);
560
+ }
549
561
  if (sets.length === 0)
550
562
  return;
551
563
  sets.push('updated_at = ?');
@@ -731,6 +743,12 @@ export class SessionManager {
731
743
  `).get(targetChannel, ownerPeerId);
732
744
  return row?.channel_id;
733
745
  }
746
+ async getSessionById(sessionId) {
747
+ const row = this.db.prepare('SELECT * FROM sessions WHERE id = ? AND deleted_at IS NULL').get(sessionId);
748
+ if (!row)
749
+ return undefined;
750
+ return this.rowToSession(row);
751
+ }
734
752
  async getActiveSession(channel, channelId) {
735
753
  const row = this.db.prepare(`
736
754
  SELECT * FROM sessions
@@ -798,6 +816,10 @@ export class SessionManager {
798
816
  .run(JSON.stringify(metadata), Date.now(), targetSessionId);
799
817
  return { ...this.rowToSession(target), metadata, updatedAt: Date.now() };
800
818
  }
819
+ updateMetadata(sessionId, metadata) {
820
+ this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
821
+ .run(JSON.stringify(metadata), Date.now(), sessionId);
822
+ }
801
823
  async renameSession(sessionId, newName) {
802
824
  const result = this.db.prepare(`
803
825
  UPDATE sessions SET name = ?, updated_at = ? WHERE id = ?
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ClaudeSessionFileAdapter } from './core/session/adapters/claude-session-file-adapter.js';
2
2
  import { CodexSessionFileAdapter } from './core/session/adapters/codex-session-file-adapter.js';
3
3
  import { GeminiSessionFileAdapter } from './core/session/adapters/gemini-session-file-adapter.js';
4
- import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig, isOwner, validateConfigIntegrity, validateChannelInstanceNames, getOwner } from './config.js';
4
+ import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig, isOwner, isAdmin, validateConfigIntegrity, validateChannelInstanceNames, getOwner } from './config.js';
5
5
  import { SessionManager } from './core/session/session-manager.js';
6
6
  import { ClaudeAgentPlugin } from './agents/claude-runner.js';
7
7
  import { CodexAgentPlugin } from './agents/codex-runner.js';
@@ -9,6 +9,9 @@ import { GeminiAgentPlugin } from './agents/gemini-runner.js';
9
9
  import { FeishuChannelPlugin } from './channels/feishu.js';
10
10
  import { WechatChannelPlugin } from './channels/wechat.js';
11
11
  import { AUNChannelPlugin } from './channels/aun.js';
12
+ import { DingtalkChannelPlugin } from './channels/dingtalk.js';
13
+ import { QQBotChannelPlugin } from './channels/qqbot.js';
14
+ import { WecomChannelPlugin } from './channels/wecom.js';
12
15
  import { MessageProcessor } from './core/message/message-processor.js';
13
16
  import { MessageQueue } from './core/message/message-queue.js';
14
17
  import { MessageBridge } from './core/message/message-bridge.js';
@@ -47,6 +50,7 @@ async function main() {
47
50
  ensureDataDirs();
48
51
  // 加载配置
49
52
  const config = loadConfig();
53
+ const paths = resolvePaths();
50
54
  // 配置完整性校验
51
55
  const integrity = validateConfigIntegrity(config);
52
56
  if (!integrity.valid) {
@@ -68,9 +72,7 @@ async function main() {
68
72
  // 统计收集器(近 1 小时滚动统计)
69
73
  const statsCollector = new StatsCollector(eventBus);
70
74
  // 初始化数据库(带 ownerResolver)
71
- const sessionManager = new SessionManager(undefined, eventBus, (channel, userId) => {
72
- return isOwner(config, channel, userId);
73
- });
75
+ const sessionManager = new SessionManager(undefined, eventBus, (channel, userId) => isOwner(config, channel, userId), (channel, userId) => isAdmin(config, channel, userId));
74
76
  logger.info('✓ Database initialized');
75
77
  // 注册会话文件适配器(Claude / Codex 各自的会话文件操作)
76
78
  sessionManager.registerFileAdapter(new ClaudeSessionFileAdapter());
@@ -118,6 +120,9 @@ async function main() {
118
120
  channelLoader.register(new FeishuChannelPlugin());
119
121
  channelLoader.register(new WechatChannelPlugin());
120
122
  channelLoader.register(new AUNChannelPlugin());
123
+ channelLoader.register(new DingtalkChannelPlugin());
124
+ channelLoader.register(new QQBotChannelPlugin());
125
+ channelLoader.register(new WecomChannelPlugin());
121
126
  const channelInstances = await channelLoader.createAll(config);
122
127
  logger.info(`✓ Created ${channelInstances.length} channel instance(s)`);
123
128
  // 启动迁移:将 sessions.channel 从 channelType 回填为实例名
@@ -163,13 +168,14 @@ async function main() {
163
168
  messageQueue.setEventBus(eventBus);
164
169
  // 回填 messageQueue 引用
165
170
  cmdHandler.setMessageQueue(messageQueue);
171
+ processor.setMessageQueue(messageQueue);
166
172
  // 默认策略
167
173
  const defaultPolicy = {
168
- canSwitchProject: (chatType, role) => chatType === 'private' || role === 'owner',
169
- canListProjects: (chatType, role) => chatType === 'private' || role === 'owner',
174
+ canSwitchProject: (chatType, role) => chatType === 'private' ? (role === 'owner' || role === 'admin') : role === 'owner',
175
+ canListProjects: (chatType, role) => chatType === 'private' ? (role === 'owner' || role === 'admin') : role === 'owner',
170
176
  canCreateSession: () => true,
171
- canDeleteSession: (chatType, role) => chatType === 'private' || role === 'owner',
172
- canImportCliSession: (chatType, role) => chatType === 'private' || role === 'owner',
177
+ canDeleteSession: (chatType, role) => chatType === 'private' ? (role === 'owner' || role === 'admin') : role === 'owner',
178
+ canImportCliSession: (chatType, role) => chatType === 'private' ? (role === 'owner' || role === 'admin') : role === 'owner',
173
179
  messagePrefix: () => '',
174
180
  showMiddleResult: () => true,
175
181
  showIdleMonitor: () => true,
@@ -214,15 +220,12 @@ async function main() {
214
220
  await handler({
215
221
  channel: channelType, channelId: chatId, content, images, chatType,
216
222
  peerId: peerId || '', peerName, messageId, mentions, threadId,
217
- replyContext: rootId ? { replyToMessageId: rootId, replyInThread: true } : undefined,
223
+ replyContext: rootId ? { replyToMessageId: rootId, replyInThread: !!threadId } : undefined,
218
224
  });
219
225
  }), (channelId, text, replyContext) => inst.channel.sendMessage(channelId, text, {
220
226
  replyToMessageId: replyContext?.replyToMessageId,
221
- replyInThread: true,
227
+ replyInThread: replyContext?.replyInThread,
222
228
  }), inst.adapter, channelType);
223
- inst.channel.onRecall?.((messageId) => {
224
- msgBridge.cancel(messageId);
225
- });
226
229
  }
227
230
  if (channelType === 'wechat') {
228
231
  // 注入 EventBus(用于 channel:health 事件)
@@ -256,6 +259,38 @@ async function main() {
256
259
  });
257
260
  }), (channelId, text, replyContext) => inst.channel.sendMessage(channelId, text, replyContext), inst.adapter, channelType);
258
261
  }
262
+ if (channelType === 'dingtalk') {
263
+ msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async (event) => {
264
+ handler({
265
+ channel: channelType,
266
+ channelId: event.channelId,
267
+ content: event.content,
268
+ images: event.images,
269
+ chatType: event.chatType || 'private',
270
+ peerId: event.peerId || '',
271
+ peerName: event.peerName,
272
+ messageId: event.messageId,
273
+ });
274
+ }), (channelId, text) => inst.channel.sendMessage(channelId, text), inst.adapter, channelType);
275
+ }
276
+ if (channelType === 'qqbot') {
277
+ msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async (event) => {
278
+ handler({
279
+ channel: channelType,
280
+ channelId: event.channelId,
281
+ content: event.content,
282
+ images: event.images,
283
+ chatType: event.chatType || 'private',
284
+ peerId: event.peerId || '',
285
+ peerName: event.peerName,
286
+ messageId: event.messageId,
287
+ });
288
+ }), (channelId, text) => inst.channel.sendMessage(channelId, text), inst.adapter, channelType);
289
+ }
290
+ // 通用:撤回消息 → 中断执行中任务(所有支持 onRecall 的渠道)
291
+ inst.channel.onRecall?.((messageId) => {
292
+ msgBridge.cancel(messageId);
293
+ });
259
294
  }
260
295
  // ── 连接所有渠道 ──
261
296
  const connected = await channelLoader.connectAll(channelInstances);
@@ -303,18 +338,17 @@ async function main() {
303
338
  const sourceChannelName = event.channelName || sourceChannelType;
304
339
  const msg = event.message;
305
340
  logger.error(`[ChannelHealth] ${sourceChannelName} auth_error: ${msg}`);
306
- const notified = new Set(); // channelType:ownerId 去重
341
+ const notified = new Set(); // channelType 去重(同类型只通知一次)
307
342
  for (const other of channelInstances) {
308
343
  const otherType = other.channelType || other.adapter.channelName;
309
344
  if (otherType === sourceChannelType)
310
345
  continue; // 跳过同类型通道
346
+ if (notified.has(otherType))
347
+ continue; // 同类型已通知过
311
348
  const ownerId = getOwner(config, other.adapter.channelName);
312
349
  if (!ownerId)
313
350
  continue;
314
- const key = `${otherType}:${ownerId}`;
315
- if (notified.has(key))
316
- continue; // 同类型已通知过此 owner
317
- notified.add(key);
351
+ notified.add(otherType);
318
352
  other.adapter.sendText(ownerId, msg).catch(err => {
319
353
  logger.error(`[ChannelHealth] Failed to notify ${other.adapter.channelName} owner:`, err);
320
354
  });
@@ -380,7 +414,7 @@ async function main() {
380
414
  const adapter = cmdHandler.getAdapter(pending.channel);
381
415
  if (adapter) {
382
416
  const replyContext = pending.rootId
383
- ? { replyToMessageId: pending.rootId, replyInThread: true }
417
+ ? { replyToMessageId: pending.rootId, replyInThread: !!pending.threadId }
384
418
  : undefined;
385
419
  await adapter.sendText(pending.channelId, '✅ 服务重启成功!', replyContext);
386
420
  logger.info(`[Restart] Notification sent via ${pending.channel}`);
@@ -395,7 +429,7 @@ async function main() {
395
429
  const readySignalPath = resolvePaths().readySignal;
396
430
  fs.writeFileSync(readySignalPath, String(Date.now()));
397
431
  logger.info(`✓ Ready signal written: ${readySignalPath}`);
398
- // IPC server — 供 CLI 查询实时状态
432
+ // IPC server — 供 CLI 查询实时状态 + Agent ctl 指令执行
399
433
  const ipcServer = new IpcServer(resolvePaths().socket, () => {
400
434
  const channels = {};
401
435
  const channelsByType = {};
@@ -425,7 +459,7 @@ async function main() {
425
459
  avgResponseMs: snap.lastHour.avgResponseMs,
426
460
  },
427
461
  };
428
- });
462
+ }, async (cmd, sessionId) => cmdHandler.handleCtl(cmd, sessionId));
429
463
  ipcServer.start();
430
464
  // 运行时配置文件监控
431
465
  const configPath = resolvePaths().config;
package/dist/ipc.js CHANGED
@@ -4,10 +4,12 @@ import { logger } from './utils/logger.js';
4
4
  export class IpcServer {
5
5
  socketPath;
6
6
  getStatus;
7
+ commandExecutor;
7
8
  server = null;
8
- constructor(socketPath, getStatus) {
9
+ constructor(socketPath, getStatus, commandExecutor) {
9
10
  this.socketPath = socketPath;
10
11
  this.getStatus = getStatus;
12
+ this.commandExecutor = commandExecutor;
11
13
  }
12
14
  start() {
13
15
  // Remove stale socket file
@@ -17,7 +19,7 @@ export class IpcServer {
17
19
  catch { }
18
20
  this.server = net.createServer((conn) => {
19
21
  let buf = '';
20
- conn.on('data', (data) => {
22
+ conn.on('data', async (data) => {
21
23
  buf += data.toString();
22
24
  // Simple newline-delimited JSON protocol
23
25
  const idx = buf.indexOf('\n');
@@ -27,7 +29,7 @@ export class IpcServer {
27
29
  buf = buf.slice(idx + 1);
28
30
  try {
29
31
  const cmd = JSON.parse(line);
30
- const response = this.handleCommand(cmd);
32
+ const response = await this.handleCommand(cmd);
31
33
  conn.end(JSON.stringify(response) + '\n');
32
34
  }
33
35
  catch {
@@ -58,12 +60,20 @@ export class IpcServer {
58
60
  }
59
61
  catch { }
60
62
  }
61
- handleCommand(cmd) {
63
+ async handleCommand(cmd) {
62
64
  switch (cmd.type) {
63
65
  case 'status':
64
66
  return this.getStatus();
65
67
  case 'ping':
66
68
  return { pong: true, pid: process.pid };
69
+ case 'ctl': {
70
+ if (!this.commandExecutor)
71
+ return { ok: false, error: 'ctl not configured' };
72
+ const { cmd: slashCmd, sessionId } = cmd;
73
+ if (!slashCmd || !sessionId)
74
+ return { ok: false, error: 'missing cmd or sessionId' };
75
+ return await this.commandExecutor(slashCmd, sessionId);
76
+ }
67
77
  default:
68
78
  return { error: `unknown command: ${cmd.type}` };
69
79
  }
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: evolclaw-ctl
3
+ version: 1.0.0
4
+ description: EvolClaw 运行时自管理指令,仅在 evolclaw 托管环境中可用
5
+ trigger: 用户询问或需要切换模型、调整推理强度、查看运行状态、压缩上下文、检查通道健康、管理权限模式、发送文件、重启服务、重连渠道时
6
+ ---
7
+
8
+ # EvolClaw Ctl
9
+
10
+ 通过 `evolclaw ctl <command> [args]` 管理运行时配置。仅在 evolclaw 托管环境中可用(`EVOLCLAW_SESSION_ID` 已设置)。
11
+
12
+ ## 可用指令
13
+
14
+ ### 查询类(所有用户)
15
+ - `evolclaw ctl help` — 显示帮助
16
+ - `evolclaw ctl status` — 显示会话状态
17
+ - `evolclaw ctl check` — 检查渠道健康状态
18
+
19
+ ### 配置类(管理员)
20
+ - `evolclaw ctl model` — 查看当前模型和可选列表
21
+ - `evolclaw ctl model <model-id>` — 切换模型(如 `opus`, `sonnet`, `haiku`)
22
+ - `evolclaw ctl effort` — 查看当前推理强度
23
+ - `evolclaw ctl effort <low|medium|high|max>` — 切换推理强度
24
+ - `evolclaw ctl compact` — 压缩当前会话上下文
25
+
26
+ ### 权限类
27
+ - `evolclaw ctl perm` — 查看当前权限模式(管理员)
28
+ - `evolclaw ctl perm <mode>` — 切换权限模式(仅 owner)
29
+
30
+ ### 运维类(仅 owner)
31
+ - `evolclaw ctl activity <all|dm|owner|none>` — 查看/控制中间输出显示模式
32
+ - `evolclaw ctl send [channel] <path>` — 发送项目内文件(仅限项目目录内)
33
+ - `evolclaw ctl restart` — 重启服务(慎用:中断所有会话)
34
+ - `evolclaw ctl restart <channel>` — 重连指定渠道(管理员可用)
35
+ - `evolclaw ctl agentmd` — 查看当前 agent.md
36
+ - `evolclaw ctl agentmd put` — 发布本地 agent.md
37
+ - `evolclaw ctl agentmd set <内容>` — 直接设置 agent.md 内容
38
+
39
+ ## 使用示例
40
+
41
+ ```bash
42
+ # 查看当前模型
43
+ evolclaw ctl model
44
+
45
+ # 切换到 opus
46
+ evolclaw ctl model opus
47
+
48
+ # 降低推理强度以加快响应
49
+ evolclaw ctl effort low
50
+
51
+ # 压缩上下文
52
+ evolclaw ctl compact
53
+
54
+ # 查看服务状态
55
+ evolclaw ctl status
56
+ ```
57
+
58
+ ## 注意事项
59
+
60
+ - 仅在 evolclaw 托管环境中可用(EVOLCLAW_SESSION_ID 环境变量已设置时)
61
+ - 权限继承当前会话用户的角色(owner / admin / guest)
62
+ - `compact` 不能在当前会话处理消息期间执行
63
+ - `send` 只能发送项目目录下的文件
64
+ - `restart` 会中断当前所有会话,谨慎使用
@@ -0,0 +1,63 @@
1
+ import fs from 'fs';
2
+ import { logger } from './logger.js';
3
+ let rules = [];
4
+ /**
5
+ * 加载错误字典文件
6
+ * 文件不存在或格式错误 → 警告日志,rules = [](不影响启动)
7
+ */
8
+ export function loadErrorDict(dictPath) {
9
+ try {
10
+ if (!fs.existsSync(dictPath)) {
11
+ logger.debug('[error-dict] 字典文件不存在,使用内置规则: %s', dictPath);
12
+ rules = [];
13
+ return;
14
+ }
15
+ const raw = JSON.parse(fs.readFileSync(dictPath, 'utf-8'));
16
+ if (!Array.isArray(raw?.rules)) {
17
+ logger.warn('[error-dict] 字典格式错误(缺少 rules 数组),忽略: %s', dictPath);
18
+ rules = [];
19
+ return;
20
+ }
21
+ // 校验每条规则的必填字段
22
+ rules = raw.rules.filter((r) => {
23
+ if (!r.id || !r.match || !r.action) {
24
+ logger.warn('[error-dict] 跳过无效规则(缺少 id/match/action): %o', r);
25
+ return false;
26
+ }
27
+ if (!['retry', 'stop', 'ignore'].includes(r.action)) {
28
+ logger.warn('[error-dict] 跳过无效 action "%s": %s', r.action, r.id);
29
+ return false;
30
+ }
31
+ return true;
32
+ });
33
+ logger.info('[error-dict] 已加载 %d 条规则: %s', rules.length, dictPath);
34
+ }
35
+ catch (err) {
36
+ logger.warn('[error-dict] 加载失败: %s — %s', dictPath, err.message);
37
+ rules = [];
38
+ }
39
+ }
40
+ /** 运行时重新加载(供 /reload 命令使用) */
41
+ export function reloadErrorDict(dictPath) {
42
+ loadErrorDict(dictPath);
43
+ }
44
+ /**
45
+ * 匹配错误消息,返回首条命中的规则
46
+ * @param errorMessage 已 toLowerCase 的错误消息
47
+ */
48
+ export function matchErrorRule(errorMessage) {
49
+ for (const rule of rules) {
50
+ if (errorMessage.includes(rule.match.toLowerCase())) {
51
+ return rule;
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+ /** 获取当前已加载的规则数量(供测试和状态查询使用) */
57
+ export function getLoadedRuleCount() {
58
+ return rules.length;
59
+ }
60
+ /** 重置规则(仅供测试使用) */
61
+ export function _resetRules() {
62
+ rules = [];
63
+ }