evolclaw 2.1.2 → 2.2.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.
Files changed (42) hide show
  1. package/README.md +10 -3
  2. package/data/evolclaw.sample.json +9 -1
  3. package/dist/agents/claude-runner.js +612 -0
  4. package/dist/agents/codex-runner.js +310 -0
  5. package/dist/channels/aun.js +416 -9
  6. package/dist/channels/feishu.js +397 -104
  7. package/dist/channels/wechat.js +84 -2
  8. package/dist/cli.js +427 -126
  9. package/dist/config.js +102 -4
  10. package/dist/core/adapters/claude-session-file-adapter.js +144 -0
  11. package/dist/core/adapters/codex-session-file-adapter.js +196 -0
  12. package/dist/core/agent-loader.js +39 -0
  13. package/dist/core/channel-loader.js +60 -0
  14. package/dist/core/command-handler.js +908 -304
  15. package/dist/core/event-bus.js +32 -0
  16. package/dist/core/ipc-server.js +71 -0
  17. package/dist/core/message-bridge.js +187 -0
  18. package/dist/core/message-processor.js +370 -227
  19. package/dist/core/message-queue.js +153 -29
  20. package/dist/core/permission.js +58 -0
  21. package/dist/core/session-file-adapter.js +7 -0
  22. package/dist/core/session-manager.js +567 -205
  23. package/dist/core/stats-collector.js +86 -0
  24. package/dist/index.js +309 -243
  25. package/dist/paths.js +1 -0
  26. package/dist/utils/init-feishu.js +2 -0
  27. package/dist/utils/init-wechat.js +2 -0
  28. package/dist/utils/init.js +285 -53
  29. package/dist/utils/ipc-client.js +36 -0
  30. package/dist/utils/migrate-project.js +122 -0
  31. package/dist/utils/{permission.js → permission-utils.js} +31 -3
  32. package/dist/utils/rich-content-renderer.js +228 -0
  33. package/dist/utils/session-file-health.js +11 -34
  34. package/dist/utils/stream-debouncer.js +122 -0
  35. package/dist/utils/stream-idle-monitor.js +1 -1
  36. package/package.json +3 -1
  37. package/dist/core/agent-runner.js +0 -348
  38. package/dist/core/message-stream.js +0 -59
  39. package/dist/index.js.bak +0 -340
  40. package/dist/utils/markdown-to-feishu.js +0 -94
  41. /package/dist/utils/{platform.js → cross-platform.js} +0 -0
  42. /package/dist/{core → utils}/message-cache.js +0 -0
@@ -3,30 +3,56 @@ import { logger } from '../utils/logger.js';
3
3
  export class MessageQueue {
4
4
  queues = new Map();
5
5
  processing = new Set();
6
+ externalLocks = new Map();
6
7
  handler;
7
8
  currentSessionKey;
8
9
  currentProjectPath;
10
+ currentAgentId;
9
11
  interruptCallback;
12
+ eventBus;
13
+ recentMessageIds = new Set();
14
+ DEDUP_WINDOW = 60_000; // 1 分钟窗口
10
15
  constructor(handler) {
11
16
  this.handler = handler;
12
17
  }
13
18
  setInterruptCallback(callback) {
14
19
  this.interruptCallback = callback;
15
20
  }
21
+ setEventBus(eventBus) {
22
+ this.eventBus = eventBus;
23
+ }
24
+ /**
25
+ * 检查消息是否应该处理(去重)
26
+ */
27
+ shouldProcess(message) {
28
+ if (!message.messageId)
29
+ return true; // 无 ID 的消息不去重
30
+ if (this.recentMessageIds.has(message.messageId)) {
31
+ logger.debug(`[Queue] Duplicate message ${message.messageId}, skipping`);
32
+ return false;
33
+ }
34
+ this.recentMessageIds.add(message.messageId);
35
+ setTimeout(() => this.recentMessageIds.delete(message.messageId), this.DEDUP_WINDOW);
36
+ return true;
37
+ }
16
38
  /**
17
39
  * 检查队列 key 是否属于指定 sessionKey
18
40
  */
19
41
  matchesSession(key, sessionKey) {
20
- return key.startsWith(sessionKey + '-');
42
+ return key.startsWith(sessionKey + '::');
21
43
  }
22
44
  /**
23
45
  * 生成项目级别的队列 key
24
46
  */
25
47
  getQueueKey(sessionKey, projectPath) {
26
- const projectName = path.basename(projectPath);
27
- return `${sessionKey}-${projectName}`;
48
+ const normalized = projectPath ? path.resolve(projectPath) : '';
49
+ return `${sessionKey}::${normalized}`;
28
50
  }
29
- async enqueue(sessionKey, message, projectPath) {
51
+ async enqueue(sessionKey, message, projectPath, options) {
52
+ // 消息去重检查
53
+ if (!this.shouldProcess(message)) {
54
+ return Promise.resolve();
55
+ }
30
56
  const queueKey = this.getQueueKey(sessionKey, projectPath);
31
57
  logger.debug(`[Queue] Enqueuing message for ${queueKey}`);
32
58
  return new Promise((resolve, reject) => {
@@ -34,11 +60,19 @@ export class MessageQueue {
34
60
  this.queues.set(queueKey, []);
35
61
  }
36
62
  this.queues.get(queueKey).push({ message, projectPath, resolve, reject });
37
- // 如果正在处理,触发中断
63
+ // 根据 interruptible 选项决定是否触发中断
38
64
  if (this.processing.has(queueKey)) {
39
- logger.debug(`[Queue] ${queueKey} is processing, triggering interrupt`);
40
- if (this.interruptCallback) {
41
- this.interruptCallback(sessionKey).catch(() => { });
65
+ if (options?.interruptible !== false) {
66
+ // 单聊:保留中断行为
67
+ logger.debug(`[Queue] ${queueKey} is processing, triggering interrupt`);
68
+ this.eventBus?.publish({ type: 'message:interrupted', sessionId: sessionKey, reason: 'new_message' });
69
+ if (this.interruptCallback) {
70
+ this.interruptCallback(sessionKey, this.currentAgentId).catch(() => { });
71
+ }
72
+ }
73
+ else {
74
+ // 群聊:FIFO,不打断
75
+ logger.debug(`[Queue] ${queueKey} is processing, message queued (FIFO)`);
42
76
  }
43
77
  }
44
78
  else {
@@ -51,6 +85,12 @@ export class MessageQueue {
51
85
  this.processing.add(queueKey);
52
86
  logger.debug(`[Queue] Processing queue ${queueKey}`);
53
87
  while (true) {
88
+ // 等待外部锁释放(/compact, /clear 等快速命令)
89
+ const lock = this.getExternalLock(queueKey);
90
+ if (lock) {
91
+ logger.debug(`[Queue] Waiting for external lock on ${queueKey}`);
92
+ await lock;
93
+ }
54
94
  const queue = this.queues.get(queueKey);
55
95
  if (!queue || queue.length === 0) {
56
96
  logger.debug(`[Queue] Queue ${queueKey} is empty, stopping`);
@@ -59,21 +99,76 @@ export class MessageQueue {
59
99
  this.currentProjectPath = undefined;
60
100
  return;
61
101
  }
62
- const { message, projectPath, resolve, reject } = queue.shift();
102
+ // FIFO 贪心合并:弹出队首连续同 peerId 的消息
103
+ const items = this.dequeueGreedy(queue);
104
+ const merged = items.length === 1 ? items[0] : this.mergeItems(items);
63
105
  this.currentSessionKey = queueKey;
64
- this.currentProjectPath = projectPath;
65
- logger.debug(`[Queue] Processing message from ${message.channel}:${message.channelId}`);
106
+ this.currentProjectPath = merged.projectPath;
107
+ this.currentAgentId = merged.message.agentId;
108
+ const resolves = items.map(i => i.resolve);
109
+ const rejects = items.map(i => i.reject);
110
+ logger.debug(`[Queue] Processing ${items.length} message(s) from ${merged.message.channel}:${merged.message.channelId}`);
66
111
  try {
67
- await this.handler(message);
112
+ await this.handler(merged.message);
68
113
  logger.debug(`[Queue] Message processed successfully`);
69
- resolve();
114
+ resolves.forEach(r => r());
70
115
  }
71
116
  catch (error) {
72
117
  logger.error(`[Queue] Message processing failed:`, error);
73
- reject(error);
118
+ rejects.forEach(r => r(error));
74
119
  }
75
120
  }
76
121
  }
122
+ /**
123
+ * 贪心弹出队首连续同 peerId 的消息。
124
+ * 遇到不同 peerId 或队列为空时停止。
125
+ */
126
+ dequeueGreedy(queue) {
127
+ const first = queue.shift();
128
+ const result = [first];
129
+ const peerId = first.message.peerId;
130
+ while (queue.length > 0 && queue[0].message.peerId === peerId) {
131
+ result.push(queue.shift());
132
+ }
133
+ if (result.length > 1) {
134
+ logger.debug(`[Queue] Greedy dequeue: merged ${result.length} messages from peerId=${peerId}`);
135
+ }
136
+ return result;
137
+ }
138
+ /**
139
+ * 合并多条同 peerId 消息:
140
+ * - content: \n 连接
141
+ * - images / mentions: 扁平合并
142
+ * - messageId: 置空(合并后不代表某一条具体消息)
143
+ * - replyContext / peerName / 其余字段: 取最后一条
144
+ */
145
+ mergeItems(items) {
146
+ const contents = [];
147
+ const allImages = [];
148
+ const allMentions = [];
149
+ for (const item of items) {
150
+ const m = item.message;
151
+ contents.push(m.content);
152
+ if (m.images)
153
+ allImages.push(...m.images);
154
+ if (m.mentions)
155
+ allMentions.push(...m.mentions);
156
+ }
157
+ const last = items[items.length - 1];
158
+ const merged = {
159
+ ...last.message,
160
+ content: contents.join('\n'),
161
+ images: allImages.length > 0 ? allImages : undefined,
162
+ mentions: allMentions.length > 0 ? allMentions : undefined,
163
+ messageId: undefined,
164
+ };
165
+ return {
166
+ message: merged,
167
+ projectPath: last.projectPath,
168
+ resolve: () => { }, // 由调用方管理
169
+ reject: () => { },
170
+ };
171
+ }
77
172
  getQueueLength(sessionKey) {
78
173
  // 计算该 sessionKey 下所有项目队列的总长度
79
174
  let total = 0;
@@ -93,24 +188,53 @@ export class MessageQueue {
93
188
  }
94
189
  return false;
95
190
  }
191
+ cancel(messageId) {
192
+ for (const queue of this.queues.values()) {
193
+ const idx = queue.findIndex(q => q.message.messageId === messageId);
194
+ if (idx !== -1) {
195
+ const [removed] = queue.splice(idx, 1);
196
+ removed.resolve();
197
+ logger.info(`[Queue] Cancelled queued message ${messageId}`);
198
+ return true;
199
+ }
200
+ }
201
+ return false;
202
+ }
96
203
  /**
97
- * 获取正在处理的项目路径
204
+ * 外部锁:快速命令(/compact, /clear)执行期间阻塞队列处理
205
+ * 返回 release 函数
98
206
  */
99
- getProcessingProject(sessionKey) {
100
- // 查找该 sessionKey 下正在处理的项目
101
- for (const key of this.processing.keys()) {
102
- if (this.matchesSession(key, sessionKey)) {
103
- // processing 中找到对应的队列,获取 projectPath
104
- const queue = this.queues.get(key);
105
- if (queue && queue.length > 0) {
106
- return queue[0].projectPath;
107
- }
108
- // 如果队列为空但仍在处理,返回当前正在处理的项目路径
109
- if (this.currentSessionKey === key) {
110
- return this.currentProjectPath;
111
- }
112
- }
207
+ acquireLock(sessionKey) {
208
+ let releaseFn;
209
+ const promise = new Promise(resolve => { releaseFn = resolve; });
210
+ this.externalLocks.set(sessionKey, promise);
211
+ return () => {
212
+ this.externalLocks.delete(sessionKey);
213
+ releaseFn();
214
+ };
215
+ }
216
+ /** 检查是否有外部锁 */
217
+ getExternalLock(queueKey) {
218
+ for (const [key, promise] of this.externalLocks) {
219
+ if (this.matchesSession(queueKey, key))
220
+ return promise;
113
221
  }
114
222
  return undefined;
115
223
  }
224
+ /**
225
+ * 获取全局队列长度(所有会话的待处理消息总数)
226
+ */
227
+ getGlobalQueueLength() {
228
+ let total = 0;
229
+ for (const queue of this.queues.values()) {
230
+ total += queue.length;
231
+ }
232
+ return total;
233
+ }
234
+ /**
235
+ * 获取全局处理中队列数量
236
+ */
237
+ getGlobalProcessingCount() {
238
+ return this.processing.size;
239
+ }
116
240
  }
@@ -0,0 +1,58 @@
1
+ import { summarizeToolInput } from '../utils/permission-utils.js';
2
+ export class PermissionGateway {
3
+ pending = new Map();
4
+ timeout = 5 * 60 * 1000;
5
+ eventBus;
6
+ setEventBus(eventBus) {
7
+ this.eventBus = eventBus;
8
+ }
9
+ /**
10
+ * 请求人工审批。调用方负责模式判断(仅 approve 模式调用此方法)。
11
+ * 黑名单检查由调用方(preToolUseHook)在调用此方法前完成。
12
+ */
13
+ async requestPermission(sessionId, toolName, toolInput, sendPrompt, summary, reason) {
14
+ const requestId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
15
+ const displaySummary = summary || summarizeToolInput(toolName, toolInput);
16
+ const reasonLine = reason ? `\n原因:${reason}` : '';
17
+ this.eventBus?.publish({ type: 'permission:requested', sessionId, requestId, toolName, input: displaySummary });
18
+ await sendPrompt(`🔐 权限请求\n工具:${toolName}\n操作:${displaySummary}${reasonLine}\n\n回复 /perm allow 批准 或 /perm deny 拒绝`);
19
+ return new Promise((resolve) => {
20
+ const timer = setTimeout(() => {
21
+ this.pending.delete(requestId);
22
+ this.eventBus?.publish({ type: 'permission:timeout', sessionId, requestId });
23
+ resolve(false);
24
+ }, this.timeout);
25
+ this.pending.set(requestId, { sessionId, resolve, timer });
26
+ });
27
+ }
28
+ resolvePermission(sessionId, requestId, approved) {
29
+ const pending = this.pending.get(requestId);
30
+ if (!pending || pending.sessionId !== sessionId)
31
+ return false;
32
+ clearTimeout(pending.timer);
33
+ pending.resolve(approved);
34
+ this.pending.delete(requestId);
35
+ this.eventBus?.publish({ type: 'permission:resolved', sessionId, requestId, approved });
36
+ return true;
37
+ }
38
+ /** 中断时取消指定会话的所有 pending 权限请求 */
39
+ cancelAll(sessionId) {
40
+ for (const [requestId, pending] of this.pending.entries()) {
41
+ if (pending.sessionId === sessionId) {
42
+ clearTimeout(pending.timer);
43
+ pending.resolve(false);
44
+ this.pending.delete(requestId);
45
+ }
46
+ }
47
+ }
48
+ /** 获取指定会话的所有 pending requestId */
49
+ getPendingRequests(sessionId) {
50
+ const ids = [];
51
+ for (const [requestId, pending] of this.pending.entries()) {
52
+ if (pending.sessionId === sessionId) {
53
+ ids.push(requestId);
54
+ }
55
+ }
56
+ return ids;
57
+ }
58
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * SessionFileAdapter — 会话文件操作的 Agent 抽象层
3
+ *
4
+ * 不同 Agent 后端(Claude / Codex)使用不同的会话文件存储格式和路径。
5
+ * 此接口将文件操作抽象为统一 API,由各 Agent 适配器分别实现。
6
+ */
7
+ export {};