metame-cli 1.4.0 → 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/README.md CHANGED
@@ -216,7 +216,7 @@ Done. Open Telegram, message your bot.
216
216
  | **Mobile Bridge** | Full Claude Code via Telegram/Feishu. Stateful sessions, file transfer both ways, real-time streaming status. |
217
217
  | **Skill Evolution** | Self-healing skill system. Auto-discovers missing skills, learns from browser recordings, evolves after every task. Skills get smarter over time. |
218
218
  | **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. |
219
+ | **Multi-Agent** | Multiple projects with dedicated chat groups. `/agent bind` for one-tap setup. True parallel execution. |
220
220
  | **Browser Automation** | Built-in Playwright MCP. Browser control out of the box for every user. |
221
221
  | **Provider Relay** | Route through any Anthropic-compatible API. Use GPT-4, DeepSeek, Gemini — zero config file mutation. |
222
222
  | **Metacognition** | Detects behavioral patterns (decision style, comfort zones, goal drift) and injects mirror observations. Zero extra API cost. |
@@ -298,10 +298,11 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
298
298
  | `/new` | Start new session (project picker) |
299
299
  | `/resume` | Pick from session list |
300
300
  | `/stop` | Interrupt current task (ESC) |
301
- | `/undo` | Rollback with file restoration |
301
+ | `/undo` | Show recent messages as buttons — tap to roll back context + code to before that message |
302
+ | `/undo <hash>` | Roll back to a specific git checkpoint |
302
303
  | `/list` | Browse & download project files |
303
304
  | `/model` | Switch model (sonnet/opus/haiku) |
304
- | `/bind <name>` | Register group as dedicated agent |
305
+ | `/agent bind <name> [dir]` | Register group as dedicated agent |
305
306
  | `/sh <cmd>` | Raw shell — bypasses Claude |
306
307
  | `/memory` | Memory stats: fact count, session tags, DB size |
307
308
  | `/memory <keyword>` | Search long-term facts by keyword |
@@ -359,14 +360,20 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
359
360
 
360
361
  > 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
362
 
362
- ## Plugin (Lightweight)
363
+ ## Plugin
363
364
 
364
- Don't need mobile access? Install as a Claude Code plugin — profile injection + slash commands only:
365
+ Install directly into Claude Code without npm:
365
366
 
366
367
  ```bash
367
368
  claude plugin install github:Yaron9/MetaMe/plugin
368
369
  ```
369
370
 
371
+ Includes: cognitive profile injection, daemon (Telegram/Feishu), heartbeat tasks, layered memory, all mobile commands, slash commands (`/metame:evolve`, `/metame:daemon`, `/metame:refresh`, etc.).
372
+
373
+ **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`.
374
+
375
+ 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.
376
+
370
377
  ## License
371
378
 
372
379
  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.3",
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)
@@ -1527,6 +1552,7 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
1527
1552
  }
1528
1553
 
1529
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)}`);
1530
1556
  const state = loadState();
1531
1557
 
1532
1558
  // --- /chatid: reply with current chatId ---
@@ -1714,7 +1740,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1714
1740
  const factCount = s.facts ?? '?';
1715
1741
  const tagFile = path.join(HOME, '.metame', 'session_tags.json');
1716
1742
  let tagCount = 0;
1717
- try { tagCount = Object.keys(JSON.parse(fs.readFileSync(tagFile, 'utf8'))).length; } catch {}
1743
+ try { tagCount = Object.keys(JSON.parse(fs.readFileSync(tagFile, 'utf8'))).length; } catch { }
1718
1744
  const lines = [
1719
1745
  `🧠 *Memory Stats*`,
1720
1746
  `━━━━━━━━━━━━━━━━`,
@@ -2269,22 +2295,25 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2269
2295
  }
2270
2296
 
2271
2297
  if (text === '/tasks') {
2298
+ const { general, project } = getAllTasks(config);
2272
2299
  let msg = '';
2273
- // Legacy flat tasks
2274
- const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
2275
- if (legacyTasks.length > 0) {
2300
+ if (general.length > 0) {
2276
2301
  msg += '📋 General:\n';
2277
- for (const t of legacyTasks) {
2302
+ for (const t of general) {
2278
2303
  const ts = state.tasks[t.name] || {};
2279
2304
  msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
2280
2305
  }
2281
2306
  }
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) {
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) {
2288
2317
  const ts = state.tasks[t.name] || {};
2289
2318
  msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
2290
2319
  }
@@ -2403,13 +2432,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2403
2432
  return;
2404
2433
  }
2405
2434
  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);
2435
+ const task = findTask(config, taskName);
2413
2436
  if (!task) { await bot.sendMessage(chatId, `❌ Task "${taskName}" not found`); return; }
2414
2437
 
2415
2438
  // Script tasks: quick, run inline
@@ -2676,7 +2699,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2676
2699
  }
2677
2700
 
2678
2701
  const session = getSession(chatId);
2679
- if (!session || !session.id || !session.cwd) {
2702
+ if (!session || !session.id) {
2680
2703
  await bot.sendMessage(chatId, 'No active session to undo.');
2681
2704
  return;
2682
2705
  }
@@ -2684,107 +2707,190 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2684
2707
  const cwd = session.cwd;
2685
2708
  const arg = text.slice(5).trim();
2686
2709
 
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 做点改动再试`);
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
+ }
2698
2740
  return;
2699
2741
  }
2700
2742
 
2701
- if (!arg) {
2702
- // /undo (no arg) — show recent checkpoints to pick from
2703
- 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();
2704
2787
  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)}` }];
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}` }];
2708
2796
  });
2709
- await bot.sendButtons(chatId, `📌 ${checkpoints.length} 个回退点 (git checkpoint):`, buttons);
2797
+ await bot.sendButtons(chatId, `↩️ 回退到哪条消息之前?(共 ${userMsgs.length} )`, buttons);
2710
2798
  } else {
2711
- let msg = '回退到哪个点?回复 /undo <hash>\n\n';
2712
- recent.forEach(cp => {
2713
- 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`;
2714
2802
  });
2715
2803
  await bot.sendMessage(chatId, msg);
2716
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.');
2717
2834
  return;
2718
2835
  }
2719
2836
 
2720
- // /undo <hash> — execute git reset
2721
2837
  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}`);
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');
2726
2844
  return;
2727
2845
  }
2728
2846
 
2729
- // Get list of files that will change
2730
- let diffFiles = '';
2847
+ // Get target message text + timestamp for display and git matching
2848
+ let targetMsg = '', targetTs = 0;
2731
2849
  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 });
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 { }
2737
2856
 
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)`);
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);
2768
2878
  }
2769
2879
  }
2770
2880
  }
2771
- } catch (truncErr) {
2772
- log('WARN', `Session truncation failed (non-fatal): ${truncErr.message}`);
2773
2881
  }
2774
2882
 
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);
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;
2783
2888
 
2784
- // Cleanup old checkpoints in background
2785
- 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}`);
2786
2892
  } catch (e) {
2787
- await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
2893
+ await bot.sendMessage(chatId, `❌ 回退失败: ${e.message}`);
2788
2894
  }
2789
2895
  return;
2790
2896
  }
@@ -3016,7 +3122,8 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
3016
3122
  '/cd <path> — 切换工作目录',
3017
3123
  '/session — 查看当前会话',
3018
3124
  '/stop — 中断当前任务 (ESC)',
3019
- '/undo — 回退上一轮操作 (ESC×2)',
3125
+ '/undo — 选择历史消息,点击回退到该条之前',
3126
+ '/undo <hash> — 回退到指定 git checkpoint',
3020
3127
  '/quit — 结束会话,重新加载 MCP/配置',
3021
3128
  '',
3022
3129
  `⚙️ /model [${currentModel}] /provider [${currentProvider}] /status /tasks /run /budget /reload`,
@@ -3117,6 +3224,80 @@ function findSessionFile(sessionId) {
3117
3224
  return null;
3118
3225
  }
3119
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
+
3120
3301
  /**
3121
3302
  * Scan all project session indexes, return most recent N sessions.
3122
3303
  * Results cached for 10 seconds to avoid repeated directory scans.
@@ -3603,13 +3784,26 @@ let lastInteractionTime = Date.now(); // updated on every incoming message
3603
3784
  let _inSleepMode = false; // tracks current sleep state for log transitions
3604
3785
 
3605
3786
  const IDLE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
3787
+ const LOCAL_ACTIVE_FILE = path.join(METAME_DIR, 'local_active');
3606
3788
 
3607
3789
  /**
3608
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).
3609
3793
  * Dream tasks (require_idle: true) only execute in this state.
3610
3794
  */
3611
3795
  function isUserIdle() {
3612
- return (Date.now() - lastInteractionTime > IDLE_THRESHOLD_MS) && activeProcesses.size === 0;
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;
3613
3807
  }
3614
3808
 
3615
3809
  // Fix3: persist child PIDs so next daemon startup can kill orphans
@@ -4590,7 +4784,7 @@ async function main() {
4590
4784
  qmd.startDaemon().then(running => {
4591
4785
  if (running) log('INFO', '[QMD] Semantic search daemon started (localhost:8181)');
4592
4786
  else log('INFO', '[QMD] Available but daemon not started — will use CLI fallback');
4593
- }).catch(() => {});
4787
+ }).catch(() => { });
4594
4788
  }
4595
4789
  } catch { /* qmd-client not available, skip */ }
4596
4790
  // Hourly heartbeat so daemon.log stays fresh even when idle (visible aliveness check)
@@ -4600,14 +4794,7 @@ async function main() {
4600
4794
 
4601
4795
  // Task executor lookup (always reads fresh config)
4602
4796
  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
- }
4797
+ const task = findTask(config, name);
4611
4798
  if (!task) return { success: false, error: `Task "${name}" not found` };
4612
4799
  return executeTask(task, config);
4613
4800
  }
@@ -4686,10 +4873,9 @@ async function main() {
4686
4873
  refreshLogMaxSize(config);
4687
4874
  if (heartbeatTimer) clearInterval(heartbeatTimer);
4688
4875
  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)`);
4876
+ const { general, project } = getAllTasks(config);
4877
+ const totalCount = general.length + project.length;
4878
+ log('INFO', `Config reloaded: ${totalCount} tasks (${project.length} in projects)`);
4693
4879
  return { success: true, tasks: totalCount };
4694
4880
  }
4695
4881
  // Expose reloadConfig to handleCommand via closure
@@ -4797,11 +4983,11 @@ if (process.argv.includes('--run')) {
4797
4983
  process.exit(1);
4798
4984
  }
4799
4985
  const config = loadConfig();
4800
- const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
4801
- const task = tasks.find(t => t.name === taskName);
4986
+ const task = findTask(config, taskName);
4802
4987
  if (!task) {
4988
+ const { all } = getAllTasks(config);
4803
4989
  console.error(`Task "${taskName}" not found in daemon.yaml`);
4804
- console.error(`Available: ${tasks.map(t => t.name).join(', ') || '(none)'}`);
4990
+ console.error(`Available: ${all.map(t => t.name).join(', ') || '(none)'}`);
4805
4991
  process.exit(1);
4806
4992
  }
4807
4993
  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
  };