metame-cli 1.5.2 → 1.5.4

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 (44) hide show
  1. package/README.md +90 -17
  2. package/index.js +76 -25
  3. package/package.json +1 -1
  4. package/scripts/bin/dispatch_to +167 -90
  5. package/scripts/daemon-admin-commands.js +225 -24
  6. package/scripts/daemon-agent-commands.js +263 -8
  7. package/scripts/daemon-bridges.js +395 -6
  8. package/scripts/daemon-claude-engine.js +749 -582
  9. package/scripts/daemon-command-router.js +104 -0
  10. package/scripts/daemon-default.yaml +9 -4
  11. package/scripts/daemon-engine-runtime.js +33 -2
  12. package/scripts/daemon-exec-commands.js +8 -5
  13. package/scripts/daemon-file-browser.js +1 -0
  14. package/scripts/daemon-remote-dispatch.js +82 -0
  15. package/scripts/daemon-runtime-lifecycle.js +87 -0
  16. package/scripts/daemon-session-commands.js +19 -11
  17. package/scripts/daemon-session-store.js +26 -8
  18. package/scripts/daemon-task-scheduler.js +2 -2
  19. package/scripts/daemon.js +363 -8
  20. package/scripts/daemon.yaml +356 -0
  21. package/scripts/distill.js +35 -16
  22. package/scripts/docs/agent-guide.md +36 -3
  23. package/scripts/docs/hook-config.md +131 -0
  24. package/scripts/docs/maintenance-manual.md +214 -3
  25. package/scripts/docs/pointer-map.md +60 -5
  26. package/scripts/feishu-adapter.js +127 -58
  27. package/scripts/hooks/hook-utils.js +61 -0
  28. package/scripts/hooks/intent-agent-manage.js +50 -0
  29. package/scripts/hooks/intent-engine.js +103 -0
  30. package/scripts/hooks/intent-file-transfer.js +51 -0
  31. package/scripts/hooks/intent-hook-config.js +28 -0
  32. package/scripts/hooks/intent-memory-recall.js +35 -0
  33. package/scripts/hooks/intent-ops-assist.js +54 -0
  34. package/scripts/hooks/intent-task-create.js +35 -0
  35. package/scripts/hooks/intent-team-dispatch.js +106 -0
  36. package/scripts/hooks/team-context.js +143 -0
  37. package/scripts/memory-extract.js +1 -1
  38. package/scripts/memory-nightly-reflect.js +109 -43
  39. package/scripts/memory-write.js +21 -4
  40. package/scripts/memory.js +55 -17
  41. package/scripts/publish-public.sh +24 -35
  42. package/scripts/qmd-client.js +1 -1
  43. package/scripts/signal-capture.js +14 -0
  44. package/scripts/team-dispatch.js +176 -0
@@ -2,9 +2,30 @@
2
2
 
3
3
  const { classifyChatUsage } = require('./usage-classifier');
4
4
  const { deriveProjectInfo } = require('./utils');
5
- const { createEngineRuntimeFactory, normalizeEngineName, ENGINE_MODEL_CONFIG } = require('./daemon-engine-runtime');
5
+ const { createEngineRuntimeFactory, normalizeEngineName, resolveEngineModel, ENGINE_MODEL_CONFIG } = require('./daemon-engine-runtime');
6
6
  const { buildAgentContextForEngine, buildMemorySnapshotContent, refreshMemorySnapshot } = require('./agent-layer');
7
7
 
8
+ /**
9
+ * Antigravity Raw Session Logging — Lossless Diary (L0)
10
+ * [PROTECTED] Append every user→AI turn to a daily markdown file.
11
+ * Isolated as a standalone function to prevent accidental deletion during edits.
12
+ */
13
+ function logRawSessionDiary(fs, path, HOME, { chatId, prompt, output, error, projectKey }) {
14
+ try {
15
+ const today = new Date().toISOString().slice(0, 10);
16
+ const ym = today.slice(0, 7); // YYYY-MM
17
+ const sessDir = path.join(HOME, '.metame', 'sessions', ym);
18
+ if (!fs.existsSync(sessDir)) fs.mkdirSync(sessDir, { recursive: true });
19
+
20
+ const diaryPath = path.join(sessDir, `${today}_${chatId}.md`);
21
+ const MAX_OUTPUT_LOG = 8000;
22
+ const outputLog = (output || error || 'No output.').slice(0, MAX_OUTPUT_LOG);
23
+ const outputTruncated = (output || '').length > MAX_OUTPUT_LOG ? '\n\n[truncated]' : '';
24
+ const entry = `\n---\ndate: ${new Date().toISOString()}\nproject: ${projectKey || 'global'}\n---\n\n## 🙋‍♂️ 用户指令\n\`\`\`text\n${prompt}\n\`\`\`\n\n## 🤖 执行实录\n${outputLog}${outputTruncated}\n`;
25
+ fs.appendFileSync(diaryPath, entry, 'utf8');
26
+ } catch (e) { console.warn(`[MetaMe] Raw session logging failed: ${e.message}`); }
27
+ }
28
+
8
29
  function createClaudeEngine(deps) {
9
30
  const {
10
31
  fs,
@@ -457,6 +478,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
457
478
  WebFetch: '🌐',
458
479
  WebSearch: '🔍',
459
480
  Task: '🤖',
481
+ Agent: '🤖',
460
482
  Skill: '🔧',
461
483
  TodoWrite: '📋',
462
484
  NotebookEdit: '📓',
@@ -520,6 +542,17 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
520
542
  let classifiedError = null;
521
543
  let lastStatusTime = 0;
522
544
  const STATUS_THROTTLE = statusThrottleMs;
545
+ // Streaming card: accumulate text and push to card in real-time (throttled)
546
+ let _streamText = '';
547
+ let _lastStreamFlush = 0;
548
+ const STREAM_THROTTLE = 1500; // ms between card edits (safe within Feishu 5 req/s limit)
549
+ function flushStream(force) {
550
+ if (!onStatus || !_streamText.trim()) return;
551
+ const now = Date.now();
552
+ if (!force && now - _lastStreamFlush < STREAM_THROTTLE) return;
553
+ _lastStreamFlush = now;
554
+ onStatus('__STREAM_TEXT__' + _streamText).catch(() => { });
555
+ }
523
556
  const writtenFiles = [];
524
557
  const toolUsageLog = [];
525
558
 
@@ -568,7 +601,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
568
601
  const ctx = recentTool.context || recentTool.skill || '';
569
602
  parts.push(`最近: ${recentTool.tool}${ctx ? ' ' + ctx : ''}`);
570
603
  }
571
- if (onStatus) onStatus(parts.join(' | ')).catch(() => { });
604
+ if (onStatus) {
605
+ const milestoneMsg = parts.join(' | ');
606
+ const msg = _streamText ? `__TOOL_OVERLAY__${_streamText}\n\n> ${milestoneMsg}` : milestoneMsg;
607
+ onStatus(msg).catch(() => { });
608
+ }
572
609
  }
573
610
  }, 30000);
574
611
 
@@ -607,11 +644,13 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
607
644
  continue;
608
645
  }
609
646
  if (event.type === 'text' && event.text) {
610
- finalResult = String(event.text);
647
+ finalResult += (finalResult ? '\n\n' : '') + String(event.text);
648
+ _streamText = finalResult;
611
649
  if (waitingForTool) {
612
650
  waitingForTool = false;
613
651
  resetIdleTimer();
614
652
  }
653
+ flushStream(); // throttled stream to card
615
654
  continue;
616
655
  }
617
656
  if (event.type === 'done') {
@@ -620,6 +659,13 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
620
659
  waitingForTool = false;
621
660
  resetIdleTimer();
622
661
  }
662
+ // Fallback: if no text streamed yet (tool-only response), use result text from done.
663
+ // Do NOT use when finalResult already has content — result duplicates streamed text.
664
+ if (!finalResult && event.result) {
665
+ finalResult = String(event.result);
666
+ _streamText = finalResult;
667
+ }
668
+ flushStream(true); // force final text flush before process ends
623
669
  continue;
624
670
  }
625
671
  if (event.type === 'tool_result') {
@@ -659,8 +705,9 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
659
705
 
660
706
  if (toolName === 'Skill' && toolInput.skill) {
661
707
  context = toolInput.skill;
662
- } else if (toolName === 'Task' && toolInput.description) {
663
- context = String(toolInput.description).slice(0, 30);
708
+ } else if ((toolName === 'Task' || toolName === 'Agent') && toolInput.description) {
709
+ const agentType = toolInput.subagent_type ? `[${toolInput.subagent_type}] ` : '';
710
+ context = (agentType + String(toolInput.description)).slice(0, 40);
664
711
  } else if (toolName.startsWith('mcp__')) {
665
712
  const parts = toolName.split('__');
666
713
  const server = parts[1] || 'unknown';
@@ -692,7 +739,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
692
739
  const status = context
693
740
  ? `${displayEmoji} ${displayName}: 「${context}」`
694
741
  : `${displayEmoji} ${displayName}...`;
695
- if (onStatus) onStatus(status).catch(() => { });
742
+ if (onStatus) {
743
+ // Overlay tool status on top of streamed text (if any); else show plain status
744
+ const msg = _streamText ? `__TOOL_OVERLAY__${_streamText}\n\n> ${status}` : status;
745
+ onStatus(msg).catch(() => { });
746
+ }
696
747
  }
697
748
  }
698
749
  });
@@ -782,11 +833,11 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
782
833
 
783
834
  // Track outbound message_id → session for reply-based session restoration.
784
835
  // Keeps last 200 entries to avoid unbounded growth.
785
- function trackMsgSession(messageId, session) {
836
+ function trackMsgSession(messageId, session, agentKey) {
786
837
  if (!messageId || !session || !session.id) return;
787
838
  const st = loadState();
788
839
  if (!st.msg_sessions) st.msg_sessions = {};
789
- st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd, engine: session.engine || getDefaultEngine() };
840
+ st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd, engine: session.engine || getDefaultEngine(), agentKey: agentKey || null };
790
841
  const keys = Object.keys(st.msg_sessions);
791
842
  if (keys.length > 200) {
792
843
  for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
@@ -831,9 +882,23 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
831
882
  // Send 🤔 ack and start typing — fire-and-forget so we don't block spawn on Telegram RTT.
832
883
  // statusMsgId is resolved via a promise; it will be ready well before the first model output.
833
884
  let statusMsgId = null;
885
+ let _lastStatusCardContent = null; // tracks last clean text written to card (for final-reply dedup)
886
+ // Early detect bound project for branded ack card (team members / dispatch agents)
887
+ const _ackChatIdStr = String(chatId);
888
+ const _ackAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map || {} : {}), ...(config.feishu ? config.feishu.chat_agent_map || {} : {}) };
889
+ const _ackBoundKey = _ackAgentMap[_ackChatIdStr] || projectKeyFromVirtualChatId(_ackChatIdStr);
890
+ const _ackBoundProj = _ackBoundKey && config.projects ? config.projects[_ackBoundKey] : null;
891
+ // _ackCardHeader: non-null for agents with icon/name (team members, dispatch); passed to editMessage to preserve header on streaming edits
892
+ const _ackCardHeader = (_ackBoundProj && _ackBoundProj.icon && _ackBoundProj.name)
893
+ ? { title: `${_ackBoundProj.icon} ${_ackBoundProj.name}`, color: _ackBoundProj.color || 'blue' }
894
+ : null;
834
895
  // Fire-and-forget: don't await Telegram RTT before spawning the engine process.
835
896
  // statusMsgId will be populated well before the first model output (~5s for codex).
836
- (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'))
897
+ // For branded agents: send a card with header so streaming edits preserve the agent identity.
898
+ const _ackFn = (_ackCardHeader && bot.sendCard)
899
+ ? () => bot.sendCard(chatId, { title: _ackCardHeader.title, body: '🤔', color: _ackCardHeader.color })
900
+ : () => (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'));
901
+ _ackFn()
837
902
  .then(msg => { if (msg && msg.message_id) statusMsgId = msg.message_id; })
838
903
  .catch(e => log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`));
839
904
  bot.sendTyping(chatId).catch(() => { });
@@ -846,234 +911,261 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
846
911
  // kill the handler, leaving the typing indicator spinning forever.
847
912
  try { // ── safety-net-start ──
848
913
 
849
- // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
850
- // Strict chats (chat_agent_map bound groups) must NOT switch agents via nickname
851
- const _strictAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
852
- const _isStrictChatSession = !!(_strictAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
853
- const agentMatch = _isStrictChatSession ? null : routeAgent(prompt, config);
854
- if (agentMatch) {
855
- const { key, proj, rest } = agentMatch;
856
- const projCwd = normalizeCwd(proj.cwd);
857
- attachOrCreateSession(chatId, projCwd, proj.name || key, proj.engine ? normalizeEngineName(proj.engine) : getDefaultEngine());
858
- log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
859
- if (!rest) {
860
- // Pure nickname call — confirm switch and stop
861
- clearInterval(typingTimer);
862
- await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
863
- return { ok: true };
914
+ // Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
915
+ // Strict chats (chat_agent_map bound groups) must NOT switch agents via nickname
916
+ const _strictAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
917
+ const _isStrictChatSession = !!(_strictAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
918
+ const agentMatch = _isStrictChatSession ? null : routeAgent(prompt, config);
919
+ if (agentMatch) {
920
+ const { key, proj, rest } = agentMatch;
921
+ const projCwd = normalizeCwd(proj.cwd);
922
+ attachOrCreateSession(chatId, projCwd, proj.name || key, proj.engine ? normalizeEngineName(proj.engine) : getDefaultEngine());
923
+ log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
924
+ if (!rest) {
925
+ // Pure nickname call — confirm switch and stop
926
+ clearInterval(typingTimer);
927
+ await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
928
+ return { ok: true };
929
+ }
930
+ // Nickname + content — strip nickname, continue with rest as prompt
931
+ prompt = rest;
864
932
  }
865
- // Nickname + content — strip nickname, continue with rest as prompt
866
- prompt = rest;
867
- }
868
933
 
869
- // Skill routing: detect skill first, then decide session
870
- // BUT: skip skill routing if agent addressed by nickname OR chat already has an active session
871
- // (active conversation should never be hijacked by keyword-based skill matching)
872
- const sessionRaw = getSession(chatId);
873
- const chatIdStr = String(chatId);
874
- const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
875
- const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
876
- const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
877
- const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
878
- const boundEngineName = (boundProject && boundProject.engine) ? normalizeEngineName(boundProject.engine) : getDefaultEngine();
879
-
880
- // Engine is determined from config only bound agent config wins, then global default.
881
- const engineName = normalizeEngineName(
882
- (boundProject && boundProject.engine) || getDefaultEngine()
883
- );
884
- const runtime = getEngineRuntime(engineName);
885
-
886
- // hasActiveSession: does the current engine have an ongoing conversation?
887
- const hasActiveSession = sessionRaw && (
888
- sessionRaw.engines ? !!(sessionRaw.engines[engineName]?.started) : !!sessionRaw.started
889
- );
890
- const skill = (agentMatch || hasActiveSession) ? null : routeSkill(prompt);
891
-
892
- if (!sessionRaw) {
893
- // No saved state for this chatId: start a fresh session.
894
- // Note: daemon_state.json persists across restarts, so this only happens on truly first use
895
- // or after an explicit /new command.
896
- createSession(chatId, boundCwd || undefined, boundProject && boundProject.name ? boundProject.name : '', boundEngineName);
897
- }
934
+ // Skill routing: detect skill first, then decide session
935
+ // BUT: skip skill routing if agent addressed by nickname OR chat already has an active session
936
+ // (active conversation should never be hijacked by keyword-based skill matching)
937
+ const chatIdStr = String(chatId);
938
+ const chatAgentMap = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
939
+ const boundProjectKey = chatAgentMap[chatIdStr] || projectKeyFromVirtualChatId(chatIdStr);
940
+ const boundProject = boundProjectKey && config.projects ? config.projects[boundProjectKey] : null;
941
+ // Each virtual chatId (including clones) keeps its own isolated session.
942
+ // Parallel tasks must not share JSONL files concurrent writes cause corruption.
943
+ const sessionChatId = boundProjectKey ? `_agent_${boundProjectKey}` : chatId;
944
+ const sessionRaw = getSession(sessionChatId);
945
+ const boundCwd = (boundProject && boundProject.cwd) ? normalizeCwd(boundProject.cwd) : null;
946
+ const boundEngineName = (boundProject && boundProject.engine) ? normalizeEngineName(boundProject.engine) : getDefaultEngine();
947
+
948
+ // Engine is determined from config only — bound agent config wins, then global default.
949
+ const engineName = normalizeEngineName(
950
+ (boundProject && boundProject.engine) || getDefaultEngine()
951
+ );
952
+ const runtime = getEngineRuntime(engineName);
953
+
954
+ // hasActiveSession: does the current engine have an ongoing conversation?
955
+ const hasActiveSession = sessionRaw && (
956
+ sessionRaw.engines ? !!(sessionRaw.engines[engineName]?.started) : !!sessionRaw.started
957
+ );
958
+ const skill = (agentMatch || hasActiveSession) ? null : routeSkill(prompt);
898
959
 
899
- // Resolve flat view for current engine (id + started are engine-specific; cwd is shared)
900
- let session = getSessionForEngine(chatId, engineName) || { cwd: boundCwd || HOME, engine: engineName, id: null, started: false };
901
- session.engine = engineName; // keep local copy for Codex resume detection below
902
-
903
- // Pre-spawn session validation: unified for all engines.
904
- // Claude checks JSONL file existence; Codex checks SQLite. Same interface, different backend.
905
- if (session.started && session.id && session.id !== '__continue__' && session.cwd) {
906
- const valid = isEngineSessionValid(engineName, session.id, session.cwd);
907
- if (!valid) {
908
- log('WARN', `${engineName} session ${session.id.slice(0, 8)} invalid for ${chatId}; starting fresh ${engineName} session`);
909
- await bot.sendMessage(chatId, '⚠️ 上次 session 已失效,已自动开启新 session。').catch(() => {});
910
- session = createSession(chatId, session.cwd, boundProject && boundProject.name ? boundProject.name : '', engineName);
960
+ if (!sessionRaw) {
961
+ // No saved state for this chatId: start a fresh session.
962
+ // Note: daemon_state.json persists across restarts, so this only happens on truly first use
963
+ // or after an explicit /new command.
964
+ createSession(sessionChatId, boundCwd || undefined, boundProject && boundProject.name ? boundProject.name : '', boundEngineName);
911
965
  }
912
- }
913
966
 
914
- const daemonCfg = (config && config.daemon) || {};
915
- const mentorCfg = (daemonCfg.mentor && typeof daemonCfg.mentor === 'object') ? daemonCfg.mentor : {};
916
- const mentorEnabled = !!(mentorEngine && mentorCfg.enabled);
917
- const excludeAgents = new Set(
918
- (Array.isArray(mentorCfg.exclude_agents) ? mentorCfg.exclude_agents : [])
919
- .map(x => String(x || '').trim())
920
- .filter(Boolean)
921
- );
922
- const chatAgentKey = boundProjectKey || 'personal';
923
- const mentorExcluded = excludeAgents.has(chatAgentKey);
924
- let mentorSuppressed = false;
925
-
926
- // Mentor pre-flight breaker: first hit sends a short reassurance; cooldown does not block normal answers.
927
- if (mentorEnabled && !mentorExcluded) {
928
- try {
929
- const breaker = mentorEngine.checkEmotionBreaker(prompt, mentorCfg);
930
- if (breaker && breaker.tripped) {
931
- mentorSuppressed = true;
932
- if (breaker.reason !== 'cooldown_active' && breaker.response) {
933
- await bot.sendMessage(chatId, breaker.response).catch(() => { });
967
+ // Resolve flat view for current engine (id + started are engine-specific; cwd is shared)
968
+ let session = getSessionForEngine(sessionChatId, engineName) || { cwd: boundCwd || HOME, engine: engineName, id: null, started: false };
969
+ session.engine = engineName; // keep local copy for Codex resume detection below
970
+
971
+ // Pre-spawn session validation: unified for all engines.
972
+ // Claude checks JSONL file existence; Codex checks SQLite. Same interface, different backend.
973
+ // Skip warning for virtual agents (team members) - they may use worktrees with fresh sessions
974
+ const isVirtualAgent = String(sessionChatId).startsWith('_agent_');
975
+ if (session.started && session.id && session.id !== '__continue__' && session.cwd) {
976
+ const valid = isEngineSessionValid(engineName, session.id, session.cwd);
977
+ if (!valid) {
978
+ log('WARN', `${engineName} session ${session.id.slice(0, 8)} invalid for ${sessionChatId}; starting fresh ${engineName} session`);
979
+ if (!isVirtualAgent) {
980
+ await bot.sendMessage(chatId, '⚠️ 上次 session 已失效,已自动开启新 session。').catch(() => { });
934
981
  }
982
+ session = createSession(sessionChatId, session.cwd, boundProject && boundProject.name ? boundProject.name : '', engineName);
935
983
  }
936
- } catch (e) {
937
- log('WARN', `Mentor breaker failed: ${e.message}`);
938
984
  }
939
- }
940
985
 
941
- // Build engine command prefer per-engine model, fall back to legacy daemon.model
942
- const engineModels = daemonCfg.models || {};
943
- const engineModel = engineModels[runtime.name] || daemonCfg.model || runtime.defaultModel;
944
- const model = (boundProject && boundProject.model) || engineModel;
945
- const args = runtime.buildArgs({
946
- model,
947
- readOnly,
948
- daemonCfg,
949
- session,
950
- cwd: session.cwd,
951
- });
986
+ const daemonCfg = (config && config.daemon) || {};
987
+ const mentorCfg = (daemonCfg.mentor && typeof daemonCfg.mentor === 'object') ? daemonCfg.mentor : {};
988
+ const mentorEnabled = !!(mentorEngine && mentorCfg.enabled);
989
+ const excludeAgents = new Set(
990
+ (Array.isArray(mentorCfg.exclude_agents) ? mentorCfg.exclude_agents : [])
991
+ .map(x => String(x || '').trim())
992
+ .filter(Boolean)
993
+ );
994
+ const chatAgentKey = boundProjectKey || 'personal';
995
+ const mentorExcluded = excludeAgents.has(chatAgentKey);
996
+ let mentorSuppressed = false;
952
997
 
953
- // Codex: write/refresh AGENTS.md = CLAUDE.md + SOUL.md on every new session.
954
- // Written as a real file (not a symlink) for Windows compatibility.
955
- // Refreshed each session so edits to CLAUDE.md or SOUL.md are always picked up.
956
- // Codex auto-loads AGENTS.md from cwd and all parent dirs up to ~.
957
- if (engineName === 'codex' && session.cwd && !session.started) {
958
- try {
959
- const parts = [];
960
- const claudeMd = path.join(session.cwd, 'CLAUDE.md');
961
- const soulMd = path.join(session.cwd, 'SOUL.md');
962
- if (fs.existsSync(claudeMd)) parts.push(fs.readFileSync(claudeMd, 'utf8').trim());
963
- if (fs.existsSync(soulMd)) {
964
- const soulContent = fs.readFileSync(soulMd, 'utf8').trim();
965
- if (soulContent) parts.push(soulContent);
966
- }
967
- if (parts.length > 0) {
968
- fs.writeFileSync(path.join(session.cwd, 'AGENTS.md'), parts.join('\n\n'), 'utf8');
969
- log('INFO', `Refreshed AGENTS.md (${parts.length} section(s)) in ${session.cwd}`);
998
+ // Mentor pre-flight breaker: first hit sends a short reassurance; cooldown does not block normal answers.
999
+ if (mentorEnabled && !mentorExcluded) {
1000
+ try {
1001
+ const breaker = mentorEngine.checkEmotionBreaker(prompt, mentorCfg);
1002
+ if (breaker && breaker.tripped) {
1003
+ mentorSuppressed = true;
1004
+ if (breaker.reason !== 'cooldown_active' && breaker.response) {
1005
+ await bot.sendMessage(chatId, breaker.response).catch(() => { });
1006
+ }
1007
+ }
1008
+ } catch (e) {
1009
+ log('WARN', `Mentor breaker failed: ${e.message}`);
970
1010
  }
971
- } catch (e) {
972
- log('WARN', `AGENTS.md refresh failed: ${e.message}`);
973
1011
  }
974
- }
975
1012
 
976
- let agentHint = '';
977
- if (!session.started && (boundProject || (session && session.cwd))) {
978
- try {
979
- // Engine-aware: Codex gets memory only (soul is already in AGENTS.md);
980
- // Claude gets soul + memory (SOUL.md is not auto-loaded by Claude).
981
- agentHint = buildAgentContextForEngine(
982
- boundProject || { cwd: session.cwd },
983
- engineName,
984
- HOME,
985
- ).hint || '';
986
- } catch (e) {
987
- log('WARN', `Agent context injection failed: ${e.message}`);
988
- }
989
- }
990
-
991
- // Memory & Knowledge Injection (RAG)
992
- let memoryHint = '';
993
- // projectKey must be declared outside the try block so the daemonHint template below can reference it.
994
- const _cid0 = String(chatId);
995
- const _agentMap0 = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
996
- const projectKey = _agentMap0[_cid0] || projectKeyFromVirtualChatId(_cid0);
997
- try {
998
- const memory = require('./memory');
1013
+ // Build engine command — prefer per-engine model, fall back to legacy daemon.model
1014
+ const model = resolveEngineModel(runtime.name, daemonCfg, boundProject && boundProject.model);
1015
+ const args = runtime.buildArgs({
1016
+ model,
1017
+ readOnly,
1018
+ daemonCfg,
1019
+ session,
1020
+ cwd: session.cwd,
1021
+ });
999
1022
 
1000
- // L1: NOW.md per-agent whiteboard injection(按 projectKey 隔离,防并发冲突)
1001
- if (!session.started) {
1023
+ // Codex: write/refresh AGENTS.md = CLAUDE.md + SOUL.md on every new session.
1024
+ // Written as a real file (not a symlink) for Windows compatibility.
1025
+ // Refreshed each session so edits to CLAUDE.md or SOUL.md are always picked up.
1026
+ // Codex auto-loads AGENTS.md from cwd and all parent dirs up to ~.
1027
+ if (engineName === 'codex' && session.cwd && !session.started) {
1002
1028
  try {
1003
- const nowDir = path.join(HOME, '.metame', 'memory', 'now');
1004
- const nowKey = projectKey || 'default';
1005
- const nowPath = path.join(nowDir, `${nowKey}.md`);
1006
- if (fs.existsSync(nowPath)) {
1007
- const nowContent = fs.readFileSync(nowPath, 'utf8').trim();
1008
- if (nowContent) {
1009
- memoryHint += `\n\n[Current task context:\n${nowContent}]`;
1010
- }
1029
+ const parts = [];
1030
+ const claudeMd = path.join(session.cwd, 'CLAUDE.md');
1031
+ const soulMd = path.join(session.cwd, 'SOUL.md');
1032
+ if (fs.existsSync(claudeMd)) parts.push(fs.readFileSync(claudeMd, 'utf8').trim());
1033
+ if (fs.existsSync(soulMd)) {
1034
+ const soulContent = fs.readFileSync(soulMd, 'utf8').trim();
1035
+ if (soulContent) parts.push(soulContent);
1011
1036
  }
1012
- } catch { /* non-critical */ }
1037
+ if (parts.length > 0) {
1038
+ fs.writeFileSync(path.join(session.cwd, 'AGENTS.md'), parts.join('\n\n'), 'utf8');
1039
+ log('INFO', `Refreshed AGENTS.md (${parts.length} section(s)) in ${session.cwd}`);
1040
+ }
1041
+ } catch (e) {
1042
+ log('WARN', `AGENTS.md refresh failed: ${e.message}`);
1043
+ }
1013
1044
  }
1014
1045
 
1015
- // 1. Inject recent session memories ONLY on first message of a session
1016
- if (!session.started) {
1017
- const recent = memory.recentSessions({ limit: 1, project: projectKey || undefined });
1018
- if (recent.length > 0) {
1019
- const items = recent.map(r => `- [${r.created_at}] ${r.summary}${r.keywords ? ' (keywords: ' + r.keywords + ')' : ''}`).join('\n');
1020
- memoryHint += `\n\n[Past session memory:\n${items}]`;
1046
+ let agentHint = '';
1047
+ if (!session.started && (boundProject || (session && session.cwd))) {
1048
+ try {
1049
+ // Engine-aware: Codex gets memory only (soul is already in AGENTS.md);
1050
+ // Claude gets soul + memory (SOUL.md is not auto-loaded by Claude).
1051
+ agentHint = buildAgentContextForEngine(
1052
+ boundProject || { cwd: session.cwd },
1053
+ engineName,
1054
+ HOME,
1055
+ ).hint || '';
1056
+ } catch (e) {
1057
+ log('WARN', `Agent context injection failed: ${e.message}`);
1021
1058
  }
1022
1059
  }
1023
1060
 
1024
- // 2. Dynamic Fact Injection (RAG) — first message only
1025
- // Facts stay in Claude's context for the rest of the session; no need to repeat.
1026
- // Uses QMD hybrid search if available, falls back to FTS5.
1027
- if (!session.started) {
1028
- const searchFn = memory.searchFactsAsync || memory.searchFacts;
1029
- const factQuery = buildFactSearchQuery(prompt, projectKey);
1030
- const facts = await Promise.resolve(searchFn(factQuery, { limit: 3, project: projectKey || undefined }));
1031
- if (facts.length > 0) {
1032
- const factItems = facts.map(f => `- [${f.relation}] ${f.value}`).join('\n');
1033
- memoryHint += `\n\n[Relevant facts:\n${factItems}]`;
1034
- log('INFO', `[MEMORY] Injected ${facts.length} facts (query_len=${factQuery.length})`);
1061
+ // Memory & Knowledge Injection (RAG)
1062
+ let memoryHint = '';
1063
+ // projectKey must be declared outside the try block so the daemonHint template below can reference it.
1064
+ const _cid0 = String(chatId);
1065
+ const _agentMap0 = { ...(config.telegram ? config.telegram.chat_agent_map : {}), ...(config.feishu ? config.feishu.chat_agent_map : {}) };
1066
+ const projectKey = _agentMap0[_cid0] || projectKeyFromVirtualChatId(_cid0);
1067
+ try {
1068
+ const memory = require('./memory');
1069
+
1070
+ // L1: NOW.md per-agent whiteboard injection(按 projectKey 隔离,防并发冲突)
1071
+ if (!session.started) {
1072
+ try {
1073
+ const nowDir = path.join(HOME, '.metame', 'memory', 'now');
1074
+ const nowKey = projectKey || 'default';
1075
+ const nowPath = path.join(nowDir, `${nowKey}.md`);
1076
+ if (fs.existsSync(nowPath)) {
1077
+ const nowContent = fs.readFileSync(nowPath, 'utf8').trim();
1078
+ if (nowContent) {
1079
+ memoryHint += `\n\n[Current task context:\n${nowContent}]`;
1080
+ }
1081
+ }
1082
+ } catch { /* non-critical */ }
1035
1083
  }
1036
- }
1037
1084
 
1038
- memory.close();
1039
- } catch (e) {
1040
- if (e.code !== 'MODULE_NOT_FOUND') log('WARN', `Memory injection failed: ${e.message}`);
1041
- }
1085
+ // 1. Inject recent session memories ONLY on first message of a session
1086
+ if (!session.started) {
1087
+ const recent = memory.recentSessions({ limit: 1, project: projectKey || undefined });
1088
+ if (recent.length > 0) {
1089
+ const items = recent.map(r => `- [${r.created_at}] ${r.summary}${r.keywords ? ' (keywords: ' + r.keywords + ')' : ''}`).join('\n');
1090
+ memoryHint += `\n\n[Past session memory:\n${items}]`;
1091
+ }
1092
+ }
1042
1093
 
1043
- // ZPD: build competence hint from brain profile
1044
- let zdpHint = '';
1045
- let brainDoc = null;
1046
- if (!session.started) {
1047
- try {
1048
- const brainPath = path.join(HOME, '.claude_profile.yaml');
1049
- if (fs.existsSync(brainPath)) {
1050
- const brain = yaml.load(fs.readFileSync(brainPath, 'utf8'));
1051
- brainDoc = brain;
1052
- const cmap = brain && brain.user_competence_map;
1053
- if (cmap && typeof cmap === 'object' && Object.keys(cmap).length > 0) {
1054
- const lines = Object.entries(cmap)
1055
- .map(([domain, level]) => ` ${domain}: ${level}`)
1056
- .join('\n');
1057
- zdpHint = `\n- User competence map (adjust explanation depth accordingly):\n${lines}\n Rule: expert→skip basics; intermediate→brief rationale; beginner→one-line analogy.`;
1094
+ // 2. Dynamic Fact Injection (RAG) first message only
1095
+ // Facts stay in Claude's context for the rest of the session; no need to repeat.
1096
+ // Uses QMD hybrid search if available, falls back to FTS5.
1097
+ if (!session.started) {
1098
+ const searchFn = memory.searchFactsAsync || memory.searchFacts;
1099
+ const factQuery = buildFactSearchQuery(prompt, projectKey);
1100
+ const facts = await Promise.resolve(searchFn(factQuery, { limit: 3, project: projectKey || undefined }));
1101
+ if (facts.length > 0) {
1102
+ // Separate capsule facts from regular facts
1103
+ const capsuleFacts = facts.filter(f => f.relation === 'knowledge_capsule');
1104
+ const regularFacts = facts.filter(f => f.relation !== 'knowledge_capsule');
1105
+
1106
+ // Inject regular facts as before
1107
+ if (regularFacts.length > 0) {
1108
+ const factItems = regularFacts.map(f => `- [${f.relation}] ${f.value}`).join('\n');
1109
+ memoryHint += `\n\n[Relevant facts:\n${factItems}]`;
1110
+ }
1111
+
1112
+ // Capsule facts: derive file path from entity and inject as direct "must read" hint
1113
+ // Entity pattern: capsule.metame_daemon_dispatch → capsules/metame-daemon-dispatch-playbook.md
1114
+ if (capsuleFacts.length > 0) {
1115
+ const capsulePaths = capsuleFacts.map(f => {
1116
+ const slug = f.entity.replace(/^capsule\./, '').replace(/_/g, '-');
1117
+ return path.join(HOME, '.metame', 'memory', 'capsules', `${slug}-playbook.md`);
1118
+ }).filter(p => fs.existsSync(p));
1119
+ if (capsulePaths.length > 0) {
1120
+ // Inject file paths only (no shell commands) — works cross-platform and with all engines.
1121
+ // Claude Code reads via Read tool; Codex/Gemini parse the path directly.
1122
+ memoryHint += `\n\n[Relevant playbook detected — read before answering:\n${capsulePaths.map(p => ` ${p}`).join('\n')}]`;
1123
+ }
1124
+ }
1125
+
1126
+ log('INFO', `[MEMORY] Injected ${regularFacts.length} facts, ${capsuleFacts.length} capsule(s) (query_len=${factQuery.length})`);
1058
1127
  }
1059
1128
  }
1060
- } catch { /* non-critical */ }
1061
- }
1062
- if (!brainDoc) {
1063
- try {
1064
- const brainPath = path.join(HOME, '.claude_profile.yaml');
1065
- if (fs.existsSync(brainPath)) brainDoc = yaml.load(fs.readFileSync(brainPath, 'utf8')) || {};
1066
- } catch { /* ignore */ }
1067
- }
1068
1129
 
1069
- // Inject daemon hints only on first message of a session
1070
- // Task-specific rules (3-5) are injected only when isTaskIntent() returns true (~250 token saving for casual chat)
1071
- let daemonHint = '';
1072
- if (!session.started) {
1073
- const taskRules = isTaskIntent(prompt) ? `
1130
+ memory.close();
1131
+ } catch (e) {
1132
+ if (e.code !== 'MODULE_NOT_FOUND') log('WARN', `Memory injection failed: ${e.message}`);
1133
+ }
1134
+
1135
+ // ZPD: build competence hint from brain profile
1136
+ let zdpHint = '';
1137
+ let brainDoc = null;
1138
+ if (!session.started) {
1139
+ try {
1140
+ const brainPath = path.join(HOME, '.claude_profile.yaml');
1141
+ if (fs.existsSync(brainPath)) {
1142
+ const brain = yaml.load(fs.readFileSync(brainPath, 'utf8'));
1143
+ brainDoc = brain;
1144
+ const cmap = brain && brain.user_competence_map;
1145
+ if (cmap && typeof cmap === 'object' && Object.keys(cmap).length > 0) {
1146
+ const lines = Object.entries(cmap)
1147
+ .map(([domain, level]) => ` ${domain}: ${level}`)
1148
+ .join('\n');
1149
+ zdpHint = `\n- User competence map (adjust explanation depth accordingly):\n${lines}\n Rule: expert→skip basics; intermediate→brief rationale; beginner→one-line analogy.`;
1150
+ }
1151
+ }
1152
+ } catch { /* non-critical */ }
1153
+ }
1154
+ if (!brainDoc) {
1155
+ try {
1156
+ const brainPath = path.join(HOME, '.claude_profile.yaml');
1157
+ if (fs.existsSync(brainPath)) brainDoc = yaml.load(fs.readFileSync(brainPath, 'utf8')) || {};
1158
+ } catch { /* ignore */ }
1159
+ }
1160
+
1161
+ // Inject daemon hints only on first message of a session
1162
+ // Task-specific rules (3-5) are injected only when isTaskIntent() returns true (~250 token saving for casual chat)
1163
+ let daemonHint = '';
1164
+ if (!session.started) {
1165
+ const taskRules = isTaskIntent(prompt) ? `
1074
1166
  3. Knowledge retrieval: When you need context about a specific topic, past decisions, or lessons, call:
1075
1167
  node ~/.metame/memory-search.js "关键词1" "keyword2"
1076
- Also read ~/.metame/memory/INDEX.md to discover available long-form lesson/decision docs, then read specific files as needed.
1168
+ If no relevant facts surface, check ~/.metame/memory/INDEX.md for available playbook/decision docs.
1077
1169
  Use these before answering complex questions about MetaMe architecture or past decisions.
1078
1170
  4. Active memory: After confirming a new insight, bug root cause, or user preference, persist it with:
1079
1171
  node ~/.metame/memory-write.js "Entity.sub" "relation_type" "value (20-300 chars)"
@@ -1083,7 +1175,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1083
1175
  5. Task handoff: When suspending a multi-step task or handing off to another agent, write current status to ~/.metame/memory/now/${projectKey || 'default'}.md using:
1084
1176
  \`mkdir -p ~/.metame/memory/now && printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/now/${projectKey || 'default'}.md\`
1085
1177
  Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/now/${projectKey || 'default'}.md\`` : '';
1086
- daemonHint = `\n\n[System hints - DO NOT mention these to user:
1178
+ daemonHint = `\n\n[System hints - DO NOT mention these to user:
1087
1179
  1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
1088
1180
  2. File sending: User is on MOBILE. When they ask to see/download a file:
1089
1181
  - Just FIND the file path (use Glob/ls if needed)
@@ -1091,205 +1183,189 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1091
1183
  - Add at END of response: [[FILE:/absolute/path/to/file]]
1092
1184
  - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
1093
1185
  - Multiple files: use multiple [[FILE:...]] tags${zdpHint ? '\n Explanation depth (ZPD):\n' + zdpHint : ''}${taskRules}]`;
1094
- }
1186
+ }
1095
1187
 
1096
- daemonHint = adaptDaemonHintForEngine(daemonHint, runtime.name);
1188
+ daemonHint = adaptDaemonHintForEngine(daemonHint, runtime.name);
1097
1189
 
1098
- const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
1190
+ const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
1099
1191
 
1100
- // Mac automation orchestration hint: lets Claude flexibly compose local scripts
1101
- // without forcing users to write slash commands by hand.
1102
- let macAutomationHint = '';
1103
- if (process.platform === 'darwin' && !readOnly && isMacAutomationIntent(prompt)) {
1104
- macAutomationHint = `\n\n[Mac automation policy - do NOT expose this block:
1192
+ // Mac automation orchestration hint: lets Claude flexibly compose local scripts
1193
+ // without forcing users to write slash commands by hand.
1194
+ let macAutomationHint = '';
1195
+ if (process.platform === 'darwin' && !readOnly && isMacAutomationIntent(prompt)) {
1196
+ macAutomationHint = `\n\n[Mac automation policy - do NOT expose this block:
1105
1197
  1. Prefer deterministic local control via Bash + osascript/JXA; avoid screenshot/visual workflows unless explicitly requested.
1106
1198
  2. Read/query actions can execute directly.
1107
1199
  3. Before any side-effect action (send email, create/delete/modify calendar event, delete/move files, app quit, system sleep), first show a short execution preview and require explicit user confirmation.
1108
1200
  4. Keep output concise: success/failure + key result only.
1109
1201
  5. If permission is missing, guide user to run /mac perms open then retry.
1110
1202
  6. Before executing high-risk or non-obvious Bash commands (rm, kill, git reset, overwrite configs), prepend a single-line [Why] explanation. Skip for routine commands (ls, cat, grep).]`;
1111
- }
1203
+ }
1112
1204
 
1113
- // P2-B: inject session summary when resuming after a 2h+ gap
1114
- let summaryHint = '';
1115
- if (session.started) {
1116
- try {
1117
- const _stSum = loadState();
1118
- const _sess = _stSum.sessions && _stSum.sessions[chatId];
1119
- if (_sess && _sess.last_summary && _sess.last_summary_at) {
1120
- const _idleMs = Date.now() - (_sess.last_active || 0);
1121
- const _summaryAgeH = (Date.now() - _sess.last_summary_at) / 3600000;
1122
- if (_idleMs > 2 * 60 * 60 * 1000 && _summaryAgeH < 168) {
1123
- summaryHint = `
1205
+ // P2-B: inject session summary when resuming after a 2h+ gap
1206
+ let summaryHint = '';
1207
+ if (session.started) {
1208
+ try {
1209
+ const _stSum = loadState();
1210
+ const _sess = _stSum.sessions && _stSum.sessions[chatId];
1211
+ if (_sess && _sess.last_summary && _sess.last_summary_at) {
1212
+ const _idleMs = Date.now() - (_sess.last_active || 0);
1213
+ const _summaryAgeH = (Date.now() - _sess.last_summary_at) / 3600000;
1214
+ if (_idleMs > 2 * 60 * 60 * 1000 && _summaryAgeH < 168) {
1215
+ summaryHint = `
1124
1216
 
1125
1217
  [上次对话摘要,供参考]: ${_sess.last_summary}`;
1126
- log('INFO', `[DAEMON] Injected session summary for ${chatId} (idle ${Math.round(_idleMs / 3600000)}h)`);
1218
+ log('INFO', `[DAEMON] Injected session summary for ${chatId} (idle ${Math.round(_idleMs / 3600000)}h)`);
1219
+ }
1127
1220
  }
1128
- }
1129
- } catch { /* non-critical */ }
1130
- }
1221
+ } catch { /* non-critical */ }
1222
+ }
1131
1223
 
1132
- // Mentor context hook: inject after memoryHint, before langGuard.
1133
- let mentorHint = '';
1134
- if (mentorEnabled && !mentorExcluded && !mentorSuppressed) {
1135
- try {
1136
- const signals = collectRecentSessionSignals(session.id, 6);
1137
- let skeleton = null;
1138
- if (sessionAnalytics && typeof sessionAnalytics.extractSkeleton === 'function') {
1139
- const file = findSessionFile(session.id);
1140
- if (file && fs.existsSync(file)) {
1141
- const st = fs.statSync(file);
1142
- if (st.size <= 2 * 1024 * 1024) {
1143
- skeleton = sessionAnalytics.extractSkeleton(file);
1224
+ // Mentor context hook: inject after memoryHint, before langGuard.
1225
+ let mentorHint = '';
1226
+ if (mentorEnabled && !mentorExcluded && !mentorSuppressed) {
1227
+ try {
1228
+ const signals = collectRecentSessionSignals(session.id, 6);
1229
+ let skeleton = null;
1230
+ if (sessionAnalytics && typeof sessionAnalytics.extractSkeleton === 'function') {
1231
+ const file = findSessionFile(session.id);
1232
+ if (file && fs.existsSync(file)) {
1233
+ const st = fs.statSync(file);
1234
+ if (st.size <= 2 * 1024 * 1024) {
1235
+ skeleton = sessionAnalytics.extractSkeleton(file);
1236
+ }
1144
1237
  }
1145
1238
  }
1146
- }
1147
- const zone = skeleton && mentorEngine.computeZone
1148
- ? mentorEngine.computeZone(skeleton).zone
1149
- : 'stretch';
1150
- const sessionState = {
1151
- zone,
1152
- recentMessages: signals.recentMessages,
1153
- cwd: session.cwd,
1154
- skeleton,
1155
- sessionStartTime: signals.sessionStartTime || new Date().toISOString(),
1156
- topic: String(prompt || '').slice(0, 120),
1157
- currentTopic: String(prompt || '').slice(0, 120),
1158
- lastUserMessage: String(prompt || '').slice(0, 200),
1159
- };
1160
- const built = mentorEngine.buildMentorPrompt(sessionState, brainDoc || {}, mentorCfg);
1161
- if (built && String(built).trim()) mentorHint = `\n\n${String(built).trim()}`;
1162
-
1163
- // Collect reflection debt: if user returns to same project+topic, inject recall prompt.
1164
- // Suppressed by quiet_until (user explicitly asked for silence), but NOT by expert skip
1165
- // (even experts may not have reviewed AI-generated code).
1166
- const quietUntil = brainDoc && brainDoc.growth ? brainDoc.growth.quiet_until : null;
1167
- const quietMs = quietUntil ? new Date(quietUntil).getTime() : 0;
1168
- const isQuiet = quietMs && quietMs > Date.now();
1169
- if (!isQuiet && mentorEngine.collectDebt) {
1170
- const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1171
- const projectId = info && info.project_id ? info.project_id : '';
1172
- if (projectId) {
1173
- const debt = mentorEngine.collectDebt(projectId, String(prompt || '').slice(0, 120));
1174
- if (debt && debt.prompt) {
1175
- mentorHint += `\n\n[Reflection debt] ${debt.prompt}`;
1239
+ const zone = skeleton && mentorEngine.computeZone
1240
+ ? mentorEngine.computeZone(skeleton).zone
1241
+ : 'stretch';
1242
+ const sessionState = {
1243
+ zone,
1244
+ recentMessages: signals.recentMessages,
1245
+ cwd: session.cwd,
1246
+ skeleton,
1247
+ sessionStartTime: signals.sessionStartTime || new Date().toISOString(),
1248
+ topic: String(prompt || '').slice(0, 120),
1249
+ currentTopic: String(prompt || '').slice(0, 120),
1250
+ lastUserMessage: String(prompt || '').slice(0, 200),
1251
+ };
1252
+ const built = mentorEngine.buildMentorPrompt(sessionState, brainDoc || {}, mentorCfg);
1253
+ if (built && String(built).trim()) mentorHint = `\n\n${String(built).trim()}`;
1254
+
1255
+ // Collect reflection debt: if user returns to same project+topic, inject recall prompt.
1256
+ // Suppressed by quiet_until (user explicitly asked for silence), but NOT by expert skip
1257
+ // (even experts may not have reviewed AI-generated code).
1258
+ const quietUntil = brainDoc && brainDoc.growth ? brainDoc.growth.quiet_until : null;
1259
+ const quietMs = quietUntil ? new Date(quietUntil).getTime() : 0;
1260
+ const isQuiet = quietMs && quietMs > Date.now();
1261
+ if (!isQuiet && mentorEngine.collectDebt) {
1262
+ const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1263
+ const projectId = info && info.project_id ? info.project_id : '';
1264
+ if (projectId) {
1265
+ const debt = mentorEngine.collectDebt(projectId, String(prompt || '').slice(0, 120));
1266
+ if (debt && debt.prompt) {
1267
+ mentorHint += `\n\n[Reflection debt] ${debt.prompt}`;
1268
+ }
1176
1269
  }
1177
1270
  }
1271
+ } catch (e) {
1272
+ log('WARN', `Mentor prompt build failed: ${e.message}`);
1178
1273
  }
1179
- } catch (e) {
1180
- log('WARN', `Mentor prompt build failed: ${e.message}`);
1181
1274
  }
1182
- }
1183
1275
 
1184
- // Language guard: only inject on first message of a new session to avoid
1185
- // linearly growing token cost on every turn in long conversations.
1186
- // Claude Code preserves session context, so the guard persists after initial injection.
1187
- const langGuard = session.started
1188
- ? ''
1189
- : '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
1190
- const fullPrompt = routedPrompt + daemonHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
1191
-
1192
- // Git checkpoint before Claude modifies files (for /undo).
1193
- // Run async (fire-and-forget) to avoid blocking Claude spawn by ~600ms.
1194
- // Completes well before Claude's first file write (~2s after spawn).
1195
- (gitCheckpointAsync || gitCheckpoint)(session.cwd, prompt).catch?.(() => {});
1196
- log('INFO', `[TIMING:${chatId}] pre-spawn +${Date.now() - _t0}ms (engine:${runtime.name} started:${session.started})`);
1197
-
1198
- // Use streaming mode to show progress
1199
- // Telegram: edit status msg in-place; Feishu: edit or fallback to new messages
1200
- let editFailed = false;
1201
- let lastFallbackStatus = 0;
1202
- const FALLBACK_THROTTLE = fallbackThrottleMs;
1203
- const onStatus = async (status) => {
1204
- try {
1205
- if (statusMsgId && bot.editMessage && !editFailed) {
1206
- const ok = await bot.editMessage(chatId, statusMsgId, status);
1207
- if (ok !== false) return; // edit succeeded (true or undefined for Telegram)
1208
- editFailed = true; // edit failed, switch to fallback permanently
1209
- }
1210
- // Fallback: send as new message with extra throttle to avoid spam
1211
- const now = Date.now();
1212
- if (now - lastFallbackStatus < FALLBACK_THROTTLE) return;
1213
- lastFallbackStatus = now;
1214
- await bot.sendMessage(chatId, status);
1215
- } catch { /* ignore status update failures */ }
1216
- };
1276
+ // Language guard: only inject on first message of a new session to avoid
1277
+ // linearly growing token cost on every turn in long conversations.
1278
+ // Claude Code preserves session context, so the guard persists after initial injection.
1279
+ const langGuard = session.started
1280
+ ? ''
1281
+ : '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
1282
+ const fullPrompt = routedPrompt + daemonHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
1283
+
1284
+ // Git checkpoint before Claude modifies files (for /undo).
1285
+ // Skip for virtual agents (team clones like _agent_yi) each has its own worktree,
1286
+ // but checkpoint uses `git add -A` which could interfere with parallel work.
1287
+ const _isVirtualAgent = String(chatId).startsWith('_agent_') || String(chatId).startsWith('_scope_');
1288
+ if (!_isVirtualAgent) {
1289
+ (gitCheckpointAsync || gitCheckpoint)(session.cwd, prompt).catch?.(() => { });
1290
+ }
1291
+ log('INFO', `[TIMING:${chatId}] pre-spawn +${Date.now() - _t0}ms (engine:${runtime.name} started:${session.started})`);
1292
+
1293
+ // Use streaming mode to show progress
1294
+ // Telegram: edit status msg in-place; Feishu: edit or fallback to new messages
1295
+ let editFailed = false;
1296
+ let lastFallbackStatus = 0;
1297
+ const FALLBACK_THROTTLE = fallbackThrottleMs;
1298
+ const onStatus = async (status) => {
1299
+ try {
1300
+ if (typeof status !== 'string') return;
1301
+
1302
+ // __STREAM_TEXT__: streamed model text edit card and track for final dedup
1303
+ if (status.startsWith('__STREAM_TEXT__')) {
1304
+ const content = status.slice('__STREAM_TEXT__'.length);
1305
+ // Set synchronously BEFORE await — this is the critical race fix.
1306
+ // flushStream(true) is called from the 'done' event (before process close),
1307
+ // so by setting here synchronously, _lastStatusCardContent is guaranteed to be
1308
+ // set before the child 'close' event fires and finalize() resolves.
1309
+ _lastStatusCardContent = content;
1310
+ if (statusMsgId && bot.editMessage && !editFailed) {
1311
+ const ok = await bot.editMessage(chatId, statusMsgId, content, _ackCardHeader);
1312
+ if (ok === false) editFailed = true;
1313
+ }
1314
+ return; // skip fallback — final reply logic will use existing card
1315
+ }
1316
+
1317
+ // __TOOL_OVERLAY__: text + tool status line — edit card but don't update _lastStatusCardContent
1318
+ if (status.startsWith('__TOOL_OVERLAY__')) {
1319
+ const content = status.slice('__TOOL_OVERLAY__'.length);
1320
+ if (statusMsgId && bot.editMessage && !editFailed) {
1321
+ await bot.editMessage(chatId, statusMsgId, content, _ackCardHeader);
1322
+ // intentionally NOT updating _lastStatusCardContent — overlay is transient
1323
+ }
1324
+ return;
1325
+ }
1217
1326
 
1218
- const wasCodexResumeAttempt = runtime.name === 'codex'
1219
- && !!(session && session.started && session.id && session.id !== '__continue__');
1220
- const onSession = async (nextSessionId) => {
1221
- const safeNextId = String(nextSessionId || '').trim();
1222
- if (!safeNextId) return;
1223
- const prevSessionId = session && session.id ? String(session.id) : '';
1224
- const wasStarted = !!(session && session.started);
1225
- session = {
1226
- ...session,
1227
- id: safeNextId,
1228
- engine: runtime.name,
1229
- started: true,
1327
+ // Plain status (tool names before any text, milestone timers, etc.)
1328
+ if (statusMsgId && bot.editMessage && !editFailed) {
1329
+ const ok = await bot.editMessage(chatId, statusMsgId, status, _ackCardHeader);
1330
+ if (ok !== false) {
1331
+ _lastStatusCardContent = status;
1332
+ return;
1333
+ }
1334
+ editFailed = true;
1335
+ }
1336
+ // Fallback: send as new message with throttle to avoid spam
1337
+ const now = Date.now();
1338
+ if (now - lastFallbackStatus < FALLBACK_THROTTLE) return;
1339
+ lastFallbackStatus = now;
1340
+ await bot.sendMessage(chatId, status);
1341
+ } catch { /* ignore status update failures */ }
1230
1342
  };
1231
- await patchSessionSerialized(chatId, (cur) => {
1232
- const engines = { ...(cur.engines || {}) };
1233
- engines[runtime.name] = { ...(engines[runtime.name] || {}), id: safeNextId, started: true };
1234
- return { ...cur, cwd: session.cwd || cur.cwd || HOME, engines };
1235
- });
1236
- if (runtime.name === 'codex' && wasStarted && prevSessionId && prevSessionId !== safeNextId && prevSessionId !== '__continue__') {
1237
- log('WARN', `Codex thread migrated for ${chatId}: ${prevSessionId.slice(0, 8)} -> ${safeNextId.slice(0, 8)}`);
1238
- }
1239
- };
1240
1343
 
1241
- let output, error, errorCode, files, toolUsageLog, timedOut, usage, sessionId;
1242
- try {
1243
- ({
1244
- output,
1245
- error,
1246
- errorCode,
1247
- timedOut,
1248
- files,
1249
- toolUsageLog,
1250
- usage,
1251
- sessionId,
1252
- } = await spawnClaudeStreaming(
1253
- args,
1254
- fullPrompt,
1255
- session.cwd,
1256
- onStatus,
1257
- 600000,
1258
- chatId,
1259
- boundProjectKey || '',
1260
- runtime,
1261
- onSession,
1262
- ));
1263
-
1264
- if (sessionId) await onSession(sessionId);
1265
-
1266
- if (shouldRetryCodexResumeFallback({
1267
- runtimeName: runtime.name,
1268
- wasResumeAttempt: wasCodexResumeAttempt,
1269
- output,
1270
- error,
1271
- errorCode,
1272
- canRetry: canRetryCodexResume(chatId),
1273
- })) {
1274
- markCodexResumeRetried(chatId);
1275
- log('WARN', `Codex resume failed for ${chatId}, retrying once with fresh exec: ${String(error).slice(0, 120)}`);
1276
- // Notify user explicitly — silent context loss is worse than a visible warning.
1277
- await bot.sendMessage(chatId, '⚠️ Codex session 已过期,上下文丢失。正在以全新 session 重试,请在回复后补充必要背景。').catch(() => {});
1278
- session = createSession(
1279
- chatId,
1280
- session.cwd,
1281
- boundProject && boundProject.name ? boundProject.name : '',
1282
- 'codex'
1283
- );
1284
- const retryArgs = runtime.buildArgs({
1285
- model,
1286
- readOnly,
1287
- daemonCfg,
1288
- session,
1289
- cwd: session.cwd,
1344
+ const wasCodexResumeAttempt = runtime.name === 'codex'
1345
+ && !!(session && session.started && session.id && session.id !== '__continue__');
1346
+ const onSession = async (nextSessionId) => {
1347
+ const safeNextId = String(nextSessionId || '').trim();
1348
+ if (!safeNextId) return;
1349
+ const prevSessionId = session && session.id ? String(session.id) : '';
1350
+ const wasStarted = !!(session && session.started);
1351
+ session = {
1352
+ ...session,
1353
+ id: safeNextId,
1354
+ engine: runtime.name,
1355
+ started: true,
1356
+ };
1357
+ await patchSessionSerialized(sessionChatId, (cur) => {
1358
+ const engines = { ...(cur.engines || {}) };
1359
+ engines[runtime.name] = { ...(engines[runtime.name] || {}), id: safeNextId, started: true };
1360
+ return { ...cur, cwd: session.cwd || cur.cwd || HOME, engines };
1290
1361
  });
1291
- // Prepend a context-loss marker so Codex knows this is a fresh session mid-conversation.
1292
- const retryPrompt = `[Note: previous Codex session expired and could not be resumed. Treating this as a new session. User message follows:]\n\n${fullPrompt}`;
1362
+ if (runtime.name === 'codex' && wasStarted && prevSessionId && prevSessionId !== safeNextId && prevSessionId !== '__continue__') {
1363
+ log('WARN', `Codex thread migrated for ${chatId}: ${prevSessionId.slice(0, 8)} -> ${safeNextId.slice(0, 8)}`);
1364
+ }
1365
+ };
1366
+
1367
+ let output, error, errorCode, files, toolUsageLog, timedOut, usage, sessionId;
1368
+ try {
1293
1369
  ({
1294
1370
  output,
1295
1371
  error,
@@ -1300,8 +1376,8 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1300
1376
  usage,
1301
1377
  sessionId,
1302
1378
  } = await spawnClaudeStreaming(
1303
- retryArgs,
1304
- retryPrompt,
1379
+ args,
1380
+ fullPrompt,
1305
1381
  session.cwd,
1306
1382
  onStatus,
1307
1383
  600000,
@@ -1310,240 +1386,331 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
1310
1386
  runtime,
1311
1387
  onSession,
1312
1388
  ));
1389
+
1313
1390
  if (sessionId) await onSession(sessionId);
1391
+
1392
+ if (shouldRetryCodexResumeFallback({
1393
+ runtimeName: runtime.name,
1394
+ wasResumeAttempt: wasCodexResumeAttempt,
1395
+ output,
1396
+ error,
1397
+ errorCode,
1398
+ canRetry: canRetryCodexResume(chatId),
1399
+ })) {
1400
+ markCodexResumeRetried(chatId);
1401
+ log('WARN', `Codex resume failed for ${chatId}, retrying once with fresh exec: ${String(error).slice(0, 120)}`);
1402
+ // Notify user explicitly — silent context loss is worse than a visible warning.
1403
+ await bot.sendMessage(chatId, '⚠️ Codex session 已过期,上下文丢失。正在以全新 session 重试,请在回复后补充必要背景。').catch(() => { });
1404
+ session = createSession(
1405
+ sessionChatId,
1406
+ session.cwd,
1407
+ boundProject && boundProject.name ? boundProject.name : '',
1408
+ 'codex'
1409
+ );
1410
+ const retryArgs = runtime.buildArgs({
1411
+ model,
1412
+ readOnly,
1413
+ daemonCfg,
1414
+ session,
1415
+ cwd: session.cwd,
1416
+ });
1417
+ // Prepend a context-loss marker so Codex knows this is a fresh session mid-conversation.
1418
+ const retryPrompt = `[Note: previous Codex session expired and could not be resumed. Treating this as a new session. User message follows:]\n\n${fullPrompt}`;
1419
+ ({
1420
+ output,
1421
+ error,
1422
+ errorCode,
1423
+ timedOut,
1424
+ files,
1425
+ toolUsageLog,
1426
+ usage,
1427
+ sessionId,
1428
+ } = await spawnClaudeStreaming(
1429
+ retryArgs,
1430
+ retryPrompt,
1431
+ session.cwd,
1432
+ onStatus,
1433
+ 600000,
1434
+ chatId,
1435
+ boundProjectKey || '',
1436
+ runtime,
1437
+ onSession,
1438
+ ));
1439
+ if (sessionId) await onSession(sessionId);
1440
+ }
1441
+ } catch (spawnErr) {
1442
+ clearInterval(typingTimer);
1443
+ if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => { });
1444
+ log('ERROR', `spawnClaudeStreaming crashed for ${chatId}: ${spawnErr.message}`);
1445
+ await bot.sendMessage(chatId, `❌ 内部错误: ${spawnErr.message}`).catch(() => { });
1446
+ return { ok: false, error: spawnErr.message };
1314
1447
  }
1315
- } catch (spawnErr) {
1316
1448
  clearInterval(typingTimer);
1317
- if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => { });
1318
- log('ERROR', `spawnClaudeStreaming crashed for ${chatId}: ${spawnErr.message}`);
1319
- await bot.sendMessage(chatId, `❌ 内部错误: ${spawnErr.message}`).catch(() => { });
1320
- return { ok: false, error: spawnErr.message };
1321
- }
1322
- clearInterval(typingTimer);
1323
1449
 
1324
- // Skill evolution: capture signal + hot path heuristic check
1325
- if (skillEvolution) {
1326
- try {
1327
- const signal = skillEvolution.extractSkillSignal(fullPrompt, output, error, files, session.cwd, toolUsageLog);
1328
- if (signal) {
1329
- skillEvolution.appendSkillSignal(signal);
1330
- skillEvolution.checkHotEvolution(signal);
1331
- }
1332
- } catch (e) { log('WARN', `Skill evolution signal capture failed: ${e.message}`); }
1333
- }
1450
+ // [PROTECTED] L0 lossless diary see logRawSessionDiary() at file top
1451
+ logRawSessionDiary(fs, path, HOME, { chatId, prompt, output, error, projectKey: boundProjectKey });
1334
1452
 
1335
- // Clean up status message
1336
- if (statusMsgId && bot.deleteMessage) {
1337
- bot.deleteMessage(chatId, statusMsgId).catch(() => { });
1338
- }
1453
+ // Skill evolution: capture signal + hot path heuristic check
1454
+ if (skillEvolution) {
1455
+ try {
1456
+ const signal = skillEvolution.extractSkillSignal(fullPrompt, output, error, files, session.cwd, toolUsageLog);
1457
+ if (signal) {
1458
+ skillEvolution.appendSkillSignal(signal);
1459
+ skillEvolution.checkHotEvolution(signal);
1460
+ }
1461
+ } catch (e) { log('WARN', `Skill evolution signal capture failed: ${e.message}`); }
1462
+ }
1339
1463
 
1340
- // Mentor post-flight debt registration (intense mode only).
1341
- if (mentorEnabled && !mentorExcluded && !mentorSuppressed && mentorEngine && typeof mentorEngine.registerDebt === 'function' && output) {
1342
- try {
1343
- const mode = resolveMentorMode(mentorCfg);
1344
- if (mode === 'intense') {
1345
- const codeLines = countCodeLines(output);
1346
- if (codeLines > 30) {
1347
- const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1348
- const projectId = info && info.project_id ? info.project_id : 'proj_default';
1349
- mentorEngine.registerDebt(projectId, String(prompt || '').slice(0, 120), codeLines);
1350
- log('INFO', `[MENTOR] Registered reflection debt (${projectId}, lines=${codeLines})`);
1464
+ // statusMsgId is always available for final reply handling (edit or delete).
1465
+ const _statusMsgIdForReply = statusMsgId || null;
1466
+
1467
+ // Mentor post-flight debt registration (intense mode only).
1468
+ if (mentorEnabled && !mentorExcluded && !mentorSuppressed && mentorEngine && typeof mentorEngine.registerDebt === 'function' && output) {
1469
+ try {
1470
+ const mode = resolveMentorMode(mentorCfg);
1471
+ if (mode === 'intense') {
1472
+ const codeLines = countCodeLines(output);
1473
+ if (codeLines > 30) {
1474
+ const info = deriveProjectInfo(session && session.cwd ? session.cwd : '');
1475
+ const projectId = info && info.project_id ? info.project_id : 'proj_default';
1476
+ mentorEngine.registerDebt(projectId, String(prompt || '').slice(0, 120), codeLines);
1477
+ log('INFO', `[MENTOR] Registered reflection debt (${projectId}, lines=${codeLines})`);
1478
+ }
1351
1479
  }
1480
+ } catch (e) {
1481
+ log('WARN', `Mentor post-flight failed: ${e.message}`);
1352
1482
  }
1353
- } catch (e) {
1354
- log('WARN', `Mentor post-flight failed: ${e.message}`);
1355
1483
  }
1356
- }
1357
1484
 
1358
- // When Claude completes with no text output (pure tool work), send a done notice
1359
- if (output === '' && !error) {
1360
- // Special case: if dispatch_to was called, send a "forwarded" confirmation
1361
- const dispatchedTargets = (toolUsageLog || [])
1362
- .filter(t => t.tool === 'Bash' && typeof t.context === 'string' && t.context.includes('dispatch_to'))
1363
- .map(t => { const m = t.context.match(/dispatch_to\s+(\S+)/); return m ? m[1] : null; })
1364
- .filter(Boolean);
1365
- if (dispatchedTargets.length > 0) {
1366
- const allProjects = (config && config.projects) || {};
1367
- const names = dispatchedTargets.map(k => (allProjects[k] && allProjects[k].name) || k).join('、');
1368
- const doneMsg = await bot.sendMessage(chatId, `✉️ 已转达给 ${names},处理中…`);
1369
- if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
1485
+ // When Claude completes with no text output (pure tool work), send a done notice
1486
+ if (output === '' && !error) {
1487
+ // Special case: if dispatch_to was called, send a "forwarded" confirmation
1488
+ const dispatchedTargets = (toolUsageLog || [])
1489
+ .filter(t => t.tool === 'Bash' && typeof t.context === 'string' && t.context.includes('dispatch_to'))
1490
+ .map(t => { const m = t.context.match(/dispatch_to\s+(\S+)/); return m ? m[1] : null; })
1491
+ .filter(Boolean);
1492
+ if (dispatchedTargets.length > 0) {
1493
+ const allProjects = (config && config.projects) || {};
1494
+ const names = dispatchedTargets.map(k => (allProjects[k] && allProjects[k].name) || k).join('、');
1495
+ const doneMsg = await bot.sendMessage(chatId, `✉️ 已转达给 ${names},处理中…`);
1496
+ if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session, String(chatId).startsWith('_agent_') ? String(chatId).slice(7) : null);
1497
+ const wasNew = !session.started;
1498
+ if (wasNew) markSessionStarted(sessionChatId, engineName);
1499
+ return { ok: true };
1500
+ }
1501
+ const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
1502
+ const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
1503
+ if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session, String(chatId).startsWith('_agent_') ? String(chatId).slice(7) : null);
1370
1504
  const wasNew = !session.started;
1371
- if (wasNew) markSessionStarted(chatId, engineName);
1505
+ if (wasNew) markSessionStarted(sessionChatId, engineName);
1372
1506
  return { ok: true };
1373
1507
  }
1374
- const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
1375
- const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
1376
- if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
1377
- const wasNew = !session.started;
1378
- if (wasNew) markSessionStarted(chatId, engineName);
1379
- return { ok: true };
1380
- }
1381
1508
 
1382
- if (output) {
1383
- if (runtime.name === 'codex') _codexResumeRetryTs.delete(String(chatId));
1384
- // Detect provider/model errors disguised as output (e.g., "model not found", API errors)
1385
- if (runtime.name === 'claude') {
1386
- const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
1387
- const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
1388
- const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
1389
- if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
1390
- try {
1391
- config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
1392
- await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
1393
- } catch (fbErr) {
1394
- log('ERROR', `Fallback failed: ${fbErr.message}`);
1395
- await bot.sendMarkdown(chatId, output);
1509
+ if (output) {
1510
+ if (runtime.name === 'codex') _codexResumeRetryTs.delete(String(chatId));
1511
+ // Detect provider/model errors disguised as output (e.g., "model not found", API errors)
1512
+ if (runtime.name === 'claude') {
1513
+ const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
1514
+ const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
1515
+ const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
1516
+ if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
1517
+ try {
1518
+ config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
1519
+ await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
1520
+ } catch (fbErr) {
1521
+ log('ERROR', `Fallback failed: ${fbErr.message}`);
1522
+ await bot.sendMarkdown(chatId, output);
1523
+ }
1524
+ return { ok: false, error: output };
1396
1525
  }
1397
- return { ok: false, error: output };
1398
1526
  }
1399
- }
1400
-
1401
- // Mark session as started after first successful call
1402
- const wasNew = !session.started;
1403
- if (wasNew) markSessionStarted(chatId, engineName);
1404
1527
 
1405
- const estimated = Math.ceil((prompt.length + output.length) / 4);
1406
- const chatCategory = classifyChatUsage(chatId, {
1407
- projectKey: boundProjectKey || '',
1408
- cwd: session && session.cwd,
1409
- homeDir: HOME,
1410
- });
1411
- recordTokens(loadState(), estimated, { category: chatCategory });
1528
+ // Mark session as started after first successful call
1529
+ const wasNew = !session.started;
1530
+ if (wasNew) markSessionStarted(sessionChatId, engineName);
1412
1531
 
1413
- // Parse [[FILE:...]] markers from output (Claude's explicit file sends)
1414
- let { markedFiles, cleanOutput } = parseFileMarkers(output);
1532
+ const estimated = Math.ceil((prompt.length + output.length) / 4);
1533
+ const chatCategory = classifyChatUsage(chatId, {
1534
+ projectKey: boundProjectKey || '',
1535
+ cwd: session && session.cwd,
1536
+ homeDir: HOME,
1537
+ });
1538
+ recordTokens(loadState(), estimated, { category: chatCategory });
1415
1539
 
1416
- // Timeout with partial results: prepend warning
1417
- if (timedOut) {
1418
- cleanOutput = `⚠️ **任务超时,以下是已完成的部分结果:**\n\n${cleanOutput}`;
1419
- }
1540
+ // Parse [[FILE:...]] markers from output (Claude's explicit file sends)
1541
+ let { markedFiles, cleanOutput } = parseFileMarkers(output);
1420
1542
 
1421
- // Match current session to a project for colored card display
1422
- let activeProject = null;
1423
- if (session && session.cwd && config && config.projects) {
1424
- const sessionCwd = path.resolve(normalizeCwd(session.cwd));
1425
- for (const [, proj] of Object.entries(config.projects)) {
1426
- if (!proj.cwd) continue;
1427
- const projCwd = path.resolve(normalizeCwd(proj.cwd));
1428
- if (sessionCwd === projCwd) { activeProject = proj; break; }
1543
+ // Timeout with partial results: prepend warning
1544
+ if (timedOut) {
1545
+ cleanOutput = `⚠️ **任务超时,以下是已完成的部分结果:**\n\n${cleanOutput}`;
1429
1546
  }
1430
- }
1431
1547
 
1432
- let replyMsg;
1433
- try {
1434
- if (activeProject && bot.sendCard) {
1435
- replyMsg = await bot.sendCard(chatId, {
1436
- title: `${activeProject.icon || '🤖'} ${activeProject.name || ''}`,
1437
- body: cleanOutput,
1438
- color: activeProject.color || 'blue',
1439
- });
1440
- } else {
1441
- replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
1442
- }
1443
- } catch (sendErr) {
1444
- log('WARN', `sendCard/sendMarkdown failed (${sendErr.message}), falling back to sendMessage`);
1445
- try { replyMsg = await bot.sendMessage(chatId, cleanOutput); } catch (e2) {
1446
- log('ERROR', `sendMessage fallback also failed: ${e2.message}`);
1548
+ // Match current session to a project for colored card display.
1549
+ // Prefer the bound project (known by virtual chatId or chat_agent_map) — avoids ambiguity
1550
+ // when multiple projects share the same cwd (e.g. team members with parent project cwd).
1551
+ let activeProject = boundProject || null;
1552
+ if (!activeProject && session && session.cwd && config && config.projects) {
1553
+ const sessionCwd = path.resolve(normalizeCwd(session.cwd));
1554
+ for (const [, proj] of Object.entries(config.projects)) {
1555
+ if (!proj.cwd) continue;
1556
+ const projCwd = path.resolve(normalizeCwd(proj.cwd));
1557
+ if (sessionCwd === projCwd) { activeProject = proj; break; }
1558
+ }
1447
1559
  }
1448
- }
1449
- if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session);
1450
1560
 
1451
- await sendFileButtons(bot, chatId, mergeFileCollections(markedFiles, files));
1561
+ let replyMsg;
1562
+ try {
1563
+ log('DEBUG', `[REPLY:${chatId}] statusMsgId=${statusMsgId} editFailed=${editFailed} activeProject=${activeProject && activeProject.name} lastCard=${_lastStatusCardContent ? _lastStatusCardContent.slice(0, 40) : 'null'}`);
1564
+
1565
+ // Strategy: always try to update the status card first (avoids sending a new card
1566
+ // while the old 🤔 card lingers, which would produce two messages).
1567
+ // If edit fails: try to delete the status card (awaited, not fire-and-forget).
1568
+ // If delete also fails: fall through to sending a new card.
1569
+ if (_statusMsgIdForReply && bot.editMessage) {
1570
+ // Skip redundant edit: streaming already wrote the final content to the card.
1571
+ // _lastStatusCardContent tracks the last __STREAM_TEXT__ write, so if it matches
1572
+ // cleanOutput the card is already showing the right content — no update needed.
1573
+ if (_lastStatusCardContent !== null && _lastStatusCardContent === cleanOutput) {
1574
+ log('DEBUG', `[REPLY:${chatId}] skipping editMessage — card already shows final content`);
1575
+ replyMsg = { message_id: _statusMsgIdForReply };
1576
+ } else {
1577
+ const editOk = await bot.editMessage(chatId, _statusMsgIdForReply, cleanOutput, _ackCardHeader);
1578
+ log('DEBUG', `[REPLY:${chatId}] editMessage result=${editOk}`);
1579
+ if (editOk !== false) {
1580
+ replyMsg = { message_id: _statusMsgIdForReply };
1581
+ } else if (bot.deleteMessage) {
1582
+ const deleted = await bot.deleteMessage(chatId, _statusMsgIdForReply).then(() => true).catch(() => false);
1583
+ log('DEBUG', `[REPLY:${chatId}] deleteMessage result=${deleted}`);
1584
+ if (!deleted) {
1585
+ // Both edit and delete failed — try one more edit attempt to avoid leaving 🤔
1586
+ log('WARN', `[REPLY:${chatId}] deleteMessage failed — status card may linger alongside new reply`);
1587
+ }
1588
+ }
1589
+ }
1590
+ } else if (_statusMsgIdForReply && bot.deleteMessage) {
1591
+ // No editMessage — delete the status card
1592
+ await bot.deleteMessage(chatId, _statusMsgIdForReply).catch(() => { });
1593
+ }
1452
1594
 
1453
- // Timeout: also send the reason after the partial result
1454
- if (timedOut && error) {
1455
- try { await bot.sendMessage(chatId, error); } catch { /* */ }
1456
- }
1595
+ if (!replyMsg) {
1596
+ if (activeProject && bot.sendCard) {
1597
+ log('DEBUG', `[REPLY:${chatId}] sending sendCard`);
1598
+ replyMsg = await bot.sendCard(chatId, {
1599
+ title: `${activeProject.icon || '🤖'} ${activeProject.name || ''}`,
1600
+ body: cleanOutput,
1601
+ color: activeProject.color || 'blue',
1602
+ });
1603
+ log('DEBUG', `[REPLY:${chatId}] sendCard done msgId=${replyMsg && replyMsg.message_id}`);
1604
+ } else {
1605
+ log('DEBUG', `[REPLY:${chatId}] sending sendMarkdown`);
1606
+ replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
1607
+ log('DEBUG', `[REPLY:${chatId}] sendMarkdown done msgId=${replyMsg && replyMsg.message_id}`);
1608
+ }
1609
+ }
1610
+ } catch (sendErr) {
1611
+ log('WARN', `sendCard/sendMarkdown failed (${sendErr.message}), falling back to sendMessage`);
1612
+ try { replyMsg = await bot.sendMessage(chatId, cleanOutput); } catch (e2) {
1613
+ log('ERROR', `sendMessage fallback also failed: ${e2.message}`);
1614
+ }
1615
+ }
1616
+ if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session, String(chatId).startsWith('_agent_') ? String(chatId).slice(7) : null);
1457
1617
 
1458
- // Auto-name: if this was the first message and session has no name, generate one
1459
- if (runtime.name === 'claude' && wasNew && !getSessionName(session.id)) {
1460
- autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
1461
- }
1618
+ await sendFileButtons(bot, chatId, mergeFileCollections(markedFiles, files));
1462
1619
 
1463
- // Auto-refresh memory-snapshot.md for this agent on first session message (fire-and-forget)
1464
- if (wasNew && boundProject && boundProject.agent_id) {
1465
- setImmediate(async () => {
1466
- try {
1467
- const memory = require('./memory');
1468
- const pKey = boundProjectKey || '';
1469
- const sessions = memory.recentSessions({ limit: 5, project: pKey });
1470
- const factsRaw = memory.searchFacts('', { limit: 10, project: pKey });
1471
- const facts = Array.isArray(factsRaw) ? factsRaw : [];
1472
- memory.close();
1473
- const snapshotContent = buildMemorySnapshotContent(sessions, facts);
1474
- const agentId = boundProject.agent_id;
1475
- if (refreshMemorySnapshot(agentId, snapshotContent, HOME)) {
1476
- log('DEBUG', `[AGENT] Memory snapshot refreshed for ${agentId}`);
1477
- }
1478
- } catch { /* non-critical — memory module may not be available */ }
1479
- });
1480
- }
1481
- return { ok: !timedOut };
1482
- } else {
1483
- const errMsg = error || 'Unknown error';
1484
- const userErrMsg = (errorCode === 'AUTH_REQUIRED' || errorCode === 'RATE_LIMIT')
1485
- ? errMsg
1486
- : `Error: ${errMsg.slice(0, 200)}`;
1487
- log('ERROR', `ask${runtime.name === 'codex' ? 'Codex' : 'Claude'} failed for ${chatId}: ${errMsg.slice(0, 300)} (${errorCode || 'NO_CODE'})`);
1488
-
1489
- // If session not found (expired/deleted), create new and retry once (Claude path)
1490
- if (runtime.name === 'claude' && (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use'))) {
1491
- log('WARN', `Session ${session.id} unusable (${errMsg.includes('already in use') ? 'locked' : 'not found'}), creating new`);
1492
- session = createSession(chatId, session.cwd, '', runtime.name);
1493
-
1494
- const retryArgs = runtime.buildArgs({
1495
- model,
1496
- readOnly,
1497
- daemonCfg,
1498
- session,
1499
- cwd: session.cwd,
1500
- });
1620
+ // Timeout: also send the reason after the partial result
1621
+ if (timedOut && error) {
1622
+ try { await bot.sendMessage(chatId, error); } catch { /* */ }
1623
+ }
1501
1624
 
1502
- const retry = await spawnClaudeStreaming(
1503
- retryArgs,
1504
- fullPrompt,
1505
- session.cwd,
1506
- onStatus,
1507
- 600000,
1508
- chatId,
1509
- boundProjectKey || '',
1510
- runtime,
1511
- onSession,
1512
- );
1513
- if (retry.sessionId) await onSession(retry.sessionId);
1514
- if (retry.output) {
1515
- markSessionStarted(chatId, runtime.name);
1516
- const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
1517
- await bot.sendMarkdown(chatId, retryClean);
1518
- await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
1519
- return { ok: true };
1520
- } else {
1521
- log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
1522
- try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1523
- return { ok: false, error: retry.error || errMsg };
1625
+ // Auto-name: if this was the first message and session has no name, generate one
1626
+ if (runtime.name === 'claude' && wasNew && !getSessionName(session.id)) {
1627
+ autoNameSession(chatId, session.id, prompt, session.cwd).catch(() => { });
1524
1628
  }
1525
- } else {
1526
- // Auto-fallback: if custom provider/model fails, revert to anthropic + opus (Claude path only)
1527
- if (runtime.name === 'claude') {
1528
- const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
1529
- const builtinModels = ENGINE_MODEL_CONFIG.claude.options;
1530
- if (activeProv !== 'anthropic' || !builtinModels.includes(model)) {
1629
+
1630
+ // Auto-refresh memory-snapshot.md for this agent on first session message (fire-and-forget)
1631
+ if (wasNew && boundProject && boundProject.agent_id) {
1632
+ setImmediate(async () => {
1531
1633
  try {
1532
- config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
1533
- await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
1534
- } catch (fallbackErr) {
1535
- log('ERROR', `Fallback failed: ${fallbackErr.message}`);
1634
+ const memory = require('./memory');
1635
+ const pKey = boundProjectKey || '';
1636
+ const sessions = memory.recentSessions({ limit: 5, project: pKey });
1637
+ const factsRaw = memory.searchFacts('', { limit: 10, project: pKey });
1638
+ const facts = Array.isArray(factsRaw) ? factsRaw : [];
1639
+ memory.close();
1640
+ const snapshotContent = buildMemorySnapshotContent(sessions, facts);
1641
+ const agentId = boundProject.agent_id;
1642
+ if (refreshMemorySnapshot(agentId, snapshotContent, HOME)) {
1643
+ log('DEBUG', `[AGENT] Memory snapshot refreshed for ${agentId}`);
1644
+ }
1645
+ } catch { /* non-critical — memory module may not be available */ }
1646
+ });
1647
+ }
1648
+ return { ok: !timedOut };
1649
+ } else {
1650
+ const errMsg = error || 'Unknown error';
1651
+ const userErrMsg = (errorCode === 'AUTH_REQUIRED' || errorCode === 'RATE_LIMIT')
1652
+ ? errMsg
1653
+ : `Error: ${errMsg.slice(0, 200)}`;
1654
+ log('ERROR', `ask${runtime.name === 'codex' ? 'Codex' : 'Claude'} failed for ${chatId}: ${errMsg.slice(0, 300)} (${errorCode || 'NO_CODE'})`);
1655
+
1656
+ // If session not found (expired/deleted), create new and retry once (Claude path)
1657
+ if (runtime.name === 'claude' && (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use'))) {
1658
+ log('WARN', `Session ${session.id} unusable (${errMsg.includes('already in use') ? 'locked' : 'not found'}), creating new`);
1659
+ session = createSession(sessionChatId, session.cwd, '', runtime.name);
1660
+
1661
+ const retryArgs = runtime.buildArgs({
1662
+ model,
1663
+ readOnly,
1664
+ daemonCfg,
1665
+ session,
1666
+ cwd: session.cwd,
1667
+ });
1668
+
1669
+ const retry = await spawnClaudeStreaming(
1670
+ retryArgs,
1671
+ fullPrompt,
1672
+ session.cwd,
1673
+ onStatus,
1674
+ 600000,
1675
+ chatId,
1676
+ boundProjectKey || '',
1677
+ runtime,
1678
+ onSession,
1679
+ );
1680
+ if (retry.sessionId) await onSession(retry.sessionId);
1681
+ if (retry.output) {
1682
+ markSessionStarted(sessionChatId, runtime.name);
1683
+ const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
1684
+ await bot.sendMarkdown(chatId, retryClean);
1685
+ await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
1686
+ return { ok: true };
1687
+ } else {
1688
+ log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
1689
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1690
+ return { ok: false, error: retry.error || errMsg };
1691
+ }
1692
+ } else {
1693
+ // Auto-fallback: if custom provider/model fails, revert to anthropic + opus (Claude path only)
1694
+ if (runtime.name === 'claude') {
1695
+ const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
1696
+ const builtinModels = ENGINE_MODEL_CONFIG.claude.options;
1697
+ if ((activeProv !== 'anthropic' || !builtinModels.includes(model)) && !errMsg.includes('Stopped by user')) {
1698
+ try {
1699
+ config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
1700
+ await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
1701
+ } catch (fallbackErr) {
1702
+ log('ERROR', `Fallback failed: ${fallbackErr.message}`);
1703
+ try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1704
+ }
1705
+ } else {
1536
1706
  try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1537
1707
  }
1538
1708
  } else {
1539
1709
  try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1540
1710
  }
1541
- } else {
1542
- try { await bot.sendMessage(chatId, userErrMsg); } catch { /* */ }
1711
+ return { ok: false, error: errMsg, errorCode };
1543
1712
  }
1544
- return { ok: false, error: errMsg, errorCode };
1545
1713
  }
1546
- }
1547
1714
 
1548
1715
  } catch (fatalErr) { // ── safety-net-catch ──
1549
1716
  clearInterval(typingTimer);