evolclaw 2.0.7 → 2.1.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.
@@ -18,6 +18,16 @@ export class MessageProcessor {
18
18
  channels = new Map();
19
19
  currentFlusher;
20
20
  currentIsGroup = false;
21
+ shouldSuppressActivities = false;
22
+ /** 判断是否为后台会话(仅主会话参与判断,话题会话独立) */
23
+ async isBackgroundSession(session, channel, channelId) {
24
+ // 话题会话独立运行,不是后台任务
25
+ if (session.threadId)
26
+ return false;
27
+ // 主会话:与当前活跃会话比对
28
+ const active = await this.sessionManager.getActiveSession(channel, channelId);
29
+ return active ? session.id !== active.id : false;
30
+ }
21
31
  constructor(agentRunner, sessionManager, config, messageCache, commandHandler) {
22
32
  this.agentRunner = agentRunner;
23
33
  this.sessionManager = sessionManager;
@@ -35,7 +45,7 @@ export class MessageProcessor {
35
45
  * 处理 compact 开始事件
36
46
  */
37
47
  handleCompactStart() {
38
- if (this.currentFlusher && !this.currentIsGroup) {
48
+ if (this.currentFlusher && !this.currentIsGroup && !this.shouldSuppressActivities) {
39
49
  this.currentFlusher.addActivity('⏳ 会话压缩中...');
40
50
  }
41
51
  }
@@ -45,7 +55,7 @@ export class MessageProcessor {
45
55
  async processMessage(message) {
46
56
  const isGroup = message.isGroup ?? false;
47
57
  this.currentIsGroup = isGroup;
48
- const idleMs = this.config.idleMonitor?.timeout ?? 120000;
58
+ const idleMs = (this.config.idleMonitor?.timeout ?? 120) * 1000;
49
59
  const streamKey = `${message.channel}-${message.channelId}`;
50
60
  const channelInfo = this.channels.get(message.channel);
51
61
  const monitorEnabled = this.config.idleMonitor?.enabled !== false;
@@ -53,6 +63,20 @@ export class MessageProcessor {
53
63
  const isOwnerUser = isOwner(this.config, message.channel, message.userId || '');
54
64
  // 非主人(群聊或单聊):空闲监控静默/简短
55
65
  const quietMode = isGroup || !isOwnerUser;
66
+ // 计算是否抑制中间输出(工具活动 + 流式文本)
67
+ const shouldSuppress = () => {
68
+ const mode = this.config.showActivities ?? 'all';
69
+ if (mode === 'all')
70
+ return false;
71
+ if (mode === 'dm-only')
72
+ return isGroup;
73
+ if (mode === 'owner-dm-only')
74
+ return isGroup || !isOwnerUser;
75
+ if (mode === 'none')
76
+ return true;
77
+ return false;
78
+ };
79
+ this.shouldSuppressActivities = shouldSuppress();
56
80
  let monitor;
57
81
  let monitorInterval;
58
82
  let rejectFn;
@@ -94,7 +118,7 @@ export class MessageProcessor {
94
118
  else {
95
119
  // notify or warn: send diagnostic message, task continues(非主人时静默)
96
120
  logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
97
- if (channelInfo && !quietMode) {
121
+ if (channelInfo && !quietMode && !shouldSuppress()) {
98
122
  try {
99
123
  await channelInfo.adapter.sendText(message.channelId, result.message);
100
124
  }
@@ -109,7 +133,7 @@ export class MessageProcessor {
109
133
  });
110
134
  try {
111
135
  await Promise.race([
112
- this._processMessageInternal(message, resetTimer, isGroup),
136
+ this._processMessageInternal(message, resetTimer, isGroup, shouldSuppress),
113
137
  timeoutPromise
114
138
  ]);
115
139
  }
@@ -119,7 +143,7 @@ export class MessageProcessor {
119
143
  // 记录错误到健康状态(仅主人的错误累计触发安全模式)
120
144
  if (channelInfo) {
121
145
  try {
122
- const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd());
146
+ const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId);
123
147
  const errorType = classifyError(error);
124
148
  // 上下文过长是可恢复错误,不累计触发安全模式
125
149
  if (errorType === ErrorType.CONTEXT_TOO_LONG) {
@@ -131,7 +155,7 @@ export class MessageProcessor {
131
155
  }
132
156
  else {
133
157
  const newCount = await this.sessionManager.recordError(session.id, errorType, error.message);
134
- await this.checkSafeMode(session.id, message.channelId, channelInfo.adapter, safeModeThreshold, newCount);
158
+ await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount);
135
159
  }
136
160
  }
137
161
  catch (statusError) {
@@ -145,17 +169,27 @@ export class MessageProcessor {
145
169
  clearInterval(monitorInterval);
146
170
  }
147
171
  }
172
+ /** 从 session 提取话题回复选项 */
173
+ getThreadSendOpts(session) {
174
+ const rootId = session.metadata?.feishu?.rootId;
175
+ return rootId ? { replyToMessageId: rootId, replyInThread: true } : undefined;
176
+ }
148
177
  /**
149
178
  * 检查是否需要进入安全模式(safeModeThreshold 为 0 时跳过)
150
179
  * 仅单聊主人会话调用(群聊和非主人已在调用侧过滤)
151
180
  */
152
- async checkSafeMode(sessionId, channelId, adapter, safeModeThreshold, consecutiveErrors) {
181
+ async checkSafeMode(session, channelId, adapter, safeModeThreshold, consecutiveErrors) {
153
182
  if (safeModeThreshold <= 0)
154
183
  return;
155
- const health = await this.sessionManager.getHealthStatus(sessionId);
184
+ const health = await this.sessionManager.getHealthStatus(session.id);
185
+ const sendOpts = this.getThreadSendOpts(session);
186
+ const isThread = !!session.threadId;
156
187
  if (consecutiveErrors >= safeModeThreshold && !health.safeMode) {
157
- await this.sessionManager.setSafeMode(sessionId, true);
158
- logger.warn(`[MessageProcessor] Session ${sessionId} entered safe mode after ${consecutiveErrors} errors`);
188
+ await this.sessionManager.setSafeMode(session.id, true);
189
+ logger.warn(`[MessageProcessor] Session ${session.id} entered safe mode after ${consecutiveErrors} errors`);
190
+ const suggestions = isThread
191
+ ? `1. /repair - 检查并修复会话(推荐,保留历史)\n2. /clear - 清空会话历史\n3. /status - 查看详细状态`
192
+ : `1. /repair - 检查并修复会话(推荐,保留历史)\n2. /new [名称] - 创建新会话(清空历史)\n3. /status - 查看详细状态`;
159
193
  await adapter.sendText(channelId, `⚠️ 安全模式已启用(连续 ${consecutiveErrors} 次异常)
160
194
 
161
195
  当前限制:
@@ -163,16 +197,13 @@ export class MessageProcessor {
163
197
  - 每次提问需要提供完整上下文
164
198
 
165
199
  建议操作:
166
- 1. /repair - 检查并修复会话(推荐,保留历史)
167
- 2. /new [名称] - 创建新会话(清空历史)
168
- 3. /status - 查看详细状态`);
200
+ ${suggestions}`, sendOpts);
169
201
  }
170
202
  else if (safeModeThreshold >= 2 && consecutiveErrors === safeModeThreshold - 1) {
171
- // 阈值前一次错误,发送警告
172
- await adapter.sendText(channelId, `⚠️ 检测到异常(${consecutiveErrors}/${safeModeThreshold})\n\n如果问题持续,系统将自动进入安全模式。建议使用 /status 查看状态。`);
203
+ await adapter.sendText(channelId, `⚠️ 检测到异常(${consecutiveErrors}/${safeModeThreshold})\n\n如果问题持续,系统将自动进入安全模式。建议使用 /status 查看状态。`, sendOpts);
173
204
  }
174
205
  }
175
- async _processMessageInternal(message, resetTimer, isGroup) {
206
+ async _processMessageInternal(message, resetTimer, isGroup, shouldSuppress) {
176
207
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
177
208
  const channelInfo = this.channels.get(message.channel);
178
209
  if (!channelInfo) {
@@ -183,17 +214,19 @@ export class MessageProcessor {
183
214
  try {
184
215
  // 检查是否为命令
185
216
  if (this.commandHandler) {
186
- const cmdResult = await this.commandHandler(message.content, message.channel, message.channelId, message.userId);
217
+ const cmdResult = await this.commandHandler(message.content, message.channel, message.channelId, message.userId, message.threadId);
187
218
  if (cmdResult) {
188
- await adapter.sendText(message.channelId, cmdResult);
219
+ // 话题消息:通过 rootId 回复到话题内
220
+ const session = message.threadId ? await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId) : undefined;
221
+ const rootId = session?.metadata?.feishu?.rootId;
222
+ const sendOpts = rootId ? { replyToMessageId: rootId, replyInThread: true } : undefined;
223
+ await adapter.sendText(message.channelId, cmdResult, sendOpts);
189
224
  return;
190
225
  }
191
226
  }
192
227
  // 解析会话和项目路径
193
228
  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;
229
+ const isBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
197
230
  // 记录收到消息
198
231
  logger.message({
199
232
  msgId: messageId,
@@ -217,43 +250,47 @@ export class MessageProcessor {
217
250
  let firstReply = true;
218
251
  const messageIsGroup = isGroup; // 捕获 isGroup 供闭包使用
219
252
  const flusher = new StreamFlusher(async (text, isFinal) => {
220
- // 动态判断是否是后台任务
221
- const currentActiveSession = await this.sessionManager.getActiveSession(message.channel, message.channelId);
222
- const isCurrentlyBackground = currentActiveSession ? session.id !== currentActiveSession.id : false;
253
+ const isCurrentlyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
223
254
  if (!isCurrentlyBackground) {
224
255
  const opts = {};
225
256
  if (isFinal)
226
257
  opts.title = '最终回复:';
227
- // 首条消息引用回复用户原消息
228
- if (firstReply && message.messageId) {
258
+ // 话题会话:所有回复指向 rootId + reply_in_thread(确保消息进入话题)
259
+ const rootId = session.metadata?.feishu?.rootId;
260
+ if (rootId) {
261
+ opts.replyToMessageId = rootId;
262
+ opts.replyInThread = true;
263
+ }
264
+ else if (firstReply && message.messageId) {
265
+ // 主会话:首条消息引用回复用户原消息
229
266
  opts.replyToMessageId = message.messageId;
230
267
  firstReply = false;
231
268
  }
232
269
  await adapter.sendText(message.channelId, text, Object.keys(opts).length ? opts : undefined);
233
270
  }
234
271
  // 后台任务:静默,不发送输出
235
- }, this.config.flushDelay ?? 4000, options?.fileMarkerPattern);
272
+ }, (this.config.flushDelay || 4) * 1000, options?.fileMarkerPattern, this.config.debug?.flusherDiag);
236
273
  // 保存当前 flusher,用于 compact 事件
237
274
  this.currentFlusher = flusher;
238
275
  // 调用 AgentRunner(含上下文过长自动 compact 重试)
239
276
  const streamKey = `${message.channel}-${message.channelId}`;
240
277
  try {
241
- const stream = await this.agentRunner.runQuery(session.id, message.content, absoluteProjectPath, session.claudeSessionId, message.images, options?.systemPromptAppend, this.sessionManager);
278
+ const stream = await this.agentRunner.runQuery(session.id, message.content, absoluteProjectPath, session.agentSessionId, message.images, options?.systemPromptAppend, this.sessionManager);
242
279
  this.agentRunner.registerStream(streamKey, stream);
243
- await this.processEventStream(stream, session, message.channelId, adapter, options, flusher, isBackground, resetTimer);
280
+ await this.processEventStream(stream, session, message.channelId, adapter, options, flusher, isBackground, resetTimer, shouldSuppress);
244
281
  }
245
282
  catch (error) {
246
- if (classifyError(error) === ErrorType.CONTEXT_TOO_LONG && session.claudeSessionId) {
283
+ if (classifyError(error) === ErrorType.CONTEXT_TOO_LONG && session.agentSessionId) {
247
284
  // 尝试 compact 压缩会话
248
285
  flusher.addActivity('⚠️ 上下文过长,正在压缩会话...');
249
286
  await flusher.flush();
250
- const compacted = await this.agentRunner.compactSession(session.id, session.claudeSessionId, absoluteProjectPath);
287
+ const compacted = await this.agentRunner.compactSession(session.id, session.agentSessionId, absoluteProjectPath);
251
288
  if (compacted) {
252
289
  // compact 成功,带 resume 重试
253
290
  flusher.addActivity('✅ 压缩完成,正在重试...');
254
- const retryStream = await this.agentRunner.runQuery(session.id, message.content, absoluteProjectPath, session.claudeSessionId, message.images, options?.systemPromptAppend, this.sessionManager);
291
+ const retryStream = await this.agentRunner.runQuery(session.id, message.content, absoluteProjectPath, session.agentSessionId, message.images, options?.systemPromptAppend, this.sessionManager);
255
292
  this.agentRunner.registerStream(streamKey, retryStream);
256
- await this.processEventStream(retryStream, session, message.channelId, adapter, options, flusher, isBackground, resetTimer);
293
+ await this.processEventStream(retryStream, session, message.channelId, adapter, options, flusher, isBackground, resetTimer, shouldSuppress);
257
294
  }
258
295
  else {
259
296
  throw new Error('CONTEXT_COMPACT_FAILED');
@@ -278,7 +315,7 @@ export class MessageProcessor {
278
315
  // 文件存在性检查:真实路径但文件不存在,告知用户
279
316
  if (!fs.existsSync(resolvedPath)) {
280
317
  logger.warn(`[${adapter.name}] File not found: ${resolvedPath}`);
281
- await adapter.sendText(message.channelId, `⚠️ 文件未找到: ${filePath}`);
318
+ await adapter.sendText(message.channelId, `⚠️ 文件未找到: ${filePath}`, this.getThreadSendOpts(session));
282
319
  continue;
283
320
  }
284
321
  logger.info(`[${adapter.name}] Sending file: ${resolvedPath}`);
@@ -287,7 +324,7 @@ export class MessageProcessor {
287
324
  }
288
325
  catch (error) {
289
326
  logger.error(`[${adapter.name}] Failed to send file: ${resolvedPath}`, error);
290
- await adapter.sendText(message.channelId, `❌ 文件发送失败: ${filePath}`);
327
+ await adapter.sendText(message.channelId, `❌ 文件发送失败: ${filePath}`, this.getThreadSendOpts(session));
291
328
  }
292
329
  }
293
330
  }
@@ -296,15 +333,16 @@ export class MessageProcessor {
296
333
  // 安全模式尾部提示:如果当前会话处于安全模式,追加提醒
297
334
  const healthStatus = await this.sessionManager.getHealthStatus(session.id);
298
335
  if (healthStatus.safeMode) {
299
- await adapter.sendText(message.channelId, '\n\n⚠️ 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /new 新建会话');
336
+ const hint = session.threadId
337
+ ? '\n\n⚠️ 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /clear 清空会话'
338
+ : '\n\n⚠️ 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /new 新建会话';
339
+ await adapter.sendText(message.channelId, hint, this.getThreadSendOpts(session));
300
340
  }
301
341
  // 清理 activeStreams(正常完成)
302
342
  this.agentRunner.cleanupStream(streamKey);
303
343
  // 记录成功响应(重置错误计数)
304
344
  await this.sessionManager.recordSuccess(session.id);
305
- // 动态判断是否是后台任务,决定是否发送通知
306
- const currentActive = await this.sessionManager.getActiveSession(message.channel, message.channelId);
307
- const isFinallyBackground = currentActive ? session.id !== currentActive.id : false;
345
+ const isFinallyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
308
346
  if (isFinallyBackground) {
309
347
  const projectName = path.basename(session.projectPath);
310
348
  const count = this.messageCache.getCount(session.id);
@@ -346,7 +384,14 @@ export class MessageProcessor {
346
384
  }
347
385
  else {
348
386
  const userMessage = getErrorMessage(error);
349
- await adapter.sendText(message.channelId, userMessage);
387
+ // 获取 session 用于话题回复(如果 resolveSession 已执行)
388
+ let sendOpts;
389
+ try {
390
+ const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId);
391
+ sendOpts = this.getThreadSendOpts(session);
392
+ }
393
+ catch { }
394
+ await adapter.sendText(message.channelId, userMessage, sendOpts);
350
395
  }
351
396
  }
352
397
  }
@@ -354,7 +399,11 @@ export class MessageProcessor {
354
399
  * 解析会话和项目路径
355
400
  */
356
401
  async resolveSession(message) {
357
- const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd());
402
+ // 话题会话:传入 rootId metadata(首条消息的 messageId 作为 rootId)
403
+ const metadata = message.threadId && message.messageId
404
+ ? { feishu: { rootId: message.messageId } }
405
+ : undefined;
406
+ const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId, metadata);
358
407
  const absoluteProjectPath = path.isAbsolute(session.projectPath)
359
408
  ? session.projectPath
360
409
  : path.resolve(process.cwd(), session.projectPath);
@@ -363,7 +412,7 @@ export class MessageProcessor {
363
412
  /**
364
413
  * 处理事件流
365
414
  */
366
- async processEventStream(stream, session, channelId, adapter, options, flusher, isBackground, resetTimer) {
415
+ async processEventStream(stream, session, channelId, adapter, options, flusher, isBackground, resetTimer, shouldSuppress) {
367
416
  let hasTextDelta = false;
368
417
  let hasReceivedText = false;
369
418
  let lastSessionId;
@@ -382,20 +431,20 @@ export class MessageProcessor {
382
431
  this.agentRunner.updateSessionId(session.id, event.session_id);
383
432
  lastSessionId = event.session_id;
384
433
  }
385
- // 动态判断当前是否是后台任务
386
- const currentActive = await this.sessionManager.getActiveSession(session.channel, session.channelId);
387
- const isCurrentlyBackground = currentActive ? session.id !== currentActive.id : false;
434
+ const isCurrentlyBackground = await this.isBackgroundSession(session, session.channel, session.channelId);
388
435
  // === 前台任务:正常处理所有事件 ===
389
436
  if (!isCurrentlyBackground) {
390
- // 流式文本事件
437
+ // 流式文本事件(抑制时跳过,只累积到 allText)
391
438
  if (event.type === 'text_delta' && event.text) {
392
439
  hasTextDelta = true;
393
440
  hasReceivedText = true;
394
- flusher.addText(event.text);
441
+ if (!shouldSuppress()) {
442
+ flusher.addText(event.text);
443
+ }
395
444
  }
396
445
  // 系统事件:compact_boundary(群聊时静默)
397
446
  if (event.type === 'system' && event.subtype === 'compact_boundary') {
398
- if (!this.currentIsGroup) {
447
+ if (!this.currentIsGroup && !shouldSuppress()) {
399
448
  const preTokens = event.compact_metadata?.pre_tokens || 0;
400
449
  flusher.addActivity(`💡 会话压缩完成,继续执行...(压缩前 tokens: ${preTokens})`);
401
450
  }
@@ -406,10 +455,10 @@ export class MessageProcessor {
406
455
  const duration = event.duration_ms ? `${Math.round(event.duration_ms / 1000)}s` : '';
407
456
  const summary = event.summary;
408
457
  const stats = [tools > 0 ? `${tools}次工具调用` : '', duration].filter(Boolean).join(', ');
409
- if (summary) {
458
+ if (summary && !shouldSuppress()) {
410
459
  flusher.addActivity(`⏳ 子任务: ${summary}${stats ? ` (${stats})` : ''}`);
411
460
  }
412
- else if (stats) {
461
+ else if (stats && !shouldSuppress()) {
413
462
  flusher.addActivity(`⏳ 子任务进行中: ${stats}`);
414
463
  }
415
464
  }
@@ -417,33 +466,42 @@ export class MessageProcessor {
417
466
  if (event.type === 'assistant' && event.message?.content) {
418
467
  for (const content of event.message.content) {
419
468
  if (content.type === 'tool_use') {
420
- const desc = this.formatToolDescription(content);
421
- flusher.addActivity(`🔧 ${content.name}${desc ? ': ' + desc : ''}`);
469
+ if (!shouldSuppress()) {
470
+ const desc = this.formatToolDescription(content);
471
+ flusher.addActivity(`🔧 ${content.name}${desc ? ': ' + desc : ''}`);
472
+ }
422
473
  }
423
474
  else if (content.type === 'text' && content.text && !hasTextDelta) {
424
475
  // 仅在没有 text_delta 事件时从 assistant 事件提取文本,避免重复
425
476
  hasReceivedText = true;
426
- flusher.addTextBlock(content.text);
477
+ if (!shouldSuppress()) {
478
+ flusher.addTextBlock(content.text);
479
+ }
427
480
  }
428
481
  }
429
482
  }
430
483
  // 工具结果事件:显示失败信息(包括权限拒绝、执行失败等所有场景)
431
484
  if (event.type === 'tool_result') {
432
485
  logger.debug(`[MessageProcessor] tool_result: is_error=${event.is_error}, error=${event.error}, content=${typeof event.content}`);
433
- if (event.is_error) {
486
+ if (event.is_error && !shouldSuppress()) {
434
487
  const toolName = event.tool_name || '工具';
435
488
  const errorMsg = event.error || (typeof event.content === 'string' ? event.content : JSON.stringify(event.content)) || '执行失败';
436
489
  flusher.addActivity(`⚠️ ${toolName}: ${errorMsg}`);
437
490
  }
438
491
  }
439
- // Result 事件:仅在没有流式文本时使用 result 作为最终输出
492
+ // Result 事件:最终输出
440
493
  if (event.type === 'result' && event.result) {
441
- logger.debug(`[MessageProcessor] result event: hasReceivedText=${hasReceivedText}, result="${event.result}"`);
442
- if (!hasReceivedText) {
443
- // 没有通过 text_delta 或 assistant 收到文本,使用 result 作为兜底
494
+ logger.debug(`[MessageProcessor] result event: hasReceivedText=${hasReceivedText}, shouldSuppress=${shouldSuppress()}, result="${event.result}"`);
495
+ if (shouldSuppress()) {
496
+ // 抑制模式:直接发送 result(跳过中间输出)
497
+ flusher.addText(event.result);
498
+ }
499
+ else if (!hasReceivedText) {
500
+ // 非抑制模式 + 无流式文本:使用 result 作为兜底
444
501
  flusher.addText(event.result);
445
502
  }
446
- await flusher.flush();
503
+ // 非抑制模式 + 有流式文本:已通过 text_delta 累积,无需再添加
504
+ await flusher.flush(true); // isFinal=true 标记最终输出
447
505
  }
448
506
  continue;
449
507
  }