evolclaw 2.0.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/README.md +191 -0
- package/bin/evolclaw +10 -0
- package/data/evolclaw.sample.json +39 -0
- package/dist/channels/aun.js +28 -0
- package/dist/channels/feishu.js +452 -0
- package/dist/cli.js +759 -0
- package/dist/config.js +81 -0
- package/dist/core/agent-runner.js +326 -0
- package/dist/core/command-handler.js +823 -0
- package/dist/core/message-cache.js +56 -0
- package/dist/core/message-processor.js +516 -0
- package/dist/core/message-queue.js +110 -0
- package/dist/core/message-stream.js +59 -0
- package/dist/core/session-manager.js +803 -0
- package/dist/index.js +239 -0
- package/dist/paths.js +45 -0
- package/dist/types.js +1 -0
- package/dist/utils/error-utils.js +54 -0
- package/dist/utils/init.js +352 -0
- package/dist/utils/logger.js +47 -0
- package/dist/utils/markdown-to-feishu.js +38 -0
- package/dist/utils/permission.js +36 -0
- package/dist/utils/session-file-health.js +67 -0
- package/dist/utils/stream-flusher.js +151 -0
- package/dist/utils/stream-idle-monitor.js +103 -0
- package/package.json +38 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 消息缓存
|
|
3
|
+
* 用于缓存后台任务的关键事件(完成、错误)
|
|
4
|
+
*/
|
|
5
|
+
export class MessageCache {
|
|
6
|
+
cache = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* 添加事件到缓存
|
|
9
|
+
*/
|
|
10
|
+
addEvent(sessionId, event) {
|
|
11
|
+
if (!this.cache.has(sessionId)) {
|
|
12
|
+
this.cache.set(sessionId, []);
|
|
13
|
+
}
|
|
14
|
+
this.cache.get(sessionId).push(event);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 获取指定会话的所有缓存事件
|
|
18
|
+
*/
|
|
19
|
+
getEvents(sessionId) {
|
|
20
|
+
return this.cache.get(sessionId) || [];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 获取指定会话的缓存事件数量
|
|
24
|
+
*/
|
|
25
|
+
getCount(sessionId) {
|
|
26
|
+
return this.cache.get(sessionId)?.length || 0;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 检查指定会话是否有缓存事件
|
|
30
|
+
*/
|
|
31
|
+
hasMessages(sessionId) {
|
|
32
|
+
return this.getCount(sessionId) > 0;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 清空指定会话的缓存事件
|
|
36
|
+
*/
|
|
37
|
+
clearEvents(sessionId) {
|
|
38
|
+
this.cache.delete(sessionId);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 清理过期的缓存事件
|
|
42
|
+
* @param maxAge 最大保留时间(毫秒),默认 72 小时
|
|
43
|
+
*/
|
|
44
|
+
cleanupExpired(maxAge = 72 * 60 * 60 * 1000) {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
for (const [sessionId, events] of this.cache.entries()) {
|
|
47
|
+
const filtered = events.filter(e => now - e.timestamp < maxAge);
|
|
48
|
+
if (filtered.length === 0) {
|
|
49
|
+
this.cache.delete(sessionId);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
this.cache.set(sessionId, filtered);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { StreamFlusher } from '../utils/stream-flusher.js';
|
|
4
|
+
import { StreamIdleMonitor } from '../utils/stream-idle-monitor.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
import { getErrorMessage, classifyError, ErrorType } from '../utils/error-utils.js';
|
|
7
|
+
import { isOwner } from '../config.js';
|
|
8
|
+
/**
|
|
9
|
+
* 统一消息处理器
|
|
10
|
+
* 负责处理来自不同渠道的消息,协调事件流处理
|
|
11
|
+
*/
|
|
12
|
+
export class MessageProcessor {
|
|
13
|
+
agentRunner;
|
|
14
|
+
sessionManager;
|
|
15
|
+
config;
|
|
16
|
+
messageCache;
|
|
17
|
+
commandHandler;
|
|
18
|
+
channels = new Map();
|
|
19
|
+
currentFlusher;
|
|
20
|
+
currentIsGroup = false;
|
|
21
|
+
constructor(agentRunner, sessionManager, config, messageCache, commandHandler) {
|
|
22
|
+
this.agentRunner = agentRunner;
|
|
23
|
+
this.sessionManager = sessionManager;
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.messageCache = messageCache;
|
|
26
|
+
this.commandHandler = commandHandler;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 注册渠道适配器
|
|
30
|
+
*/
|
|
31
|
+
registerChannel(adapter, options) {
|
|
32
|
+
this.channels.set(adapter.name, { adapter, options });
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 处理 compact 开始事件
|
|
36
|
+
*/
|
|
37
|
+
handleCompactStart() {
|
|
38
|
+
if (this.currentFlusher && !this.currentIsGroup) {
|
|
39
|
+
this.currentFlusher.addActivity('⏳ 会话压缩中...');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 处理消息(主入口)
|
|
44
|
+
*/
|
|
45
|
+
async processMessage(message) {
|
|
46
|
+
const isGroup = message.isGroup ?? false;
|
|
47
|
+
this.currentIsGroup = isGroup;
|
|
48
|
+
const idleMs = this.config.timeout?.idle ?? 120000;
|
|
49
|
+
const streamKey = `${message.channel}-${message.channelId}`;
|
|
50
|
+
const channelInfo = this.channels.get(message.channel);
|
|
51
|
+
const monitorEnabled = this.config.idleMonitor?.enabled !== false;
|
|
52
|
+
const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
|
|
53
|
+
const isOwnerUser = isOwner(this.config, message.channel, message.userId || '');
|
|
54
|
+
// 非主人(群聊或单聊):空闲监控静默/简短
|
|
55
|
+
const quietMode = isGroup || !isOwnerUser;
|
|
56
|
+
let monitor;
|
|
57
|
+
let monitorInterval;
|
|
58
|
+
let rejectFn;
|
|
59
|
+
const resetTimer = (eventType, toolName) => {
|
|
60
|
+
monitor?.recordEvent(eventType || 'unknown', toolName);
|
|
61
|
+
};
|
|
62
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
63
|
+
rejectFn = reject;
|
|
64
|
+
if (!monitorEnabled)
|
|
65
|
+
return;
|
|
66
|
+
monitor = new StreamIdleMonitor(idleMs);
|
|
67
|
+
monitorInterval = setInterval(async () => {
|
|
68
|
+
// Drain all pending levels in one tick
|
|
69
|
+
let result = monitor.check();
|
|
70
|
+
while (result) {
|
|
71
|
+
if (result.action === 'kill') {
|
|
72
|
+
logger.warn(`[MessageProcessor] Idle monitor: kill after ${result.idleSec}s idle, stream: ${streamKey}`);
|
|
73
|
+
// 先发送诊断信息,让用户知道发生了什么
|
|
74
|
+
if (channelInfo) {
|
|
75
|
+
try {
|
|
76
|
+
const msg = quietMode
|
|
77
|
+
? `⚠️ 任务超时(${result.idleSec}秒无响应),已自动中断`
|
|
78
|
+
: result.message;
|
|
79
|
+
await channelInfo.adapter.sendText(message.channelId, msg);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
logger.debug(`[MessageProcessor] Failed to send kill diagnostic message:`, e);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
await this.agentRunner.interrupt(streamKey);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
logger.debug(`[MessageProcessor] Interrupt failed (may already be cleaned up):`, e);
|
|
90
|
+
}
|
|
91
|
+
rejectFn(new Error('SDK_TIMEOUT'));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// notify or warn: send diagnostic message, task continues(非主人时静默)
|
|
96
|
+
logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
|
|
97
|
+
if (channelInfo && !quietMode) {
|
|
98
|
+
try {
|
|
99
|
+
await channelInfo.adapter.sendText(message.channelId, result.message);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
result = monitor.check();
|
|
107
|
+
}
|
|
108
|
+
}, 30000);
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
await Promise.race([
|
|
112
|
+
this._processMessageInternal(message, resetTimer),
|
|
113
|
+
timeoutPromise
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// 超时错误:kill 级别已发送诊断信息,无需再发
|
|
118
|
+
// 非超时错误走通用处理
|
|
119
|
+
// 记录错误到健康状态(仅主人的错误累计触发安全模式)
|
|
120
|
+
if (channelInfo) {
|
|
121
|
+
try {
|
|
122
|
+
const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd());
|
|
123
|
+
const errorType = classifyError(error);
|
|
124
|
+
// 上下文过长是可恢复错误,不累计触发安全模式
|
|
125
|
+
if (errorType === ErrorType.CONTEXT_TOO_LONG) {
|
|
126
|
+
logger.info(`[MessageProcessor] Context too long error, skipping safe mode accumulation`);
|
|
127
|
+
}
|
|
128
|
+
else if (quietMode) {
|
|
129
|
+
// 群聊或非主人的错误只记日志,不累计
|
|
130
|
+
logger.info(`[MessageProcessor] Non-owner/group error (user=${message.userId}, group=${isGroup}), skipping safe mode accumulation`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const newCount = await this.sessionManager.recordError(session.id, errorType, error.message);
|
|
134
|
+
await this.checkSafeMode(session.id, message.channelId, channelInfo.adapter, safeModeThreshold, newCount);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (statusError) {
|
|
138
|
+
logger.error('[MessageProcessor] Failed to update health status:', statusError);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
if (monitorInterval)
|
|
145
|
+
clearInterval(monitorInterval);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 检查是否需要进入安全模式(safeModeThreshold 为 0 时跳过)
|
|
150
|
+
* 仅单聊主人会话调用(群聊和非主人已在调用侧过滤)
|
|
151
|
+
*/
|
|
152
|
+
async checkSafeMode(sessionId, channelId, adapter, safeModeThreshold, consecutiveErrors) {
|
|
153
|
+
if (safeModeThreshold <= 0)
|
|
154
|
+
return;
|
|
155
|
+
const health = await this.sessionManager.getHealthStatus(sessionId);
|
|
156
|
+
if (consecutiveErrors >= safeModeThreshold && !health.safeMode) {
|
|
157
|
+
await this.sessionManager.setSafeMode(sessionId, true);
|
|
158
|
+
logger.warn(`[MessageProcessor] Session ${sessionId} entered safe mode after ${consecutiveErrors} errors`);
|
|
159
|
+
await adapter.sendText(channelId, `⚠️ 安全模式已启用(连续 ${consecutiveErrors} 次异常)
|
|
160
|
+
|
|
161
|
+
当前限制:
|
|
162
|
+
- 无法记住之前的对话
|
|
163
|
+
- 每次提问需要提供完整上下文
|
|
164
|
+
|
|
165
|
+
建议操作:
|
|
166
|
+
1. /repair - 检查并修复会话(推荐,保留历史)
|
|
167
|
+
2. /new [名称] - 创建新会话(清空历史)
|
|
168
|
+
3. /status - 查看详细状态`);
|
|
169
|
+
}
|
|
170
|
+
else if (safeModeThreshold >= 2 && consecutiveErrors === safeModeThreshold - 1) {
|
|
171
|
+
// 阈值前一次错误,发送警告
|
|
172
|
+
await adapter.sendText(channelId, `⚠️ 检测到异常(${consecutiveErrors}/${safeModeThreshold})\n\n如果问题持续,系统将自动进入安全模式。建议使用 /status 查看状态。`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async _processMessageInternal(message, resetTimer) {
|
|
176
|
+
const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
|
|
177
|
+
const channelInfo = this.channels.get(message.channel);
|
|
178
|
+
if (!channelInfo) {
|
|
179
|
+
logger.error(`[MessageProcessor] Unknown channel: ${message.channel}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const { adapter, options } = channelInfo;
|
|
183
|
+
try {
|
|
184
|
+
// 检查是否为命令
|
|
185
|
+
if (this.commandHandler) {
|
|
186
|
+
const cmdResult = await this.commandHandler(message.content, message.channel, message.channelId, message.userId);
|
|
187
|
+
if (cmdResult) {
|
|
188
|
+
await adapter.sendText(message.channelId, cmdResult);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// 解析会话和项目路径
|
|
193
|
+
const { session, absoluteProjectPath } = await this.resolveSession(message);
|
|
194
|
+
// 判断是否是后台任务
|
|
195
|
+
const activeSession = await this.sessionManager.getActiveSession(message.channel, message.channelId);
|
|
196
|
+
const isBackground = activeSession ? session.id !== activeSession.id : false;
|
|
197
|
+
// 记录收到消息
|
|
198
|
+
logger.message({
|
|
199
|
+
msgId: messageId,
|
|
200
|
+
sessionId: session.id,
|
|
201
|
+
dir: 'inbound',
|
|
202
|
+
status: 'received'
|
|
203
|
+
});
|
|
204
|
+
const imageInfo = message.images && message.images.length > 0 ? ` [${message.images.length} image(s)]` : '';
|
|
205
|
+
const modeInfo = isBackground ? ' [后台]' : '';
|
|
206
|
+
logger.info(`[${message.channel}] ${message.channelId}: ${message.content}${imageInfo}${modeInfo}`);
|
|
207
|
+
// 记录开始处理
|
|
208
|
+
logger.message({
|
|
209
|
+
msgId: messageId,
|
|
210
|
+
sessionId: session.id,
|
|
211
|
+
dir: 'inbound',
|
|
212
|
+
status: 'processing'
|
|
213
|
+
});
|
|
214
|
+
const startTime = Date.now();
|
|
215
|
+
// 创建 StreamFlusher,传入文件标记模式用于自动过滤
|
|
216
|
+
// 使用动态判断,确保切换项目后不会继续输出
|
|
217
|
+
let firstReply = true;
|
|
218
|
+
const flusher = new StreamFlusher(async (text, isFinal) => {
|
|
219
|
+
// 动态判断是否是后台任务
|
|
220
|
+
const currentActiveSession = await this.sessionManager.getActiveSession(message.channel, message.channelId);
|
|
221
|
+
const isCurrentlyBackground = currentActiveSession ? session.id !== currentActiveSession.id : false;
|
|
222
|
+
if (!isCurrentlyBackground) {
|
|
223
|
+
const opts = {};
|
|
224
|
+
if (isFinal)
|
|
225
|
+
opts.title = '最终回复:';
|
|
226
|
+
// 首条消息引用回复用户原消息
|
|
227
|
+
if (firstReply && message.messageId) {
|
|
228
|
+
opts.replyToMessageId = message.messageId;
|
|
229
|
+
firstReply = false;
|
|
230
|
+
}
|
|
231
|
+
await adapter.sendText(message.channelId, text, Object.keys(opts).length ? opts : undefined);
|
|
232
|
+
}
|
|
233
|
+
// 后台任务:静默,不发送输出
|
|
234
|
+
}, this.config.flushDelay ?? 4000, options?.fileMarkerPattern);
|
|
235
|
+
// 保存当前 flusher,用于 compact 事件
|
|
236
|
+
this.currentFlusher = flusher;
|
|
237
|
+
// 调用 AgentRunner(含上下文过长自动 compact 重试)
|
|
238
|
+
const streamKey = `${message.channel}-${message.channelId}`;
|
|
239
|
+
try {
|
|
240
|
+
const stream = await this.agentRunner.runQuery(session.id, message.content, absoluteProjectPath, session.claudeSessionId, message.images, options?.systemPromptAppend, this.sessionManager);
|
|
241
|
+
this.agentRunner.registerStream(streamKey, stream);
|
|
242
|
+
await this.processEventStream(stream, session, message.channelId, adapter, options, flusher, isBackground, resetTimer);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (this.isContextTooLongError(error) && session.claudeSessionId) {
|
|
246
|
+
// 尝试 compact 压缩会话
|
|
247
|
+
flusher.addActivity('⚠️ 上下文过长,正在压缩会话...');
|
|
248
|
+
await flusher.flush();
|
|
249
|
+
const compacted = await this.agentRunner.compactSession(session.id, session.claudeSessionId, absoluteProjectPath);
|
|
250
|
+
if (compacted) {
|
|
251
|
+
// compact 成功,带 resume 重试
|
|
252
|
+
flusher.addActivity('✅ 压缩完成,正在重试...');
|
|
253
|
+
const retryStream = await this.agentRunner.runQuery(session.id, message.content, absoluteProjectPath, session.claudeSessionId, message.images, options?.systemPromptAppend, this.sessionManager);
|
|
254
|
+
this.agentRunner.registerStream(streamKey, retryStream);
|
|
255
|
+
await this.processEventStream(retryStream, session, message.channelId, adapter, options, flusher, isBackground, resetTimer);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
throw new Error('CONTEXT_COMPACT_FAILED');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// 处理文件标记(Feishu 专用)- 提取并发送文件
|
|
266
|
+
if (options?.fileMarkerPattern && adapter.sendFile) {
|
|
267
|
+
const fullText = flusher.getFinalText();
|
|
268
|
+
const fileMatches = [...fullText.matchAll(options.fileMarkerPattern)];
|
|
269
|
+
for (const match of fileMatches) {
|
|
270
|
+
const filePath = match[1].trim();
|
|
271
|
+
const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
|
|
272
|
+
logger.info(`[${adapter.name}] Sending file: ${resolvedPath}`);
|
|
273
|
+
try {
|
|
274
|
+
await adapter.sendFile(message.channelId, resolvedPath);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
logger.error(`[${adapter.name}] Failed to send file: ${resolvedPath}`, error);
|
|
278
|
+
await adapter.sendText(message.channelId, `❌ 文件发送失败: ${filePath}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Flush 剩余内容(文件标记已在 flush 时自动移除)
|
|
283
|
+
await flusher.flush(true);
|
|
284
|
+
// 安全模式尾部提示:如果当前会话处于安全模式,追加提醒
|
|
285
|
+
const healthStatus = await this.sessionManager.getHealthStatus(session.id);
|
|
286
|
+
if (healthStatus.safeMode) {
|
|
287
|
+
await adapter.sendText(message.channelId, '\n\n⚠️ 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /new 新建会话');
|
|
288
|
+
}
|
|
289
|
+
// 清理 activeStreams(正常完成)
|
|
290
|
+
this.agentRunner.cleanupStream(streamKey);
|
|
291
|
+
// 记录成功响应(重置错误计数)
|
|
292
|
+
await this.sessionManager.recordSuccess(session.id);
|
|
293
|
+
// 动态判断是否是后台任务,决定是否发送通知
|
|
294
|
+
const currentActive = await this.sessionManager.getActiveSession(message.channel, message.channelId);
|
|
295
|
+
const isFinallyBackground = currentActive ? session.id !== currentActive.id : false;
|
|
296
|
+
if (isFinallyBackground) {
|
|
297
|
+
const projectName = path.basename(session.projectPath);
|
|
298
|
+
const count = this.messageCache.getCount(session.id);
|
|
299
|
+
await adapter.sendText(message.channelId, `[后台-${projectName}] ✓ 任务完成 (${count}条消息已缓存)`);
|
|
300
|
+
}
|
|
301
|
+
const duration = Date.now() - startTime;
|
|
302
|
+
// 记录处理完成
|
|
303
|
+
logger.message({
|
|
304
|
+
msgId: messageId,
|
|
305
|
+
sessionId: session.id,
|
|
306
|
+
dir: 'inbound',
|
|
307
|
+
status: 'completed',
|
|
308
|
+
duration
|
|
309
|
+
});
|
|
310
|
+
// 记录发送响应
|
|
311
|
+
logger.message({
|
|
312
|
+
msgId: `${messageId}_reply`,
|
|
313
|
+
sessionId: session.id,
|
|
314
|
+
dir: 'outbound',
|
|
315
|
+
status: 'sent'
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
logger.error(`[${message.channel}] Error:`, error);
|
|
320
|
+
// 记录处理失败
|
|
321
|
+
logger.message({
|
|
322
|
+
msgId: messageId,
|
|
323
|
+
sessionId: message.channelId,
|
|
324
|
+
dir: 'inbound',
|
|
325
|
+
status: 'failed',
|
|
326
|
+
error: error instanceof Error ? error.message : String(error)
|
|
327
|
+
});
|
|
328
|
+
if (error instanceof Error) {
|
|
329
|
+
logger.error(`[${message.channel}] Error stack:`, error.stack);
|
|
330
|
+
}
|
|
331
|
+
// 发送用户友好的错误消息(SDK_TIMEOUT 已在 kill 级别发过提示,跳过)
|
|
332
|
+
if (error instanceof Error && error.message === 'SDK_TIMEOUT') {
|
|
333
|
+
logger.info(`[MessageProcessor] SDK_TIMEOUT error, skip sending duplicate message`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
const userMessage = getErrorMessage(error);
|
|
337
|
+
await adapter.sendText(message.channelId, userMessage);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* 解析会话和项目路径
|
|
343
|
+
*/
|
|
344
|
+
async resolveSession(message) {
|
|
345
|
+
const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd());
|
|
346
|
+
const absoluteProjectPath = path.isAbsolute(session.projectPath)
|
|
347
|
+
? session.projectPath
|
|
348
|
+
: path.resolve(process.cwd(), session.projectPath);
|
|
349
|
+
return { session, absoluteProjectPath };
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* 处理事件流
|
|
353
|
+
*/
|
|
354
|
+
async processEventStream(stream, session, channelId, adapter, options, flusher, isBackground, resetTimer) {
|
|
355
|
+
let hasTextDelta = false;
|
|
356
|
+
let hasReceivedText = false;
|
|
357
|
+
let lastSessionId;
|
|
358
|
+
try {
|
|
359
|
+
for await (const event of stream) {
|
|
360
|
+
// 每收到事件重置空闲超时,传入事件类型和工具名
|
|
361
|
+
const toolName = event.type === 'assistant'
|
|
362
|
+
? event.message?.content?.find((c) => c.type === 'tool_use')?.name
|
|
363
|
+
: undefined;
|
|
364
|
+
resetTimer(event.type, toolName);
|
|
365
|
+
// 记录所有事件类型(INFO级别,便于诊断)
|
|
366
|
+
logger.info(`[MessageProcessor] Event: type=${event.type}, subtype=${event.subtype || 'none'}`);
|
|
367
|
+
// 提取 session_id(只在首次或变化时更新)
|
|
368
|
+
if (event.session_id && event.session_id !== lastSessionId) {
|
|
369
|
+
logger.info(`[MessageProcessor] Extracted session_id: ${event.session_id} for session: ${session.id}`);
|
|
370
|
+
this.agentRunner.updateSessionId(session.id, event.session_id);
|
|
371
|
+
lastSessionId = event.session_id;
|
|
372
|
+
}
|
|
373
|
+
// 动态判断当前是否是后台任务
|
|
374
|
+
const currentActive = await this.sessionManager.getActiveSession(session.channel, session.channelId);
|
|
375
|
+
const isCurrentlyBackground = currentActive ? session.id !== currentActive.id : false;
|
|
376
|
+
// === 前台任务:正常处理所有事件 ===
|
|
377
|
+
if (!isCurrentlyBackground) {
|
|
378
|
+
// 流式文本事件
|
|
379
|
+
if (event.type === 'text_delta' && event.text) {
|
|
380
|
+
hasTextDelta = true;
|
|
381
|
+
hasReceivedText = true;
|
|
382
|
+
flusher.addText(event.text);
|
|
383
|
+
}
|
|
384
|
+
// 系统事件:compact_boundary(群聊时静默)
|
|
385
|
+
if (event.type === 'system' && event.subtype === 'compact_boundary') {
|
|
386
|
+
if (!this.currentIsGroup) {
|
|
387
|
+
const preTokens = event.compact_metadata?.pre_tokens || 0;
|
|
388
|
+
flusher.addActivity(`💡 会话压缩完成,继续执行...(压缩前 tokens: ${preTokens})`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// 系统事件:task_progress(子任务进度)
|
|
392
|
+
if (event.type === 'system' && event.subtype === 'task_progress') {
|
|
393
|
+
const tools = event.tool_uses ?? 0;
|
|
394
|
+
const duration = event.duration_ms ? `${Math.round(event.duration_ms / 1000)}s` : '';
|
|
395
|
+
const summary = event.summary;
|
|
396
|
+
const stats = [tools > 0 ? `${tools}次工具调用` : '', duration].filter(Boolean).join(', ');
|
|
397
|
+
if (summary) {
|
|
398
|
+
flusher.addActivity(`⏳ 子任务: ${summary}${stats ? ` (${stats})` : ''}`);
|
|
399
|
+
}
|
|
400
|
+
else if (stats) {
|
|
401
|
+
flusher.addActivity(`⏳ 子任务进行中: ${stats}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Assistant 事件:提取工具调用和文本内容
|
|
405
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
406
|
+
for (const content of event.message.content) {
|
|
407
|
+
if (content.type === 'tool_use') {
|
|
408
|
+
const desc = this.formatToolDescription(content);
|
|
409
|
+
flusher.addActivity(`🔧 ${content.name}${desc ? ': ' + desc : ''}`);
|
|
410
|
+
}
|
|
411
|
+
else if (content.type === 'text' && content.text && !hasTextDelta) {
|
|
412
|
+
// 仅在没有 text_delta 事件时从 assistant 事件提取文本,避免重复
|
|
413
|
+
hasReceivedText = true;
|
|
414
|
+
flusher.addTextBlock(content.text);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// 工具结果事件:显示失败信息(包括权限拒绝、执行失败等所有场景)
|
|
419
|
+
if (event.type === 'tool_result') {
|
|
420
|
+
logger.debug(`[MessageProcessor] tool_result: is_error=${event.is_error}, error=${event.error}, content=${typeof event.content}`);
|
|
421
|
+
if (event.is_error) {
|
|
422
|
+
const toolName = event.tool_name || '工具';
|
|
423
|
+
const errorMsg = event.error || (typeof event.content === 'string' ? event.content : JSON.stringify(event.content)) || '执行失败';
|
|
424
|
+
flusher.addActivity(`⚠️ ${toolName}: ${errorMsg}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// Result 事件:仅在没有流式文本时使用 result 作为最终输出
|
|
428
|
+
if (event.type === 'result' && event.result) {
|
|
429
|
+
logger.debug(`[MessageProcessor] result event: hasReceivedText=${hasReceivedText}, result="${event.result}"`);
|
|
430
|
+
if (!hasReceivedText) {
|
|
431
|
+
// 没有通过 text_delta 或 assistant 收到文本,使用 result 作为兜底
|
|
432
|
+
flusher.addText(event.result);
|
|
433
|
+
}
|
|
434
|
+
await flusher.flush();
|
|
435
|
+
}
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
// === 后台任务:只处理 result 事件,仅缓存不发送 ===
|
|
439
|
+
if (event.type !== 'result') {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (event.subtype === 'success') {
|
|
443
|
+
this.messageCache.addEvent(session.id, {
|
|
444
|
+
type: 'completed',
|
|
445
|
+
message: event.result,
|
|
446
|
+
timestamp: Date.now(),
|
|
447
|
+
metadata: {
|
|
448
|
+
duration: event.duration_ms,
|
|
449
|
+
cost: event.total_cost_usd
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
else if (event.is_error === true) {
|
|
454
|
+
this.messageCache.addEvent(session.id, {
|
|
455
|
+
type: 'error',
|
|
456
|
+
message: event.errors?.join('\n') || '未知错误',
|
|
457
|
+
timestamp: Date.now(),
|
|
458
|
+
metadata: {
|
|
459
|
+
errorType: event.subtype
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
// 捕获 SDK 进程崩溃或流迭代错误
|
|
467
|
+
logger.error('[MessageProcessor] Stream processing error:', error);
|
|
468
|
+
if (error instanceof Error && error.message.includes('process exited')) {
|
|
469
|
+
flusher.addActivity('❌ Claude Code 进程异常退出,请重试');
|
|
470
|
+
}
|
|
471
|
+
throw error; // 重新抛出,让外层处理
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* 判断是否为上下文过长错误
|
|
476
|
+
*/
|
|
477
|
+
isContextTooLongError(error) {
|
|
478
|
+
const msg = (error?.message || String(error)).toLowerCase();
|
|
479
|
+
return msg.includes('上下文过长') || msg.includes('context too long')
|
|
480
|
+
|| msg.includes('context_length_exceeded');
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* 格式化工具描述(通用)
|
|
484
|
+
*/
|
|
485
|
+
formatToolDescription(toolUse) {
|
|
486
|
+
const input = toolUse.input || {};
|
|
487
|
+
return (input.description ||
|
|
488
|
+
input.file_path ||
|
|
489
|
+
input.pattern ||
|
|
490
|
+
(typeof input.command === 'string' ? input.command.substring(0, 80) : undefined) ||
|
|
491
|
+
(typeof input.prompt === 'string' ? input.prompt.substring(0, 80) : undefined) ||
|
|
492
|
+
(typeof input.query === 'string' ? input.query.substring(0, 80) : undefined) ||
|
|
493
|
+
'');
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* 解析文件路径,支持相对路径和绝对路径
|
|
497
|
+
* 优先在项目根目录查找,兜底尝试 .openclaw/workspace/
|
|
498
|
+
*/
|
|
499
|
+
resolveFilePath(filePath, projectPath) {
|
|
500
|
+
if (path.isAbsolute(filePath)) {
|
|
501
|
+
return filePath;
|
|
502
|
+
}
|
|
503
|
+
// 优先在项目根目录查找
|
|
504
|
+
const rootPath = path.join(projectPath, filePath);
|
|
505
|
+
if (fs.existsSync(rootPath)) {
|
|
506
|
+
return rootPath;
|
|
507
|
+
}
|
|
508
|
+
// 兜底:尝试 .openclaw/workspace/
|
|
509
|
+
const workspacePath = path.join(projectPath, '.openclaw', 'workspace', filePath);
|
|
510
|
+
if (fs.existsSync(workspacePath)) {
|
|
511
|
+
return workspacePath;
|
|
512
|
+
}
|
|
513
|
+
// 都找不到,返回项目根目录路径
|
|
514
|
+
return rootPath;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
export class MessageQueue {
|
|
4
|
+
queues = new Map();
|
|
5
|
+
processing = new Set();
|
|
6
|
+
handler;
|
|
7
|
+
currentSessionKey;
|
|
8
|
+
currentProjectPath;
|
|
9
|
+
interruptCallback;
|
|
10
|
+
constructor(handler) {
|
|
11
|
+
this.handler = handler;
|
|
12
|
+
}
|
|
13
|
+
setInterruptCallback(callback) {
|
|
14
|
+
this.interruptCallback = callback;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 生成项目级别的队列 key
|
|
18
|
+
*/
|
|
19
|
+
getQueueKey(sessionKey, projectPath) {
|
|
20
|
+
const projectName = path.basename(projectPath);
|
|
21
|
+
return `${sessionKey}-${projectName}`;
|
|
22
|
+
}
|
|
23
|
+
async enqueue(sessionKey, message, projectPath) {
|
|
24
|
+
const queueKey = this.getQueueKey(sessionKey, projectPath);
|
|
25
|
+
logger.debug(`[Queue] Enqueuing message for ${queueKey}`);
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
if (!this.queues.has(queueKey)) {
|
|
28
|
+
this.queues.set(queueKey, []);
|
|
29
|
+
}
|
|
30
|
+
this.queues.get(queueKey).push({ message, projectPath, resolve, reject });
|
|
31
|
+
// 如果正在处理,触发中断
|
|
32
|
+
if (this.processing.has(queueKey)) {
|
|
33
|
+
logger.debug(`[Queue] ${queueKey} is processing, triggering interrupt`);
|
|
34
|
+
if (this.interruptCallback) {
|
|
35
|
+
this.interruptCallback(sessionKey).catch(() => { });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
logger.debug(`[Queue] Starting to process ${queueKey}`);
|
|
40
|
+
this.processNext(queueKey);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async processNext(queueKey) {
|
|
45
|
+
this.processing.add(queueKey);
|
|
46
|
+
logger.debug(`[Queue] Processing queue ${queueKey}`);
|
|
47
|
+
while (true) {
|
|
48
|
+
const queue = this.queues.get(queueKey);
|
|
49
|
+
if (!queue || queue.length === 0) {
|
|
50
|
+
logger.debug(`[Queue] Queue ${queueKey} is empty, stopping`);
|
|
51
|
+
this.processing.delete(queueKey);
|
|
52
|
+
this.currentSessionKey = undefined;
|
|
53
|
+
this.currentProjectPath = undefined;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const { message, projectPath, resolve, reject } = queue.shift();
|
|
57
|
+
this.currentSessionKey = queueKey;
|
|
58
|
+
this.currentProjectPath = projectPath;
|
|
59
|
+
logger.debug(`[Queue] Processing message from ${message.channel}:${message.channelId}`);
|
|
60
|
+
try {
|
|
61
|
+
await this.handler(message);
|
|
62
|
+
logger.debug(`[Queue] Message processed successfully`);
|
|
63
|
+
resolve();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
logger.error(`[Queue] Message processing failed:`, error);
|
|
67
|
+
reject(error);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
getQueueLength(sessionKey) {
|
|
72
|
+
// 计算该 sessionKey 下所有项目队列的总长度
|
|
73
|
+
let total = 0;
|
|
74
|
+
for (const [key, queue] of this.queues.entries()) {
|
|
75
|
+
if (key.startsWith(sessionKey + '-')) {
|
|
76
|
+
total += queue.length;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return total;
|
|
80
|
+
}
|
|
81
|
+
isProcessing(sessionKey) {
|
|
82
|
+
// 检查该 sessionKey 下是否有任何项目队列在处理
|
|
83
|
+
for (const key of this.processing.keys()) {
|
|
84
|
+
if (key.startsWith(sessionKey + '-')) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 获取正在处理的项目路径
|
|
92
|
+
*/
|
|
93
|
+
getProcessingProject(sessionKey) {
|
|
94
|
+
// 查找该 sessionKey 下正在处理的项目
|
|
95
|
+
for (const key of this.processing.keys()) {
|
|
96
|
+
if (key.startsWith(sessionKey + '-')) {
|
|
97
|
+
// 从 processing 中找到对应的队列,获取 projectPath
|
|
98
|
+
const queue = this.queues.get(key);
|
|
99
|
+
if (queue && queue.length > 0) {
|
|
100
|
+
return queue[0].projectPath;
|
|
101
|
+
}
|
|
102
|
+
// 如果队列为空但仍在处理,返回当前正在处理的项目路径
|
|
103
|
+
if (this.currentSessionKey === key) {
|
|
104
|
+
return this.currentProjectPath;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|