metame-cli 1.3.23 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scripts/daemon.js CHANGED
@@ -27,6 +27,7 @@ const LOG_FILE = path.join(METAME_DIR, 'daemon.log');
27
27
  const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
28
28
  const DISPATCH_DIR = path.join(METAME_DIR, 'dispatch');
29
29
  const DISPATCH_LOG = path.join(DISPATCH_DIR, 'dispatch-log.jsonl');
30
+ const SOCK_PATH = path.join(METAME_DIR, 'daemon.sock');
30
31
 
31
32
  // Skill evolution module (hot path + cold path)
32
33
  let skillEvolution = null;
@@ -318,11 +319,13 @@ function executeTask(task, config) {
318
319
  if (task.type === 'script') {
319
320
  log('INFO', `Executing script task: ${task.name} → ${task.command}`);
320
321
  try {
322
+ const scriptEnv = { ...process.env, METAME_ROOT: process.env.METAME_ROOT || '' };
323
+ delete scriptEnv.CLAUDECODE;
321
324
  const output = execSync(task.command, {
322
325
  encoding: 'utf8',
323
- timeout: 120000,
326
+ timeout: (task.timeout || 120) * 1000,
324
327
  maxBuffer: 1024 * 1024,
325
- env: { ...process.env, METAME_ROOT: process.env.METAME_ROOT || '' },
328
+ env: scriptEnv,
326
329
  }).trim();
327
330
 
328
331
  state.tasks[task.name] = {
@@ -331,7 +334,8 @@ function executeTask(task, config) {
331
334
  output_preview: output.slice(0, 200),
332
335
  };
333
336
  saveState(state);
334
- log('INFO', `Script task ${task.name} completed`);
337
+ if (output) log('INFO', `Script task ${task.name} completed: ${output.slice(0, 300)}`);
338
+ else log('INFO', `Script task ${task.name} completed`);
335
339
  return { success: true, output, tokens: 0 };
336
340
  } catch (e) {
337
341
  log('ERROR', `Script task ${task.name} failed: ${e.message}`);
@@ -545,15 +549,62 @@ function setDispatchHandler(fn) { _handleCommand = fn; }
545
549
  function createNullBot(onOutput) {
546
550
  const noop = async () => ({ message_id: '_virtual' });
547
551
  return {
548
- sendMessage: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
549
- sendMarkdown: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
550
- sendCard: async (chatId, card) => { if (onOutput) onOutput(typeof card === 'object' ? (card.body || card.title || JSON.stringify(card)) : card); return { message_id: '_virtual' }; },
551
- sendButtons: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
552
- sendTyping: async () => {},
553
- editMessage: async () => {},
554
- deleteMessage: async () => {},
555
- sendFile: noop,
556
- downloadFile: noop,
552
+ sendMessage: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
553
+ sendMarkdown: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
554
+ sendCard: async (chatId, card) => { if (onOutput) onOutput(typeof card === 'object' ? (card.body || card.title || JSON.stringify(card)) : card); return { message_id: '_virtual' }; },
555
+ sendRawCard: async (chatId, header) => { if (onOutput) onOutput(header); return { message_id: '_virtual' }; },
556
+ sendButtons: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
557
+ sendTyping: async () => { },
558
+ editMessage: async () => { },
559
+ deleteMessage: async () => { },
560
+ sendFile: noop,
561
+ downloadFile: noop,
562
+ };
563
+ }
564
+
565
+ /**
566
+ * Forward bot: routes all calls to a real bot with a fixed chatId.
567
+ * Used for dispatch tasks so Claude's streaming output appears in the target's Feishu channel.
568
+ */
569
+ function createStreamForwardBot(realBot, chatId) {
570
+ // Track edit-broken state independently so dispatch failures don't poison realBot's flag
571
+ let _editBroken = false;
572
+ return {
573
+ sendMessage: async (_, text) => {
574
+ log('INFO', `[StreamBot→${chatId.slice(-8)}] msg: ${String(text).slice(0, 80)}`);
575
+ return realBot.sendMessage(chatId, text);
576
+ },
577
+ sendMarkdown: async (_, text) => {
578
+ log('INFO', `[StreamBot→${chatId.slice(-8)}] md: ${String(text).slice(0, 80)}`);
579
+ return realBot.sendMarkdown(chatId, text);
580
+ },
581
+ sendCard: async (_, card) => {
582
+ const title = typeof card === 'object' ? (card.title || card.body || '').slice(0, 60) : String(card).slice(0, 60);
583
+ log('INFO', `[StreamBot→${chatId.slice(-8)}] card: ${title}`);
584
+ return realBot.sendCard(chatId, card);
585
+ },
586
+ sendRawCard: async (_, header, elements) => {
587
+ log('INFO', `[StreamBot→${chatId.slice(-8)}] rawcard: ${String(header).slice(0, 60)}`);
588
+ return realBot.sendRawCard(chatId, header, elements);
589
+ },
590
+ sendButtons: async (_, text, buttons) => realBot.sendButtons(chatId, text, buttons),
591
+ sendTyping: async () => realBot.sendTyping(chatId),
592
+ editMessage: async (_, msgId, text) => {
593
+ if (_editBroken) return false;
594
+ log('INFO', `[StreamBot→${chatId.slice(-8)}] edit ${String(msgId).slice(-8)}: ${String(text).slice(0, 60)}`);
595
+ try {
596
+ return await realBot.editMessage(chatId, msgId, text);
597
+ } catch (e) {
598
+ const code = e?.code || e?.response?.data?.code;
599
+ if (code === 230001 || code === 230002 || /permission|forbidden/i.test(String(e))) {
600
+ _editBroken = true;
601
+ }
602
+ return false;
603
+ }
604
+ },
605
+ deleteMessage: async (_, msgId) => realBot.deleteMessage(chatId, msgId),
606
+ sendFile: async (_, filePath, caption) => realBot.sendFile(chatId, filePath, caption),
607
+ downloadFile: async (...args) => realBot.downloadFile(...args),
557
608
  };
558
609
  }
559
610
 
@@ -564,8 +615,8 @@ function createNullBot(onOutput) {
564
615
  * @param {object} config - current daemon config
565
616
  * @returns {{ success: boolean, id?: string, error?: string }}
566
617
  */
567
- function dispatchTask(targetProject, message, config, replyFn) {
568
- const LIMITS = { max_per_hour_per_target: 5, max_total_per_hour: 20, max_depth: 2 };
618
+ function dispatchTask(targetProject, message, config, replyFn, streamOptions = null) {
619
+ const LIMITS = { max_per_hour_per_target: 20, max_total_per_hour: 60, max_depth: 2 };
569
620
 
570
621
  // Anti-storm: check chain depth
571
622
  const chain = message.chain || [];
@@ -638,22 +689,22 @@ function dispatchTask(targetProject, message, config, replyFn) {
638
689
  }
639
690
 
640
691
  // Inject ack-first instruction for all dispatched tasks
641
- prompt = `[行为要求:收到此任务后,请先用 dispatch_to 回复一条消息说明「收到,计划:xxx」,再开始执行。]\n\n${prompt}`;
692
+ // Note: do NOT require dispatch_to (Bash) here — dispatched tasks run readOnly=true, Bash is blocked.
693
+ // Daemon sends the ack autonomously; Claude should just state its plan in the reply text.
694
+ prompt = `[行为要求:回复开头用1-2句「计划:xxx」说明执行方案,再开始执行。不要调用 dispatch_to,daemon 会自动转发你的回复。]\n\n${prompt}`;
642
695
 
643
696
  // Prefer target's real Feishu chatId so dispatch reuses the existing session
644
697
  // (--resume, no CLAUDE.md re-read, no token waste). Fall back to _agent_* virtual
645
- // chatId only if: target has no Feishu chat configured, OR caller requested new_session.
646
- const feishuChatMap = (config.feishu && config.feishu.chat_agent_map) || {};
647
- const realChatId = Object.entries(feishuChatMap).find(([, v]) => v === targetProject)?.[0];
698
+ // All dispatches use _agent_* virtual chatId to ensure a clean session with
699
+ // the correct project context. Real Feishu chatIds are only for direct user messages.
648
700
  const forceNew = !!fullMsg.new_session;
649
- const dispatchChatId = (!forceNew && realChatId) ? realChatId : `_agent_${targetProject}`;
650
- const sessionMode = forceNew ? 'fresh session (forced)' : realChatId ? 'existing session' : 'fresh session';
701
+ const dispatchChatId = `_agent_${targetProject}`;
702
+ const sessionMode = forceNew ? 'fresh session (forced)' : 'existing virtual session';
651
703
  log('INFO', `Dispatching ${fullMsg.type} to ${targetProject} via ${sessionMode}: ${rawPrompt.slice(0, 80)}`);
652
704
 
653
- const nullBot = createNullBot((output) => {
705
+ const outputHandler = (output) => {
654
706
  const outStr = typeof output === 'object' ? (output.body || JSON.stringify(output)) : String(output);
655
707
  log('INFO', `Dispatch output from ${targetProject}: ${outStr.slice(0, 200)}`);
656
- // Forward meaningful output back to the requester (skip typing indicators)
657
708
  if (replyFn && outStr.trim().length > 2) {
658
709
  replyFn(outStr);
659
710
  } else if (!replyFn && fullMsg.callback && fullMsg.from && config) {
@@ -669,19 +720,163 @@ function dispatchTask(targetProject, message, config, replyFn) {
669
720
  chain: [], // reset chain for callbacks
670
721
  }, config);
671
722
  }
672
- });
673
- // readOnly=true: dispatched agents must not write/edit files on behalf of other agents
674
- _handleCommand(nullBot, dispatchChatId, prompt, config, null, null, true).catch(e => {
723
+ };
724
+ // If streamOptions provided, use real bot so output appears in target's Feishu channel.
725
+ // Otherwise fall back to nullBot which captures output for replyFn.
726
+ const nullBot = streamOptions?.bot && streamOptions?.chatId
727
+ ? createStreamForwardBot(streamOptions.bot, streamOptions.chatId)
728
+ : createNullBot(outputHandler);
729
+ // Permission inheritance: if daemon runs with dangerously_skip_permissions, dispatched agents
730
+ // inherit the same level — they need Write access for implementation tasks.
731
+ // Otherwise fall back to readOnly (safe default for untrusted daemon configs).
732
+ // When forceNew=true, clear any cached session for this virtual chatId so
733
+ // attachOrCreateSession in handleCommand actually creates a fresh Claude session.
734
+ if (forceNew) {
735
+ const st = loadState();
736
+ if (st.sessions && st.sessions[dispatchChatId]) {
737
+ delete st.sessions[dispatchChatId];
738
+ saveState(st);
739
+ }
740
+ }
741
+ const dispatchReadOnly = !(config.daemon && config.daemon.dangerously_skip_permissions);
742
+ _handleCommand(nullBot, dispatchChatId, prompt, config, null, null, dispatchReadOnly).catch(e => {
675
743
  log('ERROR', `Dispatch handleCommand failed for ${targetProject}: ${e.message}`);
676
744
  });
677
745
 
678
746
  return { success: true, id: fullMsg.id };
679
747
  }
680
748
 
749
+ /**
750
+ * Spawn memory-extract.js as a detached background process.
751
+ * Called on sleep mode entry to consolidate session facts.
752
+ */
753
+ /**
754
+ * Spawn session-summarize.js for sessions that have been idle 2-24 hours.
755
+ * Called on sleep mode entry. Skips sessions that already have a fresh summary.
756
+ */
757
+ function spawnSessionSummaries() {
758
+ const scriptPath = path.join(__dirname, 'session-summarize.js');
759
+ if (!fs.existsSync(scriptPath)) return;
760
+ const state = loadState();
761
+ const now = Date.now();
762
+ const TWO_HOURS = 2 * 60 * 60 * 1000;
763
+ const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
764
+ for (const [cid, sess] of Object.entries(state.sessions || {})) {
765
+ if (!sess.id || !sess.started) continue;
766
+ const lastActive = sess.last_active || 0;
767
+ const idleMs = now - lastActive;
768
+ if (idleMs < TWO_HOURS || idleMs > SEVEN_DAYS) continue;
769
+ // Skip if summary is already newer than last activity
770
+ if ((sess.last_summary_at || 0) > lastActive) continue;
771
+ try {
772
+ const child = spawn(process.execPath, [scriptPath, cid, sess.id], {
773
+ detached: true, stdio: 'ignore',
774
+ });
775
+ child.unref();
776
+ log('INFO', `[DAEMON] Session summary spawned for ${cid} (idle ${Math.round(idleMs / 3600000)}h)`);
777
+ } catch (e) {
778
+ log('WARN', `[DAEMON] Failed to spawn session summary: ${e.message}`);
779
+ }
780
+ }
781
+ }
782
+
681
783
  /**
682
784
  * Physiological heartbeat: zero-token awareness check.
683
785
  * Runs every tick unconditionally.
684
786
  */
787
+ /**
788
+ * Handle a single dispatch message (from socket or pending.jsonl fallback).
789
+ */
790
+ function handleDispatchItem(item, config) {
791
+ if (!item.target || !item.prompt) return;
792
+ if (!(config && config.projects && config.projects[item.target])) {
793
+ log('WARN', `dispatch: unknown target "${item.target}"`);
794
+ return;
795
+ }
796
+ log('INFO', `Dispatch: ${item.from || '?'} → ${item.target}: ${item.prompt.slice(0, 60)}`);
797
+ let pendingReplyFn = null;
798
+ let streamOptions = null;
799
+ const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
800
+ if (liveBot) {
801
+ const feishuMap = (config.feishu && config.feishu.chat_agent_map) || {};
802
+ const allowedFeishuIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
803
+ const agentChatIds = new Set(Object.keys(feishuMap));
804
+ const targetChatId = Object.entries(feishuMap).find(([, v]) => v === item.target)?.[0] || null;
805
+ if (targetChatId) {
806
+ streamOptions = { bot: liveBot, chatId: targetChatId };
807
+ const ackText = `📬 **新任务**\n\n> ${item.prompt.slice(0, 120)}${item.prompt.length > 120 ? '...' : ''}`;
808
+ liveBot.sendMarkdown(targetChatId, ackText).catch(() =>
809
+ liveBot.sendMessage(targetChatId, ackText.replace(/\*\*/g, '')).catch(e =>
810
+ log('WARN', `Dispatch ack failed: ${e.message}`)
811
+ )
812
+ );
813
+ } else {
814
+ const _userSources = new Set(['unknown', 'claude_session', '_claude_session', 'user']);
815
+ let senderChatId = null;
816
+ if (!_userSources.has(item.from)) {
817
+ senderChatId = Object.entries(feishuMap).find(([, v]) => v === item.from)?.[0] || null;
818
+ }
819
+ if (!senderChatId) {
820
+ senderChatId = allowedFeishuIds.map(String).find(id => !agentChatIds.has(id)) || null;
821
+ }
822
+ if (senderChatId) {
823
+ const targetProj = (config.projects || {})[item.target] || {};
824
+ const ackText = `📬 已接收,转发给 ${targetProj.icon || '🤖'} **${targetProj.name || item.target}**...\n\n> ${item.prompt.slice(0, 100)}${item.prompt.length > 100 ? '...' : ''}`;
825
+ liveBot.sendMarkdown(senderChatId, ackText).catch(() =>
826
+ liveBot.sendMessage(senderChatId, ackText.replace(/\*\*/g, '')).catch(e =>
827
+ log('WARN', `Dispatch ack to sender failed: ${e.message}`)
828
+ )
829
+ );
830
+ pendingReplyFn = (output) => {
831
+ const text = `${targetProj.icon || '📬'} **${targetProj.name || item.target}** 回复:\n\n${output.slice(0, 2000)}`;
832
+ liveBot.sendMarkdown(senderChatId, text).catch(e => {
833
+ log('WARN', `Dispatch reply (markdown) failed: ${e.message}`);
834
+ liveBot.sendMessage(senderChatId, text.replace(/\*\*/g, '')).catch(e2 =>
835
+ log('ERROR', `Dispatch reply (text) failed: ${e2.message}`)
836
+ );
837
+ });
838
+ };
839
+ }
840
+ }
841
+ }
842
+ dispatchTask(item.target, {
843
+ from: item.from || 'claude_session',
844
+ type: 'task', priority: 'normal',
845
+ payload: { title: item.prompt.slice(0, 60), prompt: item.prompt },
846
+ callback: false,
847
+ new_session: !!item.new_session,
848
+ }, config, pendingReplyFn, streamOptions);
849
+ }
850
+
851
+ /**
852
+ * Start Unix Domain Socket server for low-latency dispatch.
853
+ */
854
+ function startDispatchSocket(config) {
855
+ const net = require('net');
856
+ try { fs.unlinkSync(SOCK_PATH); } catch { /* ok */ }
857
+ const server = net.createServer((conn) => {
858
+ let buf = '';
859
+ conn.on('data', d => { buf += d; });
860
+ conn.on('end', () => {
861
+ try {
862
+ const item = JSON.parse(buf);
863
+ handleDispatchItem(item, config);
864
+ conn.write(JSON.stringify({ ok: true }) + '\n');
865
+ } catch (e) {
866
+ try { conn.write(JSON.stringify({ ok: false, error: e.message }) + '\n'); } catch { /* ignore */ }
867
+ }
868
+ });
869
+ conn.on('error', () => { /* ignore client disconnect */ });
870
+ });
871
+ server.on('error', (e) => {
872
+ log('WARN', `[DAEMON] Dispatch socket error: ${e.message} — file polling still active`);
873
+ });
874
+ server.listen(SOCK_PATH, () => {
875
+ log('INFO', `[DAEMON] Dispatch socket ready: ${SOCK_PATH}`);
876
+ });
877
+ return server;
878
+ }
879
+
685
880
  function physiologicalHeartbeat(config) {
686
881
  // 1. Update last_alive timestamp
687
882
  const state = loadState();
@@ -702,38 +897,7 @@ function physiologicalHeartbeat(config) {
702
897
  const items = content.split('\n').filter(Boolean)
703
898
  .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
704
899
  for (const item of items) {
705
- if (!item.target || !item.prompt) continue;
706
- if (!(config && config.projects && config.projects[item.target])) {
707
- log('WARN', `pending dispatch: unknown target "${item.target}"`);
708
- continue;
709
- }
710
- log('INFO', `Pending dispatch: ${item.from || '?'} → ${item.target}: ${item.prompt.slice(0, 60)}`);
711
- // Build replyFn: use live bot from bridge ref (always fresh, survives reconnects)
712
- let pendingReplyFn = null;
713
- const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
714
- if (liveBot) {
715
- const feishuMap = (config.feishu && config.feishu.chat_agent_map) || {};
716
- const targetChatId = Object.entries(feishuMap).find(([, v]) => v === item.target)?.[0];
717
- if (targetChatId) {
718
- const proj = (config.projects || {})[item.target] || {};
719
- pendingReplyFn = (output) => {
720
- const text = `${proj.icon || '📬'} **${proj.name || item.target}**\n\n${output.slice(0, 2000)}`;
721
- liveBot.sendMarkdown(targetChatId, text).catch(e => {
722
- log('WARN', `Dispatch reply to ${item.target} (markdown) failed: ${e.message}`);
723
- liveBot.sendMessage(targetChatId, text).catch(e2 => {
724
- log('ERROR', `Dispatch reply to ${item.target} (text) failed: ${e2.message}`);
725
- });
726
- });
727
- };
728
- }
729
- }
730
- dispatchTask(item.target, {
731
- from: item.from || 'claude_session',
732
- type: 'task', priority: 'normal',
733
- payload: { title: item.prompt.slice(0, 60), prompt: item.prompt },
734
- callback: false,
735
- new_session: !!item.new_session,
736
- }, config, pendingReplyFn);
900
+ handleDispatchItem(item, config);
737
901
  }
738
902
  }
739
903
  }
@@ -760,19 +924,41 @@ function physiologicalHeartbeat(config) {
760
924
  }
761
925
 
762
926
  // ---------------------------------------------------------
763
- // HEARTBEAT SCHEDULER
927
+ // HEARTBEAT TASK HELPERS (single source of truth)
764
928
  // ---------------------------------------------------------
765
- function startHeartbeat(config, notifyFn) {
766
- const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
767
- const projectTasks = [];
768
- const legacyNames = new Set(legacyTasks.map(t => t.name));
769
- for (const [key, proj] of Object.entries(config.projects || {})) {
929
+
930
+ /**
931
+ * Collect all heartbeat tasks from config (general + per-project).
932
+ * Each project task gets _project metadata attached.
933
+ * Returns { general: [...], project: [...], all: [...] }
934
+ */
935
+ function getAllTasks(cfg) {
936
+ const general = (cfg.heartbeat && cfg.heartbeat.tasks) || [];
937
+ const project = [];
938
+ const generalNames = new Set(general.map(t => t.name));
939
+ for (const [key, proj] of Object.entries(cfg.projects || {})) {
770
940
  for (const t of (proj.heartbeat_tasks || [])) {
771
- if (legacyNames.has(t.name)) log('WARN', `Duplicate task name "${t.name}" in project "${key}" and legacy heartbeat — will run twice`);
772
- projectTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
941
+ if (generalNames.has(t.name)) log('WARN', `Duplicate task name "${t.name}" in project "${key}" and general heartbeat`);
942
+ project.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
773
943
  }
774
944
  }
775
- const tasks = [...legacyTasks, ...projectTasks];
945
+ return { general, project, all: [...general, ...project] };
946
+ }
947
+
948
+ /**
949
+ * Find a task by name across all groups.
950
+ */
951
+ function findTask(cfg, name) {
952
+ const { general, project } = getAllTasks(cfg);
953
+ const found = general.find(t => t.name === name) || project.find(t => t.name === name);
954
+ return found || null;
955
+ }
956
+
957
+ // ---------------------------------------------------------
958
+ // HEARTBEAT SCHEDULER
959
+ // ---------------------------------------------------------
960
+ function startHeartbeat(config, notifyFn) {
961
+ const { all: tasks } = getAllTasks(config);
776
962
 
777
963
  const enabledTasks = tasks.filter(t => t.enabled !== false);
778
964
  const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
@@ -807,6 +993,18 @@ function startHeartbeat(config, notifyFn) {
807
993
  // ① Physiological heartbeat (zero token, pure awareness)
808
994
  physiologicalHeartbeat(config);
809
995
 
996
+ // Sleep mode detection — log transitions once
997
+ const idle = isUserIdle();
998
+ if (idle && !_inSleepMode) {
999
+ _inSleepMode = true;
1000
+ log('INFO', '[DAEMON] Entering Sleep Mode');
1001
+ // Generate summaries for sessions idle 2-24h
1002
+ spawnSessionSummaries();
1003
+ } else if (!idle && _inSleepMode) {
1004
+ _inSleepMode = false;
1005
+ log('INFO', '[DAEMON] Exiting Sleep Mode — local activity detected');
1006
+ }
1007
+
810
1008
  // ② Task heartbeat (burns tokens on schedule)
811
1009
  const currentTime = Date.now();
812
1010
  for (const task of enabledTasks) {
@@ -814,6 +1012,12 @@ function startHeartbeat(config, notifyFn) {
814
1012
  const intervalSec = parseInterval(task.interval);
815
1013
  nextRun[task.name] = currentTime + intervalSec * 1000;
816
1014
 
1015
+ // Dream tasks: only run when user is idle
1016
+ if (task.require_idle && !isUserIdle()) {
1017
+ log('INFO', `[DAEMON] Deferring dream task "${task.name}" — user active`);
1018
+ continue;
1019
+ }
1020
+
817
1021
  if (runningTasks.has(task.name)) {
818
1022
  log('WARN', `Task ${task.name} still running — skipping this interval`);
819
1023
  continue;
@@ -1348,6 +1552,7 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
1348
1552
  }
1349
1553
 
1350
1554
  async function handleCommand(bot, chatId, text, config, executeTaskByName, senderId = null, readOnly = false) {
1555
+ if (text && !text.startsWith('/chatid') && !text.startsWith('/myid')) log('INFO', `CMD [${String(chatId).slice(-8)}]: ${text.slice(0, 80)}`);
1351
1556
  const state = loadState();
1352
1557
 
1353
1558
  // --- /chatid: reply with current chatId ---
@@ -1522,6 +1727,56 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1522
1727
  return;
1523
1728
  }
1524
1729
 
1730
+ // /memory [keyword] — show memory stats or search facts
1731
+ if (text === '/memory' || text.startsWith('/memory ')) {
1732
+ const query = text.startsWith('/memory ') ? text.slice(8).trim() : '';
1733
+ let memMod;
1734
+ try { memMod = require('./memory'); } catch { await bot.sendMessage(chatId, '❌ Memory module not available'); return; }
1735
+
1736
+ if (!query) {
1737
+ // Stats view
1738
+ try {
1739
+ const s = memMod.stats();
1740
+ const factCount = s.facts ?? '?';
1741
+ const tagFile = path.join(HOME, '.metame', 'session_tags.json');
1742
+ let tagCount = 0;
1743
+ try { tagCount = Object.keys(JSON.parse(fs.readFileSync(tagFile, 'utf8'))).length; } catch { }
1744
+ const lines = [
1745
+ `🧠 *Memory Stats*`,
1746
+ `━━━━━━━━━━━━━━━━`,
1747
+ `📌 Facts: ${factCount}`,
1748
+ `🏷 Sessions tagged: ${tagCount}`,
1749
+ `🗃 Sessions in DB: ${s.count}`,
1750
+ `💾 DB size: ${s.dbSizeKB} KB`,
1751
+ s.newestDate ? `🕐 Last updated: ${new Date(s.newestDate).toLocaleDateString()}` : '',
1752
+ ``,
1753
+ `搜索: /memory <关键词>`,
1754
+ ].filter(l => l !== undefined && !(l === '' && false));
1755
+ await bot.sendMessage(chatId, lines.join('\n'));
1756
+ } catch (e) {
1757
+ await bot.sendMessage(chatId, `❌ Memory stats error: ${e.message}`);
1758
+ }
1759
+ } else {
1760
+ // Search facts
1761
+ try {
1762
+ const results = await memMod.searchFactsAsync(query, { limit: 5 });
1763
+ if (!results || results.length === 0) {
1764
+ await bot.sendMessage(chatId, `🔍 No facts found for「${query}」`);
1765
+ return;
1766
+ }
1767
+ let msg = `🔍 *Facts: "${query}"* (${results.length})\n━━━━━━━━━━━━━━━━\n`;
1768
+ for (const r of results) {
1769
+ const tag = r.confidence === 'high' ? '🟢' : '🟡';
1770
+ msg += `${tag} *${r.entity}*\n${r.value}\n\n`;
1771
+ }
1772
+ await bot.sendMessage(chatId, msg.trim());
1773
+ } catch (e) {
1774
+ await bot.sendMessage(chatId, `❌ Search error: ${e.message}`);
1775
+ }
1776
+ }
1777
+ return;
1778
+ }
1779
+
1525
1780
  // /sessions — compact list, tap to see details, then tap to switch
1526
1781
  if (text === '/sessions') {
1527
1782
  const allSessions = listRecentSessions(15);
@@ -1530,11 +1785,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1530
1785
  return;
1531
1786
  }
1532
1787
  if (bot.sendButtons) {
1533
- await bot.sendCard(chatId, '📋 Recent Sessions', buildSessionCardElements(allSessions));
1788
+ await bot.sendRawCard(chatId, '📋 Recent Sessions', buildSessionCardElements(allSessions));
1534
1789
  } else {
1790
+ const _tags1 = loadSessionTags();
1535
1791
  let msg = '📋 Recent sessions:\n\n';
1536
1792
  allSessions.forEach((s, i) => {
1537
- msg += sessionRichLabel(s, i + 1) + '\n';
1793
+ msg += sessionRichLabel(s, i + 1, _tags1) + '\n';
1538
1794
  });
1539
1795
  await bot.sendMessage(chatId, msg);
1540
1796
  }
@@ -1555,7 +1811,11 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1555
1811
  const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
1556
1812
  const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
1557
1813
  const ago = formatRelativeTime(new Date(timeMs).toISOString());
1558
- const title = s.customTitle || '';
1814
+ const sessionTags = loadSessionTags();
1815
+ const tagEntry = sessionTags[s.sessionId] || {};
1816
+ const tagName = tagEntry.name || '';
1817
+ const tags = (tagEntry.tags || []).slice(0, 5);
1818
+ const title = s.customTitle || tagName || '';
1559
1819
  const summary = s.summary || '';
1560
1820
  const firstMsg = (s.firstPrompt || '').replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '');
1561
1821
  const msgs = s.messageCount || '?';
@@ -1563,6 +1823,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1563
1823
  let detail = `📋 Session Detail\n`;
1564
1824
  detail += `━━━━━━━━━━━━━━━━━━━━\n`;
1565
1825
  if (title) detail += `📝 Title: ${title}\n`;
1826
+ if (tags.length) detail += `🏷 Tags: ${tags.map(t => '#' + t).join(' ')}\n`;
1566
1827
  if (summary) detail += `💡 Summary: ${summary}\n`;
1567
1828
  detail += `📁 Project: ${projName}\n`;
1568
1829
  detail += `📂 Path: ${proj}\n`;
@@ -1575,6 +1836,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1575
1836
  // Build rich detail as markdown body + buttons
1576
1837
  let body = '';
1577
1838
  if (title) body += `**📝 ${title}**\n`;
1839
+ if (tags.length) body += `${tags.map(t => `\`${t}\``).join(' ')}\n`;
1578
1840
  if (summary) body += `💡 ${summary}\n`;
1579
1841
  body += `📁 ${projName} · 📂 ${proj}\n`;
1580
1842
  body += `💬 ${msgs} messages · 🕐 ${ago}\n`;
@@ -1583,12 +1845,14 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1583
1845
  const elements = [
1584
1846
  { tag: 'div', text: { tag: 'lark_md', content: body } },
1585
1847
  { tag: 'hr' },
1586
- { tag: 'action', actions: [
1587
- { tag: 'button', text: { tag: 'plain_text', content: '▶️ Switch to this session' }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } },
1588
- { tag: 'button', text: { tag: 'plain_text', content: '⬅️ Back to list' }, type: 'default', value: { cmd: '/sessions' } },
1589
- ] },
1848
+ {
1849
+ tag: 'action', actions: [
1850
+ { tag: 'button', text: { tag: 'plain_text', content: '▶️ Switch to this session' }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } },
1851
+ { tag: 'button', text: { tag: 'plain_text', content: '⬅️ Back to list' }, type: 'default', value: { cmd: '/sessions' } },
1852
+ ]
1853
+ },
1590
1854
  ];
1591
- await bot.sendCard(chatId, '📋 Session Detail', elements);
1855
+ await bot.sendRawCard(chatId, '📋 Session Detail', elements);
1592
1856
  } else if (bot.sendButtons) {
1593
1857
  await bot.sendButtons(chatId, detail, [
1594
1858
  [{ text: '▶️ Switch to this session', callback_data: `/resume ${s.sessionId}` }],
@@ -1614,17 +1878,18 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1614
1878
  return;
1615
1879
  }
1616
1880
  const headerTitle = curCwd ? `📋 Sessions in ${path.basename(curCwd)}` : '📋 Recent Sessions';
1617
- if (bot.sendCard) {
1618
- await bot.sendCard(chatId, headerTitle, buildSessionCardElements(recentSessions));
1881
+ if (bot.sendRawCard) {
1882
+ await bot.sendRawCard(chatId, headerTitle, buildSessionCardElements(recentSessions));
1619
1883
  } else if (bot.sendButtons) {
1620
1884
  const buttons = recentSessions.map(s => {
1621
1885
  return [{ text: sessionLabel(s), callback_data: `/resume ${s.sessionId}` }];
1622
1886
  });
1623
1887
  await bot.sendButtons(chatId, headerTitle, buttons);
1624
1888
  } else {
1889
+ const _tags2 = loadSessionTags();
1625
1890
  let msg = `${title}\n\n`;
1626
1891
  recentSessions.forEach((s, i) => {
1627
- msg += sessionRichLabel(s, i + 1) + '\n';
1892
+ msg += sessionRichLabel(s, i + 1, _tags2) + '\n';
1628
1893
  });
1629
1894
  await bot.sendMessage(chatId, msg);
1630
1895
  }
@@ -2030,22 +2295,25 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2030
2295
  }
2031
2296
 
2032
2297
  if (text === '/tasks') {
2298
+ const { general, project } = getAllTasks(config);
2033
2299
  let msg = '';
2034
- // Legacy flat tasks
2035
- const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
2036
- if (legacyTasks.length > 0) {
2300
+ if (general.length > 0) {
2037
2301
  msg += '📋 General:\n';
2038
- for (const t of legacyTasks) {
2302
+ for (const t of general) {
2039
2303
  const ts = state.tasks[t.name] || {};
2040
2304
  msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
2041
2305
  }
2042
2306
  }
2043
- // Project tasks grouped
2044
- for (const [, proj] of Object.entries(config.projects || {})) {
2045
- const pTasks = proj.heartbeat_tasks || [];
2046
- if (pTasks.length === 0) continue;
2047
- msg += `\n${proj.icon || '🤖'} ${proj.name || proj}:\n`;
2048
- for (const t of pTasks) {
2307
+ // Project tasks grouped by _project
2308
+ const byProject = new Map();
2309
+ for (const t of project) {
2310
+ const pk = t._project.key;
2311
+ if (!byProject.has(pk)) byProject.set(pk, { proj: t._project, tasks: [] });
2312
+ byProject.get(pk).tasks.push(t);
2313
+ }
2314
+ for (const [, { proj, tasks }] of byProject) {
2315
+ msg += `\n${proj.icon} ${proj.name}:\n`;
2316
+ for (const t of tasks) {
2049
2317
  const ts = state.tasks[t.name] || {};
2050
2318
  msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
2051
2319
  }
@@ -2124,16 +2392,15 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2124
2392
  const projInfo = config.projects[targetKey] || {};
2125
2393
  // Find the target project's own Feishu chat (reverse lookup of chat_agent_map)
2126
2394
  const feishuChatAgentMap = (config.feishu && config.feishu.chat_agent_map) || {};
2127
- const targetChatId = Object.entries(feishuChatAgentMap).find(([, v]) => v === targetKey)?.[0] || chatId;
2128
- const replyFn = (output) => {
2129
- // Send target agent's reply into the target project's own Feishu chat
2395
+ const targetChatId = Object.entries(feishuChatAgentMap).find(([, v]) => v === targetKey)?.[0] || null;
2396
+ // Stream work directly to target's channel if available; otherwise fallback replyFn
2397
+ const dispatchStreamOptions = targetChatId ? { bot, chatId: targetChatId } : null;
2398
+ const replyFn = targetChatId ? null : (output) => {
2130
2399
  const text = `${projInfo.icon || '📬'} **${projInfo.name || targetKey}**\n\n${output.slice(0, 2000)}`;
2131
- bot.sendMarkdown(targetChatId, text)
2132
- .then(() => log('INFO', `Dispatch reply sent to ${targetChatId}`))
2400
+ bot.sendMarkdown(chatId, text)
2133
2401
  .catch(e => {
2134
2402
  log('WARN', `Dispatch sendMarkdown failed: ${e.message}, trying sendMessage`);
2135
- bot.sendMessage(targetChatId, text)
2136
- .catch(e2 => log('ERROR', `Dispatch reply failed: ${e2.message}`));
2403
+ bot.sendMessage(chatId, text).catch(e2 => log('ERROR', `Dispatch reply failed: ${e2.message}`));
2137
2404
  });
2138
2405
  };
2139
2406
 
@@ -2143,7 +2410,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2143
2410
  priority: 'normal',
2144
2411
  payload: { title: prompt.slice(0, 60), prompt },
2145
2412
  callback: false,
2146
- }, config, replyFn);
2413
+ }, config, replyFn, dispatchStreamOptions);
2147
2414
 
2148
2415
  if (result.success) {
2149
2416
  await bot.sendMessage(chatId, `✅ 已派发给 ${projInfo.name || targetName},执行中…`);
@@ -2165,13 +2432,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2165
2432
  return;
2166
2433
  }
2167
2434
  const taskName = text.slice(5).trim();
2168
- const allRunTasks = [...(config.heartbeat && config.heartbeat.tasks || [])];
2169
- for (const [key, proj] of Object.entries(config.projects || {})) {
2170
- for (const t of (proj.heartbeat_tasks || [])) {
2171
- allRunTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
2172
- }
2173
- }
2174
- const task = allRunTasks.find(t => t.name === taskName);
2435
+ const task = findTask(config, taskName);
2175
2436
  if (!task) { await bot.sendMessage(chatId, `❌ Task "${taskName}" not found`); return; }
2176
2437
 
2177
2438
  // Script tasks: quick, run inline
@@ -2438,7 +2699,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2438
2699
  }
2439
2700
 
2440
2701
  const session = getSession(chatId);
2441
- if (!session || !session.id || !session.cwd) {
2702
+ if (!session || !session.id) {
2442
2703
  await bot.sendMessage(chatId, 'No active session to undo.');
2443
2704
  return;
2444
2705
  }
@@ -2446,107 +2707,190 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2446
2707
  const cwd = session.cwd;
2447
2708
  const arg = text.slice(5).trim();
2448
2709
 
2449
- // Git-based undo: list checkpoints or reset to one
2450
- // First check if cwd is even a git repo
2451
- let isGitRepo = false;
2452
- try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore', timeout: 3000 }); isGitRepo = true; } catch { }
2453
- const checkpoints = isGitRepo ? listCheckpoints(cwd) : [];
2454
- if (!isGitRepo) {
2455
- await bot.sendMessage(chatId, `⚠️ 当前项目不在 git 仓库中,无法使用 /undo\n📁 ${cwd}\n\n切换到 git 项目后重试(/agent bind 或 /cd 切换目录)`);
2456
- return;
2457
- }
2458
- if (checkpoints.length === 0) {
2459
- await bot.sendMessage(chatId, `⚠️ 还没有回退点\n📁 ${path.basename(cwd)}\n\nCheckpoint 在 Claude 修改文件前自动创建,先让 Claude 做点改动再试`);
2710
+ // /undo <hash> git reset to specific checkpoint (advanced usage)
2711
+ if (arg) {
2712
+ if (!cwd) {
2713
+ await bot.sendMessage(chatId, ' 当前 session 无工作目录,无法执行 git undo');
2714
+ return;
2715
+ }
2716
+ let isGitRepo = false;
2717
+ try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore', timeout: 3000 }); isGitRepo = true; } catch { }
2718
+ const checkpoints = isGitRepo ? listCheckpoints(cwd) : [];
2719
+ const match = checkpoints.find(cp => cp.hash.startsWith(arg));
2720
+ if (!match) {
2721
+ await bot.sendMessage(chatId, `❌ 未找到 checkpoint: ${arg}`);
2722
+ return;
2723
+ }
2724
+ try {
2725
+ let diffFiles = '';
2726
+ try { diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000 }).trim(); } catch { }
2727
+ execSync(`git reset --hard ${match.hash}`, { cwd, stdio: 'ignore', timeout: 10000 });
2728
+ // Truncate context to checkpoint time (covers multi-turn rollback)
2729
+ truncateSessionToCheckpoint(session.id, match.message);
2730
+ const fileList = diffFiles ? diffFiles.split('\n').map(f => path.basename(f)).join(', ') : '';
2731
+ const fileCount = diffFiles ? diffFiles.split('\n').length : 0;
2732
+ let msg = `⏪ 已回退到 ${cpDisplayLabel(match.message)}`;
2733
+ if (fileCount > 0) msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
2734
+ log('INFO', `/undo <hash> executed for ${chatId}: reset to ${match.hash.slice(0, 8)}, files=${fileCount}`);
2735
+ await bot.sendMessage(chatId, msg);
2736
+ cleanupCheckpoints(cwd);
2737
+ } catch (e) {
2738
+ await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
2739
+ }
2460
2740
  return;
2461
2741
  }
2462
2742
 
2463
- if (!arg) {
2464
- // /undo (no arg) — show recent checkpoints to pick from
2465
- const recent = checkpoints.slice(0, 6); // newest first (already sorted)
2743
+ // /undo (no arg) — show recent user messages as buttons to pick rollback point
2744
+ try {
2745
+ const sessionFile = findSessionFile(session.id);
2746
+ if (!sessionFile) {
2747
+ await bot.sendMessage(chatId, '⚠️ 找不到 session 文件,无法列出历史消息');
2748
+ return;
2749
+ }
2750
+ const lines = fs.readFileSync(sessionFile, 'utf8').split('\n').filter(l => l.trim());
2751
+
2752
+ // Helper: extract real user text (skip tool_result entries and system annotations)
2753
+ const extractUserText = (obj) => {
2754
+ try {
2755
+ const content = obj.message?.content;
2756
+ if (typeof content === 'string') return content.trim();
2757
+ if (Array.isArray(content)) {
2758
+ // Skip entries that are purely tool results
2759
+ if (content.every(c => c.type === 'tool_result')) return '';
2760
+ // Find first text item that isn't a system annotation (exact patterns only)
2761
+ const SYSTEM_ANNOTATION = /^\[(Image source|Pasted|Attachment|File):/;
2762
+ const item = content.find(c => c.type === 'text' && c.text && !SYSTEM_ANNOTATION.test(c.text));
2763
+ return item?.text?.trim() || '';
2764
+ }
2765
+ } catch { }
2766
+ return '';
2767
+ };
2768
+
2769
+ // Collect only real human-written user messages (skip tool results / annotations)
2770
+ const userMsgs = [];
2771
+ for (let i = 0; i < lines.length; i++) {
2772
+ try {
2773
+ const obj = JSON.parse(lines[i]);
2774
+ if (obj.type === 'user' && obj.message?.role === 'user') {
2775
+ const text = extractUserText(obj);
2776
+ if (text) userMsgs.push({ idx: i, obj, text });
2777
+ }
2778
+ } catch { }
2779
+ }
2780
+ if (userMsgs.length === 0) {
2781
+ await bot.sendMessage(chatId, '⚠️ 没有可回退的历史消息');
2782
+ return;
2783
+ }
2784
+
2785
+ // Show last 10 (most recent first)
2786
+ const recent = userMsgs.slice(-10).reverse();
2466
2787
  if (bot.sendButtons) {
2467
- const buttons = recent.map((cp) => {
2468
- const label = cpDisplayLabel(cp.message);
2469
- return [{ text: `⏪ ${label}`, callback_data: `/undo ${cp.hash.slice(0, 10)}` }];
2788
+ const buttons = recent.map(({ idx, text, obj }) => {
2789
+ const msgText = text.replace(/\n/g, ' ').slice(0, 28);
2790
+ let timeLabel = '';
2791
+ if (obj.timestamp) {
2792
+ const d = new Date(obj.timestamp);
2793
+ if (!isNaN(d)) timeLabel = ` (${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')})`;
2794
+ }
2795
+ return [{ text: `⏪ ${msgText}${timeLabel}`, callback_data: `/undo_to ${idx}` }];
2470
2796
  });
2471
- await bot.sendButtons(chatId, `📌 ${checkpoints.length} 个回退点 (git checkpoint):`, buttons);
2797
+ await bot.sendButtons(chatId, `↩️ 回退到哪条消息之前?(共 ${userMsgs.length} )`, buttons);
2472
2798
  } else {
2473
- let msg = '回退到哪个点?回复 /undo <hash>\n\n';
2474
- recent.forEach(cp => {
2475
- msg += `${cp.hash.slice(0, 8)} ${cpDisplayLabel(cp.message)}\n`;
2799
+ let msg = '回退到哪条消息之前?回复 /undo_to <序号>\n\n';
2800
+ recent.forEach(({ idx, text }) => {
2801
+ msg += `[${idx}] ${text.slice(0, 40)}\n`;
2476
2802
  });
2477
2803
  await bot.sendMessage(chatId, msg);
2478
2804
  }
2805
+ } catch (e) {
2806
+ await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
2807
+ }
2808
+ return;
2809
+ }
2810
+
2811
+ // /undo_to <lineIdx> — restore session to before the message at given JSONL line index
2812
+ if (text.startsWith('/undo_to ')) {
2813
+ const idx = parseInt(text.slice(9).trim(), 10);
2814
+ if (isNaN(idx) || idx < 0) {
2815
+ await bot.sendMessage(chatId, '❌ 无效的回退序号');
2816
+ return;
2817
+ }
2818
+
2819
+ // Kill any running task
2820
+ if (messageQueue.has(chatId)) {
2821
+ const q = messageQueue.get(chatId);
2822
+ if (q.timer) clearTimeout(q.timer);
2823
+ messageQueue.delete(chatId);
2824
+ }
2825
+ const proc2 = activeProcesses.get(chatId);
2826
+ if (proc2 && proc2.child) {
2827
+ proc2.aborted = true;
2828
+ try { process.kill(-proc2.child.pid, 'SIGINT'); } catch { proc2.child.kill('SIGINT'); }
2829
+ }
2830
+
2831
+ const session2 = getSession(chatId);
2832
+ if (!session2 || !session2.id) {
2833
+ await bot.sendMessage(chatId, 'No active session.');
2479
2834
  return;
2480
2835
  }
2481
2836
 
2482
- // /undo <hash> — execute git reset
2483
2837
  try {
2484
- // Verify the hash exists and is a checkpoint
2485
- const match = checkpoints.find(cp => cp.hash.startsWith(arg));
2486
- if (!match) {
2487
- await bot.sendMessage(chatId, `❌ 未找到 checkpoint: ${arg}`);
2838
+ const sessionFile2 = findSessionFile(session2.id);
2839
+ if (!sessionFile2) { await bot.sendMessage(chatId, '❌ 找不到 session 文件'); return; }
2840
+
2841
+ const lines2 = fs.readFileSync(sessionFile2, 'utf8').split('\n').filter(l => l.trim());
2842
+ if (idx >= lines2.length) {
2843
+ await bot.sendMessage(chatId, '❌ 序号超出范围,session 已变化,请重新 /undo');
2488
2844
  return;
2489
2845
  }
2490
2846
 
2491
- // Get list of files that will change
2492
- let diffFiles = '';
2847
+ // Get target message text + timestamp for display and git matching
2848
+ let targetMsg = '', targetTs = 0;
2493
2849
  try {
2494
- diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000 }).trim();
2495
- } catch { /* ignore */ }
2496
-
2497
- // Reset working tree to checkpoint
2498
- execSync(`git reset --hard ${match.hash}`, { cwd, stdio: 'ignore', timeout: 10000 });
2850
+ const obj = JSON.parse(lines2[idx]);
2851
+ const content = obj.message?.content;
2852
+ if (typeof content === 'string') targetMsg = content;
2853
+ else if (Array.isArray(content)) targetMsg = content.find(c => c.type === 'text')?.text || '';
2854
+ if (obj.timestamp) targetTs = new Date(obj.timestamp).getTime() || 0;
2855
+ } catch { }
2499
2856
 
2500
- // Also truncate JSONL session history (best-effort, non-fatal)
2501
- try {
2502
- const sessionFile = findSessionFile(session.id);
2503
- if (sessionFile) {
2504
- const fileContent = fs.readFileSync(sessionFile, 'utf8');
2505
- const lines = fileContent.split('\n').filter(l => l.trim());
2506
- // Find the last user message that was sent BEFORE this checkpoint
2507
- // Use the checkpoint timestamp from the commit message
2508
- const cpTs = cpExtractTimestamp(match.message);
2509
- const cpTime = new Date(cpTs).getTime();
2510
- if (cpTime) {
2511
- // Find the first user message AFTER checkpoint time → truncate before it
2512
- let cutIdx = -1;
2513
- for (let i = lines.length - 1; i >= 0; i--) {
2514
- try {
2515
- const obj = JSON.parse(lines[i]);
2516
- if (obj.type === 'user' && obj.timestamp) {
2517
- const msgTime = new Date(obj.timestamp).getTime();
2518
- if (msgTime && msgTime >= cpTime) {
2519
- cutIdx = i;
2520
- } else {
2521
- break; // Found a message before checkpoint, stop
2522
- }
2523
- }
2524
- } catch { }
2525
- }
2526
- if (cutIdx > 0) {
2527
- const kept = lines.slice(0, cutIdx);
2528
- fs.writeFileSync(sessionFile, kept.join('\n') + '\n', 'utf8');
2529
- log('INFO', `Truncated session at line ${cutIdx} (${lines.length - cutIdx} lines removed)`);
2857
+ // Git reset first (before JSONL truncation) so failure leaves state consistent
2858
+ let gitMsg2 = '';
2859
+ const cwd2 = session2.cwd;
2860
+ if (cwd2) {
2861
+ let isGitRepo2 = false;
2862
+ try { execSync('git rev-parse --is-inside-work-tree', { cwd: cwd2, stdio: 'ignore', timeout: 3000 }); isGitRepo2 = true; } catch { }
2863
+ if (isGitRepo2) {
2864
+ // Exclude safety checkpoints from matching to avoid confusion
2865
+ const checkpoints2 = listCheckpoints(cwd2).filter(cp => !cp.message.includes('[metame-safety]'));
2866
+ const cpMatch = targetTs
2867
+ ? checkpoints2.find(cp => { const t = new Date(cpExtractTimestamp(cp.message) || 0).getTime(); return t > 0 && t <= targetTs; })
2868
+ : checkpoints2[0];
2869
+ if (cpMatch) {
2870
+ let diffFiles2 = '';
2871
+ try { diffFiles2 = execSync(`git diff --name-only HEAD ${cpMatch.hash}`, { cwd: cwd2, encoding: 'utf8', timeout: 5000 }).trim(); } catch { }
2872
+ if (diffFiles2) {
2873
+ // Save current state with distinct prefix (excluded from normal /undo list)
2874
+ gitCheckpoint(cwd2, `[metame-safety] before rollback to: ${targetMsg.slice(0, 40)}`);
2875
+ execSync(`git reset --hard ${cpMatch.hash}`, { cwd: cwd2, stdio: 'ignore', timeout: 10000 });
2876
+ gitMsg2 = `\n📁 ${diffFiles2.split('\n').length} 个文件已恢复`;
2877
+ cleanupCheckpoints(cwd2);
2530
2878
  }
2531
2879
  }
2532
2880
  }
2533
- } catch (truncErr) {
2534
- log('WARN', `Session truncation failed (non-fatal): ${truncErr.message}`);
2535
2881
  }
2536
2882
 
2537
- const fileList = diffFiles ? diffFiles.split('\n').map(f => path.basename(f)).join(', ') : '';
2538
- const fileCount = diffFiles ? diffFiles.split('\n').length : 0;
2539
- const label = cpDisplayLabel(match.message);
2540
- let msg = `⏪ 已回退\n📝 ${label}\n🔀 git reset --hard ${match.hash.slice(0, 8)}`;
2541
- if (fileCount > 0) {
2542
- msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
2543
- }
2544
- await bot.sendMessage(chatId, msg);
2883
+ // Truncate JSONL after git reset succeeds
2884
+ const kept2 = lines2.slice(0, idx);
2885
+ fs.writeFileSync(sessionFile2, kept2.length ? kept2.join('\n') + '\n' : '', 'utf8');
2886
+ _sessionFileCache.delete(session2.id);
2887
+ const removed2 = lines2.length - kept2.length;
2545
2888
 
2546
- // Cleanup old checkpoints in background
2547
- cleanupCheckpoints(cwd);
2889
+ const preview = targetMsg.replace(/\n/g, ' ').slice(0, 30) || `行 ${idx}`;
2890
+ log('INFO', `/undo_to ${idx} for ${chatId}: removed=${removed2} lines${gitMsg2 ? ', ' + gitMsg2.trim() : ''}`);
2891
+ await bot.sendMessage(chatId, `⏪ 已回退到「${preview}」之前\n🧠 上下文回滚 ${removed2} 行${gitMsg2}`);
2548
2892
  } catch (e) {
2549
- await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
2893
+ await bot.sendMessage(chatId, `❌ 回退失败: ${e.message}`);
2550
2894
  }
2551
2895
  return;
2552
2896
  }
@@ -2778,10 +3122,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2778
3122
  '/cd <path> — 切换工作目录',
2779
3123
  '/session — 查看当前会话',
2780
3124
  '/stop — 中断当前任务 (ESC)',
2781
- '/undo — 回退上一轮操作 (ESC×2)',
3125
+ '/undo — 选择历史消息,点击回退到该条之前',
3126
+ '/undo <hash> — 回退到指定 git checkpoint',
2782
3127
  '/quit — 结束会话,重新加载 MCP/配置',
2783
3128
  '',
2784
3129
  `⚙️ /model [${currentModel}] /provider [${currentProvider}] /status /tasks /run /budget /reload`,
3130
+ `🧠 /memory — 记忆统计 · /memory <关键词> — 搜索事实`,
2785
3131
  `🔧 /doctor /fix /reset /sh <cmd> /nosleep [${caffeinateProcess ? 'ON' : 'OFF'}]`,
2786
3132
  '',
2787
3133
  '直接打字即可对话 💬',
@@ -2878,6 +3224,80 @@ function findSessionFile(sessionId) {
2878
3224
  return null;
2879
3225
  }
2880
3226
 
3227
+ /**
3228
+ * Truncate the last conversation turn (user message + assistant response) from a session JSONL.
3229
+ * Finds the last {type:"user"} entry and removes it plus everything after.
3230
+ * Returns the number of lines removed, or 0 if nothing was truncated.
3231
+ */
3232
+ function truncateSessionLastTurn(sessionId) {
3233
+ try {
3234
+ const sessionFile = findSessionFile(sessionId);
3235
+ if (!sessionFile) return 0;
3236
+ const fileContent = fs.readFileSync(sessionFile, 'utf8');
3237
+ const lines = fileContent.split('\n').filter(l => l.trim());
3238
+ // Find the last user-type entry (walk backwards)
3239
+ let cutIdx = -1;
3240
+ for (let i = lines.length - 1; i >= 0; i--) {
3241
+ try {
3242
+ const obj = JSON.parse(lines[i]);
3243
+ if (obj.type === 'user') { cutIdx = i; break; }
3244
+ } catch { /* skip malformed lines */ }
3245
+ }
3246
+ if (cutIdx <= 0) return 0; // nothing to cut (keep at least line 0)
3247
+ const kept = lines.slice(0, cutIdx);
3248
+ fs.writeFileSync(sessionFile, kept.join('\n') + '\n', 'utf8');
3249
+ // Invalidate cache so next findSessionFile call re-reads fresh
3250
+ _sessionFileCache.delete(sessionId);
3251
+ const removed = lines.length - kept.length;
3252
+ log('INFO', `truncateSessionLastTurn: removed ${removed} lines from ${path.basename(sessionFile)}`);
3253
+ return removed;
3254
+ } catch (e) {
3255
+ log('WARN', `truncateSessionLastTurn failed: ${e.message}`);
3256
+ return 0;
3257
+ }
3258
+ }
3259
+
3260
+ /**
3261
+ * Truncate session JSONL to the point before a given checkpoint (timestamp-based).
3262
+ * Used for /undo <hash> to handle multi-turn rollback correctly.
3263
+ * Falls back to truncateSessionLastTurn if timestamp parsing fails.
3264
+ */
3265
+ function truncateSessionToCheckpoint(sessionId, checkpointMessage) {
3266
+ try {
3267
+ const cpTs = cpExtractTimestamp(checkpointMessage);
3268
+ const cpTime = cpTs ? new Date(cpTs).getTime() : 0;
3269
+ if (!cpTime) return truncateSessionLastTurn(sessionId);
3270
+
3271
+ const sessionFile = findSessionFile(sessionId);
3272
+ if (!sessionFile) return 0;
3273
+ const fileContent = fs.readFileSync(sessionFile, 'utf8');
3274
+ const lines = fileContent.split('\n').filter(l => l.trim());
3275
+
3276
+ // Find the first user message at or after checkpoint time → cut there
3277
+ let cutIdx = -1;
3278
+ for (let i = 0; i < lines.length; i++) {
3279
+ try {
3280
+ const obj = JSON.parse(lines[i]);
3281
+ if (obj.type === 'user' && obj.timestamp) {
3282
+ const msgTime = new Date(obj.timestamp).getTime();
3283
+ if (msgTime && msgTime >= cpTime) { cutIdx = i; break; }
3284
+ }
3285
+ } catch { /* skip malformed lines */ }
3286
+ }
3287
+ if (cutIdx <= 0) return truncateSessionLastTurn(sessionId); // fallback
3288
+
3289
+ const kept = lines.slice(0, cutIdx);
3290
+ fs.writeFileSync(sessionFile, kept.join('\n') + '\n', 'utf8');
3291
+ _sessionFileCache.delete(sessionId);
3292
+ const removed = lines.length - kept.length;
3293
+ log('INFO', `truncateSessionToCheckpoint: removed ${removed} lines from ${path.basename(sessionFile)}`);
3294
+ return removed;
3295
+ } catch (e) {
3296
+ log('WARN', `truncateSessionToCheckpoint failed: ${e.message}`);
3297
+ return truncateSessionLastTurn(sessionId); // fallback
3298
+ }
3299
+ }
3300
+
2881
3301
  /**
2882
3302
  * Scan all project session indexes, return most recent N sessions.
2883
3303
  * Results cached for 10 seconds to avoid repeated directory scans.
@@ -3014,6 +3434,13 @@ function listRecentSessions(limit, cwd) {
3014
3434
  return all.slice(0, limit || 10);
3015
3435
  }
3016
3436
 
3437
+ /** Load session_tags.json — returns {} if missing or malformed */
3438
+ function loadSessionTags() {
3439
+ try {
3440
+ return JSON.parse(fs.readFileSync(path.join(HOME, '.metame', 'session_tags.json'), 'utf8'));
3441
+ } catch { return {}; }
3442
+ }
3443
+
3017
3444
  /**
3018
3445
  * Get the actual file mtime of a session's .jsonl file (most accurate)
3019
3446
  */
@@ -3062,7 +3489,7 @@ function sessionLabel(s) {
3062
3489
  /**
3063
3490
  * Get the display title for a session using fallback chain: name → summary → firstPrompt
3064
3491
  */
3065
- function sessionDisplayTitle(s, maxLen) {
3492
+ function sessionDisplayTitle(s, maxLen, sessionTags) {
3066
3493
  maxLen = maxLen || 50;
3067
3494
  // Newlines → space; strip null bytes, surrogates, replacement char, other non-printable control chars
3068
3495
  const sanitize = (t) => t
@@ -3070,18 +3497,24 @@ function sessionDisplayTitle(s, maxLen) {
3070
3497
  .replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F\uFFFD\uD800-\uDFFF]/g, '')
3071
3498
  .replace(/\s+/g, ' ')
3072
3499
  .trim();
3500
+ // Priority: user name > our P2-A name > user's first message > Claude native summary
3501
+ // firstPrompt is authentic user content; s.summary is Claude's auto-generated index field (unreliable)
3073
3502
  if (s.customTitle) return sanitize(s.customTitle).slice(0, maxLen);
3074
- if (s.summary) return sanitize(s.summary).slice(0, maxLen);
3503
+ // P2-A: use Haiku-generated session name (only if memory-extract has processed this session)
3504
+ const tagEntry = sessionTags && sessionTags[s.sessionId];
3505
+ if (tagEntry && tagEntry.name) return sanitize(tagEntry.name).slice(0, maxLen);
3506
+ // Not yet processed by P2-A: show the user's actual first message
3075
3507
  if (s.firstPrompt) {
3076
3508
  const clean = s.firstPrompt
3077
3509
  .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
3078
3510
  .replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '')
3079
3511
  .replace(/\[System hints[\s\S]*/i, '');
3080
- // Take first non-empty line after stripping noise
3081
3512
  const firstLine = clean.split('\n').map(l => l.trim()).find(l => l.length > 2) || '';
3082
3513
  const sanitized = sanitize(firstLine);
3083
3514
  if (sanitized && sanitized.length > 2) return sanitized.slice(0, maxLen);
3084
3515
  }
3516
+ // Last resort: Claude's native auto-summary from sessions-index.json
3517
+ if (s.summary) return sanitize(s.summary).slice(0, maxLen);
3085
3518
  return '';
3086
3519
  }
3087
3520
 
@@ -3089,17 +3522,20 @@ function sessionDisplayTitle(s, maxLen) {
3089
3522
  * Format a session entry into a rich text block for non-button contexts (Feishu text).
3090
3523
  * Shows: name, title/summary, project, time, and /resume shortcut.
3091
3524
  */
3092
- function sessionRichLabel(s, index) {
3093
- const title = sessionDisplayTitle(s, 50);
3525
+ function sessionRichLabel(s, index, sessionTags) {
3526
+ sessionTags = sessionTags || loadSessionTags();
3527
+ const title = sessionDisplayTitle(s, 50, sessionTags);
3094
3528
  const proj = s.projectPath ? path.basename(s.projectPath) : '~';
3095
3529
  const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
3096
3530
  const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
3097
3531
  const ago = formatRelativeTime(new Date(timeMs).toISOString());
3098
3532
  const shortId = s.sessionId.slice(0, 8);
3533
+ const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 3);
3099
3534
 
3100
3535
  let line = `${index}. `;
3101
3536
  if (title) line += `${title}${title.length >= 50 ? '..' : ''}`;
3102
3537
  else line += `(unnamed)`;
3538
+ if (tags.length) line += ` ${tags.map(t => `#${t}`).join(' ')}`;
3103
3539
  line += `\n 📁${proj} · ${ago}`;
3104
3540
  line += `\n /resume ${shortId}`;
3105
3541
  return line;
@@ -3109,16 +3545,19 @@ function sessionRichLabel(s, index) {
3109
3545
  * Build Feishu card elements for a list of sessions (used by /sessions and /resume)
3110
3546
  */
3111
3547
  function buildSessionCardElements(sessions) {
3548
+ const sessionTags = loadSessionTags();
3112
3549
  const elements = [];
3113
3550
  sessions.forEach((s, i) => {
3114
3551
  if (i > 0) elements.push({ tag: 'hr' });
3115
- const title = sessionDisplayTitle(s, 60);
3552
+ const title = sessionDisplayTitle(s, 60, sessionTags);
3116
3553
  const proj = s.projectPath ? path.basename(s.projectPath) : '~';
3117
3554
  const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
3118
3555
  const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
3119
3556
  const ago = formatRelativeTime(new Date(timeMs).toISOString());
3120
3557
  const shortId = s.sessionId.slice(0, 6);
3558
+ const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 4);
3121
3559
  let desc = `**${i + 1}. ${title || '(unnamed)'}**\n📁${proj} · ${ago}`;
3560
+ if (tags.length) desc += `\n${tags.map(t => `\`${t}\``).join(' ')}`;
3122
3561
  elements.push({ tag: 'div', text: { tag: 'lark_md', content: desc } });
3123
3562
  elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: `▶️ Switch #${shortId}` }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } }] });
3124
3563
  });
@@ -3340,6 +3779,33 @@ const CONTENT_EXTENSIONS = new Set([
3340
3779
  // Active Claude processes per chat (for /stop)
3341
3780
  const activeProcesses = new Map(); // chatId -> { child, aborted }
3342
3781
 
3782
+ // Activity tracking for idle/sleep detection
3783
+ let lastInteractionTime = Date.now(); // updated on every incoming message
3784
+ let _inSleepMode = false; // tracks current sleep state for log transitions
3785
+
3786
+ const IDLE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
3787
+ const LOCAL_ACTIVE_FILE = path.join(METAME_DIR, 'local_active');
3788
+
3789
+ /**
3790
+ * Returns true when user has been inactive for >30min AND no sessions are running.
3791
+ * Checks BOTH mobile adapter activity (Telegram/Feishu) AND the local_active heartbeat
3792
+ * file (updated by Claude Code / index.js on each session start).
3793
+ * Dream tasks (require_idle: true) only execute in this state.
3794
+ */
3795
+ function isUserIdle() {
3796
+ // Check mobile adapter activity (Telegram/Feishu)
3797
+ if (Date.now() - lastInteractionTime <= IDLE_THRESHOLD_MS) return false;
3798
+ // Check local desktop activity via ~/.metame/local_active mtime
3799
+ try {
3800
+ if (fs.existsSync(LOCAL_ACTIVE_FILE)) {
3801
+ const mtime = fs.statSync(LOCAL_ACTIVE_FILE).mtimeMs;
3802
+ if (Date.now() - mtime < IDLE_THRESHOLD_MS) return false;
3803
+ }
3804
+ } catch { /* ignore — treat as idle if file unreadable */ }
3805
+ // Only idle if no active Claude sub-processes either
3806
+ return activeProcesses.size === 0;
3807
+ }
3808
+
3343
3809
  // Fix3: persist child PIDs so next daemon startup can kill orphans
3344
3810
  const ACTIVE_PIDS_FILE = path.join(HOME, '.metame', 'active_claude_pids.json');
3345
3811
  function saveActivePids() {
@@ -3736,24 +4202,6 @@ function trackMsgSession(messageId, session) {
3736
4202
  saveState(st);
3737
4203
  }
3738
4204
 
3739
- function lazyDistill() {
3740
- const now = Date.now();
3741
- const st = loadState();
3742
- const lastDistillTime = st.last_distill_time || 0;
3743
- if (now - lastDistillTime < 4 * 60 * 60 * 1000) return; // 4h cooldown
3744
- const distillPath = path.join(HOME, '.metame', 'distill.js');
3745
- const signalsPath = path.join(HOME, '.metame', 'raw_signals.jsonl');
3746
- if (!fs.existsSync(distillPath)) return;
3747
- if (!fs.existsSync(signalsPath)) return;
3748
- const content = fs.readFileSync(signalsPath, 'utf8').trim();
3749
- if (!content) return;
3750
- st.last_distill_time = now;
3751
- saveState(st);
3752
- const lines = content.split('\n').filter(l => l.trim()).length;
3753
- log('INFO', `Distilling ${lines} signal(s) in background...`);
3754
- const bg = spawn('node', [distillPath], { detached: true, stdio: 'ignore' });
3755
- bg.unref();
3756
- }
3757
4205
 
3758
4206
  /**
3759
4207
  * Shared ask logic — full Claude Code session (stateful, with tools)
@@ -3761,8 +4209,20 @@ function lazyDistill() {
3761
4209
  */
3762
4210
  async function askClaude(bot, chatId, prompt, config, readOnly = false) {
3763
4211
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
3764
- // Trigger background distill on first message / every 4h
3765
- try { lazyDistill(); } catch { /* non-fatal */ }
4212
+ // Track interaction time for idle/sleep detection
4213
+ lastInteractionTime = Date.now();
4214
+ // Track per-session last_active for summary generation (P2-B)
4215
+ try {
4216
+ const _st = loadState();
4217
+ if (_st.sessions && _st.sessions[chatId]) {
4218
+ _st.sessions[chatId].last_active = Date.now();
4219
+ saveState(_st);
4220
+ }
4221
+ } catch { /* non-critical */ }
4222
+ if (_inSleepMode) {
4223
+ _inSleepMode = false;
4224
+ log('INFO', '[DAEMON] Exiting Sleep Mode — user active');
4225
+ }
3766
4226
  // Send a single status message, updated in-place, deleted on completion
3767
4227
  let statusMsgId = null;
3768
4228
  try {
@@ -3864,6 +4324,42 @@ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
3864
4324
  args.push('--session-id', session.id);
3865
4325
  }
3866
4326
 
4327
+ // Memory & Knowledge Injection (RAG)
4328
+ let memoryHint = '';
4329
+ try {
4330
+ const memory = require('./memory');
4331
+ const _cid = String(chatId);
4332
+ const _cfg = loadConfig();
4333
+ const _agentMap = { ...(_cfg.telegram ? _cfg.telegram.chat_agent_map : {}), ...(_cfg.feishu ? _cfg.feishu.chat_agent_map : {}) };
4334
+ const projectKey = _agentMap[_cid] || (_cid.startsWith('_agent_') ? _cid.slice(7) : null);
4335
+
4336
+ // 1. Inject recent session memories ONLY on first message of a session
4337
+ if (!session.started) {
4338
+ const recent = memory.recentSessions({ limit: 3, project: projectKey || undefined });
4339
+ if (recent.length > 0) {
4340
+ const items = recent.map(r => `- [${r.created_at}] ${r.summary}${r.keywords ? ' (keywords: ' + r.keywords + ')' : ''}`).join('\n');
4341
+ memoryHint += `\n\n<!-- MEMORY:START -->\n[Session memory - recent context from past sessions, use to inform your responses:\n${items}]\n<!-- MEMORY:END -->`;
4342
+ }
4343
+ }
4344
+
4345
+ // 2. Dynamic Fact Injection (RAG) — first message only
4346
+ // Facts stay in Claude's context for the rest of the session; no need to repeat.
4347
+ // Uses QMD hybrid search if available, falls back to FTS5.
4348
+ if (!session.started) {
4349
+ const searchFn = memory.searchFactsAsync || memory.searchFacts;
4350
+ const facts = await Promise.resolve(searchFn(prompt, { limit: 5, project: projectKey || undefined }));
4351
+ if (facts.length > 0) {
4352
+ const factItems = facts.map(f => `- [${f.relation}] ${f.value}`).join('\n');
4353
+ memoryHint += `\n\n<!-- FACTS:START -->\n[Relevant knowledge and user preferences retrieved for this query. Follow these constraints implicitly:\n${factItems}]\n<!-- FACTS:END -->`;
4354
+ log('INFO', `[MEMORY] Injected ${facts.length} facts based on prompt`);
4355
+ }
4356
+ }
4357
+
4358
+ memory.close();
4359
+ } catch (e) {
4360
+ if (e.code !== 'MODULE_NOT_FOUND') log('WARN', `Memory injection failed: ${e.message}`);
4361
+ }
4362
+
3867
4363
  // Inject daemon hints only on first message of a session
3868
4364
  const daemonHint = !session.started ? `\n\n[System hints - DO NOT mention these to user:
3869
4365
  1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
@@ -3875,7 +4371,27 @@ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
3875
4371
  - Multiple files: use multiple [[FILE:...]] tags]` : '';
3876
4372
 
3877
4373
  const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
3878
- const fullPrompt = routedPrompt + daemonHint;
4374
+
4375
+ // P2-B: inject session summary when resuming after a 2h+ gap
4376
+ let summaryHint = '';
4377
+ if (session.started) {
4378
+ try {
4379
+ const _stSum = loadState();
4380
+ const _sess = _stSum.sessions && _stSum.sessions[chatId];
4381
+ if (_sess && _sess.last_summary && _sess.last_summary_at) {
4382
+ const _idleMs = Date.now() - (_sess.last_active || 0);
4383
+ const _summaryAgeH = (Date.now() - _sess.last_summary_at) / 3600000;
4384
+ if (_idleMs > 2 * 60 * 60 * 1000 && _summaryAgeH < 168) {
4385
+ summaryHint = `
4386
+
4387
+ [上次对话摘要,供参考]: ${_sess.last_summary}`;
4388
+ log('INFO', `[DAEMON] Injected session summary for ${chatId} (idle ${Math.round(_idleMs / 3600000)}h)`);
4389
+ }
4390
+ }
4391
+ } catch { /* non-critical */ }
4392
+ }
4393
+
4394
+ const fullPrompt = routedPrompt + daemonHint + summaryHint + memoryHint;
3879
4395
 
3880
4396
  // Git checkpoint before Claude modifies files (for /undo)
3881
4397
  // Pass the user prompt as label so checkpoint list is human-readable
@@ -4248,6 +4764,29 @@ async function main() {
4248
4764
 
4249
4765
  log('INFO', `MetaMe daemon started (PID: ${process.pid})`);
4250
4766
  killOrphanPids(); // Fix3: kill any claude processes left by previous daemon
4767
+
4768
+ // Pre-initialize memory DB at startup so the file exists before any agent session needs it.
4769
+ // This prevents Claude Code from showing a "new file" permission dialog mid-task on the desktop.
4770
+ try {
4771
+ const memMod = require('./memory');
4772
+ memMod.stats(); // triggers DB + schema creation
4773
+ memMod.close();
4774
+ log('INFO', `Memory DB ready: ${memMod.DB_PATH}`);
4775
+ } catch (e) {
4776
+ log('WARN', `Memory DB pre-init failed (non-fatal, will retry on first use): ${e.message}`);
4777
+ }
4778
+
4779
+ // Start QMD semantic search daemon if available (optional, non-fatal)
4780
+ try {
4781
+ const qmd = require('./qmd-client');
4782
+ if (qmd.isAvailable()) {
4783
+ qmd.ensureCollection();
4784
+ qmd.startDaemon().then(running => {
4785
+ if (running) log('INFO', '[QMD] Semantic search daemon started (localhost:8181)');
4786
+ else log('INFO', '[QMD] Available but daemon not started — will use CLI fallback');
4787
+ }).catch(() => { });
4788
+ }
4789
+ } catch { /* qmd-client not available, skip */ }
4251
4790
  // Hourly heartbeat so daemon.log stays fresh even when idle (visible aliveness check)
4252
4791
  setInterval(() => {
4253
4792
  log('INFO', `Daemon heartbeat — uptime: ${Math.round(process.uptime() / 60)}m, active sessions: ${activeProcesses.size}`);
@@ -4255,14 +4794,7 @@ async function main() {
4255
4794
 
4256
4795
  // Task executor lookup (always reads fresh config)
4257
4796
  function executeTaskByName(name) {
4258
- const legacy = (config.heartbeat && config.heartbeat.tasks) || [];
4259
- let task = legacy.find(t => t.name === name);
4260
- if (!task) {
4261
- for (const [key, proj] of Object.entries(config.projects || {})) {
4262
- const found = (proj.heartbeat_tasks || []).find(t => t.name === name);
4263
- if (found) { task = { ...found, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } }; break; }
4264
- }
4265
- }
4797
+ const task = findTask(config, name);
4266
4798
  if (!task) return { success: false, error: `Task "${name}" not found` };
4267
4799
  return executeTask(task, config);
4268
4800
  }
@@ -4327,6 +4859,9 @@ async function main() {
4327
4859
  }
4328
4860
  };
4329
4861
 
4862
+ // Start dispatch socket server (low-latency IPC, fallback: file polling still works)
4863
+ const dispatchSocket = startDispatchSocket(config);
4864
+
4330
4865
  // Start heartbeat scheduler
4331
4866
  let heartbeatTimer = startHeartbeat(config, notifyFn);
4332
4867
 
@@ -4338,10 +4873,9 @@ async function main() {
4338
4873
  refreshLogMaxSize(config);
4339
4874
  if (heartbeatTimer) clearInterval(heartbeatTimer);
4340
4875
  heartbeatTimer = startHeartbeat(config, notifyFn);
4341
- const legacyCount = (config.heartbeat && config.heartbeat.tasks || []).length;
4342
- const projectCount = Object.values(config.projects || {}).reduce((n, p) => n + (p.heartbeat_tasks || []).length, 0);
4343
- const totalCount = legacyCount + projectCount;
4344
- log('INFO', `Config reloaded: ${totalCount} tasks (${projectCount} in projects)`);
4876
+ const { general, project } = getAllTasks(config);
4877
+ const totalCount = general.length + project.length;
4878
+ log('INFO', `Config reloaded: ${totalCount} tasks (${project.length} in projects)`);
4345
4879
  return { success: true, tasks: totalCount };
4346
4880
  }
4347
4881
  // Expose reloadConfig to handleCommand via closure
@@ -4389,7 +4923,7 @@ async function main() {
4389
4923
  });
4390
4924
  // Hook: after every Claude task completes, check if restart is pending
4391
4925
  const _origDelete = activeProcesses.delete.bind(activeProcesses);
4392
- activeProcesses.delete = function(key) {
4926
+ activeProcesses.delete = function (key) {
4393
4927
  const result = _origDelete(key);
4394
4928
  if (_pendingRestart && activeProcesses.size === 0) {
4395
4929
  log('INFO', 'All tasks completed — executing deferred restart...');
@@ -4413,8 +4947,12 @@ async function main() {
4413
4947
  fs.unwatchFile(CONFIG_FILE);
4414
4948
  fs.unwatchFile(DAEMON_SCRIPT);
4415
4949
  if (heartbeatTimer) clearInterval(heartbeatTimer);
4950
+ if (dispatchSocket) try { dispatchSocket.close(); } catch { }
4951
+ try { fs.unlinkSync(SOCK_PATH); } catch { }
4416
4952
  if (telegramBridge) telegramBridge.stop();
4417
4953
  if (feishuBridge) feishuBridge.stop();
4954
+ // Stop QMD semantic search daemon if it was started
4955
+ try { require('./qmd-client').stopDaemon(); } catch { /* ignore */ }
4418
4956
  // Kill all tracked claude process groups before exiting (covers sub-agents too)
4419
4957
  for (const [cid, proc] of activeProcesses) {
4420
4958
  try { process.kill(-proc.child.pid, 'SIGKILL'); } catch { try { proc.child.kill('SIGKILL'); } catch { } }
@@ -4445,11 +4983,11 @@ if (process.argv.includes('--run')) {
4445
4983
  process.exit(1);
4446
4984
  }
4447
4985
  const config = loadConfig();
4448
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
4449
- const task = tasks.find(t => t.name === taskName);
4986
+ const task = findTask(config, taskName);
4450
4987
  if (!task) {
4988
+ const { all } = getAllTasks(config);
4451
4989
  console.error(`Task "${taskName}" not found in daemon.yaml`);
4452
- console.error(`Available: ${tasks.map(t => t.name).join(', ') || '(none)'}`);
4990
+ console.error(`Available: ${all.map(t => t.name).join(', ') || '(none)'}`);
4453
4991
  process.exit(1);
4454
4992
  }
4455
4993
  const result = executeTask(task, config);