evolclaw 2.2.0 → 2.4.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 (43) hide show
  1. package/README.md +49 -27
  2. package/data/evolclaw.sample.json +6 -3
  3. package/dist/agents/claude-runner.js +125 -52
  4. package/dist/agents/codex-runner.js +10 -5
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +283 -95
  7. package/dist/channels/feishu.js +556 -96
  8. package/dist/channels/wechat.js +98 -74
  9. package/dist/cli.js +232 -57
  10. package/dist/config.js +185 -31
  11. package/dist/core/channel-loader.js +11 -4
  12. package/dist/core/command-handler.js +803 -247
  13. package/dist/core/interaction-router.js +68 -0
  14. package/dist/core/message/message-bridge.js +217 -0
  15. package/dist/core/{message-processor.js → message/message-processor.js} +411 -124
  16. package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
  17. package/dist/{utils → core/message}/stream-debouncer.js +1 -1
  18. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  19. package/dist/core/permission.js +212 -11
  20. package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
  21. package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
  22. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  23. package/dist/{utils → core/session}/session-file-health.js +1 -1
  24. package/dist/core/{session-manager.js → session/session-manager.js} +61 -11
  25. package/dist/index.js +140 -57
  26. package/dist/{core/ipc-server.js → ipc.js} +36 -1
  27. package/dist/types.js +3 -0
  28. package/dist/utils/cross-platform.js +38 -1
  29. package/dist/utils/error-utils.js +130 -5
  30. package/dist/utils/init-channel.js +649 -0
  31. package/dist/utils/init.js +55 -150
  32. package/dist/utils/logger.js +8 -3
  33. package/dist/utils/media-cache.js +207 -0
  34. package/dist/{core → utils}/stats-collector.js +16 -0
  35. package/package.json +3 -3
  36. package/dist/core/message-bridge.js +0 -187
  37. package/dist/utils/init-feishu.js +0 -263
  38. package/dist/utils/init-wechat.js +0 -172
  39. package/dist/utils/ipc-client.js +0 -36
  40. package/dist/utils/permission-utils.js +0 -71
  41. /package/dist/{utils → core/message}/message-cache.js +0 -0
  42. /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
  43. /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
@@ -0,0 +1,68 @@
1
+ import { logger } from '../utils/logger.js';
2
+ export class InteractionRouter {
3
+ handlers = new Map();
4
+ register(id, sessionId, callback, opts) {
5
+ // Clear any existing handler for this ID
6
+ const existing = this.handlers.get(id);
7
+ if (existing?.timer)
8
+ clearTimeout(existing.timer);
9
+ let timer;
10
+ if (opts?.timeoutMs && opts.timeoutMs > 0) {
11
+ timer = setTimeout(() => {
12
+ this.handlers.delete(id);
13
+ logger.debug(`[InteractionRouter] Timeout for interaction: ${id}`);
14
+ opts.onTimeout?.();
15
+ }, opts.timeoutMs);
16
+ }
17
+ this.handlers.set(id, { callback, timer, sessionId, messageId: opts?.messageId });
18
+ }
19
+ handle(response) {
20
+ const handler = this.handlers.get(response.id);
21
+ if (!handler)
22
+ return false;
23
+ if (handler.timer)
24
+ clearTimeout(handler.timer);
25
+ this.handlers.delete(response.id);
26
+ try {
27
+ const result = handler.callback(response.action, response.values, response.operatorId);
28
+ // Catch async callback errors to prevent unhandled rejections
29
+ if (result && typeof result.catch === 'function') {
30
+ result.catch((err) => {
31
+ logger.error(`[InteractionRouter] Async callback error for ${response.id}:`, err);
32
+ });
33
+ }
34
+ }
35
+ catch (err) {
36
+ logger.error(`[InteractionRouter] Callback error for ${response.id}:`, err);
37
+ }
38
+ return true;
39
+ }
40
+ cancelAll(sessionId) {
41
+ for (const [id, handler] of this.handlers.entries()) {
42
+ if (handler.sessionId === sessionId) {
43
+ if (handler.timer)
44
+ clearTimeout(handler.timer);
45
+ this.handlers.delete(id);
46
+ }
47
+ }
48
+ }
49
+ cancel(id) {
50
+ const handler = this.handlers.get(id);
51
+ if (handler) {
52
+ if (handler.timer)
53
+ clearTimeout(handler.timer);
54
+ this.handlers.delete(id);
55
+ }
56
+ }
57
+ getPending(sessionId) {
58
+ const ids = [];
59
+ for (const [id, handler] of this.handlers.entries()) {
60
+ if (handler.sessionId === sessionId)
61
+ ids.push(id);
62
+ }
63
+ return ids;
64
+ }
65
+ getMessageId(id) {
66
+ return this.handlers.get(id)?.messageId;
67
+ }
68
+ }
@@ -0,0 +1,217 @@
1
+ import { logger } from '../../utils/logger.js';
2
+ import { StreamDebouncer } from './stream-debouncer.js';
3
+ /**
4
+ * MessageBridge — Channel 与 Core 之间的消息桥梁
5
+ *
6
+ * 入站管线:Channel.onMessage → owner 绑定 → 命令路由 → session 解析
7
+ * → 策略前缀 → 构造 Message → debounce → ACK → enqueue
8
+ * 出站:命令响应通过 sendReply 回调直接发送到渠道
9
+ */
10
+ export class MessageBridge {
11
+ config;
12
+ sessionManager;
13
+ processor;
14
+ messageQueue;
15
+ cmdHandler;
16
+ eventBus;
17
+ debouncers = new Map();
18
+ defaultDebounce;
19
+ constructor(config, sessionManager, processor, messageQueue, cmdHandler, eventBus) {
20
+ this.config = config;
21
+ this.sessionManager = sessionManager;
22
+ this.processor = processor;
23
+ this.messageQueue = messageQueue;
24
+ this.cmdHandler = cmdHandler;
25
+ this.eventBus = eventBus;
26
+ this.defaultDebounce = config.debounce ?? 2;
27
+ }
28
+ getDebouncer(channelName, channelType) {
29
+ let d = this.debouncers.get(channelName);
30
+ if (!d) {
31
+ let seconds = this.defaultDebounce;
32
+ // 查找渠道级 debounce 配置:先用 channelType(如 'feishu')在 config.channels 里查
33
+ const type = channelType || channelName;
34
+ const raw = this.config.channels?.[type];
35
+ if (raw) {
36
+ if (Array.isArray(raw)) {
37
+ const inst = raw.find((i) => (i.name || type) === channelName);
38
+ if (inst?.debounce !== undefined)
39
+ seconds = inst.debounce;
40
+ }
41
+ else if (raw.debounce !== undefined) {
42
+ seconds = raw.debounce;
43
+ }
44
+ }
45
+ d = new StreamDebouncer(seconds);
46
+ this.debouncers.set(channelName, d);
47
+ }
48
+ return d;
49
+ }
50
+ /**
51
+ * 为渠道注册消息桥梁:入站处理管线 + 出站命令响应
52
+ *
53
+ * @param channelName 渠道实例名(用于 debounce 隔离)
54
+ * @param onMessage 注册入站消息监听
55
+ * @param sendReply 出站:命令响应发送回调
56
+ * @param adapter 渠道适配器(用于 ACK)
57
+ * @param channelType 渠道类型(feishu/wechat/aun),用于 session 和 message.channel
58
+ */
59
+ register(channelName, onMessage, sendReply, adapter, channelType) {
60
+ const effectiveChannelType = channelType || channelName;
61
+ onMessage(async (msg) => {
62
+ try {
63
+ let content = msg.content.trim();
64
+ // 0. 自定义消息快速路径(menu.query 等)
65
+ if (await this.handleCustomPayload(content, channelName, msg, sendReply, adapter))
66
+ return;
67
+ // 1. owner 绑定(按实例名绑定)
68
+ if (msg.peerId)
69
+ await this.autoBindOwner(channelName, msg.peerId);
70
+ // 2. 命令快速路径(去除引用前缀后检查,兼容话题中引用上文的情况)
71
+ const contentForCmd = content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
72
+ const cmdContent = contentForCmd || content;
73
+ if (this.cmdHandler.isCommand(cmdContent)) {
74
+ logger.debug(`[MessageBridge] Command detected: "${cmdContent}", routing to handler`);
75
+ }
76
+ if (await this.handleCommand(cmdContent, channelName, msg.channelId, (text) => sendReply(msg.channelId, text, msg.replyContext), msg.peerId, msg.threadId))
77
+ return;
78
+ // 3. session 解析(使用 Channel 层填充的 chatType)
79
+ const chatType = msg.chatType || 'private';
80
+ const metadata = {};
81
+ // 话题会话创建时写入 replyContext(用于 threadId 路由);主会话不写(避免群聊覆盖)
82
+ if (msg.threadId && msg.replyContext)
83
+ metadata.replyContext = msg.replyContext;
84
+ // 写入实例名(审计 + 精确出站路由)
85
+ metadata.channelName = channelName;
86
+ if (chatType === 'private' && msg.peerId) {
87
+ metadata.peerId = msg.peerId;
88
+ if (msg.peerName)
89
+ metadata.peerName = msg.peerName;
90
+ }
91
+ const session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, this.config.projects?.defaultPath || process.cwd(), msg.threadId, Object.keys(metadata).length ? metadata : undefined, undefined, msg.peerId, chatType);
92
+ // 4. 消息前缀(由 policy 决定)
93
+ const channelInfo = this.processor.getChannelInfo?.(channelName);
94
+ if (channelInfo?.policy) {
95
+ const prefix = channelInfo.policy.messagePrefix(chatType, msg.peerName);
96
+ if (prefix)
97
+ content = prefix + content;
98
+ }
99
+ // 5. 构造完整消息(channel 字段存实例名,用于 session 精确匹配)
100
+ const fullMessage = {
101
+ channel: channelName, channelId: msg.channelId, content,
102
+ chatType,
103
+ images: msg.images, timestamp: Date.now(),
104
+ peerId: msg.peerId, peerName: msg.peerName,
105
+ messageId: msg.messageId,
106
+ mentions: msg.mentions, threadId: msg.threadId,
107
+ replyContext: msg.replyContext,
108
+ };
109
+ // 6. ACK + debounce/enqueue
110
+ // ACK 在到达时立即做(每条独立 ACK),不等合并
111
+ // Interrupt 模式(单聊)→ 入队前 debounce 合并
112
+ // FIFO 模式(群聊) → 跳过 debouncer,独立入队,出队时贪心合并
113
+ if (fullMessage.messageId)
114
+ adapter?.acknowledge?.(fullMessage.messageId).catch(() => { });
115
+ const isInterrupt = chatType !== 'group';
116
+ const doEnqueue = async (m) => {
117
+ return this.messageQueue.enqueue(session.id, m, session.projectPath, {
118
+ interruptible: isInterrupt,
119
+ });
120
+ };
121
+ if (isInterrupt) {
122
+ const debouncer = this.getDebouncer(channelName, effectiveChannelType);
123
+ if (debouncer.enabled) {
124
+ const debounceKey = msg.peerId ? `${session.id}:${msg.peerId}` : session.id;
125
+ await debouncer.submit(debounceKey, fullMessage, doEnqueue);
126
+ }
127
+ else {
128
+ await doEnqueue(fullMessage);
129
+ }
130
+ }
131
+ else {
132
+ // 群聊 FIFO:直接入队,由 MessageQueue.processNext 出队时合并
133
+ await doEnqueue(fullMessage);
134
+ }
135
+ }
136
+ catch (error) {
137
+ logger.error(`[MessageBridge] Error in onMessage handler for ${channelName}:`, error);
138
+ }
139
+ });
140
+ }
141
+ /** 自定义消息快速路径:拦截 menu.query 等自定义 payload,返回 true 表示已处理 */
142
+ async handleCustomPayload(content, channel, msg, sendReply, adapter) {
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(content);
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ if (!parsed || typeof parsed !== 'object' || !parsed.type)
151
+ return false;
152
+ if (parsed.type === 'menu.query') {
153
+ const identity = this.sessionManager.resolveIdentity(channel, msg.peerId);
154
+ const isAdmin = identity.role === 'owner';
155
+ const items = this.cmdHandler.getMenuItems(isAdmin, msg.chatType || 'private');
156
+ const response = JSON.stringify({ type: 'menu.response', items });
157
+ if (adapter?.sendCustomPayload) {
158
+ adapter.sendCustomPayload(msg.channelId, response);
159
+ }
160
+ else {
161
+ await sendReply(msg.channelId, response);
162
+ }
163
+ return true;
164
+ }
165
+ return false;
166
+ }
167
+ /** 首次交互自动绑定 owner */
168
+ async autoBindOwner(channel, userId) {
169
+ const { getOwner, setOwner } = await import('../../config.js');
170
+ const currentOwner = getOwner(this.config, channel);
171
+ // currentOwner === undefined means either no owner set, or instance not found
172
+ // In both cases, try to set — setOwner is a no-op for unknown instances
173
+ if (currentOwner === undefined) {
174
+ setOwner(this.config, channel, userId);
175
+ logger.info(`[Owner] Auto-bound ${channel} owner: ${userId}`);
176
+ this.eventBus.publish({ type: 'channel:owner-bound', channel, userId });
177
+ }
178
+ }
179
+ /** 命令快速路径:返回 true 表示已处理 */
180
+ async handleCommand(content, channel, channelId, sendReply, userId, threadId) {
181
+ if (!this.cmdHandler.isCommand(content))
182
+ return false;
183
+ logger.info(`[${channel}] ${channelId}: ${content}`);
184
+ const cmdResult = await this.cmdHandler.handle(content, channel, channelId, (_cid, text, opts) => sendReply(text), userId, threadId);
185
+ logger.debug(`[MessageBridge] handleCommand: result type=${typeof cmdResult}, value=${cmdResult === null ? 'null' : cmdResult === undefined ? 'undefined' : 'string'}`);
186
+ if (cmdResult === undefined)
187
+ return false;
188
+ if (cmdResult) {
189
+ try {
190
+ await sendReply(cmdResult);
191
+ }
192
+ catch (error) {
193
+ logger.error(`[${channel}] Failed to send command response:`, error);
194
+ }
195
+ }
196
+ return true;
197
+ }
198
+ /**
199
+ * 撤回消息:先查 debounce 窗口,再查 message queue。
200
+ * @returns true 如果找到并取消
201
+ */
202
+ cancel(messageId) {
203
+ // 阶段 1: debounce 窗口(尚未入队)
204
+ for (const d of this.debouncers.values()) {
205
+ if (d.cancel(messageId))
206
+ return true;
207
+ }
208
+ // 阶段 2: 已入队但未处理(合并后 messageId 可能是逗号分隔的多个 id)
209
+ return this.messageQueue.cancel(messageId);
210
+ }
211
+ /** 清理资源 */
212
+ dispose() {
213
+ for (const d of this.debouncers.values())
214
+ d.dispose();
215
+ this.debouncers.clear();
216
+ }
217
+ }