evolclaw 3.1.4 → 3.1.5

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 (85) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/agents/claude-runner.js +348 -156
  3. package/dist/agents/kit-renderer.js +176 -21
  4. package/dist/aun/aid/agentmd.js +68 -103
  5. package/dist/aun/aid/client.js +1 -29
  6. package/dist/aun/aid/identity.js +105 -64
  7. package/dist/aun/aid/index.js +2 -1
  8. package/dist/aun/aid/store.js +74 -0
  9. package/dist/aun/msg/p2p.js +26 -2
  10. package/dist/aun/rpc/connection.js +23 -30
  11. package/dist/channels/aun.js +77 -88
  12. package/dist/channels/dingtalk.js +1 -0
  13. package/dist/channels/feishu.js +270 -190
  14. package/dist/channels/qqbot.js +1 -0
  15. package/dist/channels/wechat.js +1 -0
  16. package/dist/channels/wecom.js +1 -0
  17. package/dist/cli/agent.js +11 -5
  18. package/dist/cli/bench.js +40 -23
  19. package/dist/cli/index.js +170 -44
  20. package/dist/cli/init-channel.js +5 -1
  21. package/dist/cli/model.js +324 -0
  22. package/dist/cli/net-check.js +133 -50
  23. package/dist/cli/watch-msg.js +7 -7
  24. package/dist/cli/watch-web/debug-log.js +18 -0
  25. package/dist/cli/watch-web/server.js +306 -0
  26. package/dist/cli/watch-web/sources/aid.js +63 -0
  27. package/dist/cli/watch-web/sources/msg.js +70 -0
  28. package/dist/cli/watch-web/sources/session.js +638 -0
  29. package/dist/cli/watch-web/sources/types.js +10 -0
  30. package/dist/cli/watch-web/static/app.js +546 -0
  31. package/dist/cli/watch-web/static/index.html +54 -0
  32. package/dist/cli/watch-web/static/style.css +247 -0
  33. package/dist/core/channel-loader.js +7 -4
  34. package/dist/core/command-handler.js +81 -86
  35. package/dist/core/evolagent-registry.js +1 -1
  36. package/dist/core/evolagent.js +4 -4
  37. package/dist/core/interaction-router.js +59 -0
  38. package/dist/core/message/message-bridge.js +6 -6
  39. package/dist/core/message/message-log.js +2 -2
  40. package/dist/core/message/message-processor.js +86 -101
  41. package/dist/core/message/stream-idle-monitor.js +21 -0
  42. package/dist/core/model/model-catalog.js +215 -0
  43. package/dist/core/model/model-scope.js +250 -0
  44. package/dist/core/relation/peer-identity.js +40 -49
  45. package/dist/core/relation/peer-key.js +16 -0
  46. package/dist/core/session/session-fs-store.js +34 -55
  47. package/dist/core/session/session-key.js +24 -0
  48. package/dist/core/session/session-manager.js +308 -251
  49. package/dist/core/session/session-mapper.js +9 -4
  50. package/dist/core/trigger/manager.js +3 -3
  51. package/dist/core/trigger/scheduler.js +2 -1
  52. package/dist/index.js +6 -2
  53. package/dist/ipc.js +22 -0
  54. package/kits/docs/GUIDE.md +2 -2
  55. package/kits/docs/INDEX.md +11 -7
  56. package/kits/docs/channels/aun.md +56 -17
  57. package/kits/docs/channels/feishu.md +41 -12
  58. package/kits/docs/context-assembly.md +181 -0
  59. package/kits/docs/evolclaw/agent.md +49 -0
  60. package/kits/docs/evolclaw/aid.md +49 -0
  61. package/kits/docs/evolclaw/ctl.md +46 -0
  62. package/kits/docs/evolclaw/group.md +82 -0
  63. package/kits/docs/evolclaw/msg.md +86 -0
  64. package/kits/docs/evolclaw/rpc.md +35 -0
  65. package/kits/docs/evolclaw/storage.md +49 -0
  66. package/kits/docs/venues/aun-group.md +10 -0
  67. package/kits/docs/venues/aun-private.md +10 -0
  68. package/kits/docs/venues/client-desktop.md +10 -0
  69. package/kits/docs/venues/client-mobile.md +10 -0
  70. package/kits/docs/venues/feishu-group.md +13 -0
  71. package/kits/docs/venues/feishu-private.md +9 -0
  72. package/kits/docs/venues/group.md +11 -0
  73. package/kits/docs/venues/private.md +10 -0
  74. package/kits/eck_manifest.json +72 -36
  75. package/kits/rules/01-overview.md +20 -10
  76. package/kits/rules/06-channel.md +30 -27
  77. package/kits/templates/system-fragments/session.md +10 -3
  78. package/kits/templates/system-fragments/venue.md +9 -0
  79. package/package.json +11 -6
  80. package/dist/aun/aid/lifecycle-log.js +0 -33
  81. package/dist/utils/aid-lifecycle-log.js +0 -33
  82. package/kits/docs/evolclaw/AGENT_CMD.md +0 -31
  83. package/kits/docs/evolclaw/MSG_GROUP.md +0 -30
  84. package/kits/docs/evolclaw/MSG_PRIVATE.md +0 -72
  85. package/kits/docs/evolclaw/tools.md +0 -25
@@ -1,14 +1,55 @@
1
1
  import { logger } from '../utils/logger.js';
2
2
  export class InteractionRouter {
3
3
  handlers = new Map();
4
+ /** sessionId → 该会话当前待应答的交互数量,用于触发 wait 生命周期钩子 */
5
+ pendingBySession = new Map();
6
+ waitHooks;
7
+ setWaitHooks(hooks) {
8
+ this.waitHooks = hooks;
9
+ }
10
+ /**
11
+ * 在 register() 之前提前标记 session 为等待状态(适用于发卡片有异步延迟的场景)。
12
+ * 必须与 unmarkWaiting() 配对使用,或后续 register() 会接管计数。
13
+ */
14
+ markWaiting(sessionId) {
15
+ this.incPending(sessionId);
16
+ }
17
+ /** 取消 markWaiting() 的占位(后续若有 register() 接管则不需调用此方法) */
18
+ unmarkWaiting(sessionId) {
19
+ this.decPending(sessionId);
20
+ }
21
+ /** 登记一个待应答交互;session 计数 0→1 时触发 onWaitStart */
22
+ incPending(sessionId) {
23
+ const next = (this.pendingBySession.get(sessionId) ?? 0) + 1;
24
+ this.pendingBySession.set(sessionId, next);
25
+ if (next === 1)
26
+ this.waitHooks?.onWaitStart(sessionId);
27
+ }
28
+ /** 注销一个待应答交互;session 计数 1→0 时触发 onWaitEnd */
29
+ decPending(sessionId) {
30
+ const cur = this.pendingBySession.get(sessionId) ?? 0;
31
+ if (cur <= 0)
32
+ return;
33
+ const next = cur - 1;
34
+ if (next === 0) {
35
+ this.pendingBySession.delete(sessionId);
36
+ this.waitHooks?.onWaitEnd(sessionId);
37
+ }
38
+ else {
39
+ this.pendingBySession.set(sessionId, next);
40
+ }
41
+ }
4
42
  register(id, sessionId, callback, opts) {
43
+ // 同 id 替换:槽位本就占用,计数不变,不触发 wait 钩子
5
44
  const existing = this.handlers.get(id);
6
45
  if (existing?.timer)
7
46
  clearTimeout(existing.timer);
47
+ const isReplacement = !!existing;
8
48
  let timer;
9
49
  if (opts?.timeoutMs && opts.timeoutMs > 0) {
10
50
  timer = setTimeout(() => {
11
51
  this.handlers.delete(id);
52
+ this.decPending(sessionId);
12
53
  logger.debug(`[InteractionRouter] Timeout for interaction: ${id}`);
13
54
  opts.onTimeout?.();
14
55
  }, opts.timeoutMs);
@@ -20,6 +61,8 @@ export class InteractionRouter {
20
61
  initiatorId: opts?.initiatorId,
21
62
  fallbackCommand: opts?.fallbackCommand,
22
63
  });
64
+ if (!isReplacement)
65
+ this.incPending(sessionId);
23
66
  }
24
67
  handle(response) {
25
68
  const handler = this.handlers.get(response.id);
@@ -28,6 +71,7 @@ export class InteractionRouter {
28
71
  if (handler.timer)
29
72
  clearTimeout(handler.timer);
30
73
  this.handlers.delete(response.id);
74
+ this.decPending(handler.sessionId);
31
75
  try {
32
76
  const result = handler.callback(response.action, response.values, response.operatorId);
33
77
  if (result && typeof result.catch === 'function') {
@@ -47,6 +91,7 @@ export class InteractionRouter {
47
91
  if (handler.timer)
48
92
  clearTimeout(handler.timer);
49
93
  this.handlers.delete(id);
94
+ this.decPending(handler.sessionId);
50
95
  }
51
96
  }
52
97
  }
@@ -56,6 +101,7 @@ export class InteractionRouter {
56
101
  if (handler.timer)
57
102
  clearTimeout(handler.timer);
58
103
  this.handlers.delete(id);
104
+ this.decPending(handler.sessionId);
59
105
  }
60
106
  }
61
107
  getPending(sessionId) {
@@ -100,6 +146,16 @@ export function renderActionAsText(req) {
100
146
  const lines = [action.title];
101
147
  if (action.body)
102
148
  lines.push(action.body);
149
+ // checkers 多选:渲染选项列表
150
+ if (action.checkers?.length) {
151
+ lines.push('');
152
+ action.checkers.forEach((chk, idx) => {
153
+ const desc = chk.description ? ` — ${chk.description}` : '';
154
+ lines.push(` ${idx + 1}. ${chk.label}${desc}`);
155
+ });
156
+ lines.push('', '回复选项编号(多选用逗号分隔),或输入自定义内容');
157
+ return lines.join('\n');
158
+ }
103
159
  if (!fb) {
104
160
  return lines.join('\n');
105
161
  }
@@ -111,5 +167,8 @@ export function renderActionAsText(req) {
111
167
  if (fb.acceptFreeText && fb.freeTextHint) {
112
168
  lines.push(` ${fb.freeTextHint}`);
113
169
  }
170
+ if (action.allowCustomInput) {
171
+ lines.push(` 或直接输入自定义内容`);
172
+ }
114
173
  return lines.join('\n');
115
174
  }
@@ -81,12 +81,12 @@ export class MessageBridge {
81
81
  logger.debug(`[MessageBridge] Command detected: "${cmdContent}", routing to handler`);
82
82
  // 命令也要记录入方向 jsonl(不创建 session,直接用 chatDirPath 计算路径)
83
83
  try {
84
- const chatDir = chatDirPath(resolvePaths().sessionsDir, msg.channelType || effectiveChannelType, msg.channelId, msg.selfId);
84
+ const chatDir = chatDirPath(resolvePaths().sessionsDir, msg.channelType || effectiveChannelType, msg.channelId, msg.selfAID || '');
85
85
  const inboundEncrypt = msg.replyContext?.metadata?.encrypted != null ? !!(msg.replyContext.metadata.encrypted) : undefined;
86
86
  const inboundChatmode = msg.replyContext?.metadata?.chatmode;
87
87
  appendMessageLog(chatDir, buildInboundEntry({
88
88
  from: msg.peerId || 'unknown',
89
- to: msg.selfId || 'self',
89
+ to: msg.selfAID || 'self',
90
90
  chatType: msg.chatType || 'private',
91
91
  groupId: msg.groupId ?? null,
92
92
  msgId: msg.messageId ?? null,
@@ -114,7 +114,7 @@ export class MessageBridge {
114
114
  if (msg.threadId && msg.replyContext)
115
115
  metadata.replyContext = msg.replyContext;
116
116
  // 写入实例名(审计 + 精确出站路由)
117
- metadata.channelName = channelName;
117
+ metadata.channelKey = channelName;
118
118
  if (chatType === 'private' && msg.peerId) {
119
119
  metadata.peerId = msg.peerId;
120
120
  if (msg.peerName)
@@ -134,7 +134,7 @@ export class MessageBridge {
134
134
  const owningAgent = this.agentRegistry?.resolveByChannel(channelName);
135
135
  const effectiveProjectPath = owningAgent?.projectPath
136
136
  ?? this.defaultProjectPath;
137
- const session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, undefined, msg.peerId, chatType, undefined, msg.selfId, msg.channelType || effectiveChannelType, msg.peerType);
137
+ const session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, undefined, msg.peerId, chatType, undefined, msg.selfAID, msg.channelType || effectiveChannelType, msg.peerType);
138
138
  // 4. 消息前缀(由 policy 决定)
139
139
  const channelInfo = this.processor.getChannelInfo?.(channelName);
140
140
  if (channelInfo?.policy) {
@@ -147,7 +147,7 @@ export class MessageBridge {
147
147
  channel: channelName,
148
148
  channelType: msg.channelType || effectiveChannelType,
149
149
  channelId: msg.channelId, content,
150
- selfId: msg.selfId,
150
+ selfAID: msg.selfAID,
151
151
  chatType,
152
152
  images: msg.images, timestamp: Date.now(),
153
153
  peerId: msg.peerId, peerName: msg.peerName,
@@ -162,7 +162,7 @@ export class MessageBridge {
162
162
  const inboundChatmode = msg.replyContext?.metadata?.chatmode;
163
163
  appendMessageLog(chatDir, buildInboundEntry({
164
164
  from: msg.peerId || 'unknown',
165
- to: msg.selfId || 'self',
165
+ to: msg.selfAID || 'self',
166
166
  chatType,
167
167
  groupId: msg.groupId ?? null,
168
168
  msgId: msg.messageId ?? null,
@@ -31,8 +31,8 @@ function formatTimestampMs(epochMs) {
31
31
  export function messageLogPath(chatDir) {
32
32
  return path.join(chatDir, MESSAGE_LOG_FILE);
33
33
  }
34
- export function resolveChatDir(sessionsDir, channelType, channelId, selfId) {
35
- return chatDirPath(sessionsDir, channelType, channelId, selfId);
34
+ export function resolveChatDir(sessionsDir, channelType, channelId, selfAID) {
35
+ return chatDirPath(sessionsDir, channelType, channelId, selfAID);
36
36
  }
37
37
  export function appendMessageLog(chatDir, entry) {
38
38
  if (entry.dir === 'in' && isDuplicate(entry.msgId)) {
@@ -12,6 +12,8 @@ import { getPackageRoot, resolveRoot } from '../../paths.js';
12
12
  import { renderKitSections } from '../../agents/kit-renderer.js';
13
13
  import { normalizeBaseagent } from '../../agents/baseagent-normalize.js';
14
14
  import { renderActionAsText, renderCommandCardAsText } from '../interaction-router.js';
15
+ import { formatPeerKey } from '../relation/peer-key.js';
16
+ import { resolveEffectiveModel } from '../model/model-scope.js';
15
17
  function getContextTooLongHint(agent) {
16
18
  if (canCompactAgent(agent)) {
17
19
  return '上下文过长,请精简提问或使用 /compact 压缩上下文';
@@ -69,7 +71,8 @@ export class MessageProcessor {
69
71
  interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
70
72
  interactionRouter;
71
73
  messageQueue;
72
- skillsEnsured = false; // 全局 SKILLS.md 是否已确保
74
+ /** sessionId 活跃的空闲监控器,用于等待用户交互期间暂停/恢复计时 */
75
+ activeMonitors = new Map();
73
76
  /**
74
77
  * Get the runner for a given (channel, baseagent) pair.
75
78
  *
@@ -99,7 +102,7 @@ export class MessageProcessor {
99
102
  if (session.threadId)
100
103
  return false;
101
104
  // 使用 session 自身的 channelType 精确定位 active.json,避免扫描误匹配
102
- const active = this.sessionManager.getActiveSessionSync(session.channel, session.channelId, session.channelType, session.selfId);
105
+ const active = this.sessionManager.getActiveSessionSync(session.channel, session.channelId, session.channelType, session.selfAID);
103
106
  return active ? session.id !== active.id : false;
104
107
  }
105
108
  constructor(agentRunnerOrMap, sessionManager, globalSettings, messageCache, eventBus, commandHandler, primaryRunnerKey) {
@@ -126,6 +129,16 @@ export class MessageProcessor {
126
129
  }
127
130
  setInteractionRouter(router) {
128
131
  this.interactionRouter = router;
132
+ // 等待用户交互期间暂停 idle 监控,应答/取消/超时后恢复——
133
+ // 避免把「正在等用户点按钮」误判为「任务卡死」而中断任务。
134
+ router.setWaitHooks({
135
+ onWaitStart: (sessionId) => {
136
+ this.activeMonitors.get(sessionId)?.pause();
137
+ },
138
+ onWaitEnd: (sessionId) => {
139
+ this.activeMonitors.get(sessionId)?.resume();
140
+ },
141
+ });
129
142
  }
130
143
  setMessageQueue(queue) {
131
144
  this.messageQueue = queue;
@@ -211,10 +224,10 @@ export class MessageProcessor {
211
224
  */
212
225
  async processMessage(message) {
213
226
  const idleMs = (this.globalSettings.idleMonitor?.timeout ?? 120) * 1000;
214
- // 先解析会话,再优先用 session.metadata.channelName 精确定位实例级 adapter
227
+ // 先解析会话,再优先用 session.metadata.channelKey 精确定位实例级 adapter
215
228
  // message.channel 现在存实例名(channelName),可直接用于精确路由
216
229
  const { session, absoluteProjectPath } = await this.resolveSession(message);
217
- const channelKey = session.metadata?.channelName || message.channel;
230
+ const channelKey = session.metadata?.channelKey || message.channel;
218
231
  const channelInfo = this.resolveChannelInfo(channelKey);
219
232
  if (!channelInfo) {
220
233
  logger.error(`[MessageProcessor] Unknown channel: ${channelKey}`);
@@ -252,6 +265,7 @@ export class MessageProcessor {
252
265
  if (!monitorEnabled)
253
266
  return;
254
267
  monitor = new StreamIdleMonitor(idleMs);
268
+ this.activeMonitors.set(streamKey, monitor);
255
269
  monitorInterval = setInterval(() => {
256
270
  // Drain all pending levels in one tick
257
271
  let result = monitor.check();
@@ -332,6 +346,7 @@ export class MessageProcessor {
332
346
  finally {
333
347
  if (monitorInterval)
334
348
  clearInterval(monitorInterval);
349
+ this.activeMonitors.delete(streamKey);
335
350
  }
336
351
  }
337
352
  /** 获取回复上下文(跟着任务走) */
@@ -341,7 +356,7 @@ export class MessageProcessor {
341
356
  /** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
342
357
  async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress) {
343
358
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
344
- const channelKey = session.metadata?.channelName || message.channel;
359
+ const channelKey = session.metadata?.channelKey || message.channel;
345
360
  const channelInfo = this.resolveChannelInfo(channelKey);
346
361
  // Per-method agent name for stats bucketing (agent.name or '<unknown>')
347
362
  const agentNameForStats = this.agentRegistry?.resolveByChannel(channelKey)?.name ?? '<unknown>';
@@ -448,7 +463,7 @@ export class MessageProcessor {
448
463
  if (baseReplyCtx) {
449
464
  Object.assign(opts, baseReplyCtx);
450
465
  }
451
- else if (firstReply && message.messageId) {
466
+ else if (firstReply && message.messageId && message.source !== 'trigger') {
452
467
  if (payload.kind === 'result.text' && payload.text) {
453
468
  opts.replyToMessageId = message.messageId;
454
469
  firstReply = false;
@@ -508,6 +523,7 @@ export class MessageProcessor {
508
523
  : message.content;
509
524
  let streamResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
510
525
  let effectiveSystemPrompt;
526
+ let modelOverride;
511
527
  try {
512
528
  // 动态构建运行时上下文提示
513
529
  const contextParts = [];
@@ -533,19 +549,41 @@ export class MessageProcessor {
533
549
  contextParts.push(persona);
534
550
  if (working)
535
551
  contextParts.push(`[当前关注]\n${working}`);
536
- // 计算 peerKey: <channel>#<urlEncode(peerId)>
552
+ // 计算 peerKey: <channelType>#<urlEncode(peerId)>
537
553
  const peerIdRaw = message.peerId;
538
554
  const peerKey = (currentChannelType && peerIdRaw)
539
- ? `${currentChannelType}#${encodeURIComponent(peerIdRaw)}`
555
+ ? formatPeerKey(currentChannelType, peerIdRaw)
540
556
  : undefined;
557
+ // 按 关系级 > agent级 > 全局 解析本次调用的模型/强度,作为 per-call 入参传入 runQuery。
558
+ // 不缓存、不绑会话——改关系级/agent级后该范围所有会话的下条消息即时生效;
559
+ // 多对端并发各自独立解析、各自传参,无共享状态可被污染。
560
+ try {
561
+ const resolved = resolveEffectiveModel({ self: selfAid || undefined, peerKey });
562
+ if (resolved.model)
563
+ modelOverride = { model: resolved.model, effort: resolved.effort };
564
+ }
565
+ catch (e) {
566
+ logger.warn(`[MessageProcessor] resolveEffectiveModel failed: ${e instanceof Error ? e.message : String(e)}`);
567
+ }
541
568
  const normalizedBaseagent = normalizeBaseagent(agent.name);
542
569
  const agentModel = (typeof agent.getModel === 'function') ? agent.getModel() : undefined;
543
570
  // Kit renderer: 组装上下文
571
+ const pkgRoot = getPackageRoot();
544
572
  const kitCtx = {
545
573
  vars: {
546
574
  EVOLCLAW_HOME: resolveRoot(),
547
- PACKAGE_ROOT: getPackageRoot(),
575
+ PACKAGE_ROOT: pkgRoot,
548
576
  CURRENT_PROJECT: absoluteProjectPath,
577
+ // ECK 派生路径(manifest 引用时需要展开)
578
+ KITS: path.join(pkgRoot, 'kits'),
579
+ KITS_RULES: path.join(pkgRoot, 'kits', 'rules'),
580
+ KITS_DOCS: path.join(pkgRoot, 'kits', 'docs'),
581
+ KITS_TEMPLATES: path.join(pkgRoot, 'kits', 'templates'),
582
+ KITS_FRAGMENTS: path.join(pkgRoot, 'kits', 'templates', 'system-fragments'),
583
+ // 路径变量(用于 manifest 路径展开,resolvePath 用 ctx.vars 取真值)
584
+ PERSONAL_DIR: selfAid ? path.join(resolveRoot(), 'agents', selfAid, 'personal') : undefined,
585
+ RELATIONS_DIR: selfAid ? path.join(resolveRoot(), 'agents', selfAid, 'relations') : undefined,
586
+ VENUES_DIR: selfAid ? path.join(resolveRoot(), 'agents', selfAid, 'venues') : undefined,
549
587
  selfAid: selfAid || undefined,
550
588
  selfName: selfName || undefined,
551
589
  hasPersona: !!persona,
@@ -553,18 +591,24 @@ export class MessageProcessor {
553
591
  peerId: peerIdRaw || undefined,
554
592
  peerKey,
555
593
  peerName: peerName || undefined,
556
- peerRole: session.identity?.role || 'unknown',
594
+ peerRole: session.identity?.role || 'anonymous',
557
595
  peerType: message.peerType || undefined,
558
596
  groupId: session.metadata?.groupId || undefined,
559
597
  chatType: session.chatType || null,
560
598
  channel: currentChannelType || null,
561
599
  venueUid: undefined,
600
+ // 群分发模式 / 客户端类型 / 权限模式
601
+ dispatch: session.metadata?.dispatchMode || undefined,
602
+ clientType: message.clientType || undefined,
603
+ permissionMode: session.metadata?.permissionMode || 'auto',
562
604
  capabilities: capParts.length > 0 ? capParts.join('、') : undefined,
563
605
  project: path.basename(absoluteProjectPath),
564
606
  sessionId: session.id,
565
607
  sessionName: session.name || undefined,
566
608
  sessionCreatedAt: session.createdAt ? new Date(session.createdAt).toISOString() : undefined,
567
609
  threadId: session.threadId || undefined,
610
+ // Stage 3: sessionKey 持久化字段
611
+ sessionKey: session.sessionKey,
568
612
  chatMode: isProactive ? 'proactive' : 'interactive',
569
613
  readonly: session.metadata?.permissionMode === 'readonly',
570
614
  baseAgent: normalizedBaseagent.canonical,
@@ -584,7 +628,7 @@ export class MessageProcessor {
584
628
  let streamRegistered = false;
585
629
  try {
586
630
  logger.info(`[MessageProcessor] agent.runQuery start: agent=${agent.name} session=${session.id} task=${taskId} attempt=${attempt}/${MAX_RETRIES} agentSessionId=${session.agentSessionId ?? 'none'}`);
587
- const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, message.images, effectiveSystemPrompt, this.sessionManager);
631
+ const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, message.images, effectiveSystemPrompt, this.sessionManager, modelOverride);
588
632
  agent.registerStream(streamKey, stream);
589
633
  streamRegistered = true;
590
634
  streamResult = await this.processEventStream(stream, session, agent, renderer, resetTimer, shouldSuppress);
@@ -613,9 +657,11 @@ export class MessageProcessor {
613
657
  await renderer.flush();
614
658
  const compacted = await agent.compact(session.id, session.agentSessionId, absoluteProjectPath);
615
659
  if (compacted) {
616
- // compact 成功,带 resume 重试(不重复原始消息,让 Agent 继续未完成的工作)
660
+ // compact 成功,清除第一次流中混入的错误文本,再重试
661
+ const ctxErrPattern = /prompt is too long|input is too long|上下文过长/i;
662
+ renderer.stripContextError(ctxErrPattern);
617
663
  renderer.addNotice('✅ 压缩完成,继续处理...', 'info', 'compact-retry', true);
618
- const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, effectiveSystemPrompt, this.sessionManager);
664
+ const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, effectiveSystemPrompt, this.sessionManager, modelOverride);
619
665
  agent.registerStream(streamKey, retryStream);
620
666
  streamResult = await this.processEventStream(retryStream, session, agent, renderer, resetTimer, shouldSuppress);
621
667
  }
@@ -636,12 +682,13 @@ export class MessageProcessor {
636
682
  contextTooLongPattern.test(errorsText) ||
637
683
  contextTooLongPattern.test(streamResult.fullText));
638
684
  if (isPromptTooLong) {
685
+ renderer.stripContextError(contextTooLongPattern);
639
686
  renderer.addNotice('上下文过长,正在压缩会话...', 'warn', 'compact-trigger', true);
640
687
  await renderer.flush();
641
688
  const compacted = await agent.compact(session.id, session.agentSessionId, absoluteProjectPath);
642
689
  if (compacted) {
643
690
  renderer.addNotice('✅ 压缩完成,继续处理...', 'info', 'compact-retry', true);
644
- const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, effectiveSystemPrompt, this.sessionManager);
691
+ const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, effectiveSystemPrompt, this.sessionManager, modelOverride);
645
692
  agent.registerStream(streamKey, retryStream);
646
693
  streamResult = await this.processEventStream(retryStream, session, agent, renderer, resetTimer, shouldSuppress);
647
694
  // 重试后仍然 prompt_too_long:清理 renderer 中可能混入的错误文本,显示友好提示
@@ -830,7 +877,7 @@ export class MessageProcessor {
830
877
  adapter.send(envelope, { kind: 'status.interrupted', metadata: { reason: interruptReason } }).catch(() => { });
831
878
  }
832
879
  else {
833
- adapter.send(envelope, { kind: 'status.completed', metadata: { durationMs, numTurns: streamResult.numTurns, usage: streamResult.usage } }).catch(() => { });
880
+ adapter.send(envelope, { kind: 'status.completed', metadata: { durationMs, ttftMs: streamResult.ttftMs, numTurns: streamResult.numTurns, usage: streamResult.usage } }).catch(() => { });
834
881
  }
835
882
  }
836
883
  if (message.triggerMeta) {
@@ -953,7 +1000,7 @@ export class MessageProcessor {
953
1000
  // 获取 session 用于话题回复(如果 resolveSession 已执行)
954
1001
  let sendOpts;
955
1002
  try {
956
- await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.agentRegistry?.resolveByChannel(message.channel)?.projectPath || process.cwd(), message.threadId, undefined, undefined, message.peerId, message.chatType, undefined, message.selfId, message.channelType, message.peerType);
1003
+ await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.agentRegistry?.resolveByChannel(message.channel)?.projectPath || process.cwd(), message.threadId, undefined, undefined, message.peerId, message.chatType, undefined, message.selfAID, message.channelType, message.peerType);
957
1004
  sendOpts = this.getReplyContext(message);
958
1005
  }
959
1006
  catch { }
@@ -990,7 +1037,7 @@ export class MessageProcessor {
990
1037
  : path.resolve(process.cwd(), session.projectPath);
991
1038
  return { session, absoluteProjectPath };
992
1039
  }
993
- const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, undefined, message.peerId, message.chatType, undefined, message.selfId, message.channelType, message.peerType);
1040
+ const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, undefined, message.peerId, message.chatType, undefined, message.selfAID, message.channelType, message.peerType);
994
1041
  // 兜底纠正1:群聊强制 proactive
995
1042
  if (message.chatType === 'group' && session.sessionMode !== 'proactive') {
996
1043
  logger.info(`[MessageProcessor] group proactive upgrade: sessionId=${session.id} ${session.sessionMode} -> proactive`);
@@ -1004,6 +1051,13 @@ export class MessageProcessor {
1004
1051
  session.sessionMode = 'proactive';
1005
1052
  await this.sessionManager.updateSession(session.id, { sessionMode: 'proactive' });
1006
1053
  }
1054
+ // Proactive→Interactive 模式切换提示:上一轮 proactive 使用了标志位,本轮已切换为 interactive
1055
+ if (session.sessionMode === 'interactive' && session.metadata?.lastProactiveFlag) {
1056
+ message.content = '本轮会话已切换为 interactive 模式,无需调用工具发送消息。\n\n' + message.content;
1057
+ delete session.metadata.lastProactiveFlag;
1058
+ await this.sessionManager.updateSession(session.id, { metadata: session.metadata });
1059
+ logger.info(`[MessageProcessor] Injected interactive mode hint for session ${session.id}`);
1060
+ }
1007
1061
  // replyContext 不再写入 session.metadata(跟着 message 走,避免群聊多人覆盖)
1008
1062
  const absoluteProjectPath = path.isAbsolute(session.projectPath)
1009
1063
  ? session.projectPath
@@ -1018,7 +1072,7 @@ export class MessageProcessor {
1018
1072
  */
1019
1073
  async processEventStream(stream, session, agent, renderer, resetTimer, shouldSuppress) {
1020
1074
  // Per-session agent name for stats bucketing
1021
- const agentNameForStats = this.agentRegistry?.resolveByChannel(session.metadata?.channelName || session.channel)?.name ?? '<unknown>';
1075
+ const agentNameForStats = this.agentRegistry?.resolveByChannel(session.metadata?.channelKey || session.channel)?.name ?? '<unknown>';
1022
1076
  let hasReceivedText = false;
1023
1077
  let hasErrorResult = false; // 是否已有 tool_result/error 事件输出过错误
1024
1078
  let completeResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
@@ -1100,7 +1154,7 @@ export class MessageProcessor {
1100
1154
  if (event.type === 'compact') {
1101
1155
  this.eventBus.publish({ type: 'runner:compact-complete', sessionId: session.id, preTokens: event.preTokens });
1102
1156
  if (!shouldSuppress()) {
1103
- renderer.addNotice(`\ud83d\udca1 会话压缩完成,继续执行...(压缩前 tokens: ${event.preTokens})`, 'info', 'compact');
1157
+ renderer.addNotice(`\ud83d\udca1 会话压缩完成,继续执行...)`, 'info', 'compact');
1104
1158
  }
1105
1159
  }
1106
1160
  // 子任务进度
@@ -1178,14 +1232,15 @@ export class MessageProcessor {
1178
1232
  // SDK 可能产生多个 complete 事件(如 subagent 或 auto-compact 二次查询),
1179
1233
  // 仅记录状态,最终 flush(true) 在流结束后统一执行
1180
1234
  if (event.type === 'complete') {
1181
- logger.info(`[MessageProcessor] complete event: isError=${event.isError} terminalReason=${event.terminalReason ?? 'none'} subtype=${event.subtype ?? 'none'} hasReceivedText=${hasReceivedText}`);
1235
+ const isAbort = event.terminalReason === 'aborted_streaming' || event.terminalReason === 'aborted_tools';
1236
+ logger.info(`[MessageProcessor] ${isAbort ? 'task interrupted' : 'complete event'}: isError=${event.isError} terminalReason=${event.terminalReason ?? 'none'} subtype=${event.subtype ?? 'none'} hasReceivedText=${hasReceivedText}`);
1182
1237
  // 自动回填会话名称
1183
1238
  if (event.sessionTitle && session.name === '默认会话') {
1184
1239
  await this.sessionManager.renameSession(session.id, event.sessionTitle);
1185
1240
  logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
1186
1241
  }
1187
1242
  // 记录完成状态 + 最后一轮回复文本(后续 complete 覆盖前序)
1188
- completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, usage: event.usage };
1243
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, usage: event.usage };
1189
1244
  // thought jsonl 写入已下沉到 aun.ts:sendThought 成功后,
1190
1245
  // 由那里按 LLM 输出的每个 text item 单独写一条,此处不再写。
1191
1246
  // 失败且无前置错误输出:显示 errors 摘要
@@ -1213,6 +1268,15 @@ export class MessageProcessor {
1213
1268
  if (renderer.hasContent()) {
1214
1269
  await renderer.flushActivitiesOnly();
1215
1270
  }
1271
+ // 检测 proactive 标志位,设置 lastProactiveFlag 供模式切换提示使用
1272
+ if (session.sessionMode === 'proactive' && lastReplyText) {
1273
+ if (/\[PROACTIVE:REPLY_CONFIRMED_(SENT|NONE)\]/.test(lastReplyText)) {
1274
+ session.metadata = session.metadata || {};
1275
+ session.metadata.lastProactiveFlag = true;
1276
+ await this.sessionManager.updateSession(session.id, { metadata: session.metadata });
1277
+ logger.debug(`[MessageProcessor] Set lastProactiveFlag for session ${session.id}`);
1278
+ }
1279
+ }
1216
1280
  }
1217
1281
  continue;
1218
1282
  }
@@ -1232,7 +1296,7 @@ export class MessageProcessor {
1232
1296
  logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
1233
1297
  }
1234
1298
  // 记录完成状态
1235
- completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, usage: event.usage };
1299
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, usage: event.usage };
1236
1300
  if (event.subtype === 'success') {
1237
1301
  this.messageCache.addEvent(session.id, {
1238
1302
  type: 'completed',
@@ -1345,85 +1409,6 @@ export class MessageProcessor {
1345
1409
  // 都找不到,返回项目根目录路径
1346
1410
  return rootPath;
1347
1411
  }
1348
- /**
1349
- * 确保全局数据目录下有最新版本的 SKILLS.md
1350
- * 目标:{EVOLCLAW_HOME}/data/SKILLS.md
1351
- */
1352
- ensureSkillsFile() {
1353
- try {
1354
- const targetDir = path.join(resolveRoot(), 'data');
1355
- const targetPath = path.join(targetDir, 'SKILLS.md');
1356
- const templatePath = path.join(getPackageRoot(), 'src', 'templates', 'skills.md');
1357
- // 模板不存在则跳过(构建环境可能没有 src/)
1358
- if (!fs.existsSync(templatePath)) {
1359
- // 尝试 dist/templates/skills.md
1360
- const distTemplatePath = path.join(getPackageRoot(), 'dist', 'templates', 'skills.md');
1361
- if (!fs.existsSync(distTemplatePath))
1362
- return;
1363
- this.copySkillsIfNeeded(distTemplatePath, targetDir, targetPath);
1364
- return;
1365
- }
1366
- this.copySkillsIfNeeded(templatePath, targetDir, targetPath);
1367
- }
1368
- catch {
1369
- // 静默失败,不影响正常消息处理
1370
- }
1371
- }
1372
- copySkillsIfNeeded(templatePath, targetDir, targetPath) {
1373
- const templateContent = fs.readFileSync(templatePath, 'utf-8');
1374
- const templateVersion = templateContent.match(/^version:\s*(.+)$/m)?.[1]?.trim() || '0';
1375
- if (fs.existsSync(targetPath)) {
1376
- const existing = fs.readFileSync(targetPath, 'utf-8');
1377
- const existingVersion = existing.match(/^version:\s*(.+)$/m)?.[1]?.trim() || '0';
1378
- if (this.compareSemver(existingVersion, templateVersion) >= 0)
1379
- return; // 已是最新
1380
- }
1381
- if (!fs.existsSync(targetDir)) {
1382
- fs.mkdirSync(targetDir, { recursive: true });
1383
- }
1384
- fs.writeFileSync(targetPath, templateContent, 'utf-8');
1385
- }
1386
- /** 简易 semver 比较:支持 "1", "1.0", "1.0.0" 等格式,返回 -1/0/1 */
1387
- compareSemver(a, b) {
1388
- const pa = a.split('.').map(Number);
1389
- const pb = b.split('.').map(Number);
1390
- const len = Math.max(pa.length, pb.length);
1391
- for (let i = 0; i < len; i++) {
1392
- const na = pa[i] || 0;
1393
- const nb = pb[i] || 0;
1394
- if (na !== nb)
1395
- return na > nb ? 1 : -1;
1396
- }
1397
- return 0;
1398
- }
1399
- /**
1400
- * 从 data/SKILLS.md 读取 frontmatter 并生成提示。
1401
- * 不缓存:每次读取保证用户编辑立即生效。
1402
- * 调用前应确保 ensureSkillsFile() 已执行过(首次落盘)。
1403
- */
1404
- getSkillsHint() {
1405
- try {
1406
- const skillsPath = path.join(resolveRoot(), 'data', 'SKILLS.md');
1407
- if (!fs.existsSync(skillsPath))
1408
- return null;
1409
- const content = fs.readFileSync(skillsPath, 'utf-8');
1410
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
1411
- if (!frontmatterMatch)
1412
- return null;
1413
- const fm = frontmatterMatch[1];
1414
- const desc = fm.match(/^description:\s*(.+)$/m)?.[1]?.trim() || 'EvolClaw 运行时管理指令';
1415
- const trigger = fm.match(/^trigger:\s*(.+)$/m)?.[1]?.trim() || '';
1416
- const parts = [
1417
- `可通过 Bash 指令管理运行时,${desc}。`,
1418
- trigger ? `触发时机:${trigger}。` : '',
1419
- `完整文档见 ${skillsPath}`,
1420
- ];
1421
- return parts.filter(Boolean).join('');
1422
- }
1423
- catch {
1424
- return null;
1425
- }
1426
- }
1427
1412
  /**
1428
1413
  * 判断文件路径是否为占位符/示例文本
1429
1414
  * 用于过滤大模型在说明文字中误写的 [SEND_FILE:...] 标记
@@ -10,6 +10,7 @@ export class StreamIdleMonitor {
10
10
  state;
11
11
  triggeredLevels = new Set();
12
12
  idleMs;
13
+ paused = false;
13
14
  constructor(idleMs) {
14
15
  this.idleMs = idleMs;
15
16
  this.state = {
@@ -22,6 +23,24 @@ export class StreamIdleMonitor {
22
23
  hasReceivedText: false,
23
24
  };
24
25
  }
26
+ /**
27
+ * 暂停空闲计时(等待用户交互期间调用,如权限确认 / AskUserQuestion / PlanMode)。
28
+ * 暂停期间 check() 始终返回 null,等待时长不计入 idle。
29
+ */
30
+ pause() {
31
+ this.paused = true;
32
+ }
33
+ /**
34
+ * 恢复空闲计时。从恢复时刻重新起算 idle,并清空已触发级别——
35
+ * 用户应答后是一次全新的执行周期,不应继承等待前的 idle 状态。
36
+ */
37
+ resume() {
38
+ if (!this.paused)
39
+ return;
40
+ this.paused = false;
41
+ this.state.lastEventTime = Date.now();
42
+ this.triggeredLevels.clear();
43
+ }
25
44
  /**
26
45
  * 记录 SDK 事件,更新状态并重置空闲计时
27
46
  */
@@ -44,6 +63,8 @@ export class StreamIdleMonitor {
44
63
  * 检查空闲状态,返回 null(未空闲)或分级结果
45
64
  */
46
65
  check() {
66
+ if (this.paused)
67
+ return null;
47
68
  const now = Date.now();
48
69
  const idleDuration = now - this.state.lastEventTime;
49
70
  const notifyThreshold = this.idleMs * NOTIFY_MULTIPLIER;