metame-cli 1.3.13 โ†’ 1.3.16

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
@@ -47,10 +47,13 @@
47
47
  * **๐Ÿชž 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.
48
48
  * **๐Ÿ“ฑ 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.
49
49
  * **๐Ÿ”„ Workflow Engine (v1.3):** Define multi-step skill chains as heartbeat tasks. Each workflow runs in a single Claude Code session via `--resume`, so step outputs flow as context to the next step. Example: `deep-research` โ†’ `tech-writing` โ†’ `wechat-publisher` โ€” fully automated content pipeline.
50
- * **โน Full Terminal Control from Mobile (v1.3.13):** `/stop` (ESC), `/undo` (ESCร—2) with native file-history restoration, `/model` interactive model switcher, concurrent task protection, daemon auto-restart, and `metame continue` for seamless mobile-to-desktop sync.
51
- * **๐Ÿฅ Emergency Recovery (v1.3.13):** `/doctor` interactive diagnostics with one-tap fix buttons, `/sh` direct shell access from your phone (bypasses Claude entirely โ€” the lifeline when everything else is broken), automatic config backup before any setting change, `/fix` to restore last known good config.
50
+ * **โน Full Terminal Control from Mobile (v1.3.10):** `/stop` (ESC), `/undo` (ESCร—2) with native file-history restoration, concurrent task protection, daemon auto-restart, and `metame continue` for seamless mobile-to-desktop sync.
52
51
  * **๐ŸŽฏ Goal Alignment & Drift Detection (v1.3.11):** MetaMe now tracks whether your sessions align with your declared goals. Each distill assesses `goal_alignment` (aligned/partial/drifted) at zero extra API cost. When you drift for 2+ consecutive sessions, a mirror observation is injected passively; after 3+ sessions, a reflection prompt gently asks: "Was this an intentional pivot, or did you lose track?" Session logs now record project, branch, intent, and file directories for richer retrospective analysis. Pattern detection can spot sustained drift trends across your session history.
52
+ * **๐Ÿ”Œ Provider Relay (v1.3.11):** Use any Anthropic-compatible API relay as your backend โ€” no file mutation, no invasion. MetaMe injects `ANTHROPIC_BASE_URL` + `ANTHROPIC_API_KEY` at spawn time. Separate provider roles for `active`, `distill`, and `daemon` tasks. CLI: `metame provider add/use/remove/test`. Config stored in `~/.metame/providers.yaml`.
53
53
  * **๐Ÿ“Š Session History Bootstrap (v1.3.12):** Solves the cold-start problem โ€” MetaMe previously needed 5-7 sessions before producing any visible feedback. Now, on first launch it auto-bootstraps your session history from existing Claude Code JSONL transcripts (zero API cost). Three complementary data layers: **Skeleton** (structural facts extracted locally โ€” tools, duration, project, branch, intent), **Facets** (interaction quality from `/insights` โ€” outcome, friction, satisfaction, when available), and **Haiku** (metacognitive judgments โ€” cognitive load, zones, goal alignment, from the existing distill call). Patterns and mirror observations can appear from your very first MetaMe session.
54
+ * **๐Ÿฅ Emergency Recovery (v1.3.13):** `/doctor` interactive diagnostics with one-tap fix buttons, `/sh` direct shell access from your phone (bypasses Claude entirely โ€” the lifeline when everything else is broken), automatic config backup before any setting change, `/fix` to restore last known good config. `/model` interactive model switcher with auto-backup.
55
+ * **๐ŸŒ Browser Automation (v1.3.15):** Native Playwright MCP integration โ€” auto-registered on first run. Every MetaMe user gets browser control capability out of the box. Combined with Skills, enables workflows like automated podcast publishing, form filling, and web scraping.
56
+ * **๐Ÿ“‚ Interactive File Browser (v1.3.15):** `/list` shows clickable button cards โ€” folders expand inline, files download on tap. Folder buttons survive daemon restarts (absolute paths, no expiry). Zero token cost.
54
57
 
55
58
  ## ๐Ÿ›  Prerequisites
56
59
 
@@ -263,6 +266,9 @@ This resumes the latest session with all mobile messages included. Also works as
263
266
  ๐Ÿ“– Read: ใ€Œconfig.yamlใ€
264
267
  โœ๏ธ Edit: ใ€Œdaemon.jsใ€
265
268
  ๐Ÿ’ป Bash: ใ€Œgit statusใ€
269
+ ๐Ÿ”ง Skill: ใ€Œwechat-publisherใ€
270
+ ๐ŸŒ Browser: ใ€Œnavigateใ€
271
+ ๐Ÿ”— MCP:server: ใ€Œactionใ€
266
272
  ```
267
273
 
268
274
  **File transfer (v1.3.8):** Seamlessly move files between your phone and computer.
@@ -325,6 +331,7 @@ Bot: ๅ›ž้€€ๅˆฐๅ“ชไธ€่ฝฎ๏ผŸ
325
331
  | `/tasks` | List scheduled heartbeat tasks |
326
332
  | `/run <name>` | Run a task immediately |
327
333
  | `/model [name]` | Interactive model switcher with buttons (sonnet, opus, haiku). Auto-backs up config before switching. |
334
+ | `/list` | File browser with clickable buttons โ€” folders expand, files download. Zero tokens. |
328
335
  | `/budget` | Today's token usage |
329
336
  | `/quiet` | Silence mirror/reflections for 48h |
330
337
  | `/reload` | Manually reload daemon.yaml (also auto-reloads on file change) |
@@ -386,6 +393,41 @@ Each step runs in the same Claude Code session. Step outputs automatically becom
386
393
  * `~/.metame/` directory set to mode 700
387
394
  * Bot tokens stored locally, never transmitted
388
395
 
396
+ ### Provider Relay โ€” Third-Party Model Support (v1.3.11)
397
+
398
+ MetaMe supports any Anthropic-compatible API relay as a backend. This means you can route Claude Code through a third-party relay that maps `sonnet`/`opus`/`haiku` to any model (GPT-4, DeepSeek, Gemini, etc.) โ€” MetaMe passes standard model names and the relay handles translation.
399
+
400
+ **How it works:** At spawn time, MetaMe injects `ANTHROPIC_BASE_URL` + `ANTHROPIC_API_KEY` environment variables. Zero file mutation โ€” `~/.claude/settings.json` is never touched.
401
+
402
+ **CLI commands:**
403
+
404
+ ```bash
405
+ metame provider # List all providers
406
+ metame provider add <name> # Add a relay (prompts for URL & key)
407
+ metame provider use <name> # Switch active provider
408
+ metame provider remove <name> # Remove a provider (can't remove 'anthropic')
409
+ metame provider test [name] # Test connectivity
410
+ metame provider set-role distill <name> # Use a different provider for background distill
411
+ metame provider set-role daemon <name> # Use a different provider for daemon tasks
412
+ ```
413
+
414
+ **Configuration** (`~/.metame/providers.yaml`):
415
+
416
+ ```yaml
417
+ active: 'anthropic'
418
+ providers:
419
+ anthropic:
420
+ label: 'Anthropic (Official)'
421
+ my-relay:
422
+ label: 'My Relay'
423
+ base_url: 'https://api.relay.example.com/v1'
424
+ api_key: 'sk-xxx'
425
+ distill_provider: null # null = use active
426
+ daemon_provider: null # null = use active
427
+ ```
428
+
429
+ Three independent provider roles let you optimize cost: e.g., use an official Anthropic key for active work, a cheaper relay for background distill, and another for daemon heartbeat tasks.
430
+
389
431
  ### Hot Reload (Refresh)
390
432
 
391
433
  If you update your profile or need to fix a broken context **without restarting your session**:
@@ -544,6 +586,20 @@ A: No. It *prepends* its meta-cognitive protocol to your existing `CLAUDE.md`. Y
544
586
  **Q: Is my data sent to a third party?**
545
587
  A: No. Your profile stays local at `~/.claude_profile.yaml`. MetaMe simply passes text to the official Claude Code tool.
546
588
 
589
+ ## ๐Ÿ“‹ Changelog
590
+
591
+ | Version | Highlights |
592
+ |---------|------------|
593
+ | **v1.3.15** | Native Playwright MCP (browser automation for all users), `/list` interactive file browser with buttons, Feishu image download fix, Skill/MCP/Agent status push, hot restart reliability (single notification, no double instance) |
594
+ | **v1.3.14** | Fix daemon crash on fresh install (missing bundled scripts) |
595
+ | **v1.3.13** | `/doctor` diagnostics, `/sh` direct shell, `/fix` config restore, `/model` interactive switcher with auto-backup, daemon state caching & config backup/restore |
596
+ | **v1.3.12** | Session history bootstrap (cold-start fix), three-layer data architecture (Skeleton + Facets + Haiku), session summary extraction |
597
+ | **v1.3.11** | Goal alignment & drift detection, provider relay system for third-party models, `/insights` facet integration |
598
+ | **v1.3.10** | `/stop`, `/undo` with file restoration, `/model`, concurrent task protection, `metame continue`, daemon auto-restart on code change |
599
+ | **v1.3.8** | Bidirectional file transfer (phone โ†” computer) |
600
+ | **v1.3.7** | Real-time streaming status on mobile |
601
+ | **v1.3** | Metacognition layer, remote Claude Code (Telegram & Feishu), workflow engine, heartbeat tasks, launchd auto-start |
602
+
547
603
  ## ๐Ÿ“„ License
548
604
 
549
605
  MIT License. Feel free to fork, modify, and evolve your own Meta-Cognition.
package/index.js CHANGED
@@ -13,6 +13,7 @@ const BRAIN_FILE = path.join(HOME_DIR, '.claude_profile.yaml');
13
13
  const PROJECT_FILE = path.join(process.cwd(), 'CLAUDE.md');
14
14
  const METAME_DIR = path.join(HOME_DIR, '.metame');
15
15
  const CLAUDE_SETTINGS = path.join(HOME_DIR, '.claude', 'settings.json');
16
+ const CLAUDE_MCP_CONFIG = path.join(HOME_DIR, '.claude', 'mcp.json'); // legacy, kept for reference
16
17
  const SIGNAL_CAPTURE_SCRIPT = path.join(METAME_DIR, 'signal-capture.js');
17
18
 
18
19
  // ---------------------------------------------------------
@@ -23,7 +24,7 @@ if (!fs.existsSync(METAME_DIR)) {
23
24
  }
24
25
 
25
26
  // Auto-deploy bundled scripts to ~/.metame/
26
- const BUNDLED_SCRIPTS = ['signal-capture.js', 'distill.js', 'schema.js', 'pending-traits.js', 'migrate-v2.js', 'daemon.js', 'telegram-adapter.js', 'feishu-adapter.js', 'daemon-default.yaml', 'providers.js', 'session-analytics.js'];
27
+ const BUNDLED_SCRIPTS = ['signal-capture.js', 'distill.js', 'schema.js', 'pending-traits.js', 'migrate-v2.js', 'daemon.js', 'telegram-adapter.js', 'feishu-adapter.js', 'daemon-default.yaml', 'providers.js', 'session-analytics.js', 'resolve-yaml.js', 'utils.js'];
27
28
  const scriptsDir = path.join(__dirname, 'scripts');
28
29
 
29
30
  for (const script of BUNDLED_SCRIPTS) {
@@ -87,6 +88,13 @@ function ensureHookInstalled() {
87
88
 
88
89
  ensureHookInstalled();
89
90
 
91
+ // ---------------------------------------------------------
92
+ // 1.6b ENSURE PROJECT-LEVEL MCP CONFIG
93
+ // ---------------------------------------------------------
94
+ // MCP servers are registered per-project via .mcp.json (not user-scope ~/.claude.json)
95
+ // so they only load when working in projects that need them.
96
+ // The daemon's heartbeat tasks use cwd: ~/AGI/Digital_Me which has its own .mcp.json.
97
+
90
98
  // ---------------------------------------------------------
91
99
  // 1.7 PASSIVE DISTILLATION (Background, post-launch)
92
100
  // ---------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.3.13",
3
+ "version": "1.3.16",
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": {
@@ -13,7 +13,7 @@
13
13
  "scripts": {
14
14
  "test": "node --test scripts/*.test.js",
15
15
  "start": "node index.js",
16
- "sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/distill.js scripts/daemon.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml scripts/providers.js scripts/utils.js plugin/scripts/ && echo 'โœ… Plugin scripts synced'",
16
+ "sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/distill.js scripts/daemon.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml scripts/providers.js scripts/utils.js scripts/resolve-yaml.js plugin/scripts/ && echo 'โœ… Plugin scripts synced'",
17
17
  "restart:daemon": "node index.js stop 2>/dev/null; sleep 1; node index.js start 2>/dev/null || echo 'โš ๏ธ Daemon not running or restart failed'",
18
18
  "precommit": "npm run sync:plugin && npm run restart:daemon"
19
19
  },
package/scripts/daemon.js CHANGED
@@ -640,6 +640,106 @@ async function sendBrowse(bot, chatId, mode, dirPath) {
640
640
  }
641
641
  }
642
642
 
643
+ const DIR_LIST_TYPE_EMOJI = {
644
+ '.md': '๐Ÿ“„', '.txt': '๐Ÿ“„', '.pdf': '๐Ÿ“•',
645
+ '.js': 'โš™๏ธ', '.ts': 'โš™๏ธ', '.py': '๐Ÿ', '.json': '๐Ÿ“‹', '.yaml': '๐Ÿ“‹', '.yml': '๐Ÿ“‹',
646
+ '.png': '๐Ÿ–ผ๏ธ', '.jpg': '๐Ÿ–ผ๏ธ', '.jpeg': '๐Ÿ–ผ๏ธ', '.gif': '๐Ÿ–ผ๏ธ', '.svg': '๐Ÿ–ผ๏ธ', '.webp': '๐Ÿ–ผ๏ธ',
647
+ '.wav': '๐ŸŽต', '.mp3': '๐ŸŽต', '.m4a': '๐ŸŽต', '.flac': '๐ŸŽต',
648
+ '.mp4': '๐ŸŽฌ', '.mov': '๐ŸŽฌ',
649
+ '.csv': '๐Ÿ“Š', '.xlsx': '๐Ÿ“Š',
650
+ '.html': '๐ŸŒ', '.css': '๐ŸŽจ',
651
+ '.sh': '๐Ÿ’ป', '.bash': '๐Ÿ’ป',
652
+ };
653
+
654
+ /**
655
+ * List directory contents with file info + download buttons + folder nav buttons.
656
+ * Zero token cost โ€” pure daemon fs operation.
657
+ */
658
+ async function sendDirListing(bot, chatId, baseDir, arg) {
659
+ let targetDir = baseDir;
660
+ let globFilter = null;
661
+
662
+ if (arg) {
663
+ if (arg.includes('*')) {
664
+ globFilter = arg;
665
+ } else {
666
+ const sub = path.resolve(baseDir, arg);
667
+ if (fs.existsSync(sub) && fs.statSync(sub).isDirectory()) {
668
+ targetDir = sub;
669
+ } else {
670
+ await bot.sendMessage(chatId, `โŒ Not found: ${arg}`);
671
+ return;
672
+ }
673
+ }
674
+ }
675
+
676
+ try {
677
+ let entries = fs.readdirSync(targetDir, { withFileTypes: true });
678
+ if (globFilter) {
679
+ const pattern = globFilter.replace(/\./g, '\\.').replace(/\*/g, '.*');
680
+ const re = new RegExp('^' + pattern + '$', 'i');
681
+ entries = entries.filter(e => re.test(e.name));
682
+ }
683
+ entries.sort((a, b) => {
684
+ if (a.isDirectory() && !b.isDirectory()) return -1;
685
+ if (!a.isDirectory() && b.isDirectory()) return 1;
686
+ return a.name.localeCompare(b.name);
687
+ });
688
+ entries = entries.filter(e => !e.name.startsWith('.'));
689
+
690
+ if (entries.length === 0) {
691
+ await bot.sendMessage(chatId, `๐Ÿ“ ${path.basename(targetDir)}/\n(empty)`);
692
+ return;
693
+ }
694
+
695
+ const allButtons = [];
696
+ const MAX_BUTTONS = 20;
697
+
698
+ for (const entry of entries.slice(0, MAX_BUTTONS)) {
699
+ const fullPath = path.join(targetDir, entry.name);
700
+ if (entry.isDirectory()) {
701
+ // Use absolute path directly for folders (survives daemon restart)
702
+ // Fall back to shortenPath only if path is too long for callback_data (64 byte limit)
703
+ const cbPath = fullPath.length <= 58 ? fullPath : shortenPath(fullPath);
704
+ allButtons.push([{ text: `๐Ÿ“‚ ${entry.name}/`, callback_data: `/list ${cbPath}` }]);
705
+ } else {
706
+ const ext = path.extname(entry.name).toLowerCase();
707
+ const emoji = DIR_LIST_TYPE_EMOJI[ext] || '๐Ÿ“Ž';
708
+ let size = '';
709
+ try {
710
+ const stat = fs.statSync(fullPath);
711
+ const bytes = stat.size;
712
+ if (bytes < 1024) size = ` ${bytes}B`;
713
+ else if (bytes < 1048576) size = ` ${(bytes / 1024).toFixed(0)}KB`;
714
+ else size = ` ${(bytes / 1048576).toFixed(1)}MB`;
715
+ } catch { /* ignore */ }
716
+ if (isContentFile(fullPath)) {
717
+ const shortId = cacheFile(fullPath);
718
+ allButtons.push([{ text: `${emoji} ${entry.name}${size}`, callback_data: `/file ${shortId}` }]);
719
+ } else {
720
+ // Non-downloadable files shown as info-only buttons (no action)
721
+ allButtons.push([{ text: `${emoji} ${entry.name}${size}`, callback_data: 'noop' }]);
722
+ }
723
+ }
724
+ }
725
+
726
+ const header = `๐Ÿ“ ${path.basename(targetDir)}/` + (entries.length > MAX_BUTTONS ? ` (${MAX_BUTTONS}/${entries.length})` : '');
727
+ if (allButtons.length > 0 && bot.sendButtons) {
728
+ await bot.sendButtons(chatId, header, allButtons);
729
+ } else {
730
+ // Fallback for adapters without button support
731
+ const lines = [header];
732
+ for (const entry of entries.slice(0, MAX_BUTTONS)) {
733
+ const isDir = entry.isDirectory();
734
+ lines.push(isDir ? ` ๐Ÿ“‚ ${entry.name}/` : ` ๐Ÿ“Ž ${entry.name}`);
735
+ }
736
+ await bot.sendMessage(chatId, lines.join('\n'));
737
+ }
738
+ } catch (e) {
739
+ await bot.sendMessage(chatId, `โŒ ${e.message}`);
740
+ }
741
+ }
742
+
643
743
  /**
644
744
  * Unified command handler โ€” shared by Telegram & Feishu
645
745
  */
@@ -653,6 +753,9 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
653
753
  const dirPath = expandPath(parts.slice(1).join(' '));
654
754
  if (mode && dirPath && fs.existsSync(dirPath)) {
655
755
  await sendBrowse(bot, chatId, mode, dirPath);
756
+ } else if (/^p\d+$/.test(dirPath)) {
757
+ await bot.sendMessage(chatId, 'โš ๏ธ Button expired. Pick again:');
758
+ await sendDirPicker(bot, chatId, mode || 'cd', 'Switch workdir:');
656
759
  } else {
657
760
  await bot.sendMessage(chatId, 'Invalid browse path.');
658
761
  }
@@ -857,6 +960,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
857
960
  const name = target.customTitle || target.summary || '';
858
961
  const label = name ? name.slice(0, 40) : target.sessionId.slice(0, 8);
859
962
  await bot.sendMessage(chatId, `๐Ÿ”„ Synced to: ${label}\n๐Ÿ“ ${path.basename(target.projectPath)}`);
963
+ await sendDirListing(bot, chatId, target.projectPath, null);
860
964
  return;
861
965
  } else {
862
966
  await bot.sendMessage(chatId, 'No recent session found.');
@@ -864,7 +968,13 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
864
968
  }
865
969
  }
866
970
  if (!fs.existsSync(newCwd)) {
867
- await bot.sendMessage(chatId, `Path not found: ${newCwd}`);
971
+ // Likely an expired path shortcode (e.g. p16) from a daemon restart
972
+ if (/^p\d+$/.test(newCwd)) {
973
+ await bot.sendMessage(chatId, 'โš ๏ธ Button expired (daemon restarted). Pick again:');
974
+ await sendDirPicker(bot, chatId, 'cd', 'Switch workdir:');
975
+ } else {
976
+ await bot.sendMessage(chatId, `Path not found: ${newCwd}`);
977
+ }
868
978
  return;
869
979
  }
870
980
  const state2 = loadState();
@@ -889,6 +999,26 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
889
999
  saveState(state2);
890
1000
  await bot.sendMessage(chatId, `๐Ÿ“ ${path.basename(newCwd)}`);
891
1001
  }
1002
+ await sendDirListing(bot, chatId, newCwd, null);
1003
+ return;
1004
+ }
1005
+
1006
+ // /list [subdir|glob|fullpath] โ€” list files (zero token, daemon-only)
1007
+ if (text === '/list' || text.startsWith('/list ')) {
1008
+ const session = getSession(chatId);
1009
+ const cwd = session?.cwd || HOME;
1010
+ const arg = text.slice(5).trim();
1011
+ // If arg is an absolute or ~ path, list that directly
1012
+ const expanded = arg ? expandPath(arg) : null;
1013
+ if (expanded && /^p\d+$/.test(expanded)) {
1014
+ // Expired shortcode from daemon restart
1015
+ await bot.sendMessage(chatId, 'โš ๏ธ Button expired. Refreshing...');
1016
+ await sendDirListing(bot, chatId, cwd, null);
1017
+ } else if (expanded && path.isAbsolute(expanded) && fs.existsSync(expanded) && fs.statSync(expanded).isDirectory()) {
1018
+ await sendDirListing(bot, chatId, expanded, null);
1019
+ } else {
1020
+ await sendDirListing(bot, chatId, cwd, arg || null);
1021
+ }
892
1022
  return;
893
1023
  }
894
1024
 
@@ -1014,6 +1144,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1014
1144
  }
1015
1145
 
1016
1146
  if (text === '/stop') {
1147
+ // Clear message queue (don't process queued messages after stop)
1148
+ if (messageQueue.has(chatId)) {
1149
+ const q = messageQueue.get(chatId);
1150
+ if (q.timer) clearTimeout(q.timer);
1151
+ messageQueue.delete(chatId);
1152
+ }
1017
1153
  const proc = activeProcesses.get(chatId);
1018
1154
  if (proc && proc.child) {
1019
1155
  proc.aborted = true;
@@ -1025,6 +1161,59 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1025
1161
  return;
1026
1162
  }
1027
1163
 
1164
+ // /quit โ€” restart session process (reloads MCP/config, keeps same session)
1165
+ if (text === '/quit') {
1166
+ // Stop running task if any
1167
+ if (messageQueue.has(chatId)) {
1168
+ const q = messageQueue.get(chatId);
1169
+ if (q.timer) clearTimeout(q.timer);
1170
+ messageQueue.delete(chatId);
1171
+ }
1172
+ const proc = activeProcesses.get(chatId);
1173
+ if (proc && proc.child) {
1174
+ proc.aborted = true;
1175
+ proc.child.kill('SIGINT');
1176
+ }
1177
+ const session = getSession(chatId);
1178
+ const name = session ? getSessionName(session.id) : null;
1179
+ const label = name || (session ? session.id.slice(0, 8) : 'none');
1180
+ await bot.sendMessage(chatId, `๐Ÿ”„ Session restarted. MCP/config reloaded.\n๐Ÿ“ ${session ? path.basename(session.cwd) : '~'} [${label}]`);
1181
+ return;
1182
+ }
1183
+
1184
+ // /publish <otp> โ€” npm publish with OTP (zero latency, no Claude)
1185
+ if (text.startsWith('/publish ')) {
1186
+ const otp = text.slice(9).trim();
1187
+ if (!otp || !/^\d{6}$/.test(otp)) {
1188
+ await bot.sendMessage(chatId, '็”จๆณ•: /publish 123456');
1189
+ return;
1190
+ }
1191
+ const session = getSession(chatId);
1192
+ const cwd = session?.cwd || HOME;
1193
+ await bot.sendMessage(chatId, `๐Ÿ“ฆ npm publish --otp=${otp} ...`);
1194
+ try {
1195
+ const child = spawn('npm', ['publish', `--otp=${otp}`], { cwd, timeout: 60000 });
1196
+ let stdout = '', stderr = '';
1197
+ child.stdout.on('data', d => { stdout += d; });
1198
+ child.stderr.on('data', d => { stderr += d; });
1199
+ await new Promise((resolve) => {
1200
+ child.on('close', resolve);
1201
+ child.on('error', resolve);
1202
+ });
1203
+ const output = (stdout + stderr).trim();
1204
+ if (output.includes('+ metame-cli@') || output.includes('npm notice')) {
1205
+ const ver = output.match(/metame-cli@([\d.]+)/);
1206
+ await bot.sendMessage(chatId, `โœ… Published${ver ? ' v' + ver[1] : ''}!`);
1207
+ } else {
1208
+ let msg = output.slice(0, 2000) || '(no output)';
1209
+ await bot.sendMessage(chatId, `โŒ ${msg}`);
1210
+ }
1211
+ } catch (e) {
1212
+ await bot.sendMessage(chatId, `โŒ ${e.message}`);
1213
+ }
1214
+ return;
1215
+ }
1216
+
1028
1217
  // /sh [command] โ€” direct shell execution (emergency lifeline)
1029
1218
  if (text === '/sh' || text.startsWith('/sh ')) {
1030
1219
  const command = text.slice(3).trim();
@@ -1058,6 +1247,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1058
1247
  }
1059
1248
 
1060
1249
  if (text === '/undo' || text.startsWith('/undo ')) {
1250
+ // Clear message queue
1251
+ if (messageQueue.has(chatId)) {
1252
+ const q = messageQueue.get(chatId);
1253
+ if (q.timer) clearTimeout(q.timer);
1254
+ messageQueue.delete(chatId);
1255
+ }
1061
1256
  // Stop running task first
1062
1257
  const proc = activeProcesses.get(chatId);
1063
1258
  if (proc && proc.child) {
@@ -1071,10 +1266,9 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1071
1266
  return;
1072
1267
  }
1073
1268
 
1074
- // Find session .jsonl file
1075
- const projDirName = session.cwd.replace(/\//g, '-');
1076
- const sessionFile = path.join(HOME, '.claude', 'projects', projDirName, session.id + '.jsonl');
1077
- if (!fs.existsSync(sessionFile)) {
1269
+ // Find session .jsonl file (scan Claude's native projects directory)
1270
+ const sessionFile = findSessionFile(session.id);
1271
+ if (!sessionFile) {
1078
1272
  await bot.sendMessage(chatId, 'Session file not found.');
1079
1273
  return;
1080
1274
  }
@@ -1226,10 +1420,13 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1226
1420
 
1227
1421
  const turnsRemoved = turns.filter(t => t.lineIdx >= targetLineIdx).length;
1228
1422
  const allAffected = [...restored, ...deleted];
1229
- const fileList = allAffected.length > 0
1230
- ? allAffected.map(f => path.basename(f)).join(', ')
1231
- : 'none';
1232
- await bot.sendMessage(chatId, `โช ๅ›ž้€€ไบ† ${turnsRemoved} ่ฝฎๅฏน่ฏ\n๐Ÿ“ ๆขๅค ${restored.length} / ๅˆ ้™ค ${deleted.length}: ${fileList}`);
1423
+ const turnsMsg = `โช ๅ›ž้€€ไบ† ${turnsRemoved} ่ฝฎๅฏน่ฏ`;
1424
+ if (allAffected.length > 0) {
1425
+ const fileList = allAffected.map(f => path.basename(f)).join(', ');
1426
+ await bot.sendMessage(chatId, `${turnsMsg}\n๐Ÿ“ ๆขๅค ${restored.length} / ๅˆ ้™ค ${deleted.length}: ${fileList}`);
1427
+ } else {
1428
+ await bot.sendMessage(chatId, `${turnsMsg}\n๐Ÿ“ ๆ— ๆ–‡ไปถๅ˜ๆ›ด้œ€่ฆๆขๅค`);
1429
+ }
1233
1430
  } catch (e) {
1234
1431
  await bot.sendMessage(chatId, `โŒ Undo failed: ${e.message}`);
1235
1432
  }
@@ -1421,6 +1618,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1421
1618
  '/session โ€” ๆŸฅ็œ‹ๅฝ“ๅ‰ไผš่ฏ',
1422
1619
  '/stop โ€” ไธญๆ–ญๅฝ“ๅ‰ไปปๅŠก (ESC)',
1423
1620
  '/undo โ€” ๅ›ž้€€ไธŠไธ€่ฝฎๆ“ไฝœ (ESCร—2)',
1621
+ '/quit โ€” ็ป“ๆŸไผš่ฏ๏ผŒ้‡ๆ–ฐๅŠ ่ฝฝ MCP/้…็ฝฎ',
1424
1622
  '',
1425
1623
  `โš™๏ธ /model [${currentModel}] /provider [${currentProvider}] /status /tasks /run /budget /reload`,
1426
1624
  '๐Ÿ”ง /doctor /fix /reset /sh <cmd>',
@@ -1431,9 +1629,42 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1431
1629
  }
1432
1630
 
1433
1631
  // --- Natural language โ†’ Claude Code session ---
1434
- // Block if a task is already running (prevent session conflict)
1632
+ // If a task is running: interrupt + collect + merge
1435
1633
  if (activeProcesses.has(chatId)) {
1436
- await bot.sendMessage(chatId, 'โณ ไปปๅŠก่ฟ›่กŒไธญ๏ผŒ่ฏท็ญ‰ๅพ…ๅฎŒๆˆๆˆ–ๅ‘ /stop ไธญๆ–ญ');
1634
+ const isFirst = !messageQueue.has(chatId);
1635
+ if (isFirst) {
1636
+ messageQueue.set(chatId, { messages: [], timer: null });
1637
+ }
1638
+ const q = messageQueue.get(chatId);
1639
+ q.messages.push(text);
1640
+ // Only notify once (first message), subsequent ones silently queue
1641
+ if (isFirst) {
1642
+ await bot.sendMessage(chatId, '๐Ÿ“ ๆ”ถๅˆฐ๏ผŒไธญๆ–ญๅฝ“ๅ‰ไปปๅŠกๅŽไธ€่ตทๅค„็†');
1643
+ }
1644
+ // Interrupt the running Claude process
1645
+ const proc = activeProcesses.get(chatId);
1646
+ if (proc && proc.child && !proc.aborted) {
1647
+ proc.aborted = true;
1648
+ proc.child.kill('SIGINT');
1649
+ }
1650
+ // Debounce: wait 5s for more messages before processing
1651
+ if (q.timer) clearTimeout(q.timer);
1652
+ q.timer = setTimeout(async () => {
1653
+ // Wait for active process to fully exit (up to 10s)
1654
+ for (let i = 0; i < 20 && activeProcesses.has(chatId); i++) {
1655
+ await sleep(500);
1656
+ }
1657
+ const msgs = q.messages.splice(0);
1658
+ messageQueue.delete(chatId);
1659
+ if (msgs.length === 0) return;
1660
+ const combined = msgs.join('\n');
1661
+ log('INFO', `Processing ${msgs.length} queued message(s) for ${chatId}`);
1662
+ try {
1663
+ await handleCommand(bot, chatId, combined, config, executeTaskByName);
1664
+ } catch (e) {
1665
+ log('ERROR', `Queue dispatch failed: ${e.message}`);
1666
+ }
1667
+ }, 5000);
1437
1668
  return;
1438
1669
  }
1439
1670
  const cd = checkCooldown(chatId);
@@ -1451,6 +1682,30 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1451
1682
  const crypto = require('crypto');
1452
1683
  const CLAUDE_PROJECTS_DIR = path.join(HOME, '.claude', 'projects');
1453
1684
 
1685
+ /**
1686
+ * Find a session's .jsonl file by scanning Claude's native projects directory.
1687
+ * This avoids guessing the directory naming convention โ€” we just search for the file.
1688
+ * Results cached for 30s to avoid repeated directory scans in loops.
1689
+ */
1690
+ const _sessionFileCache = new Map(); // sessionId -> { path, ts }
1691
+ function findSessionFile(sessionId) {
1692
+ if (!sessionId || !fs.existsSync(CLAUDE_PROJECTS_DIR)) return null;
1693
+ const cached = _sessionFileCache.get(sessionId);
1694
+ if (cached && Date.now() - cached.ts < 30000) return cached.path;
1695
+ const target = sessionId + '.jsonl';
1696
+ try {
1697
+ for (const proj of fs.readdirSync(CLAUDE_PROJECTS_DIR)) {
1698
+ const candidate = path.join(CLAUDE_PROJECTS_DIR, proj, target);
1699
+ if (fs.existsSync(candidate)) {
1700
+ _sessionFileCache.set(sessionId, { path: candidate, ts: Date.now() });
1701
+ return candidate;
1702
+ }
1703
+ }
1704
+ } catch { /* ignore */ }
1705
+ _sessionFileCache.set(sessionId, { path: null, ts: Date.now() });
1706
+ return null;
1707
+ }
1708
+
1454
1709
  /**
1455
1710
  * Scan all project session indexes, return most recent N sessions.
1456
1711
  * Results cached for 10 seconds to avoid repeated directory scans.
@@ -1537,10 +1792,9 @@ function listRecentSessions(limit, cwd) {
1537
1792
  */
1538
1793
  function getSessionFileMtime(sessionId, projectPath) {
1539
1794
  try {
1540
- if (!projectPath) return null;
1541
- const projDirName = projectPath.replace(/\//g, '-');
1542
- const sessionFile = path.join(CLAUDE_PROJECTS_DIR, projDirName, sessionId + '.jsonl');
1543
- if (fs.existsSync(sessionFile)) {
1795
+ if (!sessionId) return null;
1796
+ const sessionFile = findSessionFile(sessionId);
1797
+ if (sessionFile) {
1544
1798
  return fs.statSync(sessionFile).mtimeMs;
1545
1799
  }
1546
1800
  } catch { /* ignore */ }
@@ -1653,12 +1907,10 @@ function getSessionName(sessionId) {
1653
1907
  */
1654
1908
  function writeSessionName(sessionId, cwd, name) {
1655
1909
  try {
1656
- const projDirName = cwd.replace(/\//g, '-');
1657
- const sessionFile = path.join(CLAUDE_PROJECTS_DIR, projDirName, sessionId + '.jsonl');
1658
- // Create directory if needed
1659
- const dir = path.dirname(sessionFile);
1660
- if (!fs.existsSync(dir)) {
1661
- fs.mkdirSync(dir, { recursive: true });
1910
+ const sessionFile = findSessionFile(sessionId);
1911
+ if (!sessionFile) {
1912
+ log('WARN', `writeSessionName: session file not found for ${sessionId.slice(0, 8)}`);
1913
+ return;
1662
1914
  }
1663
1915
  const entry = JSON.stringify({ type: 'custom-title', customTitle: name, sessionId }) + '\n';
1664
1916
  fs.appendFileSync(sessionFile, entry, 'utf8');
@@ -1771,6 +2023,9 @@ const TOOL_EMOJI = {
1771
2023
  WebFetch: '๐ŸŒ',
1772
2024
  WebSearch: '๐Ÿ”',
1773
2025
  Task: '๐Ÿค–',
2026
+ Skill: '๐Ÿ”ง',
2027
+ TodoWrite: '๐Ÿ“‹',
2028
+ NotebookEdit: '๐Ÿ““',
1774
2029
  default: '๐Ÿ”ง',
1775
2030
  };
1776
2031
 
@@ -1788,6 +2043,9 @@ const CONTENT_EXTENSIONS = new Set([
1788
2043
  // Active Claude processes per chat (for /stop)
1789
2044
  const activeProcesses = new Map(); // chatId -> { child, aborted }
1790
2045
 
2046
+ // Message queue for messages received while a task is running
2047
+ const messageQueue = new Map(); // chatId -> { messages: string[], notified: false }
2048
+
1791
2049
  // File cache for button callbacks (shortId -> fullPath)
1792
2050
  const fileCache = new Map();
1793
2051
  const FILE_CACHE_TTL = 1800000; // 30 minutes
@@ -1886,9 +2144,33 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
1886
2144
  lastStatusTime = now;
1887
2145
  const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
1888
2146
 
1889
- // Extract brief context from tool input
2147
+ // Resolve display name and context for MCP/Skill/Task tools
2148
+ let displayName = toolName;
2149
+ let displayEmoji = emoji;
1890
2150
  let context = '';
1891
- if (block.input) {
2151
+
2152
+ if (toolName === 'Skill' && block.input?.skill) {
2153
+ // Skill invocation: show skill name
2154
+ context = block.input.skill;
2155
+ } else if (toolName === 'Task' && block.input?.description) {
2156
+ // Agent task: show description
2157
+ context = block.input.description.slice(0, 30);
2158
+ } else if (toolName.startsWith('mcp__')) {
2159
+ // MCP tool: mcp__server__action โ†’ "MCP server: action"
2160
+ const parts = toolName.split('__');
2161
+ const server = parts[1] || 'unknown';
2162
+ const action = parts.slice(2).join('_') || '';
2163
+ if (server === 'playwright') {
2164
+ displayEmoji = '๐ŸŒ';
2165
+ displayName = 'Browser';
2166
+ context = action.replace(/_/g, ' ');
2167
+ } else {
2168
+ displayEmoji = '๐Ÿ”—';
2169
+ displayName = `MCP:${server}`;
2170
+ context = action.replace(/_/g, ' ').slice(0, 25);
2171
+ }
2172
+ } else if (block.input) {
2173
+ // Standard tools: extract brief context
1892
2174
  if (block.input.file_path) {
1893
2175
  // Insert zero-width space before extension to prevent link parsing
1894
2176
  const basename = path.basename(block.input.file_path);
@@ -1909,8 +2191,8 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
1909
2191
  }
1910
2192
 
1911
2193
  const status = context
1912
- ? `${emoji} ${toolName}: ใ€Œ${context}ใ€`
1913
- : `${emoji} ${toolName}...`;
2194
+ ? `${displayEmoji} ${displayName}: ใ€Œ${context}ใ€`
2195
+ : `${displayEmoji} ${displayName}...`;
1914
2196
 
1915
2197
  if (onStatus) {
1916
2198
  onStatus(status).catch(() => {});
@@ -2041,11 +2323,14 @@ async function askClaude(bot, chatId, prompt) {
2041
2323
  - Multiple files: use multiple [[FILE:...]] tags]`;
2042
2324
  const fullPrompt = prompt + daemonHint;
2043
2325
 
2044
- // Use streaming mode to show progress (edit status msg in-place)
2326
+ // Use streaming mode to show progress
2327
+ // Telegram: edit status msg in-place; Feishu/others: send new messages
2045
2328
  const onStatus = async (status) => {
2046
2329
  try {
2047
2330
  if (statusMsgId && bot.editMessage) {
2048
2331
  await bot.editMessage(chatId, statusMsgId, status);
2332
+ } else {
2333
+ await bot.sendMessage(chatId, status);
2049
2334
  }
2050
2335
  } catch { /* ignore status update failures */ }
2051
2336
  };
@@ -2137,6 +2422,9 @@ async function askClaude(bot, chatId, prompt) {
2137
2422
  log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
2138
2423
  try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
2139
2424
  }
2425
+ } else if (errMsg === 'Stopped by user' && messageQueue.has(chatId)) {
2426
+ // Interrupted by message queue โ€” suppress error, queue timer will handle it
2427
+ log('INFO', `Task interrupted by new message for ${chatId}`);
2140
2428
  } else {
2141
2429
  try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
2142
2430
  }
@@ -2219,8 +2507,11 @@ function killExistingDaemon() {
2219
2507
  if (oldPid && oldPid !== process.pid) {
2220
2508
  process.kill(oldPid, 'SIGTERM');
2221
2509
  log('INFO', `Killed existing daemon (PID: ${oldPid})`);
2222
- // Brief pause to let it clean up
2223
- require('child_process').execSync('sleep 1', { stdio: 'ignore' });
2510
+ // Wait for old process to actually exit (up to 5s)
2511
+ for (let i = 0; i < 10; i++) {
2512
+ try { process.kill(oldPid, 0); } catch { break; } // throws if process gone
2513
+ require('child_process').execSync('sleep 0.5', { stdio: 'ignore' });
2514
+ }
2224
2515
  }
2225
2516
  } catch {
2226
2517
  // Process doesn't exist or already dead
@@ -2351,22 +2642,17 @@ async function main() {
2351
2642
 
2352
2643
  // Auto-restart: watch daemon.js for code changes (hot restart)
2353
2644
  const DAEMON_SCRIPT = path.join(METAME_DIR, 'daemon.js');
2645
+ const _startTime = Date.now();
2354
2646
  let _restartDebounce = null;
2355
2647
  fs.watchFile(DAEMON_SCRIPT, { interval: 3000 }, (curr, prev) => {
2356
2648
  if (curr.mtimeMs === prev.mtimeMs) return;
2649
+ // Ignore file changes within 10s of startup (avoids restart loop)
2650
+ if (Date.now() - _startTime < 10000) return;
2357
2651
  if (_restartDebounce) clearTimeout(_restartDebounce);
2358
- _restartDebounce = setTimeout(async () => {
2359
- log('INFO', 'daemon.js changed on disk โ€” auto-restarting...');
2360
- await notifyFn('๐Ÿ”„ Code updated, daemon restarting...').catch(() => {});
2361
- // Spawn new daemon process, then exit
2362
- const { spawn } = require('child_process');
2363
- const newDaemon = spawn(process.execPath, [DAEMON_SCRIPT], {
2364
- detached: true,
2365
- stdio: 'ignore',
2366
- env: { ...process.env, METAME_ROOT: process.env.METAME_ROOT || path.dirname(__dirname) },
2367
- });
2368
- newDaemon.unref();
2369
- setTimeout(() => process.exit(0), 500);
2652
+ _restartDebounce = setTimeout(() => {
2653
+ log('INFO', 'daemon.js changed on disk โ€” exiting for restart...');
2654
+ // Don't notify here โ€” the NEW process will notify after startup
2655
+ process.exit(0);
2370
2656
  }, 2000);
2371
2657
  });
2372
2658
 
@@ -2374,6 +2660,10 @@ async function main() {
2374
2660
  telegramBridge = await startTelegramBridge(config, executeTaskByName);
2375
2661
  feishuBridge = await startFeishuBridge(config, executeTaskByName);
2376
2662
 
2663
+ // Notify once on startup (single message, no duplicates)
2664
+ await sleep(1500); // Let polling settle
2665
+ await notifyFn('โœ… Daemon ready.').catch(() => {});
2666
+
2377
2667
  // Graceful shutdown
2378
2668
  const shutdown = () => {
2379
2669
  log('INFO', 'Daemon shutting down...');
@@ -121,18 +121,13 @@ function createBot(config) {
121
121
  async downloadFile(messageId, fileKey, destPath, msgType = 'file') {
122
122
  try {
123
123
  let res;
124
- if (msgType === 'image') {
125
- // Images use im.image.get API
126
- res = await client.im.image.get({
127
- path: { image_key: fileKey },
128
- });
129
- } else {
130
- // Files and media use im.messageResource.get API
131
- res = await client.im.messageResource.get({
132
- path: { message_id: messageId, file_key: fileKey },
133
- params: { type: 'file' },
134
- });
135
- }
124
+ // All message attachments (images, files, media) use messageResource.get
125
+ // im.image.get only works for images uploaded by the app itself
126
+ const resourceType = msgType === 'image' ? 'image' : 'file';
127
+ res = await client.im.messageResource.get({
128
+ path: { message_id: messageId, file_key: fileKey },
129
+ params: { type: resourceType },
130
+ });
136
131
 
137
132
  // SDK returns writeFile method or getReadableStream
138
133
  if (res && res.writeFile) {
@@ -293,7 +288,7 @@ function createBot(config) {
293
288
  fileInfo = {
294
289
  messageId: msg.message_id,
295
290
  fileKey: content.file_key || content.image_key,
296
- fileName: content.file_name || content.image_key || `file_${Date.now()}`,
291
+ fileName: content.file_name || (content.image_key ? `image_${Date.now()}.png` : `file_${Date.now()}`),
297
292
  msgType: msg.message_type, // 'file', 'image', or 'media'
298
293
  };
299
294
  } catch {}