metame-cli 1.4.0 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -205,6 +205,8 @@ metame daemon install-launchd # Auto-start on boot + crash recovery
205
205
 
206
206
  Done. Open Telegram, message your bot.
207
207
 
208
+ > **First message?** New chats aren't whitelisted yet. The bot will reply with a one-step setup command — just send `/bind personal ~/` and you're in.
209
+
208
210
  ---
209
211
 
210
212
  ## Core Capabilities
@@ -216,7 +218,7 @@ Done. Open Telegram, message your bot.
216
218
  | **Mobile Bridge** | Full Claude Code via Telegram/Feishu. Stateful sessions, file transfer both ways, real-time streaming status. |
217
219
  | **Skill Evolution** | Self-healing skill system. Auto-discovers missing skills, learns from browser recordings, evolves after every task. Skills get smarter over time. |
218
220
  | **Heartbeat System** | Three-layer programmable nervous system. Layer 0 kernel always-on (zero config). Layer 1 system evolution built-in (distill + memory + skills). Layer 2 your custom scheduled tasks with `require_idle`, `precondition`, `notify`, workflows. |
219
- | **Multi-Agent** | Multiple projects with dedicated chat groups. `/bind` for one-tap setup. True parallel execution. |
221
+ | **Multi-Agent** | Multiple projects with dedicated chat groups. `/agent bind` for one-tap setup. True parallel execution. |
220
222
  | **Browser Automation** | Built-in Playwright MCP. Browser control out of the box for every user. |
221
223
  | **Provider Relay** | Route through any Anthropic-compatible API. Use GPT-4, DeepSeek, Gemini — zero config file mutation. |
222
224
  | **Metacognition** | Detects behavioral patterns (decision style, comfort zones, goal drift) and injects mirror observations. Zero extra API cost. |
@@ -298,10 +300,11 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
298
300
  | `/new` | Start new session (project picker) |
299
301
  | `/resume` | Pick from session list |
300
302
  | `/stop` | Interrupt current task (ESC) |
301
- | `/undo` | Rollback with file restoration |
303
+ | `/undo` | Show recent messages as buttons — tap to roll back context + code to before that message |
304
+ | `/undo <hash>` | Roll back to a specific git checkpoint |
302
305
  | `/list` | Browse & download project files |
303
306
  | `/model` | Switch model (sonnet/opus/haiku) |
304
- | `/bind <name>` | Register group as dedicated agent |
307
+ | `/agent bind <name> [dir]` | Register group as dedicated agent |
305
308
  | `/sh <cmd>` | Raw shell — bypasses Claude |
306
309
  | `/memory` | Memory stats: fact count, session tags, DB size |
307
310
  | `/memory <keyword>` | Search long-term facts by keyword |
@@ -340,7 +343,7 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
340
343
  ## Security
341
344
 
342
345
  - All data stays on your machine. No cloud, no telemetry.
343
- - `allowed_chat_ids` whitelist — unauthorized users are silently ignored.
346
+ - `allowed_chat_ids` whitelist — unauthorized users get a one-step `/bind` guide instead of silent rejection.
344
347
  - `operator_ids` for shared groups — non-operators get read-only mode.
345
348
  - `~/.metame/` directory is mode 700.
346
349
  - Bot tokens stored locally, never transmitted.
@@ -359,14 +362,20 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
359
362
 
360
363
  > Both memory consolidation and session summarization run in the background via Haiku (`--model haiku`). Input is capped by code: skeleton text ≤ 3,000 chars, summary output ≤ 500 chars. Neither runs per-message — memory consolidation triggers on sleep mode (30-min idle), summaries trigger once per idle session.
361
364
 
362
- ## Plugin (Lightweight)
365
+ ## Plugin
363
366
 
364
- Don't need mobile access? Install as a Claude Code plugin — profile injection + slash commands only:
367
+ Install directly into Claude Code without npm:
365
368
 
366
369
  ```bash
367
370
  claude plugin install github:Yaron9/MetaMe/plugin
368
371
  ```
369
372
 
373
+ Includes: cognitive profile injection, daemon (Telegram/Feishu), heartbeat tasks, layered memory, all mobile commands, slash commands (`/metame:evolve`, `/metame:daemon`, `/metame:refresh`, etc.).
374
+
375
+ **One key difference from the npm CLI:** the plugin daemon starts when you open Claude Code and stops when you close it. It does not run 24/7 in the background. For always-on mobile access (receiving messages while Claude Code is closed), use the npm CLI with `metame daemon install-launchd`.
376
+
377
+ Use the plugin if you prefer not to install a global npm package and only need mobile access while Claude Code is open. Use the npm CLI (`metame-cli`) for 24/7 daemon, the `metame` command, and first-run interview.
378
+
370
379
  ## License
371
380
 
372
381
  MIT
package/index.js CHANGED
@@ -140,7 +140,21 @@ function ensureHookInstalled() {
140
140
  ensureHookInstalled();
141
141
 
142
142
  // ---------------------------------------------------------
143
- // 1.6b ENSURE PROJECT-LEVEL MCP CONFIG
143
+ // 1.6b LOCAL ACTIVITY HEARTBEAT
144
+ // ---------------------------------------------------------
145
+ // Touch ~/.metame/local_active so the daemon knows the user is active on desktop.
146
+ // This prevents dream tasks (require_idle: true) from firing during live Claude sessions.
147
+ try {
148
+ const localActiveFile = path.join(METAME_DIR, 'local_active');
149
+ // Ensure file exists (open with 'a' is a no-op if it already exists)
150
+ fs.closeSync(fs.openSync(localActiveFile, 'a'));
151
+ // Update mtime so daemon idle detection sees fresh activity
152
+ const now = new Date();
153
+ fs.utimesSync(localActiveFile, now, now);
154
+ } catch { /* non-fatal */ }
155
+
156
+ // ---------------------------------------------------------
157
+ // 1.6c ENSURE PROJECT-LEVEL MCP CONFIG
144
158
  // ---------------------------------------------------------
145
159
  // MCP servers are registered per-project via .mcp.json (not user-scope ~/.claude.json)
146
160
  // so they only load when working in projects that need them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.4.0",
3
+ "version": "1.4.5",
4
4
  "description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
5
5
  "main": "index.js",
6
6
  "bin": {
package/scripts/daemon.js CHANGED
@@ -924,19 +924,41 @@ function physiologicalHeartbeat(config) {
924
924
  }
925
925
 
926
926
  // ---------------------------------------------------------
927
- // HEARTBEAT SCHEDULER
927
+ // HEARTBEAT TASK HELPERS (single source of truth)
928
928
  // ---------------------------------------------------------
929
- function startHeartbeat(config, notifyFn) {
930
- const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
931
- const projectTasks = [];
932
- const legacyNames = new Set(legacyTasks.map(t => t.name));
933
- 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 || {})) {
934
940
  for (const t of (proj.heartbeat_tasks || [])) {
935
- if (legacyNames.has(t.name)) log('WARN', `Duplicate task name "${t.name}" in project "${key}" and legacy heartbeat — will run twice`);
936
- 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 || '🤖' } });
937
943
  }
938
944
  }
939
- 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);
940
962
 
941
963
  const enabledTasks = tasks.filter(t => t.enabled !== false);
942
964
  const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
@@ -978,6 +1000,9 @@ function startHeartbeat(config, notifyFn) {
978
1000
  log('INFO', '[DAEMON] Entering Sleep Mode');
979
1001
  // Generate summaries for sessions idle 2-24h
980
1002
  spawnSessionSummaries();
1003
+ } else if (!idle && _inSleepMode) {
1004
+ _inSleepMode = false;
1005
+ log('INFO', '[DAEMON] Exiting Sleep Mode — local activity detected');
981
1006
  }
982
1007
 
983
1008
  // ② Task heartbeat (burns tokens on schedule)
@@ -1101,9 +1126,10 @@ async function startTelegramBridge(config, executeTaskByName) {
1101
1126
  // Exception: /bind and /agent bind/new are allowed from any chat so users can self-register new groups
1102
1127
  const allowedIds = (loadConfig().telegram && loadConfig().telegram.allowed_chat_ids) || [];
1103
1128
  const trimmedText = msg.text && msg.text.trim();
1104
- const isBindCmd = trimmedText && (trimmedText.startsWith('/agent bind') || trimmedText.startsWith('/agent new'));
1129
+ const isBindCmd = trimmedText && (trimmedText.startsWith('/bind') || trimmedText.startsWith('/agent bind') || trimmedText.startsWith('/agent new'));
1105
1130
  if (!allowedIds.includes(chatId) && !isBindCmd) {
1106
1131
  log('WARN', `Rejected message from unauthorized chat: ${chatId}`);
1132
+ bot.sendMessage(chatId, `⚠️ This chat (ID: ${chatId}) is not authorized.\n\nTo get started, send:\n/bind personal ~/\n\nThis will register this chat and bind it to your home directory.`).catch(() => {});
1107
1133
  continue;
1108
1134
  }
1109
1135
 
@@ -1527,6 +1553,7 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
1527
1553
  }
1528
1554
 
1529
1555
  async function handleCommand(bot, chatId, text, config, executeTaskByName, senderId = null, readOnly = false) {
1556
+ if (text && !text.startsWith('/chatid') && !text.startsWith('/myid')) log('INFO', `CMD [${String(chatId).slice(-8)}]: ${text.slice(0, 80)}`);
1530
1557
  const state = loadState();
1531
1558
 
1532
1559
  // --- /chatid: reply with current chatId ---
@@ -1714,7 +1741,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1714
1741
  const factCount = s.facts ?? '?';
1715
1742
  const tagFile = path.join(HOME, '.metame', 'session_tags.json');
1716
1743
  let tagCount = 0;
1717
- try { tagCount = Object.keys(JSON.parse(fs.readFileSync(tagFile, 'utf8'))).length; } catch {}
1744
+ try { tagCount = Object.keys(JSON.parse(fs.readFileSync(tagFile, 'utf8'))).length; } catch { }
1718
1745
  const lines = [
1719
1746
  `🧠 *Memory Stats*`,
1720
1747
  `━━━━━━━━━━━━━━━━`,
@@ -1971,6 +1998,11 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1971
1998
  }
1972
1999
  }
1973
2000
 
2001
+ // /bind <name> [cwd] → alias for /agent bind <name> [cwd]
2002
+ if (text === '/bind' || text.startsWith('/bind ')) {
2003
+ text = '/agent bind' + text.slice(5);
2004
+ }
2005
+
1974
2006
  if (text === '/agent' || text.startsWith('/agent ')) {
1975
2007
  const agentArg = text === '/agent' ? '' : text.slice(7).trim();
1976
2008
  const agentParts = agentArg.split(/\s+/);
@@ -2269,22 +2301,25 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2269
2301
  }
2270
2302
 
2271
2303
  if (text === '/tasks') {
2304
+ const { general, project } = getAllTasks(config);
2272
2305
  let msg = '';
2273
- // Legacy flat tasks
2274
- const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
2275
- if (legacyTasks.length > 0) {
2306
+ if (general.length > 0) {
2276
2307
  msg += '📋 General:\n';
2277
- for (const t of legacyTasks) {
2308
+ for (const t of general) {
2278
2309
  const ts = state.tasks[t.name] || {};
2279
2310
  msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
2280
2311
  }
2281
2312
  }
2282
- // Project tasks grouped
2283
- for (const [, proj] of Object.entries(config.projects || {})) {
2284
- const pTasks = proj.heartbeat_tasks || [];
2285
- if (pTasks.length === 0) continue;
2286
- msg += `\n${proj.icon || '🤖'} ${proj.name || proj}:\n`;
2287
- for (const t of pTasks) {
2313
+ // Project tasks grouped by _project
2314
+ const byProject = new Map();
2315
+ for (const t of project) {
2316
+ const pk = t._project.key;
2317
+ if (!byProject.has(pk)) byProject.set(pk, { proj: t._project, tasks: [] });
2318
+ byProject.get(pk).tasks.push(t);
2319
+ }
2320
+ for (const [, { proj, tasks }] of byProject) {
2321
+ msg += `\n${proj.icon} ${proj.name}:\n`;
2322
+ for (const t of tasks) {
2288
2323
  const ts = state.tasks[t.name] || {};
2289
2324
  msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
2290
2325
  }
@@ -2403,13 +2438,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2403
2438
  return;
2404
2439
  }
2405
2440
  const taskName = text.slice(5).trim();
2406
- const allRunTasks = [...(config.heartbeat && config.heartbeat.tasks || [])];
2407
- for (const [key, proj] of Object.entries(config.projects || {})) {
2408
- for (const t of (proj.heartbeat_tasks || [])) {
2409
- allRunTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
2410
- }
2411
- }
2412
- const task = allRunTasks.find(t => t.name === taskName);
2441
+ const task = findTask(config, taskName);
2413
2442
  if (!task) { await bot.sendMessage(chatId, `❌ Task "${taskName}" not found`); return; }
2414
2443
 
2415
2444
  // Script tasks: quick, run inline
@@ -2676,7 +2705,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2676
2705
  }
2677
2706
 
2678
2707
  const session = getSession(chatId);
2679
- if (!session || !session.id || !session.cwd) {
2708
+ if (!session || !session.id) {
2680
2709
  await bot.sendMessage(chatId, 'No active session to undo.');
2681
2710
  return;
2682
2711
  }
@@ -2684,107 +2713,190 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2684
2713
  const cwd = session.cwd;
2685
2714
  const arg = text.slice(5).trim();
2686
2715
 
2687
- // Git-based undo: list checkpoints or reset to one
2688
- // First check if cwd is even a git repo
2689
- let isGitRepo = false;
2690
- try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore', timeout: 3000 }); isGitRepo = true; } catch { }
2691
- const checkpoints = isGitRepo ? listCheckpoints(cwd) : [];
2692
- if (!isGitRepo) {
2693
- await bot.sendMessage(chatId, `⚠️ 当前项目不在 git 仓库中,无法使用 /undo\n📁 ${cwd}\n\n切换到 git 项目后重试(/agent bind 或 /cd 切换目录)`);
2694
- return;
2695
- }
2696
- if (checkpoints.length === 0) {
2697
- await bot.sendMessage(chatId, `⚠️ 还没有回退点\n📁 ${path.basename(cwd)}\n\nCheckpoint 在 Claude 修改文件前自动创建,先让 Claude 做点改动再试`);
2716
+ // /undo <hash> git reset to specific checkpoint (advanced usage)
2717
+ if (arg) {
2718
+ if (!cwd) {
2719
+ await bot.sendMessage(chatId, ' 当前 session 无工作目录,无法执行 git undo');
2720
+ return;
2721
+ }
2722
+ let isGitRepo = false;
2723
+ try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore', timeout: 3000 }); isGitRepo = true; } catch { }
2724
+ const checkpoints = isGitRepo ? listCheckpoints(cwd) : [];
2725
+ const match = checkpoints.find(cp => cp.hash.startsWith(arg));
2726
+ if (!match) {
2727
+ await bot.sendMessage(chatId, `❌ 未找到 checkpoint: ${arg}`);
2728
+ return;
2729
+ }
2730
+ try {
2731
+ let diffFiles = '';
2732
+ try { diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000 }).trim(); } catch { }
2733
+ execSync(`git reset --hard ${match.hash}`, { cwd, stdio: 'ignore', timeout: 10000 });
2734
+ // Truncate context to checkpoint time (covers multi-turn rollback)
2735
+ truncateSessionToCheckpoint(session.id, match.message);
2736
+ const fileList = diffFiles ? diffFiles.split('\n').map(f => path.basename(f)).join(', ') : '';
2737
+ const fileCount = diffFiles ? diffFiles.split('\n').length : 0;
2738
+ let msg = `⏪ 已回退到 ${cpDisplayLabel(match.message)}`;
2739
+ if (fileCount > 0) msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
2740
+ log('INFO', `/undo <hash> executed for ${chatId}: reset to ${match.hash.slice(0, 8)}, files=${fileCount}`);
2741
+ await bot.sendMessage(chatId, msg);
2742
+ cleanupCheckpoints(cwd);
2743
+ } catch (e) {
2744
+ await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
2745
+ }
2698
2746
  return;
2699
2747
  }
2700
2748
 
2701
- if (!arg) {
2702
- // /undo (no arg) — show recent checkpoints to pick from
2703
- const recent = checkpoints.slice(0, 6); // newest first (already sorted)
2749
+ // /undo (no arg) — show recent user messages as buttons to pick rollback point
2750
+ try {
2751
+ const sessionFile = findSessionFile(session.id);
2752
+ if (!sessionFile) {
2753
+ await bot.sendMessage(chatId, '⚠️ 找不到 session 文件,无法列出历史消息');
2754
+ return;
2755
+ }
2756
+ const lines = fs.readFileSync(sessionFile, 'utf8').split('\n').filter(l => l.trim());
2757
+
2758
+ // Helper: extract real user text (skip tool_result entries and system annotations)
2759
+ const extractUserText = (obj) => {
2760
+ try {
2761
+ const content = obj.message?.content;
2762
+ if (typeof content === 'string') return content.trim();
2763
+ if (Array.isArray(content)) {
2764
+ // Skip entries that are purely tool results
2765
+ if (content.every(c => c.type === 'tool_result')) return '';
2766
+ // Find first text item that isn't a system annotation (exact patterns only)
2767
+ const SYSTEM_ANNOTATION = /^\[(Image source|Pasted|Attachment|File):/;
2768
+ const item = content.find(c => c.type === 'text' && c.text && !SYSTEM_ANNOTATION.test(c.text));
2769
+ return item?.text?.trim() || '';
2770
+ }
2771
+ } catch { }
2772
+ return '';
2773
+ };
2774
+
2775
+ // Collect only real human-written user messages (skip tool results / annotations)
2776
+ const userMsgs = [];
2777
+ for (let i = 0; i < lines.length; i++) {
2778
+ try {
2779
+ const obj = JSON.parse(lines[i]);
2780
+ if (obj.type === 'user' && obj.message?.role === 'user') {
2781
+ const text = extractUserText(obj);
2782
+ if (text) userMsgs.push({ idx: i, obj, text });
2783
+ }
2784
+ } catch { }
2785
+ }
2786
+ if (userMsgs.length === 0) {
2787
+ await bot.sendMessage(chatId, '⚠️ 没有可回退的历史消息');
2788
+ return;
2789
+ }
2790
+
2791
+ // Show last 10 (most recent first)
2792
+ const recent = userMsgs.slice(-10).reverse();
2704
2793
  if (bot.sendButtons) {
2705
- const buttons = recent.map((cp) => {
2706
- const label = cpDisplayLabel(cp.message);
2707
- return [{ text: `⏪ ${label}`, callback_data: `/undo ${cp.hash.slice(0, 10)}` }];
2794
+ const buttons = recent.map(({ idx, text, obj }) => {
2795
+ const msgText = text.replace(/\n/g, ' ').slice(0, 28);
2796
+ let timeLabel = '';
2797
+ if (obj.timestamp) {
2798
+ const d = new Date(obj.timestamp);
2799
+ if (!isNaN(d)) timeLabel = ` (${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')})`;
2800
+ }
2801
+ return [{ text: `⏪ ${msgText}${timeLabel}`, callback_data: `/undo_to ${idx}` }];
2708
2802
  });
2709
- await bot.sendButtons(chatId, `📌 ${checkpoints.length} 个回退点 (git checkpoint):`, buttons);
2803
+ await bot.sendButtons(chatId, `↩️ 回退到哪条消息之前?(共 ${userMsgs.length} )`, buttons);
2710
2804
  } else {
2711
- let msg = '回退到哪个点?回复 /undo <hash>\n\n';
2712
- recent.forEach(cp => {
2713
- msg += `${cp.hash.slice(0, 8)} ${cpDisplayLabel(cp.message)}\n`;
2805
+ let msg = '回退到哪条消息之前?回复 /undo_to <序号>\n\n';
2806
+ recent.forEach(({ idx, text }) => {
2807
+ msg += `[${idx}] ${text.slice(0, 40)}\n`;
2714
2808
  });
2715
2809
  await bot.sendMessage(chatId, msg);
2716
2810
  }
2811
+ } catch (e) {
2812
+ await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
2813
+ }
2814
+ return;
2815
+ }
2816
+
2817
+ // /undo_to <lineIdx> — restore session to before the message at given JSONL line index
2818
+ if (text.startsWith('/undo_to ')) {
2819
+ const idx = parseInt(text.slice(9).trim(), 10);
2820
+ if (isNaN(idx) || idx < 0) {
2821
+ await bot.sendMessage(chatId, '❌ 无效的回退序号');
2822
+ return;
2823
+ }
2824
+
2825
+ // Kill any running task
2826
+ if (messageQueue.has(chatId)) {
2827
+ const q = messageQueue.get(chatId);
2828
+ if (q.timer) clearTimeout(q.timer);
2829
+ messageQueue.delete(chatId);
2830
+ }
2831
+ const proc2 = activeProcesses.get(chatId);
2832
+ if (proc2 && proc2.child) {
2833
+ proc2.aborted = true;
2834
+ try { process.kill(-proc2.child.pid, 'SIGINT'); } catch { proc2.child.kill('SIGINT'); }
2835
+ }
2836
+
2837
+ const session2 = getSession(chatId);
2838
+ if (!session2 || !session2.id) {
2839
+ await bot.sendMessage(chatId, 'No active session.');
2717
2840
  return;
2718
2841
  }
2719
2842
 
2720
- // /undo <hash> — execute git reset
2721
2843
  try {
2722
- // Verify the hash exists and is a checkpoint
2723
- const match = checkpoints.find(cp => cp.hash.startsWith(arg));
2724
- if (!match) {
2725
- await bot.sendMessage(chatId, `❌ 未找到 checkpoint: ${arg}`);
2844
+ const sessionFile2 = findSessionFile(session2.id);
2845
+ if (!sessionFile2) { await bot.sendMessage(chatId, '❌ 找不到 session 文件'); return; }
2846
+
2847
+ const lines2 = fs.readFileSync(sessionFile2, 'utf8').split('\n').filter(l => l.trim());
2848
+ if (idx >= lines2.length) {
2849
+ await bot.sendMessage(chatId, '❌ 序号超出范围,session 已变化,请重新 /undo');
2726
2850
  return;
2727
2851
  }
2728
2852
 
2729
- // Get list of files that will change
2730
- let diffFiles = '';
2853
+ // Get target message text + timestamp for display and git matching
2854
+ let targetMsg = '', targetTs = 0;
2731
2855
  try {
2732
- diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000 }).trim();
2733
- } catch { /* ignore */ }
2734
-
2735
- // Reset working tree to checkpoint
2736
- execSync(`git reset --hard ${match.hash}`, { cwd, stdio: 'ignore', timeout: 10000 });
2856
+ const obj = JSON.parse(lines2[idx]);
2857
+ const content = obj.message?.content;
2858
+ if (typeof content === 'string') targetMsg = content;
2859
+ else if (Array.isArray(content)) targetMsg = content.find(c => c.type === 'text')?.text || '';
2860
+ if (obj.timestamp) targetTs = new Date(obj.timestamp).getTime() || 0;
2861
+ } catch { }
2737
2862
 
2738
- // Also truncate JSONL session history (best-effort, non-fatal)
2739
- try {
2740
- const sessionFile = findSessionFile(session.id);
2741
- if (sessionFile) {
2742
- const fileContent = fs.readFileSync(sessionFile, 'utf8');
2743
- const lines = fileContent.split('\n').filter(l => l.trim());
2744
- // Find the last user message that was sent BEFORE this checkpoint
2745
- // Use the checkpoint timestamp from the commit message
2746
- const cpTs = cpExtractTimestamp(match.message);
2747
- const cpTime = new Date(cpTs).getTime();
2748
- if (cpTime) {
2749
- // Find the first user message AFTER checkpoint time → truncate before it
2750
- let cutIdx = -1;
2751
- for (let i = lines.length - 1; i >= 0; i--) {
2752
- try {
2753
- const obj = JSON.parse(lines[i]);
2754
- if (obj.type === 'user' && obj.timestamp) {
2755
- const msgTime = new Date(obj.timestamp).getTime();
2756
- if (msgTime && msgTime >= cpTime) {
2757
- cutIdx = i;
2758
- } else {
2759
- break; // Found a message before checkpoint, stop
2760
- }
2761
- }
2762
- } catch { }
2763
- }
2764
- if (cutIdx > 0) {
2765
- const kept = lines.slice(0, cutIdx);
2766
- fs.writeFileSync(sessionFile, kept.join('\n') + '\n', 'utf8');
2767
- log('INFO', `Truncated session at line ${cutIdx} (${lines.length - cutIdx} lines removed)`);
2863
+ // Git reset first (before JSONL truncation) so failure leaves state consistent
2864
+ let gitMsg2 = '';
2865
+ const cwd2 = session2.cwd;
2866
+ if (cwd2) {
2867
+ let isGitRepo2 = false;
2868
+ try { execSync('git rev-parse --is-inside-work-tree', { cwd: cwd2, stdio: 'ignore', timeout: 3000 }); isGitRepo2 = true; } catch { }
2869
+ if (isGitRepo2) {
2870
+ // Exclude safety checkpoints from matching to avoid confusion
2871
+ const checkpoints2 = listCheckpoints(cwd2).filter(cp => !cp.message.includes('[metame-safety]'));
2872
+ const cpMatch = targetTs
2873
+ ? checkpoints2.find(cp => { const t = new Date(cpExtractTimestamp(cp.message) || 0).getTime(); return t > 0 && t <= targetTs; })
2874
+ : checkpoints2[0];
2875
+ if (cpMatch) {
2876
+ let diffFiles2 = '';
2877
+ try { diffFiles2 = execSync(`git diff --name-only HEAD ${cpMatch.hash}`, { cwd: cwd2, encoding: 'utf8', timeout: 5000 }).trim(); } catch { }
2878
+ if (diffFiles2) {
2879
+ // Save current state with distinct prefix (excluded from normal /undo list)
2880
+ gitCheckpoint(cwd2, `[metame-safety] before rollback to: ${targetMsg.slice(0, 40)}`);
2881
+ execSync(`git reset --hard ${cpMatch.hash}`, { cwd: cwd2, stdio: 'ignore', timeout: 10000 });
2882
+ gitMsg2 = `\n📁 ${diffFiles2.split('\n').length} 个文件已恢复`;
2883
+ cleanupCheckpoints(cwd2);
2768
2884
  }
2769
2885
  }
2770
2886
  }
2771
- } catch (truncErr) {
2772
- log('WARN', `Session truncation failed (non-fatal): ${truncErr.message}`);
2773
2887
  }
2774
2888
 
2775
- const fileList = diffFiles ? diffFiles.split('\n').map(f => path.basename(f)).join(', ') : '';
2776
- const fileCount = diffFiles ? diffFiles.split('\n').length : 0;
2777
- const label = cpDisplayLabel(match.message);
2778
- let msg = `⏪ 已回退\n📝 ${label}\n🔀 git reset --hard ${match.hash.slice(0, 8)}`;
2779
- if (fileCount > 0) {
2780
- msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
2781
- }
2782
- await bot.sendMessage(chatId, msg);
2889
+ // Truncate JSONL after git reset succeeds
2890
+ const kept2 = lines2.slice(0, idx);
2891
+ fs.writeFileSync(sessionFile2, kept2.length ? kept2.join('\n') + '\n' : '', 'utf8');
2892
+ _sessionFileCache.delete(session2.id);
2893
+ const removed2 = lines2.length - kept2.length;
2783
2894
 
2784
- // Cleanup old checkpoints in background
2785
- cleanupCheckpoints(cwd);
2895
+ const preview = targetMsg.replace(/\n/g, ' ').slice(0, 30) || `行 ${idx}`;
2896
+ log('INFO', `/undo_to ${idx} for ${chatId}: removed=${removed2} lines${gitMsg2 ? ', ' + gitMsg2.trim() : ''}`);
2897
+ await bot.sendMessage(chatId, `⏪ 已回退到「${preview}」之前\n🧠 上下文回滚 ${removed2} 行${gitMsg2}`);
2786
2898
  } catch (e) {
2787
- await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
2899
+ await bot.sendMessage(chatId, `❌ 回退失败: ${e.message}`);
2788
2900
  }
2789
2901
  return;
2790
2902
  }
@@ -3016,7 +3128,8 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
3016
3128
  '/cd <path> — 切换工作目录',
3017
3129
  '/session — 查看当前会话',
3018
3130
  '/stop — 中断当前任务 (ESC)',
3019
- '/undo — 回退上一轮操作 (ESC×2)',
3131
+ '/undo — 选择历史消息,点击回退到该条之前',
3132
+ '/undo <hash> — 回退到指定 git checkpoint',
3020
3133
  '/quit — 结束会话,重新加载 MCP/配置',
3021
3134
  '',
3022
3135
  `⚙️ /model [${currentModel}] /provider [${currentProvider}] /status /tasks /run /budget /reload`,
@@ -3117,6 +3230,80 @@ function findSessionFile(sessionId) {
3117
3230
  return null;
3118
3231
  }
3119
3232
 
3233
+ /**
3234
+ * Truncate the last conversation turn (user message + assistant response) from a session JSONL.
3235
+ * Finds the last {type:"user"} entry and removes it plus everything after.
3236
+ * Returns the number of lines removed, or 0 if nothing was truncated.
3237
+ */
3238
+ function truncateSessionLastTurn(sessionId) {
3239
+ try {
3240
+ const sessionFile = findSessionFile(sessionId);
3241
+ if (!sessionFile) return 0;
3242
+ const fileContent = fs.readFileSync(sessionFile, 'utf8');
3243
+ const lines = fileContent.split('\n').filter(l => l.trim());
3244
+ // Find the last user-type entry (walk backwards)
3245
+ let cutIdx = -1;
3246
+ for (let i = lines.length - 1; i >= 0; i--) {
3247
+ try {
3248
+ const obj = JSON.parse(lines[i]);
3249
+ if (obj.type === 'user') { cutIdx = i; break; }
3250
+ } catch { /* skip malformed lines */ }
3251
+ }
3252
+ if (cutIdx <= 0) return 0; // nothing to cut (keep at least line 0)
3253
+ const kept = lines.slice(0, cutIdx);
3254
+ fs.writeFileSync(sessionFile, kept.join('\n') + '\n', 'utf8');
3255
+ // Invalidate cache so next findSessionFile call re-reads fresh
3256
+ _sessionFileCache.delete(sessionId);
3257
+ const removed = lines.length - kept.length;
3258
+ log('INFO', `truncateSessionLastTurn: removed ${removed} lines from ${path.basename(sessionFile)}`);
3259
+ return removed;
3260
+ } catch (e) {
3261
+ log('WARN', `truncateSessionLastTurn failed: ${e.message}`);
3262
+ return 0;
3263
+ }
3264
+ }
3265
+
3266
+ /**
3267
+ * Truncate session JSONL to the point before a given checkpoint (timestamp-based).
3268
+ * Used for /undo <hash> to handle multi-turn rollback correctly.
3269
+ * Falls back to truncateSessionLastTurn if timestamp parsing fails.
3270
+ */
3271
+ function truncateSessionToCheckpoint(sessionId, checkpointMessage) {
3272
+ try {
3273
+ const cpTs = cpExtractTimestamp(checkpointMessage);
3274
+ const cpTime = cpTs ? new Date(cpTs).getTime() : 0;
3275
+ if (!cpTime) return truncateSessionLastTurn(sessionId);
3276
+
3277
+ const sessionFile = findSessionFile(sessionId);
3278
+ if (!sessionFile) return 0;
3279
+ const fileContent = fs.readFileSync(sessionFile, 'utf8');
3280
+ const lines = fileContent.split('\n').filter(l => l.trim());
3281
+
3282
+ // Find the first user message at or after checkpoint time → cut there
3283
+ let cutIdx = -1;
3284
+ for (let i = 0; i < lines.length; i++) {
3285
+ try {
3286
+ const obj = JSON.parse(lines[i]);
3287
+ if (obj.type === 'user' && obj.timestamp) {
3288
+ const msgTime = new Date(obj.timestamp).getTime();
3289
+ if (msgTime && msgTime >= cpTime) { cutIdx = i; break; }
3290
+ }
3291
+ } catch { /* skip malformed lines */ }
3292
+ }
3293
+ if (cutIdx <= 0) return truncateSessionLastTurn(sessionId); // fallback
3294
+
3295
+ const kept = lines.slice(0, cutIdx);
3296
+ fs.writeFileSync(sessionFile, kept.join('\n') + '\n', 'utf8');
3297
+ _sessionFileCache.delete(sessionId);
3298
+ const removed = lines.length - kept.length;
3299
+ log('INFO', `truncateSessionToCheckpoint: removed ${removed} lines from ${path.basename(sessionFile)}`);
3300
+ return removed;
3301
+ } catch (e) {
3302
+ log('WARN', `truncateSessionToCheckpoint failed: ${e.message}`);
3303
+ return truncateSessionLastTurn(sessionId); // fallback
3304
+ }
3305
+ }
3306
+
3120
3307
  /**
3121
3308
  * Scan all project session indexes, return most recent N sessions.
3122
3309
  * Results cached for 10 seconds to avoid repeated directory scans.
@@ -3603,13 +3790,26 @@ let lastInteractionTime = Date.now(); // updated on every incoming message
3603
3790
  let _inSleepMode = false; // tracks current sleep state for log transitions
3604
3791
 
3605
3792
  const IDLE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
3793
+ const LOCAL_ACTIVE_FILE = path.join(METAME_DIR, 'local_active');
3606
3794
 
3607
3795
  /**
3608
3796
  * Returns true when user has been inactive for >30min AND no sessions are running.
3797
+ * Checks BOTH mobile adapter activity (Telegram/Feishu) AND the local_active heartbeat
3798
+ * file (updated by Claude Code / index.js on each session start).
3609
3799
  * Dream tasks (require_idle: true) only execute in this state.
3610
3800
  */
3611
3801
  function isUserIdle() {
3612
- return (Date.now() - lastInteractionTime > IDLE_THRESHOLD_MS) && activeProcesses.size === 0;
3802
+ // Check mobile adapter activity (Telegram/Feishu)
3803
+ if (Date.now() - lastInteractionTime <= IDLE_THRESHOLD_MS) return false;
3804
+ // Check local desktop activity via ~/.metame/local_active mtime
3805
+ try {
3806
+ if (fs.existsSync(LOCAL_ACTIVE_FILE)) {
3807
+ const mtime = fs.statSync(LOCAL_ACTIVE_FILE).mtimeMs;
3808
+ if (Date.now() - mtime < IDLE_THRESHOLD_MS) return false;
3809
+ }
3810
+ } catch { /* ignore — treat as idle if file unreadable */ }
3811
+ // Only idle if no active Claude sub-processes either
3812
+ return activeProcesses.size === 0;
3613
3813
  }
3614
3814
 
3615
3815
  // Fix3: persist child PIDs so next daemon startup can kill orphans
@@ -4410,9 +4610,10 @@ async function startFeishuBridge(config, executeTaskByName) {
4410
4610
  const liveCfg = loadConfig();
4411
4611
  const allowedIds = (liveCfg.feishu && liveCfg.feishu.allowed_chat_ids) || [];
4412
4612
  const trimmedText = text && text.trim();
4413
- const isBindCmd = trimmedText && (trimmedText.startsWith('/agent bind') || trimmedText.startsWith('/agent new'));
4613
+ const isBindCmd = trimmedText && (trimmedText.startsWith('/bind') || trimmedText.startsWith('/agent bind') || trimmedText.startsWith('/agent new'));
4414
4614
  if (!allowedIds.includes(chatId) && !isBindCmd) {
4415
4615
  log('WARN', `Feishu: rejected message from ${chatId}`);
4616
+ bot.sendMessage(chatId, `⚠️ 此会话 (ID: ${chatId}) 未授权。\n\n发送以下命令注册:\n/bind personal ~/\n\n这会将此会话绑定到你的主目录。`).catch(() => {});
4416
4617
  return;
4417
4618
  }
4418
4619
 
@@ -4590,7 +4791,7 @@ async function main() {
4590
4791
  qmd.startDaemon().then(running => {
4591
4792
  if (running) log('INFO', '[QMD] Semantic search daemon started (localhost:8181)');
4592
4793
  else log('INFO', '[QMD] Available but daemon not started — will use CLI fallback');
4593
- }).catch(() => {});
4794
+ }).catch(() => { });
4594
4795
  }
4595
4796
  } catch { /* qmd-client not available, skip */ }
4596
4797
  // Hourly heartbeat so daemon.log stays fresh even when idle (visible aliveness check)
@@ -4600,14 +4801,7 @@ async function main() {
4600
4801
 
4601
4802
  // Task executor lookup (always reads fresh config)
4602
4803
  function executeTaskByName(name) {
4603
- const legacy = (config.heartbeat && config.heartbeat.tasks) || [];
4604
- let task = legacy.find(t => t.name === name);
4605
- if (!task) {
4606
- for (const [key, proj] of Object.entries(config.projects || {})) {
4607
- const found = (proj.heartbeat_tasks || []).find(t => t.name === name);
4608
- if (found) { task = { ...found, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } }; break; }
4609
- }
4610
- }
4804
+ const task = findTask(config, name);
4611
4805
  if (!task) return { success: false, error: `Task "${name}" not found` };
4612
4806
  return executeTask(task, config);
4613
4807
  }
@@ -4686,10 +4880,9 @@ async function main() {
4686
4880
  refreshLogMaxSize(config);
4687
4881
  if (heartbeatTimer) clearInterval(heartbeatTimer);
4688
4882
  heartbeatTimer = startHeartbeat(config, notifyFn);
4689
- const legacyCount = (config.heartbeat && config.heartbeat.tasks || []).length;
4690
- const projectCount = Object.values(config.projects || {}).reduce((n, p) => n + (p.heartbeat_tasks || []).length, 0);
4691
- const totalCount = legacyCount + projectCount;
4692
- log('INFO', `Config reloaded: ${totalCount} tasks (${projectCount} in projects)`);
4883
+ const { general, project } = getAllTasks(config);
4884
+ const totalCount = general.length + project.length;
4885
+ log('INFO', `Config reloaded: ${totalCount} tasks (${project.length} in projects)`);
4693
4886
  return { success: true, tasks: totalCount };
4694
4887
  }
4695
4888
  // Expose reloadConfig to handleCommand via closure
@@ -4797,11 +4990,11 @@ if (process.argv.includes('--run')) {
4797
4990
  process.exit(1);
4798
4991
  }
4799
4992
  const config = loadConfig();
4800
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
4801
- const task = tasks.find(t => t.name === taskName);
4993
+ const task = findTask(config, taskName);
4802
4994
  if (!task) {
4995
+ const { all } = getAllTasks(config);
4803
4996
  console.error(`Task "${taskName}" not found in daemon.yaml`);
4804
- console.error(`Available: ${tasks.map(t => t.name).join(', ') || '(none)'}`);
4997
+ console.error(`Available: ${all.map(t => t.name).join(', ') || '(none)'}`);
4805
4998
  process.exit(1);
4806
4999
  }
4807
5000
  const result = executeTask(task, config);
@@ -82,14 +82,14 @@ async function distill() {
82
82
  }
83
83
 
84
84
  try {
85
- // 3. Parse signals (preserve confidence from signal-capture)
85
+ // 3. Parse signals (preserve confidence + type from signal-capture)
86
86
  const signals = [];
87
87
  let highConfidenceCount = 0;
88
88
  for (const line of lines) {
89
89
  try {
90
90
  const entry = JSON.parse(line);
91
91
  if (entry.prompt) {
92
- signals.push(entry.prompt);
92
+ signals.push({ text: entry.prompt, type: entry.type || 'implicit' });
93
93
  if (entry.confidence === 'high') highConfidenceCount++;
94
94
  }
95
95
  } catch {
@@ -127,18 +127,24 @@ async function distill() {
127
127
  }
128
128
 
129
129
  // 5. Build distillation prompt (compact + session-aware)
130
- const userMessages = signals
131
- .map((s, i) => `${i + 1}. "${s}"`)
132
- .join('\n');
130
+ // Input budget: keep total prompt under INPUT_TOKEN_BUDGET to control cost/latency.
131
+ // Priority: system prompt + profile + writable keys (must keep) > user messages > session context
132
+ const INPUT_TOKEN_BUDGET = 4000; // ~12K chars mixed zh/en
133
133
 
134
134
  const writableKeys = getWritableKeysForPrompt();
135
135
 
136
- // Session context section (only when skeleton exists, ~60 tokens)
136
+ // Reserve budget for fixed parts (system prompt template ~600 tokens, profile, writable keys)
137
+ const fixedOverhead = 600; // system prompt template + rules
138
+ const profileTokens = estimateTokens(currentProfile);
139
+ const keysTokens = estimateTokens(writableKeys);
140
+ const reservedTokens = fixedOverhead + profileTokens + keysTokens;
141
+ const availableForContent = Math.max(INPUT_TOKEN_BUDGET - reservedTokens, 200);
142
+
143
+ // Build session context (lower priority — truncate first)
137
144
  let sessionSection = sessionContext
138
145
  ? `\nSESSION CONTEXT (what actually happened in the latest coding session):\n${sessionContext}\n`
139
146
  : '';
140
147
 
141
- // Add summary for long sessions (~30 tokens when present)
142
148
  if (sessionSummary) {
143
149
  const pivotText = sessionSummary.pivots.length > 0
144
150
  ? `\nPivots: ${sessionSummary.pivots.join('; ')}`
@@ -146,12 +152,41 @@ async function distill() {
146
152
  sessionSection += `Summary: ${sessionSummary.intent} → ${sessionSummary.outcome}${pivotText}\n`;
147
153
  }
148
154
 
149
- // Goal context section (~11 tokens when present)
150
155
  let goalContext = '';
151
156
  if (sessionAnalytics) {
152
157
  try { goalContext = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch { }
153
158
  }
154
- const goalSection = goalContext ? `\n${goalContext}\n` : '';
159
+ let goalSection = goalContext ? `\n${goalContext}\n` : '';
160
+
161
+ // Allocate remaining budget: user messages get priority over session context
162
+ const sessionTokens = estimateTokens(sessionSection + goalSection);
163
+ let budgetForMessages = availableForContent - sessionTokens;
164
+
165
+ // If not enough room, drop session context first, then trim messages
166
+ if (budgetForMessages < 100) {
167
+ sessionSection = '';
168
+ goalSection = '';
169
+ budgetForMessages = availableForContent;
170
+ }
171
+
172
+ // Format signals: tag metacognitive signals so Haiku treats them differently
173
+ const formatSignal = (s, i) => {
174
+ const tag = s.type === 'metacognitive' ? ' [META]' : '';
175
+ return `${i + 1}. "${s.text}"${tag}`;
176
+ };
177
+
178
+ // Truncate user messages to fit budget (keep most recent, they're more relevant)
179
+ let truncatedSignals = signals;
180
+ let userMessages = signals.map(formatSignal).join('\n');
181
+ if (estimateTokens(userMessages) > budgetForMessages) {
182
+ // Drop oldest messages until we fit
183
+ while (truncatedSignals.length > 1 && estimateTokens(
184
+ truncatedSignals.map(formatSignal).join('\n')
185
+ ) > budgetForMessages) {
186
+ truncatedSignals = truncatedSignals.slice(1);
187
+ }
188
+ userMessages = truncatedSignals.map(formatSignal).join('\n');
189
+ }
155
190
 
156
191
  const distillPrompt = `You are a MetaMe cognitive profile distiller. Extract COGNITIVE TRAITS and PREFERENCES — how the user thinks, decides, and communicates. NOT a memory system. Do NOT store facts.
157
192
 
@@ -172,6 +207,7 @@ RULES:
172
207
  3. Only output fields from WRITABLE FIELDS. Any other key will be rejected.
173
208
  4. For enum fields, use one of the listed values.
174
209
  5. Strong directives (以后一律/always/never/from now on) → _confidence: high. Otherwise: normal.
210
+ 6. Messages tagged [META] are metacognitive signals (self-reflection, strategy shifts, error awareness). These are HIGH VALUE for cognition fields — extract decision_style, error_response, receptive_to_challenge, and behavioral patterns from them.
175
211
  7. Add _confidence and _source blocks mapping field keys to confidence level and triggering quote.
176
212
  8. NEVER extract agent identity or role definitions. Messages like "你是贾维斯/你的角色是.../you are Jarvis" define the AGENT, not the USER. The profile is about the USER's cognition only.
177
213
 
@@ -118,7 +118,7 @@ function createBot(config) {
118
118
  * Send markdown as Feishu interactive card (lark_md renders bold, lists, code, links)
119
119
  */
120
120
  async sendMarkdown(chatId, markdown) {
121
- const elements = toMdChunks(markdown).map(c => ({ tag: 'markdown', content: c }));
121
+ const elements = toMdChunks(markdown).map(c => ({ tag: 'markdown', content: c, text_size: 'x-large' }));
122
122
  return _sendInteractive(chatId, { schema: '2.0', body: { elements } });
123
123
  },
124
124
 
@@ -132,7 +132,7 @@ function createBot(config) {
132
132
  */
133
133
  async sendCard(chatId, { title, body, color = 'blue' }) {
134
134
  const header = { title: { tag: 'plain_text', content: title }, template: color };
135
- const elements = body ? toMdChunks(body).map(c => ({ tag: 'markdown', content: c })) : [];
135
+ const elements = body ? toMdChunks(body).map(c => ({ tag: 'markdown', content: c, text_size: 'x-large' })) : [];
136
136
  return _sendInteractive(chatId, { schema: '2.0', header, body: { elements } });
137
137
  },
138
138
 
@@ -28,8 +28,9 @@ const FACT_EXTRACTION_PROMPT = `你是精准的知识提取引擎。从以下会
28
28
  - bug_lesson(Bug根因:什么设计/假设导致了问题)
29
29
  - arch_convention(架构约定:系统组件的行为边界)
30
30
  - config_fact(配置事实:某个值的真实含义,尤其反直觉的)
31
+ - config_change(配置变更:用户选择/确认了某个具体配置值,如”字体选了x-large”、”间隔改为2h”)
31
32
  - user_pref(用户明确表达的偏好/红线)
32
- - workflow_rule(工作流戒律:如“不要在某情况下做某事”的反常识流)
33
+ - workflow_rule(工作流戒律:如”不要在某情况下做某事”的反常识流)
33
34
  - project_milestone(项目里程碑:主要架构重构、版本发布等跨会话级成果)
34
35
 
35
36
  绝对不提取:
@@ -3,12 +3,14 @@
3
3
  * memory-search.js — Cross-session memory recall CLI
4
4
  *
5
5
  * Usage:
6
- * node memory-search.js "<query>" # search both sessions and facts
7
- * node memory-search.js --facts "<query>" # search facts only
8
- * node memory-search.js --sessions "<query>" # search sessions only
9
- * node memory-search.js --recent # show recent sessions
6
+ * node memory-search.js "<query>" # hybrid search (QMD + FTS5)
7
+ * node memory-search.js "<q1>" "<q2>" "<q3>" # multi-keyword parallel search
8
+ * node memory-search.js --facts "<query>" # search facts only
9
+ * node memory-search.js --sessions "<query>" # search sessions only
10
+ * node memory-search.js --recent # show recent sessions
10
11
  *
11
- * Called by Claude via Bash tool when it needs to recall past knowledge.
12
+ * Multi-keyword: results are deduplicated by fact ID, best rank wins.
13
+ * Async: uses QMD hybrid search (BM25 + vector) when available, falls back to FTS5.
12
14
  */
13
15
 
14
16
  'use strict';
@@ -16,7 +18,6 @@
16
18
  const path = require('path');
17
19
  const os = require('os');
18
20
 
19
- // Support both local dev and installed (~/.metame/) paths
20
21
  const memoryPath = [
21
22
  path.join(os.homedir(), '.metame', 'memory.js'),
22
23
  path.join(__dirname, 'memory.js'),
@@ -31,69 +32,97 @@ const memory = require(memoryPath);
31
32
 
32
33
  const args = process.argv.slice(2);
33
34
  const mode = args[0] && args[0].startsWith('--') ? args[0] : null;
34
- const query = mode ? args[1] : args[0];
35
+ const queries = mode ? args.slice(1) : args;
35
36
 
36
- try {
37
- if (mode === '--recent') {
38
- const rows = memory.recentSessions({ limit: 5 });
39
- console.log(JSON.stringify(rows.map(r => ({
40
- type: 'session',
41
- project: r.project,
42
- date: r.created_at,
43
- summary: r.summary,
44
- })), null, 2));
45
-
46
- } else if (mode === '--facts') {
47
- if (!query) { console.log('[]'); process.exit(0); }
48
- const facts = memory.searchFacts(query, { limit: 5 });
49
- console.log(JSON.stringify(facts.map(f => ({
37
+ async function main() {
38
+ try {
39
+ if (mode === '--recent') {
40
+ const rows = memory.recentSessions({ limit: 5 });
41
+ console.log(JSON.stringify(rows.map(r => ({
42
+ type: 'session',
43
+ project: r.project,
44
+ date: r.created_at,
45
+ summary: r.summary,
46
+ })), null, 2));
47
+ return;
48
+ }
49
+
50
+ if (!queries.length || !queries[0]) {
51
+ console.log('[]');
52
+ return;
53
+ }
54
+
55
+ const useAsync = typeof memory.searchFactsAsync === 'function';
56
+
57
+ if (mode === '--facts') {
58
+ const results = await searchMulti(queries, { searchFn: q => useAsync ? memory.searchFactsAsync(q, { limit: 5 }) : memory.searchFacts(q, { limit: 5 }), type: 'fact' });
59
+ console.log(JSON.stringify(results, null, 2));
60
+ return;
61
+ }
62
+
63
+ if (mode === '--sessions') {
64
+ const results = await searchMulti(queries, { searchFn: q => Promise.resolve(memory.searchSessions(q, { limit: 5 })), type: 'session' });
65
+ console.log(JSON.stringify(results, null, 2));
66
+ return;
67
+ }
68
+
69
+ // Default: search both facts and sessions, all queries in parallel
70
+ const factResults = await searchMulti(queries, {
71
+ searchFn: q => useAsync ? memory.searchFactsAsync(q, { limit: 5 }) : Promise.resolve(memory.searchFacts(q, { limit: 5 })),
50
72
  type: 'fact',
51
- entity: f.entity,
52
- relation: f.relation,
53
- value: f.value,
54
- confidence: f.confidence,
55
- date: f.created_at,
56
- })), null, 2));
57
-
58
- } else if (mode === '--sessions') {
59
- if (!query) { console.log('[]'); process.exit(0); }
60
- const sessions = memory.searchSessions(query, { limit: 5 });
61
- console.log(JSON.stringify(sessions.map(s => ({
73
+ limit: 5,
74
+ });
75
+
76
+ const sessionResults = await searchMulti(queries, {
77
+ searchFn: q => Promise.resolve(memory.searchSessions(q, { limit: 3 })),
62
78
  type: 'session',
63
- project: s.project,
64
- date: s.created_at,
65
- summary: s.summary,
66
- })), null, 2));
67
-
68
- } else {
69
- // Default: search both facts and sessions
70
- if (!query) { console.log('[]'); process.exit(0); }
71
- const facts = (typeof memory.searchFacts === 'function')
72
- ? memory.searchFacts(query, { limit: 3 })
73
- : [];
74
- const sessions = memory.searchSessions(query, { limit: 3 });
75
-
76
- const results = [
77
- ...facts.map(f => ({
78
- type: 'fact',
79
- entity: f.entity,
80
- relation: f.relation,
81
- value: f.value,
82
- confidence: f.confidence,
83
- date: f.created_at,
84
- })),
85
- ...sessions.map(s => ({
86
- type: 'session',
87
- project: s.project,
88
- date: s.created_at,
89
- summary: s.summary,
90
- })),
91
- ];
79
+ limit: 3,
80
+ });
81
+
82
+ console.log(JSON.stringify([...factResults, ...sessionResults], null, 2));
92
83
 
93
- console.log(JSON.stringify(results, null, 2));
84
+ } catch (e) {
85
+ console.log('[]');
86
+ } finally {
87
+ try { memory.close(); } catch {}
94
88
  }
95
- } catch (e) {
96
- console.log('[]');
97
- } finally {
98
- try { memory.close(); } catch {}
99
89
  }
90
+
91
+ /**
92
+ * Run multiple queries in parallel, deduplicate and format results.
93
+ */
94
+ async function searchMulti(queries, { searchFn, type, limit = 5 }) {
95
+ const allResults = await Promise.all(queries.map(q => searchFn(q).catch(() => [])));
96
+
97
+ // Deduplicate by id (facts) or created_at+project (sessions)
98
+ const seen = new Set();
99
+ const merged = [];
100
+
101
+ for (const batch of allResults) {
102
+ for (const item of (batch || [])) {
103
+ const key = type === 'fact'
104
+ ? `f:${item.id || item.entity + item.value}`
105
+ : `s:${item.created_at}:${item.project}`;
106
+ if (!seen.has(key)) {
107
+ seen.add(key);
108
+ merged.push(type === 'fact' ? {
109
+ type: 'fact',
110
+ entity: item.entity,
111
+ relation: item.relation,
112
+ value: item.value,
113
+ confidence: item.confidence,
114
+ date: item.created_at,
115
+ } : {
116
+ type: 'session',
117
+ project: item.project,
118
+ date: item.created_at,
119
+ summary: item.summary,
120
+ });
121
+ }
122
+ }
123
+ }
124
+
125
+ return merged.slice(0, limit);
126
+ }
127
+
128
+ main();
package/scripts/schema.js CHANGED
@@ -44,13 +44,10 @@ const SCHEMA = {
44
44
  'cognition.info_processing.entry_point': { tier: 'T3', type: 'enum', values: ['big_picture', 'details', 'examples'] },
45
45
  'cognition.info_processing.preferred_format': { tier: 'T3', type: 'enum', values: ['structured', 'narrative', 'visual_metaphor'] },
46
46
  'cognition.abstraction.default_level': { tier: 'T3', type: 'enum', values: ['strategic', 'architectural', 'implementation', 'operational'] },
47
- 'cognition.abstraction.range': { tier: 'T3', type: 'enum', values: ['narrow', 'wide'] },
48
47
  'cognition.cognitive_load.chunk_size': { tier: 'T3', type: 'enum', values: ['small', 'medium', 'large'] },
49
- 'cognition.cognitive_load.preferred_response_length': { tier: 'T3', type: 'enum', values: ['concise', 'moderate', 'comprehensive'] },
50
- 'cognition.motivation.primary_driver': { tier: 'T3', type: 'enum', values: ['autonomy', 'competence', 'meaning', 'social_proof'] },
51
- 'cognition.motivation.energy_source': { tier: 'T3', type: 'enum', values: ['creation', 'optimization', 'problem_solving', 'teaching'] },
52
- 'cognition.metacognition.self_awareness': { tier: 'T3', type: 'enum', values: ['high', 'medium', 'low'] },
48
+ 'cognition.motivation.driver': { tier: 'T3', type: 'enum', values: ['autonomy', 'competence', 'meaning', 'creation', 'optimization'] },
53
49
  'cognition.metacognition.receptive_to_challenge': { tier: 'T3', type: 'enum', values: ['yes', 'sometimes', 'no'] },
50
+ 'cognition.metacognition.error_response': { tier: 'T3', type: 'enum', values: ['quick_pivot', 'root_cause_first', 'seek_help', 'retry_same'] },
54
51
 
55
52
  // === T4: Context ===
56
53
  'context.focus': { tier: 'T4', type: 'string', maxChars: 80 },
@@ -29,6 +29,10 @@ const IMPLICIT_EN = /I (prefer|like|hate|usually|tend to|always)/i;
29
29
  const CORRECTION_ZH = /不是.*我(要|想|说)的|我说的不是|你理解错了|不对.*应该/;
30
30
  const CORRECTION_EN = /(no,? I meant|that's not what I|you misunderstood|wrong.+should be)/i;
31
31
 
32
+ // Metacognitive signals → normal confidence (self-reflection, strategy shifts)
33
+ const META_ZH = /我(发现|意识到|觉得|反思|总结|复盘)|想错了|换个(思路|方向|方案)|回头(想想|看看)|之前的(方案|思路|方向).*(不行|不对|有问题)|我的(问题|毛病|习惯)是|下次(应该|要|得)/;
34
+ const META_EN = /(I realize|looking back|on reflection|my (mistake|problem|habit) is|let me rethink|wrong approach|next time I should)/i;
35
+
32
36
  // Read JSON from stdin
33
37
  let input = '';
34
38
  process.stdin.setEncoding('utf8');
@@ -38,7 +42,11 @@ process.stdin.on('end', () => {
38
42
  const data = JSON.parse(input);
39
43
  const prompt = (data.prompt || '').trim();
40
44
 
45
+ // === LAYER 0: Metacognitive bypass — always capture self-reflection ===
46
+ const isMeta = META_ZH.test(prompt) || META_EN.test(prompt);
47
+
41
48
  // === LAYER 1: Hard filters (definitely not preferences) ===
49
+ // Metacognitive signals bypass all hard filters — they reveal how user thinks
42
50
 
43
51
  // Skip empty or very short messages
44
52
  // Chinese chars carry more info per char, so use weighted length
@@ -48,43 +56,43 @@ process.stdin.on('end', () => {
48
56
  }
49
57
 
50
58
  // Skip messages that are purely code or file paths
51
- if (/^(```|\/[\w/]+\.\w+$)/.test(prompt)) {
59
+ if (!isMeta && /^(```|\/[\w/]+\.\w+$)/.test(prompt)) {
52
60
  process.exit(0);
53
61
  }
54
62
 
55
63
  // Skip common non-preference commands
56
- if (/^(\/\w+|!metame|git |npm |pnpm |yarn |brew |sudo |cd |ls |cat |mkdir )/.test(prompt)) {
64
+ if (!isMeta && /^(\/\w+|!metame|git |npm |pnpm |yarn |brew |sudo |cd |ls |cat |mkdir )/.test(prompt)) {
57
65
  process.exit(0);
58
66
  }
59
67
 
60
68
  // Skip pure task instructions (fix/add/delete/refactor/debug/deploy/test/run/build)
61
- if (/^(帮我|请你|麻烦)?\s*(fix|add|delete|remove|refactor|debug|deploy|test|run|build|create|update|implement|write|generate|make)/i.test(prompt)) {
69
+ if (!isMeta && /^(帮我|请你|麻烦)?\s*(fix|add|delete|remove|refactor|debug|deploy|test|run|build|create|update|implement|write|generate|make)/i.test(prompt)) {
62
70
  process.exit(0);
63
71
  }
64
- if (/^(帮我|请你|麻烦)?\s*(修|加|删|重构|调试|部署|测试|运行|构建|创建|更新|实现|写|生成|做)/.test(prompt)) {
72
+ if (!isMeta && /^(帮我|请你|麻烦)?\s*(修|加|删|重构|调试|部署|测试|运行|构建|创建|更新|实现|写|生成|做)/.test(prompt)) {
65
73
  process.exit(0);
66
74
  }
67
75
 
68
76
  // Skip agent identity definitions (these belong in project CLAUDE.md, not user profile)
69
- if (/^(你是|你叫|你的(角色|身份|职责|任务)|你负责|你现在是|from now on you are|you are now|your role is)/i.test(prompt)) {
77
+ if (!isMeta && /^(你是|你叫|你的(角色|身份|职责|任务)|你负责|你现在是|from now on you are|you are now|your role is)/i.test(prompt)) {
70
78
  process.exit(0);
71
79
  }
72
80
 
73
81
  // Skip pasted error logs / stack traces
74
- if (/^(Error|TypeError|SyntaxError|ReferenceError|at\s+\w+|Traceback|FATAL|WARN|ERR!)/i.test(prompt)) {
82
+ if (!isMeta && /^(Error|TypeError|SyntaxError|ReferenceError|at\s+\w+|Traceback|FATAL|WARN|ERR!)/i.test(prompt)) {
75
83
  process.exit(0);
76
84
  }
77
- if (prompt.split('\n').length > 10) {
85
+ if (!isMeta && prompt.split('\n').length > 10) {
78
86
  // Multi-line pastes are usually code or logs, not preferences
79
87
  process.exit(0);
80
88
  }
81
89
 
82
90
  // Skip pure questions with no preference signal
83
- if (/^(what|how|why|where|when|which|can you|could you|is there|are there|does|do you)\s/i.test(prompt) &&
91
+ if (!isMeta && /^(what|how|why|where|when|which|can you|could you|is there|are there|does|do you)\s/i.test(prompt) &&
84
92
  !/prefer|like|hate|always|never|style|习惯|偏好|喜欢|讨厌/.test(prompt)) {
85
93
  process.exit(0);
86
94
  }
87
- if (/^(什么|怎么|为什么|哪|能不能|可以|是不是)\s/.test(prompt) &&
95
+ if (!isMeta && /^(什么|怎么|为什么|哪|能不能|可以|是不是)\s/.test(prompt) &&
88
96
  !/偏好|喜欢|讨厌|习惯|以后|一律|总是|永远|记住/.test(prompt)) {
89
97
  process.exit(0);
90
98
  }
@@ -93,12 +101,14 @@ process.stdin.on('end', () => {
93
101
  const isStrong = STRONG_SIGNAL_ZH.test(prompt) || STRONG_SIGNAL_EN.test(prompt);
94
102
  const isCorrection = CORRECTION_ZH.test(prompt) || CORRECTION_EN.test(prompt);
95
103
  const confidence = (isStrong || isCorrection) ? 'high' : 'normal';
104
+ const signalType = isMeta ? 'metacognitive' : isCorrection ? 'correction' : isStrong ? 'directive' : 'implicit';
96
105
 
97
106
  // Append to buffer
98
107
  const entry = {
99
108
  ts: new Date().toISOString(),
100
109
  prompt: prompt,
101
110
  confidence: confidence,
111
+ type: signalType,
102
112
  session: data.session_id || null,
103
113
  cwd: data.cwd || null
104
114
  };