evolclaw 2.4.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.
- package/README.md +33 -14
- package/dist/agents/claude-runner.js +224 -23
- package/dist/agents/codex-runner.js +2 -8
- package/dist/agents/gemini-runner.js +1 -8
- package/dist/channels/aun.js +438 -53
- package/dist/channels/dingtalk.js +506 -0
- package/dist/channels/feishu.js +31 -231
- package/dist/channels/qqbot.js +391 -0
- package/dist/channels/wechat.js +36 -38
- package/dist/channels/wecom.js +549 -0
- package/dist/cli.js +69 -9
- package/dist/config.js +98 -2
- package/dist/core/command-handler.js +462 -112
- package/dist/core/message/message-bridge.js +8 -5
- package/dist/core/message/message-processor.js +146 -54
- package/dist/core/message/message-queue.js +48 -0
- package/dist/core/message/stream-flusher.js +2 -2
- package/dist/core/session/session-manager.js +21 -3
- package/dist/index.js +48 -13
- package/dist/ipc.js +14 -4
- package/dist/templates/skills.md +64 -0
- package/dist/utils/error-dict.js +63 -0
- package/dist/utils/error-utils.js +156 -56
- package/dist/utils/format.js +32 -0
- package/dist/utils/init-channel.js +734 -8
- package/dist/utils/init.js +33 -2
- package/dist/utils/media-cache.js +2 -0
- package/dist/utils/stats-collector.js +0 -8
- package/package.json +9 -3
|
@@ -102,6 +102,7 @@ export class MessageBridge {
|
|
|
102
102
|
chatType,
|
|
103
103
|
images: msg.images, timestamp: Date.now(),
|
|
104
104
|
peerId: msg.peerId, peerName: msg.peerName,
|
|
105
|
+
peerType: msg.peerType,
|
|
105
106
|
messageId: msg.messageId,
|
|
106
107
|
mentions: msg.mentions, threadId: msg.threadId,
|
|
107
108
|
replyContext: msg.replyContext,
|
|
@@ -151,8 +152,7 @@ export class MessageBridge {
|
|
|
151
152
|
return false;
|
|
152
153
|
if (parsed.type === 'menu.query') {
|
|
153
154
|
const identity = this.sessionManager.resolveIdentity(channel, msg.peerId);
|
|
154
|
-
const
|
|
155
|
-
const items = this.cmdHandler.getMenuItems(isAdmin, msg.chatType || 'private');
|
|
155
|
+
const items = this.cmdHandler.getMenuItems(identity.role, msg.chatType || 'private');
|
|
156
156
|
const response = JSON.stringify({ type: 'menu.response', items });
|
|
157
157
|
if (adapter?.sendCustomPayload) {
|
|
158
158
|
adapter.sendCustomPayload(msg.channelId, response);
|
|
@@ -196,8 +196,8 @@ export class MessageBridge {
|
|
|
196
196
|
return true;
|
|
197
197
|
}
|
|
198
198
|
/**
|
|
199
|
-
* 撤回消息:先查 debounce 窗口,再查 message queue
|
|
200
|
-
* @returns true
|
|
199
|
+
* 撤回消息:先查 debounce 窗口,再查 message queue,最后查正在执行的任务。
|
|
200
|
+
* @returns true 如果找到并取消/中断
|
|
201
201
|
*/
|
|
202
202
|
cancel(messageId) {
|
|
203
203
|
// 阶段 1: debounce 窗口(尚未入队)
|
|
@@ -206,7 +206,10 @@ export class MessageBridge {
|
|
|
206
206
|
return true;
|
|
207
207
|
}
|
|
208
208
|
// 阶段 2: 已入队但未处理(合并后 messageId 可能是逗号分隔的多个 id)
|
|
209
|
-
|
|
209
|
+
if (this.messageQueue.cancel(messageId))
|
|
210
|
+
return true;
|
|
211
|
+
// 阶段 3: 正在执行的任务 → 触发 interrupt
|
|
212
|
+
return this.messageQueue.cancelActive(messageId);
|
|
210
213
|
}
|
|
211
214
|
/** 清理资源 */
|
|
212
215
|
dispose() {
|
|
@@ -7,6 +7,7 @@ import { logger } from '../../utils/logger.js';
|
|
|
7
7
|
import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, prefixErrorType, isRetryableError } from '../../utils/error-utils.js';
|
|
8
8
|
import { summarizeToolInput } from '../permission.js';
|
|
9
9
|
import { getOwner } from '../../config.js';
|
|
10
|
+
import { getPackageRoot, resolveRoot } from '../../paths.js';
|
|
10
11
|
/**
|
|
11
12
|
* 统一消息处理器
|
|
12
13
|
* 负责处理来自不同渠道的消息,协调事件流处理
|
|
@@ -25,6 +26,9 @@ export class MessageProcessor {
|
|
|
25
26
|
defaultAgentId;
|
|
26
27
|
interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
|
|
27
28
|
interactionRouter;
|
|
29
|
+
messageQueue;
|
|
30
|
+
skillsHintDesc = undefined; // undefined=未加载, null=无模板, string=缓存描述
|
|
31
|
+
skillsEnsured = false; // 全局 SKILLS.md 是否已确保
|
|
28
32
|
/** 按 agentId 获取 agent,回退到默认 */
|
|
29
33
|
getAgent(agentId) {
|
|
30
34
|
if (agentId && this.agentMap.has(agentId))
|
|
@@ -67,6 +71,9 @@ export class MessageProcessor {
|
|
|
67
71
|
setInteractionRouter(router) {
|
|
68
72
|
this.interactionRouter = router;
|
|
69
73
|
}
|
|
74
|
+
setMessageQueue(queue) {
|
|
75
|
+
this.messageQueue = queue;
|
|
76
|
+
}
|
|
70
77
|
/**
|
|
71
78
|
* 注册渠道适配器
|
|
72
79
|
*/
|
|
@@ -149,7 +156,6 @@ export class MessageProcessor {
|
|
|
149
156
|
// 按 session.agentId 选择 agent 后端
|
|
150
157
|
const agent = this.getAgent(session.agentId);
|
|
151
158
|
const monitorEnabled = this.config.idleMonitor?.enabled !== false;
|
|
152
|
-
const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
|
|
153
159
|
const showIdleMonitor = policy.showIdleMonitor(chatType, identityRole);
|
|
154
160
|
// 计算是否抑制中间输出(工具活动 + 流式文本)
|
|
155
161
|
const shouldSuppress = () => {
|
|
@@ -219,25 +225,24 @@ export class MessageProcessor {
|
|
|
219
225
|
if (channelInfo) {
|
|
220
226
|
try {
|
|
221
227
|
const errorType = classifyError(error);
|
|
222
|
-
//
|
|
228
|
+
// 上下文过长是可恢复错误,不累计错误计数
|
|
223
229
|
if (errorType === ErrorType.CONTEXT_TOO_LONG) {
|
|
224
|
-
logger.info(`[MessageProcessor] Context too long error, skipping
|
|
225
|
-
// 认证错误(401 / Invalid API Key
|
|
230
|
+
logger.info(`[MessageProcessor] Context too long error, skipping error accumulation`);
|
|
231
|
+
// 认证错误(401 / Invalid API Key)不是会话问题,不累计
|
|
226
232
|
}
|
|
227
233
|
else if (errorType === ErrorType.AUTH_ERROR) {
|
|
228
|
-
logger.info(`[MessageProcessor] Auth error (invalid API key), skipping
|
|
229
|
-
// API 错误(5xx /
|
|
234
|
+
logger.info(`[MessageProcessor] Auth error (invalid API key), skipping error accumulation`);
|
|
235
|
+
// API 错误(5xx / 算力池切换等)是平台暂时性问题,不累计
|
|
230
236
|
}
|
|
231
237
|
else if (errorType === ErrorType.API_ERROR) {
|
|
232
|
-
logger.info(`[MessageProcessor] API error, skipping
|
|
238
|
+
logger.info(`[MessageProcessor] API error, skipping error accumulation`);
|
|
233
239
|
}
|
|
234
240
|
else if (!policy.accumulateErrors(chatType, identityRole)) {
|
|
235
|
-
logger.info(`[MessageProcessor] Non-accumulating error (chatType=${chatType}, identity=${identityRole}), skipping
|
|
241
|
+
logger.info(`[MessageProcessor] Non-accumulating error (chatType=${chatType}, identity=${identityRole}), skipping error accumulation`);
|
|
236
242
|
}
|
|
237
243
|
else {
|
|
238
244
|
const prefixed = prefixErrorType(ERROR_PREFIX.INFRA, errorType);
|
|
239
|
-
|
|
240
|
-
await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount, message);
|
|
245
|
+
await this.sessionManager.recordError(session.id, prefixed, error.message);
|
|
241
246
|
}
|
|
242
247
|
}
|
|
243
248
|
catch (statusError) {
|
|
@@ -255,35 +260,7 @@ export class MessageProcessor {
|
|
|
255
260
|
getReplyContext(message) {
|
|
256
261
|
return message.replyContext;
|
|
257
262
|
}
|
|
258
|
-
/**
|
|
259
|
-
* 检查是否需要进入安全模式(safeModeThreshold 为 0 时跳过)
|
|
260
|
-
*/
|
|
261
|
-
async checkSafeMode(session, channelId, adapter, safeModeThreshold, consecutiveErrors, message) {
|
|
262
|
-
if (safeModeThreshold <= 0)
|
|
263
|
-
return;
|
|
264
|
-
const health = await this.sessionManager.getHealthStatus(session.id);
|
|
265
|
-
const sendOpts = this.getReplyContext(message);
|
|
266
|
-
const isThread = !!session.threadId;
|
|
267
|
-
if (consecutiveErrors >= safeModeThreshold && !health.safeMode) {
|
|
268
|
-
await this.sessionManager.setSafeMode(session.id, true);
|
|
269
|
-
logger.warn(`[MessageProcessor] Session ${session.id} entered safe mode after ${consecutiveErrors} errors`);
|
|
270
|
-
this.eventBus.publish({ type: 'session:safe-mode-entered', sessionId: session.id, consecutiveErrors });
|
|
271
|
-
const suggestions = isThread
|
|
272
|
-
? `1. /repair - 检查并修复会话(推荐,保留历史)\n2. /clear - 清空会话历史\n3. /status - 查看详细状态`
|
|
273
|
-
: `1. /repair - 检查并修复会话(推荐,保留历史)\n2. /new [名称] - 创建新会话(清空历史)\n3. /status - 查看详细状态`;
|
|
274
|
-
await adapter.sendText(channelId, `\u26a0\ufe0f 安全模式已启用(连续 ${consecutiveErrors} 次异常)
|
|
275
|
-
|
|
276
|
-
当前限制:
|
|
277
|
-
- 无法记住之前的对话
|
|
278
|
-
- 每次提问需要提供完整上下文
|
|
279
|
-
|
|
280
|
-
建议操作:
|
|
281
|
-
${suggestions}`, sendOpts);
|
|
282
|
-
}
|
|
283
|
-
else if (safeModeThreshold >= 2 && consecutiveErrors === safeModeThreshold - 1) {
|
|
284
|
-
await adapter.sendText(channelId, `\u26a0\ufe0f 检测到异常(${consecutiveErrors}/${safeModeThreshold})\n\n如果问题持续,系统将自动进入安全模式。建议使用 /status 查看状态。`, sendOpts);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
263
|
+
/** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
|
|
287
264
|
async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress) {
|
|
288
265
|
const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
|
|
289
266
|
const channelKey = session.metadata?.channelName || message.channel;
|
|
@@ -365,14 +342,21 @@ ${suggestions}`, sendOpts);
|
|
|
365
342
|
await adapter.sendText(message.channelId, text, this.getReplyContext(message));
|
|
366
343
|
});
|
|
367
344
|
// 设置权限审批的交互上下文(支持交互卡片)
|
|
368
|
-
agent.setPermissionContext?.({
|
|
345
|
+
agent.setPermissionContext?.(session.id, {
|
|
369
346
|
adapter,
|
|
370
347
|
channelId: message.channelId,
|
|
371
348
|
replyContext: this.getReplyContext(message),
|
|
372
349
|
interactionRouter: this.interactionRouter,
|
|
350
|
+
interceptNextMessage: this.messageQueue
|
|
351
|
+
? (sessionKey, handler) => this.messageQueue.interceptNext(sessionKey, handler)
|
|
352
|
+
: undefined,
|
|
353
|
+
cancelIntercept: this.messageQueue
|
|
354
|
+
? (sessionKey) => this.messageQueue.cancelIntercept(sessionKey)
|
|
355
|
+
: undefined,
|
|
373
356
|
});
|
|
374
|
-
// 设置 per-session 权限模式(动态默认值:owner → bypass,guest → readonly)
|
|
375
|
-
const
|
|
357
|
+
// 设置 per-session 权限模式(动态默认值:owner → bypass,admin → auto,guest → readonly)
|
|
358
|
+
const role = session.identity?.role;
|
|
359
|
+
const defaultPermMode = role === 'owner' ? 'bypass' : role === 'admin' ? 'auto' : 'readonly';
|
|
376
360
|
agent.setMode(session.metadata?.permissionMode ?? defaultPermMode);
|
|
377
361
|
// 标记会话为处理中(实时持久化,重启后可恢复)
|
|
378
362
|
this.sessionManager.markProcessing(session.id);
|
|
@@ -390,6 +374,7 @@ ${suggestions}`, sendOpts);
|
|
|
390
374
|
// 1. 当前环境信息
|
|
391
375
|
const peerLabel = session.identity?.role || 'unknown';
|
|
392
376
|
const peerName = message.peerName || session.metadata?.peerName;
|
|
377
|
+
const peerType = message.peerType;
|
|
393
378
|
const envParts = [
|
|
394
379
|
`会话通道: ${currentChannelType}`,
|
|
395
380
|
`当前项目: ${path.basename(absoluteProjectPath)}`,
|
|
@@ -399,6 +384,8 @@ ${suggestions}`, sendOpts);
|
|
|
399
384
|
envParts.push(`对端身份: ${peerLabel}`);
|
|
400
385
|
if (peerName)
|
|
401
386
|
envParts.push(`对端名称: ${peerName}`);
|
|
387
|
+
if (peerType && peerType !== 'unknown')
|
|
388
|
+
envParts.push(`对端类型: ${peerType}`);
|
|
402
389
|
if (session.chatType)
|
|
403
390
|
envParts.push(`聊天类型: ${session.chatType}`);
|
|
404
391
|
if (session.agentId && session.agentId !== 'claude')
|
|
@@ -440,6 +427,15 @@ ${suggestions}`, sendOpts);
|
|
|
440
427
|
if (message.chatType === 'group' && message.peerId) {
|
|
441
428
|
contextParts.push(`[群聊回复规则] 回复时必须在开头添加 @${message.peerId} 来通知对方`);
|
|
442
429
|
}
|
|
430
|
+
// 5. Agent ctl 自管理指令提示 + SKILLS.md 生成
|
|
431
|
+
if (!this.skillsEnsured) {
|
|
432
|
+
this.ensureSkillsFile();
|
|
433
|
+
this.skillsEnsured = true;
|
|
434
|
+
}
|
|
435
|
+
const skillsHint = this.getSkillsHint();
|
|
436
|
+
if (skillsHint) {
|
|
437
|
+
contextParts.push(`[EvolClaw 自管理] ${skillsHint}`);
|
|
438
|
+
}
|
|
443
439
|
const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
|
|
444
440
|
// 可重试错误(403/429/5xx)指数退避重试,最多 3 次
|
|
445
441
|
const MAX_RETRIES = 3;
|
|
@@ -579,14 +575,6 @@ ${suggestions}`, sendOpts);
|
|
|
579
575
|
}
|
|
580
576
|
// Flush 剩余内容(文件标记已在 flush 时自动移除)
|
|
581
577
|
await flusher.flush(true);
|
|
582
|
-
// 安全模式尾部提示:如果当前会话处于安全模式,追加提醒
|
|
583
|
-
const healthStatus = await this.sessionManager.getHealthStatus(session.id);
|
|
584
|
-
if (healthStatus.safeMode) {
|
|
585
|
-
const hint = session.threadId
|
|
586
|
-
? '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /clear 清空会话'
|
|
587
|
-
: '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /new 新建会话';
|
|
588
|
-
await adapter.sendText(message.channelId, hint, this.getReplyContext(message));
|
|
589
|
-
}
|
|
590
578
|
// 清理 activeStreams(正常完成)
|
|
591
579
|
agent.cleanupStream(streamKey);
|
|
592
580
|
// 清除处理中状态
|
|
@@ -606,15 +594,13 @@ ${suggestions}`, sendOpts);
|
|
|
606
594
|
errorType,
|
|
607
595
|
terminalReason: streamResult.terminalReason
|
|
608
596
|
});
|
|
609
|
-
//
|
|
597
|
+
// 系统级 subtype 仍累计错误计数,供 /status 诊断使用
|
|
610
598
|
if (isInfraError(rawSubtype, streamResult.terminalReason)) {
|
|
611
599
|
const chatType = message.chatType || 'private';
|
|
612
600
|
const identityRole = session.identity?.role || 'anonymous';
|
|
613
|
-
const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
|
|
614
601
|
const { policy } = channelInfo;
|
|
615
602
|
if (policy.accumulateErrors(chatType, identityRole)) {
|
|
616
|
-
|
|
617
|
-
await this.checkSafeMode(session, message.channelId, adapter, safeModeThreshold, newCount, message);
|
|
603
|
+
await this.sessionManager.recordError(session.id, errorType, errorSummary);
|
|
618
604
|
}
|
|
619
605
|
}
|
|
620
606
|
logger.message({
|
|
@@ -959,9 +945,22 @@ ${suggestions}`, sendOpts);
|
|
|
959
945
|
}
|
|
960
946
|
catch (error) {
|
|
961
947
|
// User interrupt (AbortError) is expected, log at info level
|
|
948
|
+
const catchInterruptReason = this.interruptedSessions.get(session.id);
|
|
949
|
+
const catchIsUserInterrupt = catchInterruptReason === 'new_message' || catchInterruptReason === 'stop';
|
|
962
950
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
963
951
|
logger.info('[MessageProcessor] Stream interrupted (AbortError)');
|
|
964
952
|
}
|
|
953
|
+
else if (catchIsUserInterrupt) {
|
|
954
|
+
// SDK telemetry noise after user-initiated interrupt — not a real error
|
|
955
|
+
logger.debug('[MessageProcessor] Stream ended after user interrupt:', error?.message?.split('\n')[0]);
|
|
956
|
+
completeResult.isError = false;
|
|
957
|
+
completeResult.hasReceivedText = hasReceivedText;
|
|
958
|
+
return completeResult;
|
|
959
|
+
}
|
|
960
|
+
else if (isRetryableError(error)) {
|
|
961
|
+
// Retryable errors (network aborts, transient API failures) are noise at ERROR level
|
|
962
|
+
logger.warn('[MessageProcessor] Stream processing error (retryable):', error?.message?.split('\n')[0]);
|
|
963
|
+
}
|
|
965
964
|
else {
|
|
966
965
|
logger.error('[MessageProcessor] Stream processing error:', error);
|
|
967
966
|
}
|
|
@@ -1005,6 +1004,99 @@ ${suggestions}`, sendOpts);
|
|
|
1005
1004
|
// 都找不到,返回项目根目录路径
|
|
1006
1005
|
return rootPath;
|
|
1007
1006
|
}
|
|
1007
|
+
/**
|
|
1008
|
+
* 确保全局数据目录下有最新版本的 SKILLS.md
|
|
1009
|
+
* 目标:{EVOLCLAW_HOME}/data/SKILLS.md
|
|
1010
|
+
*/
|
|
1011
|
+
ensureSkillsFile() {
|
|
1012
|
+
try {
|
|
1013
|
+
const targetDir = path.join(resolveRoot(), 'data');
|
|
1014
|
+
const targetPath = path.join(targetDir, 'SKILLS.md');
|
|
1015
|
+
const templatePath = path.join(getPackageRoot(), 'src', 'templates', 'skills.md');
|
|
1016
|
+
// 模板不存在则跳过(构建环境可能没有 src/)
|
|
1017
|
+
if (!fs.existsSync(templatePath)) {
|
|
1018
|
+
// 尝试 dist/templates/skills.md
|
|
1019
|
+
const distTemplatePath = path.join(getPackageRoot(), 'dist', 'templates', 'skills.md');
|
|
1020
|
+
if (!fs.existsSync(distTemplatePath))
|
|
1021
|
+
return;
|
|
1022
|
+
this.copySkillsIfNeeded(distTemplatePath, targetDir, targetPath);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
this.copySkillsIfNeeded(templatePath, targetDir, targetPath);
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
// 静默失败,不影响正常消息处理
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
copySkillsIfNeeded(templatePath, targetDir, targetPath) {
|
|
1032
|
+
const templateContent = fs.readFileSync(templatePath, 'utf-8');
|
|
1033
|
+
const templateVersion = templateContent.match(/^version:\s*(.+)$/m)?.[1]?.trim() || '0';
|
|
1034
|
+
if (fs.existsSync(targetPath)) {
|
|
1035
|
+
const existing = fs.readFileSync(targetPath, 'utf-8');
|
|
1036
|
+
const existingVersion = existing.match(/^version:\s*(.+)$/m)?.[1]?.trim() || '0';
|
|
1037
|
+
if (this.compareSemver(existingVersion, templateVersion) >= 0)
|
|
1038
|
+
return; // 已是最新
|
|
1039
|
+
}
|
|
1040
|
+
if (!fs.existsSync(targetDir)) {
|
|
1041
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
1042
|
+
}
|
|
1043
|
+
fs.writeFileSync(targetPath, templateContent, 'utf-8');
|
|
1044
|
+
}
|
|
1045
|
+
/** 简易 semver 比较:支持 "1", "1.0", "1.0.0" 等格式,返回 -1/0/1 */
|
|
1046
|
+
compareSemver(a, b) {
|
|
1047
|
+
const pa = a.split('.').map(Number);
|
|
1048
|
+
const pb = b.split('.').map(Number);
|
|
1049
|
+
const len = Math.max(pa.length, pb.length);
|
|
1050
|
+
for (let i = 0; i < len; i++) {
|
|
1051
|
+
const na = pa[i] || 0;
|
|
1052
|
+
const nb = pb[i] || 0;
|
|
1053
|
+
if (na !== nb)
|
|
1054
|
+
return na > nb ? 1 : -1;
|
|
1055
|
+
}
|
|
1056
|
+
return 0;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* 从模板 frontmatter 缓存提示(懒加载,整个进程只读一次模板文件)
|
|
1060
|
+
*/
|
|
1061
|
+
getSkillsHint() {
|
|
1062
|
+
if (this.skillsHintDesc === undefined) {
|
|
1063
|
+
this.skillsHintDesc = this.loadSkillsHint();
|
|
1064
|
+
}
|
|
1065
|
+
return this.skillsHintDesc;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* 从包模板源读取 frontmatter 并生成提示(仅执行一次)
|
|
1069
|
+
*/
|
|
1070
|
+
loadSkillsHint() {
|
|
1071
|
+
try {
|
|
1072
|
+
const candidates = [
|
|
1073
|
+
path.join(getPackageRoot(), 'src', 'templates', 'skills.md'),
|
|
1074
|
+
path.join(getPackageRoot(), 'dist', 'templates', 'skills.md'),
|
|
1075
|
+
];
|
|
1076
|
+
for (const templatePath of candidates) {
|
|
1077
|
+
if (!fs.existsSync(templatePath))
|
|
1078
|
+
continue;
|
|
1079
|
+
const content = fs.readFileSync(templatePath, 'utf-8');
|
|
1080
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1081
|
+
if (!frontmatterMatch)
|
|
1082
|
+
continue;
|
|
1083
|
+
const fm = frontmatterMatch[1];
|
|
1084
|
+
const desc = fm.match(/^description:\s*(.+)$/m)?.[1]?.trim() || 'EvolClaw 运行时管理指令';
|
|
1085
|
+
const trigger = fm.match(/^trigger:\s*(.+)$/m)?.[1]?.trim() || '';
|
|
1086
|
+
const skillsPath = path.join(resolveRoot(), 'data', 'SKILLS.md');
|
|
1087
|
+
const parts = [
|
|
1088
|
+
`可通过 Bash 执行 \`evolclaw ctl <cmd>\` 管理运行时:${desc}`,
|
|
1089
|
+
trigger ? `触发时机:${trigger}` : '',
|
|
1090
|
+
`完整文档见 ${skillsPath}`,
|
|
1091
|
+
];
|
|
1092
|
+
return parts.filter(Boolean).join('\n');
|
|
1093
|
+
}
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
catch {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1008
1100
|
/**
|
|
1009
1101
|
* 判断文件路径是否为占位符/示例文本
|
|
1010
1102
|
* 用于过滤大模型在说明文字中误写的 [SEND_FILE:...] 标记
|
|
@@ -8,10 +8,12 @@ export class MessageQueue {
|
|
|
8
8
|
currentSessionKey;
|
|
9
9
|
currentProjectPath;
|
|
10
10
|
currentAgentId;
|
|
11
|
+
activeMessageIds = new Set(); // 正在执行的消息 ID
|
|
11
12
|
interruptCallback;
|
|
12
13
|
eventBus;
|
|
13
14
|
recentMessageIds = new Set();
|
|
14
15
|
DEDUP_WINDOW = 60_000; // 1 分钟窗口
|
|
16
|
+
interceptors = new Map();
|
|
15
17
|
constructor(handler) {
|
|
16
18
|
this.handler = handler;
|
|
17
19
|
}
|
|
@@ -21,6 +23,19 @@ export class MessageQueue {
|
|
|
21
23
|
setEventBus(eventBus) {
|
|
22
24
|
this.eventBus = eventBus;
|
|
23
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* 注册一次性消息拦截器:下一条来自 sessionKey 的消息不入队、不触发 interrupt,
|
|
28
|
+
* 直接传给 handler。用于 AskUserQuestion 的"手动输入"场景。
|
|
29
|
+
*/
|
|
30
|
+
interceptNext(sessionKey, handler) {
|
|
31
|
+
this.interceptors.set(sessionKey, handler);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 取消拦截器(超时或卡片被其他方式回答时调用)
|
|
35
|
+
*/
|
|
36
|
+
cancelIntercept(sessionKey) {
|
|
37
|
+
this.interceptors.delete(sessionKey);
|
|
38
|
+
}
|
|
24
39
|
/**
|
|
25
40
|
* 检查消息是否应该处理(去重)
|
|
26
41
|
*/
|
|
@@ -53,6 +68,14 @@ export class MessageQueue {
|
|
|
53
68
|
if (!this.shouldProcess(message)) {
|
|
54
69
|
return Promise.resolve();
|
|
55
70
|
}
|
|
71
|
+
// 拦截器检查:AskUserQuestion 等场景的一次性消息拦截
|
|
72
|
+
const interceptor = this.interceptors.get(sessionKey);
|
|
73
|
+
if (interceptor) {
|
|
74
|
+
this.interceptors.delete(sessionKey);
|
|
75
|
+
logger.debug(`[Queue] Message intercepted for ${sessionKey}`);
|
|
76
|
+
interceptor(message);
|
|
77
|
+
return Promise.resolve();
|
|
78
|
+
}
|
|
56
79
|
const queueKey = this.getQueueKey(sessionKey, projectPath);
|
|
57
80
|
logger.debug(`[Queue] Enqueuing message for ${queueKey}`);
|
|
58
81
|
return new Promise((resolve, reject) => {
|
|
@@ -97,6 +120,7 @@ export class MessageQueue {
|
|
|
97
120
|
this.processing.delete(queueKey);
|
|
98
121
|
this.currentSessionKey = undefined;
|
|
99
122
|
this.currentProjectPath = undefined;
|
|
123
|
+
this.activeMessageIds.clear();
|
|
100
124
|
return;
|
|
101
125
|
}
|
|
102
126
|
// FIFO 贪心合并:弹出队首连续同 peerId 的消息
|
|
@@ -105,6 +129,12 @@ export class MessageQueue {
|
|
|
105
129
|
this.currentSessionKey = queueKey;
|
|
106
130
|
this.currentProjectPath = merged.projectPath;
|
|
107
131
|
this.currentAgentId = merged.message.agentId;
|
|
132
|
+
// 记录正在执行的 messageId(用于撤回中断)
|
|
133
|
+
this.activeMessageIds.clear();
|
|
134
|
+
for (const item of items) {
|
|
135
|
+
if (item.message.messageId)
|
|
136
|
+
this.activeMessageIds.add(item.message.messageId);
|
|
137
|
+
}
|
|
108
138
|
const resolves = items.map(i => i.resolve);
|
|
109
139
|
const rejects = items.map(i => i.reject);
|
|
110
140
|
logger.debug(`[Queue] Processing ${items.length} message(s) from ${merged.message.channel}:${merged.message.channelId}`);
|
|
@@ -200,6 +230,24 @@ export class MessageQueue {
|
|
|
200
230
|
}
|
|
201
231
|
return false;
|
|
202
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* 撤回正在执行的消息:如果 messageId 正在处理中,触发 interrupt。
|
|
235
|
+
* @returns true 如果找到并触发了中断
|
|
236
|
+
*/
|
|
237
|
+
cancelActive(messageId) {
|
|
238
|
+
if (!this.activeMessageIds.has(messageId))
|
|
239
|
+
return false;
|
|
240
|
+
if (!this.currentSessionKey)
|
|
241
|
+
return false;
|
|
242
|
+
// 从 queueKey 提取 sessionKey
|
|
243
|
+
const sessionKey = this.currentSessionKey.split('::')[0];
|
|
244
|
+
logger.info(`[Queue] Recalled active message ${messageId}, interrupting session ${sessionKey}`);
|
|
245
|
+
this.eventBus?.publish({ type: 'message:interrupted', sessionId: sessionKey, reason: 'recalled' });
|
|
246
|
+
if (this.interruptCallback) {
|
|
247
|
+
this.interruptCallback(sessionKey, this.currentAgentId).catch(() => { });
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
203
251
|
/**
|
|
204
252
|
* 外部锁:快速命令(/compact, /clear)执行期间阻塞队列处理
|
|
205
253
|
* 返回 release 函数
|
|
@@ -161,13 +161,13 @@ export class StreamFlusher {
|
|
|
161
161
|
if (this.diagEnabled)
|
|
162
162
|
diag(this.instanceId, 'flushActivitiesOnly', { outputLen: output.length });
|
|
163
163
|
if (output) {
|
|
164
|
+
this.sentContent = true; // 同步标记,避免 timer flush 未 await 时的竞态
|
|
164
165
|
const text = output;
|
|
165
166
|
// chain 保持不断裂:单条失败不阻塞后续(catch → resolve)
|
|
166
167
|
this.sendChain = this.sendChain
|
|
167
168
|
.then(() => this.send(text, false, false))
|
|
168
169
|
.catch(e => { logger.warn('[StreamFlusher] send failed:', e); });
|
|
169
170
|
await this.sendChain;
|
|
170
|
-
this.sentContent = true;
|
|
171
171
|
this.lastFlush = Date.now();
|
|
172
172
|
this.flushCount++;
|
|
173
173
|
}
|
|
@@ -207,6 +207,7 @@ export class StreamFlusher {
|
|
|
207
207
|
if (this.diagEnabled)
|
|
208
208
|
diag(this.instanceId, 'flush', { isFinal, outputLen: output.length, flushCount: this.flushCount, sinceLastFlush: Date.now() - this.lastFlush, preview: output.substring(0, 80) });
|
|
209
209
|
if (output) {
|
|
210
|
+
this.sentContent = true; // 同步标记,避免 timer flush 未 await 时的竞态
|
|
210
211
|
const text = output;
|
|
211
212
|
const final = isFinal;
|
|
212
213
|
const ht = hasText;
|
|
@@ -214,7 +215,6 @@ export class StreamFlusher {
|
|
|
214
215
|
.then(() => this.send(text, final, ht))
|
|
215
216
|
.catch(e => { logger.warn('[StreamFlusher] send failed:', e); });
|
|
216
217
|
await this.sendChain;
|
|
217
|
-
this.sentContent = true;
|
|
218
218
|
this.lastFlush = Date.now();
|
|
219
219
|
this.flushCount++;
|
|
220
220
|
}
|
|
@@ -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
|
-
|
|
66
|
-
|
|
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
|