evolclaw 3.1.11 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +27 -2
  3. package/dist/agents/{resolve.js → baseagent.js} +34 -5
  4. package/dist/agents/claude-runner.js +120 -27
  5. package/dist/agents/codex-app-server-client.js +364 -0
  6. package/dist/agents/codex-runner.js +1069 -141
  7. package/dist/agents/gemini-runner.js +2 -2
  8. package/dist/agents/runner-types.js +28 -0
  9. package/dist/aun/aid/control-aid.js +67 -0
  10. package/dist/aun/aid/identity.js +20 -7
  11. package/dist/aun/aid/store.js +2 -2
  12. package/dist/aun/storage/download.js +1 -1
  13. package/dist/aun/storage/upload.js +13 -1
  14. package/dist/channels/aun.js +538 -325
  15. package/dist/channels/dingtalk.js +77 -140
  16. package/dist/channels/feishu.js +98 -151
  17. package/dist/channels/qqbot.js +75 -138
  18. package/dist/channels/wechat.js +75 -136
  19. package/dist/channels/wecom.js +75 -138
  20. package/dist/cli/agent.js +44 -13
  21. package/dist/cli/index.js +207 -46
  22. package/dist/cli/init-channel.js +38 -148
  23. package/dist/cli/init.js +192 -85
  24. package/dist/cli/model.js +1 -1
  25. package/dist/cli/stats.js +558 -0
  26. package/dist/cli/version.js +87 -0
  27. package/dist/cli/watch-msg.js +5 -2
  28. package/dist/config-store.js +48 -11
  29. package/dist/core/channel-loader.js +84 -82
  30. package/dist/core/command-handler.js +754 -172
  31. package/dist/core/daemon-file-cache.js +216 -0
  32. package/dist/core/evolagent-registry.js +4 -0
  33. package/dist/core/evolagent.js +28 -23
  34. package/dist/core/interaction-router.js +8 -0
  35. package/dist/core/message/command-handler-agent-control.js +215 -0
  36. package/dist/core/message/create-status.js +67 -0
  37. package/dist/core/message/im-renderer.js +35 -13
  38. package/dist/core/message/items-formatter.js +9 -1
  39. package/dist/core/message/message-bridge.js +52 -22
  40. package/dist/core/message/message-log.js +1 -0
  41. package/dist/core/message/message-processor.js +336 -68
  42. package/dist/core/message/message-queue.js +15 -8
  43. package/dist/core/message/pending-hints.js +232 -0
  44. package/dist/core/message/response-depth.js +56 -0
  45. package/dist/core/model/model-catalog.js +1 -1
  46. package/dist/core/model/model-scope.js +40 -7
  47. package/dist/core/permission.js +9 -12
  48. package/dist/core/relation/peer-identity.js +16 -1
  49. package/dist/core/session/adapters/claude-session-file-adapter.js +48 -5
  50. package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
  51. package/dist/core/session/session-manager.js +27 -13
  52. package/dist/core/session/session-title.js +26 -0
  53. package/dist/core/stats/billing.js +151 -0
  54. package/dist/core/stats/budget.js +93 -0
  55. package/dist/core/stats/db.js +314 -0
  56. package/dist/core/stats/eck-vars.js +84 -0
  57. package/dist/core/stats/index.js +10 -0
  58. package/dist/core/stats/normalizer.js +78 -0
  59. package/dist/core/stats/query.js +760 -0
  60. package/dist/core/stats/writer.js +115 -0
  61. package/dist/core/trigger/manager.js +34 -0
  62. package/dist/core/trigger/parser.js +9 -3
  63. package/dist/core/trigger/scheduler.js +20 -17
  64. package/dist/{agents → eck}/kit-renderer.js +5 -1
  65. package/dist/{agents → eck}/manifest-engine.js +127 -35
  66. package/dist/{agents → eck}/message-renderer.js +26 -1
  67. package/dist/index.js +185 -8
  68. package/dist/ipc.js +22 -0
  69. package/dist/paths.js +7 -3
  70. package/dist/utils/cross-platform.js +23 -5
  71. package/dist/utils/ecweb-pair.js +20 -0
  72. package/dist/utils/stats.js +14 -0
  73. package/kits/docs/evolclaw/INDEX.md +3 -1
  74. package/kits/docs/evolclaw/fs-architecture.md +1215 -0
  75. package/kits/docs/evolclaw/fs.md +131 -0
  76. package/kits/docs/evolclaw/group-fs.md +209 -0
  77. package/kits/docs/evolclaw/stats.md +70 -0
  78. package/kits/docs/venues/aun-group.md +29 -6
  79. package/kits/docs/venues/group.md +5 -4
  80. package/kits/eck_manifest.json +12 -0
  81. package/kits/eck_message_manifest.json +30 -3
  82. package/kits/rules/05-venue.md +1 -1
  83. package/kits/templates/message-fragments/inject-default.md +2 -0
  84. package/kits/templates/message-fragments/item.md +1 -1
  85. package/kits/templates/system-fragments/response-depth.md +16 -0
  86. package/package.json +4 -4
  87. package/dist/agents/baseagent-normalize.js +0 -19
  88. package/dist/core/relation/peer-key.js +0 -16
  89. package/dist/utils/channel-helpers.js +0 -46
@@ -2,20 +2,25 @@ import path from 'path';
2
2
  import fs from 'fs';
3
3
  import os from 'os';
4
4
  import crypto from 'crypto';
5
- import { hasCompact } from '../../agents/claude-runner.js';
5
+ import { hasCompact } from '../../agents/runner-types.js';
6
6
  import { IMRenderer } from './im-renderer.js';
7
7
  import { StreamIdleMonitor } from './stream-idle-monitor.js';
8
8
  import { logger } from '../../utils/logger.js';
9
9
  import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, prefixErrorType, isRetryableError } from '../../utils/error-utils.js';
10
10
  import { summarizeToolInput } from '../permission.js';
11
11
  import { DEFAULT_PERMISSION_MODE } from '../../types.js';
12
- import { getPackageRoot, resolveRoot } from '../../paths.js';
13
- import { renderKitSections } from '../../agents/kit-renderer.js';
14
- import { renderMessageBody } from '../../agents/message-renderer.js';
15
- import { normalizeBaseagent } from '../../agents/baseagent-normalize.js';
12
+ import { getPackageRoot, resolveRoot, resolvePaths } from '../../paths.js';
13
+ import { renderKitSections } from '../../eck/kit-renderer.js';
14
+ import { renderMessageBody } from '../../eck/message-renderer.js';
15
+ import { consumeHints, hintsToSubMessages, composeHintFallback } from './pending-hints.js';
16
+ import { resolveResponseDepth as computeResponseDepth } from './response-depth.js';
17
+ import { normalizeBaseagent } from '../../agents/baseagent.js';
16
18
  import { renderActionAsText, renderCommandCardAsText } from '../interaction-router.js';
17
- import { formatPeerKey } from '../relation/peer-key.js';
19
+ import { formatPeerKey } from '../relation/peer-identity.js';
18
20
  import { resolveEffectiveModel } from '../model/model-scope.js';
21
+ import { insertUsageEvent, insertContextBreakdown, insertModelCalls } from '../stats/writer.js';
22
+ import { normalizeUsage } from '../stats/normalizer.js';
23
+ import { getBudgetStatus } from '../stats/budget.js';
19
24
  /** OS 信息在进程生命周期内是常量,模块加载时算一次。例: "Windows 11 Pro (win32 10.0.26200)" */
20
25
  const OS_INFO = (() => {
21
26
  let label = '';
@@ -192,6 +197,34 @@ export class MessageProcessor {
192
197
  const globalCm = agent.config?.chatmode ?? this.globalSettings.chatmode;
193
198
  return agent.getContext(channelName, chatType, globalCm);
194
199
  }
200
+ /**
201
+ * 观察者插话(v0.3):消费当前 (对端, thread) 的待用提示,转成 owner-hint SubMessage。
202
+ * 一次性语义:consumeHints 回放算有效集后清该 thread(其它 thread 残留则保留,否则删文件)。
203
+ * 仅 aun 渠道(pending-hints 落在 sessions/aun/<self>/<对端>/)。
204
+ */
205
+ consumeOwnerHints(session, message) {
206
+ const channelType = session.channelType || message.channelType || session.channel;
207
+ if (channelType !== 'aun')
208
+ return [];
209
+ const selfAID = session.selfAID || message.selfAID;
210
+ if (!selfAID)
211
+ return [];
212
+ // 会话定位键:私聊=对端 AID,群聊=groupId(均为 session.channelId)。
213
+ const peerChannelId = session.channelId;
214
+ if (!peerChannelId)
215
+ return [];
216
+ try {
217
+ const hints = consumeHints(resolvePaths().sessionsDir, 'aun', peerChannelId, selfAID, session.threadId);
218
+ if (hints.length === 0)
219
+ return [];
220
+ logger.info(`[MessageProcessor] consumed ${hints.length} owner-hint(s) for ${peerChannelId} thread=${session.threadId || 'main'}`);
221
+ return hintsToSubMessages(hints);
222
+ }
223
+ catch (e) {
224
+ logger.warn(`[MessageProcessor] consumeOwnerHints failed: ${e instanceof Error ? e.message : String(e)}`);
225
+ return [];
226
+ }
227
+ }
195
228
  /**
196
229
  * 注册渠道适配器
197
230
  */
@@ -262,6 +295,8 @@ export class MessageProcessor {
262
295
  // 先解析会话,再优先用 session.metadata.channelKey 精确定位实例级 adapter
263
296
  // message.channel 现在存实例名(channelName),可直接用于精确路由
264
297
  const { session, absoluteProjectPath } = await this.resolveSession(message);
298
+ // 群聊响应深度决策(resolveSession 之后、_processMessageInternal 之前)
299
+ const responseDepth = await this.resolveResponseDepth(message, session);
265
300
  // thread(feishu) pending strategy: inject replyContext so first reply creates the thread
266
301
  if (message.triggerMeta?.pendingThread && message.triggerMeta?.rootMessageId) {
267
302
  const triggerId = message.triggerMeta.triggerId;
@@ -295,16 +330,9 @@ export class MessageProcessor {
295
330
  const streamKey = session.id;
296
331
  const chatType = message.chatType || 'private';
297
332
  const identityRole = session.identity?.role || 'anonymous';
298
- const agentNameForMonitor = this.agentRegistry?.resolveByChannel(channelKey)?.name ?? '<unknown>';
299
- // Resolve agent context from registry (Phase 2 foundation)
300
- const agentContext = this.getAgentContext(channelKey, chatType);
301
- if (agentContext) {
302
- logger.debug(`[MessageProcessor] Agent context resolved: ${agentContext.name} (${agentContext.baseagent})`);
303
- }
304
- // 按 session.agentId 选择 agent 后端
305
- const agent = this.getAgent(channelKey, session.agentId);
306
333
  const monitorEnabled = this.globalSettings.idleMonitor?.enabled !== false;
307
- const showIdleMonitor = policy.showIdleMonitor(chatType, identityRole);
334
+ // session.agentId 选择 agent 后端(idle-kill 路径需要 interrupt)
335
+ const agent = this.getAgent(channelKey, session.agentId);
308
336
  // 计算是否抑制中间输出(工具活动 + 流式文本)
309
337
  const shouldSuppress = () => {
310
338
  return !policy.showMiddleResult(chatType, identityRole);
@@ -313,6 +341,7 @@ export class MessageProcessor {
313
341
  let monitor;
314
342
  let monitorInterval;
315
343
  let rejectFn;
344
+ let lastIdleSec = 0;
316
345
  const resetTimer = (eventType, toolName) => {
317
346
  monitor?.recordEvent(eventType || 'unknown', toolName);
318
347
  };
@@ -329,17 +358,9 @@ export class MessageProcessor {
329
358
  let result = monitor.check();
330
359
  while (result) {
331
360
  if (result.action === 'kill') {
361
+ lastIdleSec = result.idleSec;
332
362
  logger.warn(`[MessageProcessor] Idle monitor: kill after ${result.idleSec}s idle, stream: ${streamKey}`);
333
363
  this.eventBus.publish({ type: 'runner:idle-timeout', sessionId: streamKey, idleSec: result.idleSec });
334
- // 后台任务也需要中断(释放资源),但不发送通知
335
- if (channelInfo && !isBackground) {
336
- const msg = showIdleMonitor
337
- ? result.message
338
- : `\u26a0\ufe0f 任务超时(${result.idleSec}秒无响应),已自动中断`;
339
- channelInfo.adapter.send(buildEnvelope({ channel: channelInfo.adapter.channelName, channelId: message.channelId, agentName: agentNameForMonitor }), { kind: 'system.notice', text: msg, subtype: 'health' }).catch(e => {
340
- logger.debug(`[MessageProcessor] Failed to send kill diagnostic message:`, e);
341
- });
342
- }
343
364
  logger.info(`[MessageProcessor] agent.interrupt invoked (idle-kill) stream=${streamKey}`);
344
365
  agent.interrupt(streamKey).catch(e => {
345
366
  logger.debug(`[MessageProcessor] Interrupt failed (may already be cleaned up):`, e);
@@ -348,15 +369,16 @@ export class MessageProcessor {
348
369
  return;
349
370
  }
350
371
  else {
351
- // notify or warn: send diagnostic message, task continues
372
+ // notify or warn: publish event, task continues
352
373
  logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
353
- if (channelInfo && showIdleMonitor && !shouldSuppress()) {
354
- if (!isBackground) {
355
- channelInfo.adapter.send(buildEnvelope({ channel: channelInfo.adapter.channelName, channelId: message.channelId, agentName: agentNameForMonitor }), { kind: 'system.notice', text: result.message, subtype: 'health' }).catch(e => {
356
- logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
357
- });
358
- }
359
- }
374
+ this.eventBus.publish({
375
+ type: result.action === 'notify' ? 'runner:idle-notify' : 'runner:idle-warn',
376
+ sessionId: streamKey,
377
+ idleSec: result.idleSec,
378
+ totalEvents: result.state.totalEvents,
379
+ totalToolCalls: result.state.totalToolCalls,
380
+ lastToolName: result.state.lastToolName,
381
+ });
360
382
  }
361
383
  result = monitor.check();
362
384
  }
@@ -364,7 +386,7 @@ export class MessageProcessor {
364
386
  });
365
387
  try {
366
388
  await Promise.race([
367
- this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress),
389
+ this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, () => lastIdleSec, responseDepth),
368
390
  timeoutPromise
369
391
  ]);
370
392
  }
@@ -412,7 +434,7 @@ export class MessageProcessor {
412
434
  return message.replyContext;
413
435
  }
414
436
  /** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
415
- async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress) {
437
+ async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, getLastIdleSec, responseDepth) {
416
438
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
417
439
  const channelKey = session.metadata?.channelKey || message.channel;
418
440
  const channelInfo = this.resolveChannelInfo(channelKey);
@@ -436,7 +458,7 @@ export class MessageProcessor {
436
458
  const taskId = `task-${crypto.randomUUID().replace(/-/g, '').slice(0, 10)}`;
437
459
  const chatmode = session.sessionMode ?? 'interactive';
438
460
  // 诊断日志:记录 inbound message_id 和生成的 task_id 的对应关系
439
- logger.info(`[MessageProcessor] Task created: inboundMsgId=${message.messageId ?? 'none'} taskId=${taskId} sessionId=${session.id} chatmode=${chatmode}`);
461
+ logger.info(`[MessageProcessor] Task created: inboundMsgId=${message.messageId ?? 'none'} taskId=${taskId} sessionId=${session.id} chatmode=${chatmode}${responseDepth && responseDepth !== 'standard' ? ` depth=${responseDepth}` : ''}`);
440
462
  // 构建带 taskId/chatmode 的 ReplyContext(本次任务所有出站消息共用)
441
463
  const taskReplyContext = () => {
442
464
  const base = this.getReplyContext(message);
@@ -474,6 +496,17 @@ export class MessageProcessor {
474
496
  agentName: agentNameForStats,
475
497
  timestamp: Date.now()
476
498
  });
499
+ // ── 硬上限检查:超限直接返回提示,不调模型 ──
500
+ {
501
+ const budgetAgentAid = session.selfAID || message.selfAID || '';
502
+ const budgetPeerKey = formatPeerKey(message.channel, message.channelId);
503
+ const budgetStatus = getBudgetStatus(resolveRoot(), budgetAgentAid, budgetPeerKey);
504
+ if (budgetStatus.hard_blocked) {
505
+ logger.warn(`[MessageProcessor] Budget hard limit reached: agent=${budgetAgentAid} peer=${budgetPeerKey} pct=${budgetStatus.pct_used.toFixed(1)}%`);
506
+ adapter.send(envelope, { kind: 'status.completed', metadata: { durationMs: 0 } }).catch(() => { });
507
+ return;
508
+ }
509
+ }
477
510
  const imageInfo = message.images && message.images.length > 0 ? ` [${message.images.length} image(s)]` : '';
478
511
  const modeInfo = isBackground ? ' [\u540e\u53f0]' : '';
479
512
  const e2eeInfo = message.replyContext?.metadata?.encrypted != null ? ` encrypt=${message.replyContext.metadata.encrypted}` : '';
@@ -559,6 +592,9 @@ export class MessageProcessor {
559
592
  agentName: agentNameForStats,
560
593
  taskId,
561
594
  chatmode: isProactive ? 'proactive' : 'interactive',
595
+ flushPending: async () => {
596
+ await renderer.flush(false);
597
+ },
562
598
  interceptNextMessage: this.messageQueue
563
599
  ? (sessionKey, handler) => this.messageQueue.interceptNext(sessionKey, handler)
564
600
  : undefined,
@@ -595,7 +631,8 @@ export class MessageProcessor {
595
631
  const currentChannelType = options?.channelType || message.channel;
596
632
  // 提取 self 信息
597
633
  const adapterAny = channelInfo.adapter;
598
- const selfAid = typeof adapterAny._selfAid === 'function' ? adapterAny._selfAid() : undefined;
634
+ const adapterSelfAid = typeof adapterAny._selfAid === 'function' ? adapterAny._selfAid() : undefined;
635
+ const selfAid = adapterSelfAid || message.selfAID || session.selfAID || undefined;
599
636
  const selfName = typeof adapterAny._selfName === 'function' ? adapterAny._selfName() : undefined;
600
637
  const peerName = message.peerName || session.metadata?.peerName;
601
638
  // 通道能力
@@ -619,6 +656,7 @@ export class MessageProcessor {
619
656
  const peerKey = (currentChannelType && peerIdRaw)
620
657
  ? formatPeerKey(currentChannelType, peerIdRaw)
621
658
  : undefined;
659
+ const normalizedBaseagent = normalizeBaseagent(agent.name);
622
660
  // 按 关系级 > agent级 > 全局 解析本次调用的模型/强度,作为 per-call 入参传入 runQuery。
623
661
  // 不缓存、不绑会话——改关系级/agent级后该范围所有会话的下条消息即时生效;
624
662
  // 多对端并发各自独立解析、各自传参,无共享状态可被污染。
@@ -638,7 +676,7 @@ export class MessageProcessor {
638
676
  let evolclawModelOverride;
639
677
  if (!skipEvolclawModel) {
640
678
  try {
641
- const resolved = resolveEffectiveModel({ self: selfAid || undefined, peerKey });
679
+ const resolved = resolveEffectiveModel({ self: selfAid || undefined, peerKey }, normalizedBaseagent.canonical);
642
680
  if (resolved.model) {
643
681
  evolclawModelOverride = { model: resolved.model, effort: resolved.effort };
644
682
  effectiveModel = resolved.model;
@@ -649,7 +687,20 @@ export class MessageProcessor {
649
687
  }
650
688
  modelOverride = evolclawModelOverride;
651
689
  }
652
- const normalizedBaseagent = normalizeBaseagent(agent.name);
690
+ // 群聊 responseDepth → effort 动态映射
691
+ // 仅当群聊且 evolclaw 作用域未显式指定 effort 时生效(显式配置优先)
692
+ if (message.chatType === 'group' && responseDepth && !(modelOverride?.effort)) {
693
+ const depthEffortMap = {
694
+ lightweight: 'low',
695
+ standard: 'medium',
696
+ deep: 'high',
697
+ };
698
+ const mappedEffort = depthEffortMap[responseDepth];
699
+ if (mappedEffort) {
700
+ modelOverride = { ...(modelOverride || {}), effort: mappedEffort };
701
+ logger.info(`[MessageProcessor] Group depth→effort: ${responseDepth} → ${mappedEffort} session=${session.id}`);
702
+ }
703
+ }
653
704
  agentModel = (typeof agent.getModel === 'function') ? agent.getModel() : undefined;
654
705
  // Kit renderer: 组装上下文
655
706
  const pkgRoot = getPackageRoot();
@@ -684,11 +735,18 @@ export class MessageProcessor {
684
735
  sameNetwork: message.sameNetwork ?? false,
685
736
  sameEgressIp: message.sameEgressIp ?? false,
686
737
  groupId: session.metadata?.groupId || undefined,
738
+ groupName: session.metadata?.groupName || undefined,
739
+ // 信封展示用:有群名则「名<ID>」,否则纯 ID。规避模板引擎无 not/else 的限制。
740
+ groupLabel: session.metadata?.groupId
741
+ ? (session.metadata?.groupName ? `${session.metadata.groupName}<${session.metadata.groupId}>` : session.metadata.groupId)
742
+ : undefined,
687
743
  chatType: session.chatType || null,
688
744
  channel: currentChannelType || null,
689
745
  venueUid: undefined,
690
746
  // 群分发模式 / 客户端类型 / 权限模式
691
- dispatch: session.metadata?.dispatchMode || undefined,
747
+ // 优先本地 session 覆盖(/dispatch 命令),fallback 到服务器 dispatch_mode
748
+ dispatch: session.metadata?.dispatchMode || message.dispatchMode || undefined,
749
+ responseDepth: responseDepth || undefined,
692
750
  clientType: message.clientType || undefined,
693
751
  permissionMode: session.metadata?.permissionMode || 'auto',
694
752
  capabilities: capParts.length > 0 ? capParts.join('、') : undefined,
@@ -714,6 +772,8 @@ export class MessageProcessor {
714
772
  modelFallbackActive: (fbState.fallbackActive || skipEvolclawModel) ? true : undefined,
715
773
  modelFallbackModel: (fbState.fallbackActive || skipEvolclawModel) ? (agentModel || undefined) : undefined,
716
774
  agentSessionId: session.agentSessionId || undefined,
775
+ // 渲染模式:各类型当前激活的 modeName(从内存 config 读,渲染层据此选 manifest section)。
776
+ renderModes: this.agentRegistry?.resolveByChannel(channelKey)?.config?.render ?? undefined,
717
777
  },
718
778
  sessionId: session.id,
719
779
  };
@@ -721,30 +781,65 @@ export class MessageProcessor {
721
781
  if (kitContext)
722
782
  contextParts.push(kitContext);
723
783
  effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
784
+ // ── Stats: context_breakdown 旁路采集(各段估算 token 数,字符数/4 近似) ──
785
+ try {
786
+ const estTokens = (s) => s ? Math.ceil(s.length / 4) : 0;
787
+ const cbModel = effectiveModel || agentModel || 'unknown';
788
+ const cbMaxTokens = 200000; // 保守默认,后续可从 model-catalog 取
789
+ const systemPromptTokens = estTokens(options?.systemPromptAppend);
790
+ const personaTokens = estTokens(persona);
791
+ const workingTokens = estTokens(working);
792
+ const kitTokens = estTokens(kitContext);
793
+ const totalEst = estTokens(effectiveSystemPrompt);
794
+ insertContextBreakdown(resolveRoot(), {
795
+ ts: Date.now(),
796
+ agent_aid: selfAid || session.selfAID || '',
797
+ session_id: session.id,
798
+ turn_count: 0, // 按 ts 排序得轮次
799
+ model: cbModel,
800
+ max_tokens: cbMaxTokens,
801
+ system_prompt: systemPromptTokens + personaTokens + workingTokens,
802
+ system_tools: 0, // 工具 schema 不在此层,留 0(后续 runner 层补)
803
+ mcp_tools: 0,
804
+ custom_agents: 0,
805
+ memory_files: kitTokens, // ECK 渲染的所有段(含 memory/skills/rules)
806
+ skills: 0,
807
+ messages: 0, // messages 段在 runner 层才知道
808
+ free_space: Math.max(0, cbMaxTokens - totalEst),
809
+ total_estimated: totalEst,
810
+ });
811
+ }
812
+ catch { /* non-fatal */ }
724
813
  // 消息渲染层:用 message manifest 逐条渲染(时间 + 群聊发送者),组装成最终正文。
725
814
  // 单条消息构造单元素 items;批量合并的消息 message.items 已由队列填充。
726
815
  let renderResult;
727
816
  const hasContent = message.content.trim() || (message.items && message.items.length > 0);
728
817
  if (hasContent) {
818
+ const peerItems = message.items && message.items.length > 0
819
+ ? message.items
820
+ : [{
821
+ peerId: message.peerId, peerName: peerName || undefined,
822
+ peerType: message.peerType,
823
+ sameDevice: message.sameDevice, sameNetwork: message.sameNetwork, sameEgressIp: message.sameEgressIp,
824
+ content: message.content, timestamp: message.timestamp,
825
+ images: message.images,
826
+ mentionAids: message.mentionAids,
827
+ }];
828
+ // 观察者插话(v0.3):消费 (对端, thread) 的待用提示,包成 owner-hint item 排在对端消息前。
829
+ // 一次性语义:consumeOwnerHints 读取并删除(见 pending-hints.ts)。在 try 外消费,
830
+ // 这样即便 renderMessageBody 抛错走 raw 兜底,也把提示原文拼进去——绝不静默丢提示。
831
+ const hintItems = this.consumeOwnerHints(session, message);
832
+ const renderItems = hintItems.length > 0 ? [...hintItems, ...peerItems] : peerItems;
729
833
  try {
730
- const renderItems = message.items && message.items.length > 0
731
- ? message.items
732
- : [{
733
- peerId: message.peerId, peerName: peerName || undefined,
734
- peerType: message.peerType,
735
- sameDevice: message.sameDevice, sameNetwork: message.sameNetwork, sameEgressIp: message.sameEgressIp,
736
- content: message.content, timestamp: message.timestamp,
737
- images: message.images,
738
- }];
739
834
  renderResult = renderMessageBody(renderItems, kitCtx.vars, session.id);
740
835
  if (renderResult.body.trim())
741
836
  effectivePrompt = wrapPrompt(renderResult.body);
742
837
  else
743
- effectivePrompt = wrapPrompt(message.content);
838
+ effectivePrompt = wrapPrompt(composeHintFallback(hintItems, message.content));
744
839
  }
745
840
  catch (e) {
746
841
  logger.warn(`[MessageProcessor] renderMessageBody failed, using raw content: ${e instanceof Error ? e.message : String(e)}`);
747
- effectivePrompt = wrapPrompt(message.content);
842
+ effectivePrompt = wrapPrompt(composeHintFallback(hintItems, message.content));
748
843
  }
749
844
  }
750
845
  // 可重试错误(403/429/5xx)指数退避重试,最多 3 次
@@ -995,7 +1090,7 @@ export class MessageProcessor {
995
1090
  adapter.send(envelope, { kind: 'status.error', metadata: { errorType: rawSubtype } }).catch(() => { });
996
1091
  }
997
1092
  if (message.triggerMeta) {
998
- this.eventBus.publish({ type: 'trigger:failed', triggerId: message.triggerMeta.triggerId, messageId: messageId, error: errorSummary });
1093
+ this.eventBus.publish({ type: 'trigger:failed', triggerId: message.triggerMeta.triggerId, name: message.triggerMeta.triggerName ?? '', messageId: messageId, error: errorSummary, targetChannel: message.channel, targetChannelId: message.channelId, fireTime: message.triggerMeta.fireTime ?? 0, phase: 'execute' });
999
1094
  }
1000
1095
  this.eventBus.publish({
1001
1096
  type: 'task:error',
@@ -1026,20 +1121,131 @@ export class MessageProcessor {
1026
1121
  else {
1027
1122
  // 真正的成功
1028
1123
  const durationMs = Date.now() - startTime;
1124
+ // ── Stats: 写入 usage_events(在 status.completed 之前,以便带上 cost) ──
1125
+ let statsCostUsd = 0;
1126
+ let statsCostCny = 0;
1127
+ let statsCacheHitRate = 0;
1128
+ if (streamResult.tokenUsage) {
1129
+ try {
1130
+ const statsAgentAid = session.selfAID || message.selfAID || '';
1131
+ const statsPeerKey = formatPeerKey(message.channel, message.channelId);
1132
+ const statsModel = streamResult.contextUsage?.model || 'unknown';
1133
+ const ctxPct = streamResult.contextUsage?.percentage;
1134
+ const event = normalizeUsage(streamResult.tokenUsage, {
1135
+ ts: Date.now(),
1136
+ agent_aid: statsAgentAid,
1137
+ peer_key: statsPeerKey,
1138
+ peer_type: session.chatType || undefined,
1139
+ session_id: session.id,
1140
+ model: statsModel,
1141
+ turns: streamResult.numTurns,
1142
+ duration_ms: durationMs,
1143
+ context_window_pct: ctxPct,
1144
+ });
1145
+ insertUsageEvent(resolveRoot(), event);
1146
+ // 逐次大模型调用明细落库(model_calls 表)
1147
+ if (streamResult.modelCalls?.length) {
1148
+ const mcRows = streamResult.modelCalls.map(mc => ({
1149
+ ts: event.ts,
1150
+ task_id: taskId,
1151
+ session_id: session.id,
1152
+ agent_session_id: session.agentSessionId ?? undefined,
1153
+ agent_aid: statsAgentAid,
1154
+ peer_key: statsPeerKey,
1155
+ call_index: mc.call_index,
1156
+ model: mc.model || statsModel,
1157
+ request_id: mc.request_id,
1158
+ message_id: mc.message_id,
1159
+ input_tokens: mc.tokenUsage.input_tokens ?? 0,
1160
+ output_tokens: mc.tokenUsage.output_tokens ?? 0,
1161
+ cache_creation_tokens: mc.tokenUsage.cache_creation_input_tokens ?? 0,
1162
+ cache_read_tokens: mc.tokenUsage.cache_read_input_tokens ?? 0,
1163
+ degraded: mc.degraded ? 1 : 0,
1164
+ }));
1165
+ insertModelCalls(resolveRoot(), mcRows);
1166
+ }
1167
+ // 计算费用(用于合入 status.completed)
1168
+ const { calcCost } = await import('../stats/billing.js');
1169
+ const cost = calcCost(resolveRoot(), { ...event, ts: event.ts, model: event.model, billing_fn: event.billing_fn });
1170
+ statsCostUsd = cost.usd ?? 0;
1171
+ statsCostCny = cost.cny ?? 0;
1172
+ const totalIn = event.input_tokens + event.cache_read_tokens;
1173
+ statsCacheHitRate = totalIn > 0 ? Math.round((event.cache_read_tokens / totalIn) * 100) / 100 : 0;
1174
+ }
1175
+ catch (e) {
1176
+ logger.debug(`[MessageProcessor] Stats write failed (non-fatal): ${e}`);
1177
+ }
1178
+ }
1179
+ // 会话累计 + model spec(用于 status.completed 统计细目)
1180
+ let sessionStats;
1181
+ let modelSpec;
1182
+ try {
1183
+ const { openReadonlyDb, getDbPath } = await import('../stats/db.js');
1184
+ const { resolveModelSpec } = await import('../stats/billing.js');
1185
+ const statsModel = streamResult.contextUsage?.model || 'unknown';
1186
+ modelSpec = resolveModelSpec(resolveRoot(), statsModel);
1187
+ const rdb = openReadonlyDb(getDbPath(resolveRoot()));
1188
+ if (rdb) {
1189
+ try {
1190
+ const row = rdb.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS input_tokens, COALESCE(SUM(output_tokens),0) AS output_tokens,
1191
+ COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens, COALESCE(SUM(cache_creation_tokens),0) AS cache_creation_tokens,
1192
+ COUNT(*) AS call_count FROM usage_events WHERE session_id = ?`).get(session.id);
1193
+ if (row) {
1194
+ // 逐行算费用太贵,用近似:最后一轮的 cost 乘以次数不准,所以这里用累加 token 近似
1195
+ sessionStats = {
1196
+ input_tokens: row.input_tokens,
1197
+ output_tokens: row.output_tokens,
1198
+ cache_read_tokens: row.cache_read_tokens,
1199
+ cache_creation_tokens: row.cache_creation_tokens,
1200
+ cost_usd: 0, cost_cny: 0,
1201
+ call_count: row.call_count,
1202
+ };
1203
+ // 快速费用估算:用会话所有行逐行算
1204
+ const rows = rdb.prepare(`SELECT * FROM usage_events WHERE session_id = ?`).all(session.id);
1205
+ const { calcCost: cc } = await import('../stats/billing.js');
1206
+ for (const r of rows) {
1207
+ const c = cc(resolveRoot(), r);
1208
+ sessionStats.cost_usd += c.usd ?? 0;
1209
+ sessionStats.cost_cny += c.cny ?? 0;
1210
+ }
1211
+ }
1212
+ }
1213
+ finally {
1214
+ rdb.close();
1215
+ }
1216
+ }
1217
+ }
1218
+ catch { /* non-fatal */ }
1029
1219
  if (message.source !== 'trigger') {
1030
1220
  if (interruptReason) {
1031
1221
  adapter.send(envelope, { kind: 'status.interrupted', metadata: { reason: interruptReason } }).catch(() => { });
1032
1222
  }
1033
1223
  else {
1034
- adapter.send(envelope, { kind: 'status.completed', metadata: { durationMs, ttftMs: streamResult.ttftMs, numTurns: streamResult.numTurns, tokenUsage: streamResult.tokenUsage, contextUsage: streamResult.contextUsage } }).catch(() => { });
1224
+ adapter.send(envelope, { kind: 'status.completed', metadata: {
1225
+ durationMs,
1226
+ ttftMs: streamResult.ttftMs,
1227
+ numTurns: streamResult.numTurns,
1228
+ tokenUsage: streamResult.tokenUsage,
1229
+ contextUsage: streamResult.contextUsage,
1230
+ lastModelCall: streamResult.lastModelCall,
1231
+ cost_usd: statsCostUsd,
1232
+ cost_cny: statsCostCny,
1233
+ cache_hit_rate: statsCacheHitRate,
1234
+ model_spec: modelSpec,
1235
+ session_total: sessionStats,
1236
+ queue: {
1237
+ pending: this.messageQueue?.getQueueLength(session.id) ?? 0,
1238
+ processing: this.messageQueue?.isProcessing(session.id) ? 1 : 0,
1239
+ },
1240
+ } }).catch(() => { });
1035
1241
  }
1036
1242
  }
1037
1243
  if (message.triggerMeta) {
1038
1244
  if (interruptReason) {
1039
- this.eventBus.publish({ type: 'trigger:skipped', triggerId: message.triggerMeta.triggerId, reason: 'interrupted' });
1245
+ this.eventBus.publish({ type: 'trigger:skipped', triggerId: message.triggerMeta.triggerId, name: message.triggerMeta.triggerName ?? '', reason: 'interrupted', targetChannel: message.channel, targetChannelId: message.channelId });
1040
1246
  }
1041
1247
  else {
1042
- this.eventBus.publish({ type: 'trigger:completed', triggerId: message.triggerMeta.triggerId, messageId: messageId, durationMs });
1248
+ this.eventBus.publish({ type: 'trigger:completed', triggerId: message.triggerMeta.triggerId, name: message.triggerMeta.triggerName ?? '', messageId: messageId, durationMs, targetChannel: message.channel, targetChannelId: message.channelId, fireTime: message.triggerMeta.fireTime ?? 0 });
1043
1249
  }
1044
1250
  }
1045
1251
  await this.sessionManager.recordSuccess(session.id);
@@ -1100,7 +1306,7 @@ export class MessageProcessor {
1100
1306
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
1101
1307
  if (!isUserInterrupt) {
1102
1308
  const statusPayload = procStatus === 'timeout'
1103
- ? { kind: 'status.timeout' }
1309
+ ? { kind: 'status.timeout', metadata: { idleSec: getLastIdleSec?.() || undefined } }
1104
1310
  : procStatus === 'interrupted'
1105
1311
  ? { kind: 'status.interrupted', metadata: { reason: 'stream_error' } }
1106
1312
  : { kind: 'status.error' };
@@ -1133,20 +1339,22 @@ export class MessageProcessor {
1133
1339
  if (error instanceof Error && !isUserInterrupt) {
1134
1340
  logger.error(`[${message.channel}] Error stack:`, error.stack);
1135
1341
  }
1136
- // 发送用户友好的错误消息(SDK_TIMEOUT 已在 kill 级别发过提示,跳过)
1342
+ // 发送用户友好的错误消息
1137
1343
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送错误提示
1138
1344
  // processEventStream 已通过 renderer 发过错误时也跳过
1139
- if (error instanceof Error && error.message === 'SDK_TIMEOUT') {
1140
- logger.info(`[MessageProcessor] SDK_TIMEOUT error, skip sending duplicate message`);
1141
- }
1142
- else if (isUserInterrupt) {
1345
+ const isTimeout = error instanceof Error && error.message === 'SDK_TIMEOUT';
1346
+ if (isUserInterrupt) {
1143
1347
  logger.info(`[MessageProcessor] User interrupt by new_message, skip sending error message`);
1144
1348
  }
1145
1349
  else if (error?._errorAlreadySent) {
1146
1350
  logger.info(`[MessageProcessor] Error already sent via renderer, skip sending duplicate message`);
1147
1351
  }
1148
1352
  else {
1149
- const userMessage = getErrorMessage(error, undefined);
1353
+ // SDK_TIMEOUT:status.timeout 已发结构化状态,此处再补一条用户可见的错误文本(result.error
1354
+ const idleSec = getLastIdleSec?.() || 0;
1355
+ const userMessage = isTimeout
1356
+ ? (idleSec > 0 ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断` : '⚠️ 任务超时,已自动中断')
1357
+ : getErrorMessage(error, undefined);
1150
1358
  // 获取 session 用于话题回复(如果 resolveSession 已执行)
1151
1359
  let sendOpts;
1152
1360
  try {
@@ -1159,7 +1367,10 @@ export class MessageProcessor {
1159
1367
  ...(sendOpts ?? {}),
1160
1368
  metadata: { ...(sendOpts?.metadata ?? {}), taskId, chatmode },
1161
1369
  };
1162
- await adapter.send({ ...envelope, replyContext: sendOpts }, { kind: 'result.text', text: userMessage, isFinal: true });
1370
+ const errorPayload = isTimeout
1371
+ ? { kind: 'result.error', text: userMessage, reason: 'timeout' }
1372
+ : { kind: 'result.text', text: userMessage, isFinal: true };
1373
+ await adapter.send({ ...envelope, replyContext: sendOpts }, errorPayload);
1163
1374
  // Proactive 可观测:catch 块的基础设施错误也透传为 thought,保证按 task_id 聚合完整
1164
1375
  }
1165
1376
  }
@@ -1168,11 +1379,24 @@ export class MessageProcessor {
1168
1379
  * 解析会话和项目路径
1169
1380
  */
1170
1381
  async resolveSession(message) {
1171
- // 话题会话创建时写入 replyContext(threadId 路由);主会话不写(避免群聊覆盖)
1172
- const metadata = (message.threadId && message.replyContext)
1173
- ? { replyContext: message.replyContext }
1382
+ // 话题会话创建时写入创建者和 replyContext(threadId 路由);主会话不写(避免群聊覆盖)
1383
+ const metadata = message.threadId
1384
+ ? {
1385
+ ...(message.replyContext ? { replyContext: message.replyContext } : {}),
1386
+ ...(message.peerId ? { peerId: message.peerId } : {}),
1387
+ ...(message.peerName ? { peerName: message.peerName } : {}),
1388
+ }
1174
1389
  : undefined;
1175
1390
  const projectPath = this.agentRegistry?.resolveByChannel(message.channel)?.projectPath || process.cwd();
1391
+ if (message.chatType === 'group' && message.threadId && message.source !== 'trigger' && message.source !== 'owner-inject') {
1392
+ const existing = await this.sessionManager.getThreadSession(message.channel, message.channelId, message.threadId);
1393
+ if (!existing) {
1394
+ const role = this.sessionManager.resolveIdentity(message.channel, message.peerId).role;
1395
+ if (role !== 'owner' && role !== 'admin') {
1396
+ throw new Error('群聊中无权限创建话题');
1397
+ }
1398
+ }
1399
+ }
1176
1400
  // current strategy: resume bound session, make it active so output is not suppressed
1177
1401
  if (message.triggerMeta?.boundSessionId) {
1178
1402
  const bound = await this.sessionManager.getSessionById(message.triggerMeta.boundSessionId);
@@ -1189,13 +1413,31 @@ export class MessageProcessor {
1189
1413
  logger.warn(`[MessageProcessor] Bound session ${message.triggerMeta.boundSessionId} not found, falling back to latest`);
1190
1414
  }
1191
1415
  }
1192
- 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);
1416
+ const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, message.topicName, message.peerId, message.chatType, undefined, message.selfAID, message.channelType, message.peerType);
1193
1417
  // 兜底纠正1:群聊强制 proactive
1194
1418
  if (message.chatType === 'group' && session.sessionMode !== 'proactive') {
1195
1419
  logger.info(`[MessageProcessor] group proactive upgrade: sessionId=${session.id} ${session.sessionMode} -> proactive`);
1196
1420
  session.sessionMode = 'proactive';
1197
1421
  await this.sessionManager.updateSession(session.id, { sessionMode: 'proactive' });
1198
1422
  }
1423
+ // 群名解析:群会话首次取群显示名(group.get),缓存到 metadata,供信封渲染。
1424
+ // 渠道私有方法 getGroupName 自带进程缓存 + 容错;取不到不阻塞(groupName 保持空,模板回退 groupId)。
1425
+ if (message.chatType === 'group' && session.metadata?.groupId && !session.metadata.groupName) {
1426
+ const adapter = this.resolveChannelInfo(message.channel)?.adapter;
1427
+ const groupName = await adapter?.getGroupName?.(session.metadata.groupId).catch(() => undefined);
1428
+ if (groupName) {
1429
+ session.metadata.groupName = groupName;
1430
+ await this.sessionManager.updateSession(session.id, { metadata: session.metadata });
1431
+ }
1432
+ }
1433
+ // 群聊分发模式同步:aun.ts 从服务器信封解析的 dispatchMode 注入到 message,
1434
+ // 此处写入 session.metadata,确保 ECK 上下文的 venue fragment 正确渲染 dispatch 变量。
1435
+ // 仅当 message.dispatchMode 有值且与 session 记录不一致时更新。
1436
+ if (message.chatType === 'group' && message.dispatchMode && session.metadata?.dispatchMode !== message.dispatchMode) {
1437
+ logger.info(`[MessageProcessor] dispatchMode sync: sessionId=${session.id} ${session.metadata?.dispatchMode ?? 'none'} -> ${message.dispatchMode}`);
1438
+ session.metadata = { ...(session.metadata || {}), dispatchMode: message.dispatchMode };
1439
+ await this.sessionManager.updateSession(session.id, { metadata: session.metadata });
1440
+ }
1199
1441
  // 兜底纠正2:旧 session 创建时没传 peerType(建为 interactive),后续非 human 消息进来时升级为 proactive。
1200
1442
  // 新建场景已由 getOrCreateSession 内部 resolveDefaultSessionMode 处理,这里只兜底历史会话。
1201
1443
  if (message.peerType && message.peerType !== 'human' && message.peerType !== 'unknown' && session.sessionMode !== 'proactive') {
@@ -1216,6 +1458,32 @@ export class MessageProcessor {
1216
1458
  : path.resolve(process.cwd(), session.projectPath);
1217
1459
  return { session, absoluteProjectPath };
1218
1460
  }
1461
+ /**
1462
+ * 群聊响应深度决策。根据 dispatch 模式、消息特征、话题轮次综合判断。
1463
+ * 返回 per-message 的瞬时深度枚举,不持久化到 session.metadata。
1464
+ * 同时更新 session.metadata 中的 topicRounds/lastTopicHash(话题追踪状态)。
1465
+ */
1466
+ async resolveResponseDepth(message, session) {
1467
+ const result = computeResponseDepth({
1468
+ chatType: message.chatType,
1469
+ content: message.content,
1470
+ selfAid: session.selfAID || message.selfAID,
1471
+ mentionAids: message.mentionAids,
1472
+ dispatch: session.metadata?.dispatchMode || message.dispatchMode,
1473
+ topicRounds: session.metadata?.topicRounds ?? 0,
1474
+ lastTopicHash: session.metadata?.lastTopicHash,
1475
+ });
1476
+ // 持久化话题追踪状态(仅群聊时有意义)
1477
+ if (message.chatType === 'group') {
1478
+ session.metadata = {
1479
+ ...(session.metadata || {}),
1480
+ topicRounds: result.topicRounds,
1481
+ lastTopicHash: result.topicHash,
1482
+ };
1483
+ await this.sessionManager.updateSession(session.id, { metadata: session.metadata });
1484
+ }
1485
+ return result.depth;
1486
+ }
1219
1487
  /**
1220
1488
  * 处理标准事件流(AgentEvent)
1221
1489
  *
@@ -1392,7 +1660,7 @@ export class MessageProcessor {
1392
1660
  logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
1393
1661
  }
1394
1662
  // 记录完成状态 + 最后一轮回复文本(后续 complete 覆盖前序)
1395
- completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage };
1663
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage, lastModelCall: event.lastModelCall, modelCalls: event.modelCalls };
1396
1664
  // thought jsonl 写入已下沉到 aun.ts:sendThought 成功后,
1397
1665
  // 由那里按 LLM 输出的每个 text item 单独写一条,此处不再写。
1398
1666
  // 失败且无前置错误输出:显示 errors 摘要
@@ -1448,7 +1716,7 @@ export class MessageProcessor {
1448
1716
  logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
1449
1717
  }
1450
1718
  // 记录完成状态
1451
- completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage };
1719
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage, lastModelCall: event.lastModelCall };
1452
1720
  if (event.subtype === 'success') {
1453
1721
  this.messageCache.addEvent(session.id, {
1454
1722
  type: 'completed',