metame-cli 1.3.1 → 1.3.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
@@ -20,7 +20,6 @@ It is not a memory system; it is a **Cognitive Mirror** .
20
20
 
21
21
  * **🧠 Global Brain (`~/.claude_profile.yaml`):** A single, portable source of truth — your identity, cognitive traits, and preferences travel with you across every project.
22
22
  * **🧬 Cognitive Evolution Engine:** MetaMe learns how you think through three channels: (1) **Passive** — silently captures your messages and distills cognitive traits via Haiku on next launch; (2) **Manual** — `!metame evolve` for explicit teaching; (3) **Confidence gates** — strong directives ("always"/"以后一律") write immediately, normal observations need 3+ consistent sightings before promotion. Schema-enforced (41 fields, 5 tiers, 800 token budget) to prevent bloat.
23
- * **🤝 Dynamic Handshake:** The "Canary Test." Claude must address you by your **Codename** in the first sentence. If it doesn't, the link is broken.
24
23
  * **🛡️ Auto-Lock:** Mark any value with `# [LOCKED]` — treated as a constitution, never auto-modified.
25
24
  * **🪞 Metacognition Layer (v1.3):** MetaMe now observes *how* you think, not just *what* you say. Behavioral pattern detection runs inside the existing Haiku distill call (zero extra cost). It tracks decision patterns, cognitive load, comfort zones, and avoidance topics across sessions. When persistent patterns emerge, MetaMe injects a one-line mirror observation — e.g., *"You tend to avoid testing until forced"* — with a 14-day cooldown per pattern. Conditional reflection prompts appear only when triggered (every 7th distill or 3x consecutive comfort zone). All injection logic runs in Node.js; Claude receives only pre-decided directives, never rules to self-evaluate.
26
25
  * **📱 Remote Claude Code (v1.3):** Full Claude Code from your phone via Telegram or Feishu (Lark). Stateful sessions with `--resume` — same conversation history, tool use, and file editing as your terminal. Interactive buttons for project/session picking, directory browser, and macOS launchd auto-start.
@@ -107,6 +106,11 @@ metame set-trait status.focus "Learning Rust"
107
106
  metame evolve "I prefer functional programming patterns"
108
107
  ```
109
108
 
109
+ **Episodic memory (keyframe, not full log):** MetaMe is not a memory system, but it captures two types of experiential "keyframes" that pure personality traits can't replace:
110
+
111
+ * **Anti-patterns** (`context.anti_patterns`, max 5): Cross-project failure lessons — e.g., *"Promise.all rejects all on single failure, use Promise.allSettled"*. Auto-expires after 60 days. Prevents the AI from repeating the same mistakes across sessions.
112
+ * **Milestones** (`context.milestones`, max 3): Recent completed landmarks — e.g., *"MetaMe v1.3 published"*. Provides continuity so Claude knows where you left off without you having to recap.
113
+
110
114
  **Anti-bias safeguards:** single observations ≠ traits, contradictions are tracked not overwritten, pending traits expire after 30 days, context fields auto-clear on staleness.
111
115
 
112
116
  **Metacognition controls:**
@@ -391,8 +395,6 @@ You might worry: *"Does this eat up my context window?"*
391
395
  **Q: Does this replace `CLAUDE.md`?**
392
396
  A: No. It *prepends* its meta-cognitive protocol to your existing `CLAUDE.md`. Your project-specific notes remain intact.
393
397
 
394
- **Q: What if Claude stops calling me by my nickname?**
395
- A: This is the "Canary Test." It means the context window has been compressed or the file link is broken. Run `/compact` in Claude or restart `metame` to fix it.
396
398
 
397
399
  **Q: Is my data sent to a third party?**
398
400
  A: No. Your profile stays local at `~/.claude_profile.yaml`. MetaMe simply passes text to the official Claude Code tool.
package/index.js CHANGED
@@ -208,20 +208,13 @@ const CORE_PROTOCOL = `
208
208
  ---
209
209
  ## 🧠 SYSTEM KERNEL: SHADOW_MODE (Active)
210
210
 
211
- **1. THE HANDSHAKE (Dynamic Verification):**
212
- * **SOURCE:** Check \`$HOME/.claude_profile.yaml\`.
213
- * **RULE:** Look for \`identity.nickname\`.
214
- * **IF FOUND:** You MUST address the user by that nickname in the very first sentence.
215
- * **IF NULL/MISSING:** Address user as "User" and immediately trigger Genesis to fix it.
216
- * **SIGNAL:** This nickname is the "Canary Test". If you omit it, the user assumes the system is broken.
217
-
218
- **2. THE BRAIN (Source of Truth):**
211
+ **1. THE BRAIN (Source of Truth):**
219
212
  * **FILE:** \`$HOME/.claude_profile.yaml\`
220
213
  * **RULE:** This file is your Ground Truth. If context is compressed (/compact), **RE-READ** this file immediately.
221
214
  * **AUTO-LOCK:** Never edit lines marked \`# [LOCKED]\` (Core Values).
222
215
  * **EVOLUTION:** Silently overwrite outdated status/focus.
223
216
 
224
- **3. EVOLUTION MECHANISM (Manual Sync):**
217
+ **2. EVOLUTION MECHANISM (Manual Sync):**
225
218
  * **PHILOSOPHY:** You respect the User's flow. You do NOT interrupt.
226
219
  * **TOOLS:**
227
220
  1. **Log Insight:** \`!metame evolve "Insight"\` (For additive knowledge).
@@ -578,11 +571,12 @@ if (isDaemon) {
578
571
  const DAEMON_SCRIPT = path.join(METAME_DIR, 'daemon.js');
579
572
 
580
573
  if (subCmd === 'init') {
581
- // Create config from template
582
- if (fs.existsSync(DAEMON_CONFIG)) {
583
- console.log("⚠️ daemon.yaml already exists at ~/.metame/daemon.yaml");
584
- console.log(" Delete it first if you want to re-initialize.");
585
- } else {
574
+ const readline = require('readline');
575
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
576
+ const ask = (q) => new Promise(r => rl.question(q, r));
577
+
578
+ // Create config from template if not exists
579
+ if (!fs.existsSync(DAEMON_CONFIG)) {
586
580
  const templateSrc = fs.existsSync(DAEMON_DEFAULT)
587
581
  ? DAEMON_DEFAULT
588
582
  : path.join(METAME_DIR, 'daemon-default.yaml');
@@ -592,45 +586,113 @@ if (isDaemon) {
592
586
  console.error("❌ Template not found. Reinstall MetaMe.");
593
587
  process.exit(1);
594
588
  }
595
- // Ensure directory permissions (700)
596
589
  try { fs.chmodSync(METAME_DIR, 0o700); } catch { /* ignore on Windows */ }
597
- console.log("✅ MetaMe daemon initialized.");
598
- console.log(` Config: ${DAEMON_CONFIG}`);
590
+ console.log("✅ Config created: ~/.metame/daemon.yaml\n");
591
+ } else {
592
+ console.log("✅ Config exists: ~/.metame/daemon.yaml\n");
593
+ }
594
+
595
+ const yaml = require(path.join(__dirname, 'node_modules', 'js-yaml'));
596
+ let cfg = yaml.load(fs.readFileSync(DAEMON_CONFIG, 'utf8')) || {};
597
+
598
+ // --- Telegram Setup ---
599
+ console.log("━━━ 📱 Telegram Setup ━━━");
600
+ console.log("Steps:");
601
+ console.log(" 1. Open Telegram, search @BotFather");
602
+ console.log(" 2. Send /newbot, follow prompts to create a bot");
603
+ console.log(" 3. Copy the bot token (looks like: 123456:ABC-DEF...)");
604
+ console.log("");
605
+
606
+ const tgToken = (await ask("Paste your Telegram bot token (Enter to skip): ")).trim();
607
+ if (tgToken) {
608
+ if (!cfg.telegram) cfg.telegram = {};
609
+ cfg.telegram.enabled = true;
610
+ cfg.telegram.bot_token = tgToken;
611
+
612
+ console.log("\nFinding your chat ID...");
613
+ console.log(" → Send any message to your bot in Telegram first, then press Enter.");
614
+ await ask("Press Enter after you've messaged your bot: ");
615
+
616
+ try {
617
+ const https = require('https');
618
+ const chatIds = await new Promise((resolve, reject) => {
619
+ https.get(`https://api.telegram.org/bot${tgToken}/getUpdates`, (res) => {
620
+ let body = '';
621
+ res.on('data', d => body += d);
622
+ res.on('end', () => {
623
+ try {
624
+ const data = JSON.parse(body);
625
+ const ids = new Set();
626
+ if (data.result) {
627
+ for (const u of data.result) {
628
+ if (u.message && u.message.chat) ids.add(u.message.chat.id);
629
+ }
630
+ }
631
+ resolve([...ids]);
632
+ } catch { resolve([]); }
633
+ });
634
+ }).on('error', () => resolve([]));
635
+ });
636
+
637
+ if (chatIds.length > 0) {
638
+ cfg.telegram.allowed_chat_ids = chatIds;
639
+ console.log(` ✅ Found chat ID(s): ${chatIds.join(', ')}`);
640
+ } else {
641
+ console.log(" ⚠️ No messages found. Make sure you messaged the bot.");
642
+ console.log(" You can set allowed_chat_ids manually in daemon.yaml later.");
643
+ }
644
+ } catch {
645
+ console.log(" ⚠️ Could not fetch chat ID. Set it manually in daemon.yaml.");
646
+ }
647
+ console.log(" ✅ Telegram configured!\n");
648
+ } else {
649
+ console.log(" Skipped.\n");
650
+ }
651
+
652
+ // --- Feishu Setup ---
653
+ console.log("━━━ 📘 Feishu (Lark) Setup ━━━");
654
+ console.log("Steps:");
655
+ console.log(" 1. Go to: https://open.feishu.cn/app");
656
+ console.log(" → Create App (企业自建应用)");
657
+ console.log(" 2. In 'Credentials' (凭证与基础信息), copy App ID & App Secret");
658
+ console.log(" 3. In 'Bot' (机器人), enable bot capability");
659
+ console.log(" 4. In 'Event Subscription' (事件订阅):");
660
+ console.log(" → Set mode to 'Long Connection' (使用长连接接收事件)");
661
+ console.log(" → Add event: im.message.receive_v1 (接收消息)");
662
+ console.log(" 5. In 'Permissions' (权限管理), add:");
663
+ console.log(" → im:message, im:message:send_as_bot, im:chat");
664
+ console.log(" 6. Publish the app version (创建版本 → 申请发布)");
665
+ console.log("");
666
+
667
+ const feishuAppId = (await ask("Paste your Feishu App ID (Enter to skip): ")).trim();
668
+ if (feishuAppId) {
669
+ const feishuSecret = (await ask("Paste your Feishu App Secret: ")).trim();
670
+ if (feishuSecret) {
671
+ if (!cfg.feishu) cfg.feishu = {};
672
+ cfg.feishu.enabled = true;
673
+ cfg.feishu.app_id = feishuAppId;
674
+ cfg.feishu.app_secret = feishuSecret;
675
+ if (!cfg.feishu.allowed_chat_ids) cfg.feishu.allowed_chat_ids = [];
676
+ console.log(" ✅ Feishu configured!");
677
+ console.log(" Note: allowed_chat_ids is empty = allow all users.");
678
+ console.log(" To restrict, add chat IDs to daemon.yaml later.\n");
679
+ }
680
+ } else {
681
+ console.log(" Skipped.\n");
599
682
  }
600
683
 
601
- console.log("\n📱 Telegram Setup (optional):");
602
- console.log(" 1. Message @BotFather on Telegram → /newbot");
603
- console.log(" 2. Copy the bot token");
604
- console.log(" 3. Edit ~/.metame/daemon.yaml:");
605
- console.log(" telegram:");
606
- console.log(" enabled: true");
607
- console.log(" bot_token: \"YOUR_TOKEN\"");
608
- console.log(" allowed_chat_ids: [YOUR_CHAT_ID]");
609
- console.log(" 4. To find your chat_id: message your bot, then run:");
610
- console.log(" curl https://api.telegram.org/botYOUR_TOKEN/getUpdates");
611
- console.log("\n📘 Feishu Setup (optional):");
612
- console.log(" 1. Go to open.feishu.cn → Create App → get app_id & app_secret");
613
- console.log(" 2. Enable Bot capability + im:message events");
614
- console.log(" 3. Enable 'Long Connection' (长连接) mode in Event Subscription");
615
- console.log(" 4. Edit ~/.metame/daemon.yaml:");
616
- console.log(" feishu:");
617
- console.log(" enabled: true");
618
- console.log(" app_id: \"YOUR_APP_ID\"");
619
- console.log(" app_secret: \"YOUR_APP_SECRET\"");
620
- console.log(" allowed_chat_ids: [CHAT_ID]");
621
-
622
- console.log("\n Then: metame daemon start");
623
-
624
- // Optional launchd setup (macOS only)
684
+ // Write config
685
+ fs.writeFileSync(DAEMON_CONFIG, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
686
+ console.log("━━━ Setup Complete ━━━");
687
+ console.log(`Config saved: ${DAEMON_CONFIG}`);
688
+ console.log("\nNext steps:");
689
+ console.log(" metame daemon start — start the daemon");
690
+ console.log(" metame daemon status check status");
625
691
  if (process.platform === 'darwin') {
626
- const plistDir = path.join(HOME_DIR, 'Library', 'LaunchAgents');
627
- const plistPath = path.join(plistDir, 'com.metame.daemon.plist');
628
- console.log("\n🍎 Auto-start on macOS (optional):");
629
- console.log(" To start daemon automatically on login:");
630
- console.log(` metame daemon start (first time to verify it works)`);
631
- console.log(` Then create: ${plistPath}`);
632
- console.log(" Or run: metame daemon install-launchd");
692
+ console.log(" metame daemon install-launchd auto-start on login");
633
693
  }
694
+
695
+ rl.close();
634
696
  process.exit(0);
635
697
  }
636
698
 
@@ -683,18 +745,21 @@ if (isDaemon) {
683
745
  }
684
746
 
685
747
  if (subCmd === 'start') {
748
+ // Kill any lingering daemon.js processes to avoid Feishu WebSocket conflicts
749
+ try {
750
+ const { execSync: es } = require('child_process');
751
+ const pids = es("pgrep -f 'node.*daemon\\.js' 2>/dev/null || true", { encoding: 'utf8' }).trim();
752
+ if (pids) {
753
+ for (const p of pids.split('\n').filter(Boolean)) {
754
+ const n = parseInt(p, 10);
755
+ if (n && n !== process.pid) try { process.kill(n, 'SIGKILL'); } catch { /* */ }
756
+ }
757
+ es('sleep 1');
758
+ }
759
+ } catch { /* ignore */ }
686
760
  // Check if already running
687
761
  if (fs.existsSync(DAEMON_PID)) {
688
- const existingPid = parseInt(fs.readFileSync(DAEMON_PID, 'utf8').trim(), 10);
689
- try {
690
- process.kill(existingPid, 0); // test if alive
691
- console.log(`⚠️ Daemon already running (PID: ${existingPid})`);
692
- console.log(" Use 'metame daemon stop' first.");
693
- process.exit(1);
694
- } catch {
695
- // Stale PID file — clean up
696
- fs.unlinkSync(DAEMON_PID);
697
- }
762
+ try { fs.unlinkSync(DAEMON_PID); } catch { /* */ }
698
763
  }
699
764
  if (!fs.existsSync(DAEMON_CONFIG)) {
700
765
  console.error("❌ No config found. Run: metame daemon init");
@@ -724,11 +789,21 @@ if (isDaemon) {
724
789
  const pid = parseInt(fs.readFileSync(DAEMON_PID, 'utf8').trim(), 10);
725
790
  try {
726
791
  process.kill(pid, 'SIGTERM');
792
+ // Wait for process to die (up to 3s), then force kill
793
+ let dead = false;
794
+ for (let i = 0; i < 6; i++) {
795
+ const { execSync: es } = require('child_process');
796
+ es('sleep 0.5');
797
+ try { process.kill(pid, 0); } catch { dead = true; break; }
798
+ }
799
+ if (!dead) {
800
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
801
+ }
727
802
  console.log(`✅ Daemon stopped (PID: ${pid})`);
728
803
  } catch (e) {
729
804
  console.log(`⚠️ Process ${pid} not found (may have already exited).`);
730
- fs.unlinkSync(DAEMON_PID);
731
805
  }
806
+ try { fs.unlinkSync(DAEMON_PID); } catch { /* ignore */ }
732
807
  process.exit(0);
733
808
  }
734
809
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.3.1",
3
+ "version": "1.3.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
@@ -256,11 +256,12 @@ function executeTask(task, config) {
256
256
  }
257
257
  const fullPrompt = preamble + taskPrompt;
258
258
 
259
+ const allowedArgs = (task.allowedTools || []).map(t => `--allowedTools ${t}`).join(' ');
259
260
  log('INFO', `Executing task: ${task.name} (model: ${model})`);
260
261
 
261
262
  try {
262
263
  const output = execSync(
263
- `claude -p --model ${model}`,
264
+ `claude -p --model ${model}${allowedArgs ? ' ' + allowedArgs : ''}`,
264
265
  {
265
266
  input: fullPrompt,
266
267
  encoding: 'utf8',
@@ -335,6 +336,7 @@ function executeWorkflow(task, config) {
335
336
  const sessionId = crypto.randomUUID();
336
337
  const outputs = [];
337
338
  let totalTokens = 0;
339
+ const allowed = task.allowedTools || [];
338
340
 
339
341
  log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}`);
340
342
 
@@ -343,6 +345,7 @@ function executeWorkflow(task, config) {
343
345
  let prompt = (step.skill ? `/${step.skill} ` : '') + (step.prompt || '');
344
346
  if (i === 0 && precheck.context) prompt += `\n\n相关数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
345
347
  const args = ['-p', '--model', model];
348
+ for (const tool of allowed) args.push('--allowedTools', tool);
346
349
  args.push(i === 0 ? '--session-id' : '--resume', sessionId);
347
350
 
348
351
  log('INFO', `Workflow ${task.name} step ${i + 1}/${steps.length}: ${step.skill || 'prompt'}`);
@@ -612,12 +615,27 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
612
615
  await sendDirPicker(bot, chatId, 'new', 'Pick a workdir:');
613
616
  return;
614
617
  }
615
- if (!fs.existsSync(arg)) {
616
- await bot.sendMessage(chatId, `Path not found: ${arg}`);
617
- return;
618
+ // Parse: /new <path> [name] — if arg contains a space after a valid path, rest is name
619
+ let dirPath = arg;
620
+ let sessionName = '';
621
+ // Try full arg as path first; if not, split on spaces to find path + name
622
+ if (!fs.existsSync(dirPath)) {
623
+ const spaceIdx = arg.indexOf(' ');
624
+ if (spaceIdx > 0) {
625
+ const maybePath = arg.slice(0, spaceIdx);
626
+ if (fs.existsSync(maybePath)) {
627
+ dirPath = maybePath;
628
+ sessionName = arg.slice(spaceIdx + 1).trim();
629
+ }
630
+ }
631
+ if (!fs.existsSync(dirPath)) {
632
+ await bot.sendMessage(chatId, `Path not found: ${dirPath}`);
633
+ return;
634
+ }
618
635
  }
619
- const session = createSession(chatId, arg);
620
- await bot.sendMessage(chatId, `New session.\nWorkdir: ${session.cwd}`);
636
+ const session = createSession(chatId, dirPath, sessionName || '');
637
+ const label = sessionName ? `[${sessionName}]` : '';
638
+ await bot.sendMessage(chatId, `New session ${label}\nWorkdir: ${session.cwd}`);
621
639
  return;
622
640
  }
623
641
 
@@ -666,11 +684,26 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
666
684
  return;
667
685
  }
668
686
 
669
- // Argument given → match by prefix or full ID
670
- const match = recentSessions.length > 0
671
- ? recentSessions.find(s => s.sessionId.startsWith(arg))
672
- : null;
673
- const fullMatch = match || listRecentSessions(50).find(s => s.sessionId.startsWith(arg));
687
+ // Argument given → match by name, then by session ID prefix
688
+ const allSessions = listRecentSessions(50);
689
+ const argLower = arg.toLowerCase();
690
+ // 1. Match by name (from session_names map)
691
+ let fullMatch = allSessions.find(s => {
692
+ const n = getSessionName(s.sessionId);
693
+ return n && n.toLowerCase() === argLower;
694
+ });
695
+ // 2. Partial name match
696
+ if (!fullMatch) {
697
+ fullMatch = allSessions.find(s => {
698
+ const n = getSessionName(s.sessionId);
699
+ return n && n.toLowerCase().includes(argLower);
700
+ });
701
+ }
702
+ // 3. Session ID prefix match
703
+ if (!fullMatch) {
704
+ fullMatch = recentSessions.find(s => s.sessionId.startsWith(arg))
705
+ || allSessions.find(s => s.sessionId.startsWith(arg));
706
+ }
674
707
  const sessionId = fullMatch ? fullMatch.sessionId : arg;
675
708
  const cwd = (fullMatch && fullMatch.projectPath) || (getSession(chatId) && getSession(chatId).cwd) || HOME;
676
709
 
@@ -681,8 +714,13 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
681
714
  created: new Date().toISOString(),
682
715
  started: true,
683
716
  };
717
+ if (fullMatch) {
718
+ const n = getSessionName(sessionId);
719
+ if (n) state2.sessions[chatId].name = n;
720
+ }
684
721
  saveState(state2);
685
- const label = fullMatch ? (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) : sessionId.slice(0, 8);
722
+ const name = getSessionName(sessionId);
723
+ const label = name || (fullMatch ? (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) : sessionId.slice(0, 8));
686
724
  await bot.sendMessage(chatId, `Resumed: ${label}\nWorkdir: ${cwd}`);
687
725
  return;
688
726
  }
@@ -708,12 +746,32 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
708
746
  return;
709
747
  }
710
748
 
749
+ if (text.startsWith('/name ')) {
750
+ const name = text.slice(6).trim();
751
+ if (!name) {
752
+ await bot.sendMessage(chatId, 'Usage: /name <session name>');
753
+ return;
754
+ }
755
+ const state2 = loadState();
756
+ if (!state2.sessions[chatId]) {
757
+ await bot.sendMessage(chatId, 'No active session. Start one first.');
758
+ return;
759
+ }
760
+ state2.sessions[chatId].name = name;
761
+ if (!state2.session_names) state2.session_names = {};
762
+ state2.session_names[state2.sessions[chatId].id] = name;
763
+ saveState(state2);
764
+ await bot.sendMessage(chatId, `Session named: ${name}`);
765
+ return;
766
+ }
767
+
711
768
  if (text === '/session') {
712
769
  const session = getSession(chatId);
713
770
  if (!session) {
714
771
  await bot.sendMessage(chatId, 'No active session. Send any message to start one.');
715
772
  } else {
716
- await bot.sendMessage(chatId, `Session: ${session.id.slice(0, 8)}...\nWorkdir: ${session.cwd}\nStarted: ${session.created}`);
773
+ const nameTag = session.name ? ` [${session.name}]` : '';
774
+ await bot.sendMessage(chatId, `Session: ${session.id.slice(0, 8)}...${nameTag}\nWorkdir: ${session.cwd}\nStarted: ${session.created}`);
717
775
  }
718
776
  return;
719
777
  }
@@ -793,9 +851,10 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
793
851
  if (text.startsWith('/')) {
794
852
  await bot.sendMessage(chatId, [
795
853
  'Commands:',
796
- '/new [path] — new session',
854
+ '/new [path] [name] — new session (optional name)',
797
855
  '/continue — resume last computer session',
798
- '/resume <id> — resume specific session',
856
+ '/resume <name|id> — resume by name or session ID',
857
+ '/name <name> — name current session',
799
858
  '/cd <path> — change workdir',
800
859
  '/session — current session info',
801
860
  '/status /tasks /run /budget /quiet /reload',
@@ -862,8 +921,10 @@ function listRecentSessions(limit, cwd) {
862
921
  * Format a session entry into a short, readable label for buttons
863
922
  */
864
923
  function sessionLabel(s) {
924
+ const name = getSessionName(s.sessionId);
865
925
  const proj = s.projectPath ? path.basename(s.projectPath) : '';
866
926
  const date = new Date(s.modified).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
927
+ if (name) return `${date} [${name}] ${proj}`;
867
928
  const title = (s.summary || '').slice(0, 28);
868
929
  return `${date} ${proj ? proj + ': ' : ''}${title}`;
869
930
  }
@@ -898,7 +959,7 @@ function getSession(chatId) {
898
959
  return state.sessions[chatId] || null;
899
960
  }
900
961
 
901
- function createSession(chatId, cwd) {
962
+ function createSession(chatId, cwd, name) {
902
963
  const state = loadState();
903
964
  const sessionId = crypto.randomUUID();
904
965
  state.sessions[chatId] = {
@@ -907,11 +968,21 @@ function createSession(chatId, cwd) {
907
968
  created: new Date().toISOString(),
908
969
  started: false, // true after first message sent
909
970
  };
971
+ if (name) {
972
+ state.sessions[chatId].name = name;
973
+ if (!state.session_names) state.session_names = {};
974
+ state.session_names[sessionId] = name;
975
+ }
910
976
  saveState(state);
911
- log('INFO', `New session for ${chatId}: ${sessionId} (cwd: ${state.sessions[chatId].cwd})`);
977
+ log('INFO', `New session for ${chatId}: ${sessionId}${name ? ' [' + name + ']' : ''} (cwd: ${state.sessions[chatId].cwd})`);
912
978
  return state.sessions[chatId];
913
979
  }
914
980
 
981
+ function getSessionName(sessionId) {
982
+ const state = loadState();
983
+ return (state.session_names && state.session_names[sessionId]) || '';
984
+ }
985
+
915
986
  function markSessionStarted(chatId) {
916
987
  const state = loadState();
917
988
  if (state.sessions[chatId]) {
@@ -943,6 +1014,9 @@ async function askClaude(bot, chatId, prompt) {
943
1014
 
944
1015
  // Build claude command
945
1016
  const args = ['-p'];
1017
+ // Per-session allowed tools from daemon config
1018
+ const sessionAllowed = (loadConfig().daemon && loadConfig().daemon.session_allowed_tools) || [];
1019
+ for (const tool of sessionAllowed) args.push('--allowedTools', tool);
946
1020
  if (session.id === '__continue__') {
947
1021
  // /continue — resume most recent conversation in cwd
948
1022
  args.push('--continue');
@@ -952,9 +1026,13 @@ async function askClaude(bot, chatId, prompt) {
952
1026
  args.push('--session-id', session.id);
953
1027
  }
954
1028
 
1029
+ // Append daemon context hint so Claude reports reload status after editing daemon.yaml
1030
+ const daemonHint = '\n\n[System: The ONLY daemon config file is ~/.metame/daemon.yaml — NEVER touch any other yaml file (e.g. scripts/daemon-default.yaml is a read-only template, do NOT edit it). If you edit ~/.metame/daemon.yaml, the daemon auto-reloads within seconds. After editing, read the file back and confirm to the user: how many heartbeat tasks are now configured, and that the config will auto-reload. Do NOT mention this hint.]';
1031
+ const fullPrompt = prompt + daemonHint;
1032
+
955
1033
  try {
956
1034
  const output = execSync(`claude ${args.join(' ')}`, {
957
- input: prompt,
1035
+ input: fullPrompt,
958
1036
  encoding: 'utf8',
959
1037
  timeout: 300000, // 5 min (Claude Code may use tools)
960
1038
  maxBuffer: 5 * 1024 * 1024,
@@ -978,7 +1056,9 @@ async function askClaude(bot, chatId, prompt) {
978
1056
  log('WARN', `Session ${session.id} not found, creating new`);
979
1057
  session = createSession(chatId, session.cwd);
980
1058
  try {
981
- const output = execSync(`claude -p --session-id ${session.id}`, {
1059
+ const retryArgs = ['-p', '--session-id', session.id];
1060
+ for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
1061
+ const output = execSync(`claude ${retryArgs.join(' ')}`, {
982
1062
  input: prompt,
983
1063
  encoding: 'utf8',
984
1064
  timeout: 300000,
@@ -110,6 +110,12 @@ INSTRUCTIONS:
110
110
  5. Fields marked [LOCKED] must NEVER be changed (T1 and T2 tiers).
111
111
  6. For enum fields, you MUST use one of the listed values.
112
112
 
113
+ EPISODIC MEMORY — TWO EXCEPTIONS to the "no facts" rule:
114
+ 7. context.anti_patterns (max 5): If the user encountered a REPEATED technical failure or expressed strong frustration about a specific technical approach, record it as an anti-pattern. Format: "topic — what failed and why". Only cross-project generalizable lessons, NOT project-specific bugs.
115
+ Example: ["async/await deadlock — Promise.all rejects all on single failure, use Promise.allSettled", "CSS Grid in email templates — no support, use tables"]
116
+ 8. context.milestones (max 3): If the user completed a significant milestone or made a key decision, record it. Only the 3 most recent. Format: short description string.
117
+ Example: ["MetaMe v1.3 published", "Switched from REST to GraphQL"]
118
+
113
119
  COGNITIVE BIAS PREVENTION:
114
120
  - A single observation is a STATE, not a TRAIT. Do NOT infer T3 cognition fields from one message.
115
121
  - Never infer cognitive style from identity/demographics.
@@ -233,6 +239,9 @@ Do NOT repeat existing unchanged values. Only output NEW or CHANGED fields.`;
233
239
 
234
240
  const profile = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
235
241
 
242
+ // Auto-expire anti_patterns older than 60 days
243
+ expireAntiPatterns(profile);
244
+
236
245
  // Read raw content to find locked lines and comments
237
246
  const rawProfile = fs.readFileSync(BRAIN_FILE, 'utf8');
238
247
  const lockedKeys = extractLockedKeys(rawProfile);
@@ -415,7 +424,18 @@ function strategicMerge(profile, updates, lockedKeys, pendingTraits, confidenceM
415
424
  }
416
425
 
417
426
  case 'T4':
418
- setNested(result, key, value);
427
+ // Stamp added date on anti_pattern entries for auto-expiry
428
+ if (key === 'context.anti_patterns' && Array.isArray(value)) {
429
+ const today = new Date().toISOString().slice(0, 10);
430
+ const existing = getNested(result, key) || [];
431
+ const existingTexts = new Set(existing.map(e => typeof e === 'string' ? e : e.text));
432
+ const stamped = value
433
+ .filter(v => !existingTexts.has(typeof v === 'string' ? v : v.text))
434
+ .map(v => typeof v === 'string' ? { text: v, added: today } : v);
435
+ setNested(result, key, [...existing, ...stamped].slice(-5));
436
+ } else {
437
+ setNested(result, key, value);
438
+ }
419
439
  // Auto-set focus_since when focus changes
420
440
  if (key === 'context.focus') {
421
441
  setNested(result, 'context.focus_since', new Date().toISOString().slice(0, 10));
@@ -455,6 +475,19 @@ function flattenObject(obj, parentKey = '', result = {}) {
455
475
  return result;
456
476
  }
457
477
 
478
+ /**
479
+ * Get a nested property by dot-path key.
480
+ */
481
+ function getNested(obj, dotPath) {
482
+ const keys = dotPath.split('.');
483
+ let current = obj;
484
+ for (const k of keys) {
485
+ if (!current || typeof current !== 'object') return undefined;
486
+ current = current[k];
487
+ }
488
+ return current;
489
+ }
490
+
458
491
  /**
459
492
  * Set a nested property by dot-path key.
460
493
  */
@@ -508,6 +541,24 @@ function truncateArrays(obj) {
508
541
  }
509
542
  }
510
543
 
544
+ /**
545
+ * Auto-expire anti_patterns older than 60 days.
546
+ * Each entry is stored as { text: "...", added: "2026-01-15" } internally.
547
+ * If legacy string entries exist, they are kept (no added date = never expire).
548
+ */
549
+ function expireAntiPatterns(profile) {
550
+ if (!profile.context || !Array.isArray(profile.context.anti_patterns)) return;
551
+ const now = Date.now();
552
+ const SIXTY_DAYS = 60 * 24 * 60 * 60 * 1000;
553
+ profile.context.anti_patterns = profile.context.anti_patterns.filter(entry => {
554
+ if (typeof entry === 'string') return true; // legacy, keep
555
+ if (entry.added) {
556
+ return (now - new Date(entry.added).getTime()) < SIXTY_DAYS;
557
+ }
558
+ return true;
559
+ });
560
+ }
561
+
511
562
  /**
512
563
  * Clean up: remove buffer and lock
513
564
  */
@@ -121,12 +121,32 @@ function createBot(config) {
121
121
  loggerLevel: Lark.LoggerLevel.info,
122
122
  });
123
123
 
124
+ // Dedup: track recent message_ids (Feishu may redeliver on slow ack)
125
+ const _seenMsgIds = new Map(); // message_id → timestamp
126
+ const DEDUP_TTL = 60000; // 60s window
127
+ function isDuplicate(msgId) {
128
+ if (!msgId) return false;
129
+ const now = Date.now();
130
+ // Cleanup old entries
131
+ if (_seenMsgIds.size > 200) {
132
+ for (const [k, t] of _seenMsgIds) {
133
+ if (now - t > DEDUP_TTL) _seenMsgIds.delete(k);
134
+ }
135
+ }
136
+ if (_seenMsgIds.has(msgId)) return true;
137
+ _seenMsgIds.set(msgId, now);
138
+ return false;
139
+ }
140
+
124
141
  const eventDispatcher = new Lark.EventDispatcher({}).register({
125
142
  'im.message.receive_v1': async (data) => {
126
143
  try {
127
144
  const msg = data.message;
128
145
  if (!msg) return;
129
146
 
147
+ // Dedup by message_id
148
+ if (isDuplicate(msg.message_id)) return;
149
+
130
150
  const chatId = msg.chat_id;
131
151
  let text = '';
132
152
 
@@ -143,7 +163,8 @@ function createBot(config) {
143
163
  text = text.replace(/@_user_\d+\s*/g, '').trim();
144
164
 
145
165
  if (text) {
146
- onMessage(chatId, text, data);
166
+ // Fire-and-forget: don't block the event loop (SDK needs fast ack)
167
+ Promise.resolve().then(() => onMessage(chatId, text, data)).catch(() => {});
147
168
  }
148
169
  } catch (e) {
149
170
  // Non-fatal
@@ -159,7 +180,7 @@ function createBot(config) {
159
180
  if (action && chatId) {
160
181
  const cmd = action.value && action.value.cmd;
161
182
  if (cmd) {
162
- onMessage(chatId, cmd, data);
183
+ Promise.resolve().then(() => onMessage(chatId, cmd, data)).catch(() => {});
163
184
  }
164
185
  }
165
186
  } catch (e) {
package/scripts/schema.js CHANGED
@@ -60,6 +60,8 @@ const SCHEMA = {
60
60
  'context.active_projects': { tier: 'T4', type: 'array', maxItems: 5 },
61
61
  'context.blockers': { tier: 'T4', type: 'array', maxItems: 3 },
62
62
  'context.energy': { tier: 'T4', type: 'enum', values: ['high', 'medium', 'low', null] },
63
+ 'context.milestones': { tier: 'T4', type: 'array', maxItems: 3 },
64
+ 'context.anti_patterns': { tier: 'T4', type: 'array', maxItems: 5 },
63
65
  'status.focus': { tier: 'T4', type: 'string', maxChars: 80 },
64
66
  'status.language': { tier: 'T4', type: 'string' },
65
67