metame-cli 1.3.18 โ†’ 1.3.20

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
@@ -54,6 +54,10 @@
54
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
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
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.
57
+ * **๐Ÿ”€ Parallel Multi-Agent Chats (v1.3.19):** Assign each agent its own dedicated Feishu/Telegram group. Messages in different groups execute in parallel โ€” no waiting for each other. Route `chatId โ†’ agent` via `chat_agent_map` in `daemon.yaml`. Create a new group, send `/bind <name>` to register it instantly.
58
+ * **๐Ÿ”ง Config Hot-Reload Fix (v1.3.19):** `allowed_chat_ids` is now read dynamically on every message โ€” no restart needed after editing `daemon.yaml`. `/fix` config restore now merges current `chatId` settings so manually-added groups are never lost.
59
+ * **๐Ÿ›ก๏ธ Daemon Auto-Restart via LaunchAgent (v1.3.19):** MetaMe's npm daemon is now managed by macOS launchd. Crashes or unexpected exits trigger an automatic restart after 5 seconds.
60
+ * **๐Ÿ‘ฅ Operator Permissions & Read-Only Mode (v1.3.19):** Add `operator_ids` to restrict who can execute Claude commands in shared groups. Non-operators can still chat and query (read/search/web only) โ€” they just can't edit files, run bash, or trigger slash commands. Use `/myid` to discover any user's Feishu open_id.
57
61
 
58
62
  ## ๐Ÿ›  Prerequisites
59
63
 
@@ -341,6 +345,9 @@ Bot: ๅ›ž้€€ๅˆฐๅ“ชไธ€่ฝฎ๏ผŸ
341
345
  | `/budget` | Today's token usage |
342
346
  | `/quiet` | Silence mirror/reflections for 48h |
343
347
  | `/reload` | Manually reload daemon.yaml (also auto-reloads on file change) |
348
+ | `/bind <name>` | Register current group as a dedicated agent chat โ€” opens directory browser to pick working directory |
349
+ | `/chatid` | Show the current group's chat ID |
350
+ | `/myid` | Show your own Feishu sender open_id (for configuring `operator_ids`) |
344
351
 
345
352
  **Heartbeat Tasks:**
346
353
 
@@ -395,6 +402,7 @@ Each step runs in the same Claude Code session. Step outputs automatically becom
395
402
  **Security:**
396
403
 
397
404
  * `allowed_chat_ids` whitelist โ€” unauthorized users silently ignored (empty = deny all)
405
+ * `operator_ids` โ€” within an allowed group, restrict command execution to specific users; non-operators get read-only chat mode
398
406
  * `dangerously_skip_permissions` enabled by default for mobile (users can't click "allow" on phone โ€” security relies on the chat ID whitelist)
399
407
  * `~/.metame/` directory set to mode 700
400
408
  * Bot tokens stored locally, never transmitted
@@ -446,6 +454,74 @@ projects:
446
454
 
447
455
  **Heartbeat task notifications** arrive as colored Feishu cards โ€” each project's color is distinct, so you can tell at a glance which agent sent the update.
448
456
 
457
+ ### Parallel Multi-Agent Chats & `/bind` Command (v1.3.19)
458
+
459
+ Give each agent its own dedicated group chat โ€” messages to different groups execute simultaneously without blocking each other.
460
+
461
+ **How it works:**
462
+
463
+ Each group is mapped to a specific agent via `chat_agent_map` in `daemon.yaml`. When a message arrives in a group, the daemon looks up which agent owns that `chatId` and dispatches the Claude call to that agent's working directory โ€” fully parallel.
464
+
465
+ **Setup โ€” `/bind` command (recommended):**
466
+
467
+ 1. Create a new Feishu or Telegram group and add your bot.
468
+ 2. In the group, send `/bind <name>` (e.g., `/bind backend`).
469
+ 3. The bot opens a Finder-style directory browser โ€” tap folders to navigate, tap a folder name to select it as the working directory.
470
+ 4. Done. The bot automatically:
471
+ - Adds the group's `chatId` to `allowed_chat_ids`
472
+ - Creates a `chat_agent_map` entry routing this group to the agent
473
+ - Creates a `projects` entry for the agent
474
+ - Sends a welcome card
475
+
476
+ > **No whitelist required:** `/bind` works in any group โ€” the new group self-registers without needing to be pre-approved in `allowed_chat_ids`.
477
+
478
+ > **Re-binding:** Send `/bind <name>` again in the same group to overwrite the previous configuration.
479
+
480
+ **Manual setup** (`~/.metame/daemon.yaml`):
481
+
482
+ ```yaml
483
+ chat_agent_map:
484
+ "oc_abc123": "backend" # chatId โ†’ project key
485
+ "oc_def456": "frontend"
486
+
487
+ projects:
488
+ backend:
489
+ name: "Backend API"
490
+ cwd: "~/projects/api"
491
+ frontend:
492
+ name: "Frontend App"
493
+ cwd: "~/projects/app"
494
+ ```
495
+
496
+ **`/chatid` command:**
497
+
498
+ In any authorized group, send `/chatid` and the bot replies with the current group's `chatId`. Useful for manual configuration.
499
+
500
+ | Command | Description |
501
+ |---------|-------------|
502
+ | `/bind <name>` | Register current group as a dedicated agent chat โ€” opens directory browser to pick working directory |
503
+ | `/chatid` | Show the current group's chat ID |
504
+ | `/myid` | Show your own Feishu sender open_id |
505
+
506
+ **Operator Permissions (`operator_ids`):**
507
+
508
+ In shared groups (e.g., a group with a colleague or tester), you can restrict who can execute Claude commands. Non-operators get a read-only chat mode โ€” they can ask questions and search, but can't edit files, run bash, or trigger slash commands.
509
+
510
+ ```yaml
511
+ feishu:
512
+ operator_ids:
513
+ - "ou_abc123yourid" # Only these users can execute commands
514
+ ```
515
+
516
+ Use `/myid` in any Feishu group to get a user's open_id. Then add it to `operator_ids` to grant full access.
517
+
518
+ | User type | Chat & query | Slash commands | Write / Edit / Bash |
519
+ |-----------|:---:|:---:|:---:|
520
+ | Operator | โœ… | โœ… | โœ… |
521
+ | Non-operator | โœ… | โŒ | โŒ |
522
+
523
+ > If `operator_ids` is empty, all whitelisted users have full access (default behavior).
524
+
449
525
  ### Provider Relay โ€” Third-Party Model Support (v1.3.11)
450
526
 
451
527
  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.
@@ -649,6 +725,7 @@ A: No. Your profile stays local at `~/.claude_profile.yaml`. MetaMe simply passe
649
725
 
650
726
  | Version | Highlights |
651
727
  |---------|------------|
728
+ | **v1.3.19** | **Parallel multi-agent group chats** โ€” `chat_agent_map` routes chatId โ†’ agent for true parallel execution; `/bind` command for one-tap group registration with Finder-style directory browser; `/chatid` to look up group ID; `allowed_chat_ids` hot-reload fix (read per message, no restart); `/fix` now merges current chatId config; daemon auto-restart via macOS LaunchAgent (5-second recovery); **operator_ids** permission layer โ€” non-operators get read-only chat mode (query/search, no write/execute); `/myid` command to retrieve Feishu open_id |
652
729
  | **v1.3.18** | **Multi-agent project isolation** โ€” `projects` in `daemon.yaml` with per-project heartbeat tasks, Feishu colored cards per project, `/agent` picker button, nickname routing (say agent name to switch instantly), reply-to-message session restoration, fix `~` expansion in project cwd |
653
730
  | **v1.3.17** | **Windows support** (WSL one-command installer), `install-systemd` for Linux/WSL daemon auto-start. Fix onboarding (Genesis interview was never injected, CLAUDE.md accumulated across runs). Marker-based cleanup, unified protocols, `--append-system-prompt` guarantees interview activation, Feishu auto-fetch chat ID, full mobile permissions, fix `/publish` false-success, auto-restart daemon on script update |
654
731
  | **v1.3.16** | Git-based `/undo` (auto-checkpoint before each turn, `git reset --hard` rollback), `/nosleep` toggle (macOS caffeinate), custom provider model passthrough (`/model` accepts any name for non-anthropic providers), auto-fallback to anthropic/opus on provider failure, message queue works on Telegram (fire-and-forget poll loop), lazy background distill |
package/index.js CHANGED
@@ -203,6 +203,21 @@ function spawnDistillBackground() {
203
203
  } catch { /* stale lock, proceed */ }
204
204
  }
205
205
 
206
+ // 4-hour cooldown: check last distill timestamp from profile
207
+ const cooldownMs = 4 * 60 * 60 * 1000;
208
+ try {
209
+ const profilePath = path.join(process.env.HOME || '', '.claude_profile.yaml');
210
+ if (fs.existsSync(profilePath)) {
211
+ const yaml = require('js-yaml');
212
+ const profile = yaml.load(fs.readFileSync(profilePath, 'utf8'));
213
+ const distillLog = profile && profile.evolution && profile.evolution.auto_distill;
214
+ if (Array.isArray(distillLog) && distillLog.length > 0) {
215
+ const lastTs = new Date(distillLog[distillLog.length - 1].ts).getTime();
216
+ if (Date.now() - lastTs < cooldownMs) return;
217
+ }
218
+ }
219
+ } catch { /* non-fatal, proceed */ }
220
+
206
221
  const hasSignals = shouldDistill();
207
222
  const bootstrap = needsBootstrap();
208
223
 
@@ -305,7 +320,7 @@ runExpiryCleanup();
305
320
  if (!fs.existsSync(BRAIN_FILE)) {
306
321
  const initialProfile = `identity:
307
322
  role: Unknown
308
- nickname: null
323
+ locale: null
309
324
  status:
310
325
  focus: Initializing
311
326
  `;
@@ -367,12 +382,12 @@ You are entering **Calibration Mode**. You are not a chatbot; you are a Psycholo
367
382
 
368
383
  5. **Shadows (Hidden Fears):** What are you avoiding? What pattern do you keep repeating? What keeps you up at night?
369
384
 
370
- 6. **Identity (Nickname + Role):** Based on everything learned, propose a nickname and role summary. Ask if it resonates.
385
+ 6. **Identity (Role + Locale):** Based on everything learned, propose a role summary and confirm their preferred language (locale). Ask if it resonates.
371
386
 
372
387
  **TERMINATION:**
373
388
  - After 5-7 exchanges, synthesize everything into \`~/.claude_profile.yaml\`.
374
389
  - **LOCK** Core Values with \`# [LOCKED]\`.
375
- - Announce: "Link Established. I see you now, [Nickname]."
390
+ - Announce: "Link Established. Profile calibrated."
376
391
  - Then proceed to **Phase 2** below.
377
392
 
378
393
  **3. SETUP WIZARD (Phase 2 โ€” Optional):**
@@ -440,7 +455,7 @@ let isKnownUser = false;
440
455
  try {
441
456
  if (fs.existsSync(BRAIN_FILE)) {
442
457
  const doc = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
443
- if (doc.identity && doc.identity.nickname && doc.identity.nickname !== 'null') {
458
+ if (doc.identity && doc.identity.locale && doc.identity.locale !== 'null') {
444
459
  isKnownUser = true;
445
460
  }
446
461
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.3.18",
3
+ "version": "1.3.20",
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
@@ -126,12 +126,34 @@ function backupConfig() {
126
126
 
127
127
  function restoreConfig() {
128
128
  const bak = CONFIG_FILE + '.bak';
129
- if (fs.existsSync(bak)) {
129
+ if (!fs.existsSync(bak)) return false;
130
+ try {
131
+ const bakCfg = yaml.load(fs.readFileSync(bak, 'utf8')) || {};
132
+ // Preserve security-critical fields from current config (chat IDs, agent map)
133
+ // so a /fix never loses manually-added channels
134
+ let curCfg = {};
135
+ try { curCfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {}; } catch {}
136
+ for (const adapter of ['feishu', 'telegram']) {
137
+ if (curCfg[adapter] && bakCfg[adapter]) {
138
+ const curIds = curCfg[adapter].allowed_chat_ids || [];
139
+ const bakIds = bakCfg[adapter].allowed_chat_ids || [];
140
+ // Union of both lists
141
+ const merged = [...new Set([...bakIds, ...curIds])];
142
+ bakCfg[adapter].allowed_chat_ids = merged;
143
+ // Merge chat_agent_map (current takes precedence)
144
+ bakCfg[adapter].chat_agent_map = Object.assign(
145
+ {}, bakCfg[adapter].chat_agent_map || {}, curCfg[adapter].chat_agent_map || {}
146
+ );
147
+ }
148
+ }
149
+ fs.writeFileSync(CONFIG_FILE, yaml.dump(bakCfg, { lineWidth: -1 }), 'utf8');
150
+ config = loadConfig();
151
+ return true;
152
+ } catch {
130
153
  fs.copyFileSync(bak, CONFIG_FILE);
131
154
  config = loadConfig();
132
155
  return true;
133
156
  }
134
- return false;
135
157
  }
136
158
 
137
159
  let _cachedState = null;
@@ -554,7 +576,7 @@ async function startTelegramBridge(config, executeTaskByName) {
554
576
 
555
577
  const { createBot } = require(path.join(__dirname, 'telegram-adapter.js'));
556
578
  const bot = createBot(config.telegram.bot_token);
557
- const allowedIds = config.telegram.allowed_chat_ids || [];
579
+ // allowedIds read dynamically per-message to support hot-reload of daemon.yaml
558
580
 
559
581
  // Verify bot
560
582
  try {
@@ -581,6 +603,7 @@ async function startTelegramBridge(config, executeTaskByName) {
581
603
  const chatId = cb.message && cb.message.chat.id;
582
604
  bot.answerCallback(cb.id).catch(() => {});
583
605
  if (chatId && cb.data) {
606
+ const allowedIds = (loadConfig().telegram && loadConfig().telegram.allowed_chat_ids) || [];
584
607
  if (!allowedIds.includes(chatId)) continue;
585
608
  // Fire-and-forget: don't block poll loop (enables message queue)
586
609
  handleCommand(bot, chatId, cb.data, config, executeTaskByName).catch(e => {
@@ -595,8 +618,11 @@ async function startTelegramBridge(config, executeTaskByName) {
595
618
  const msg = update.message;
596
619
  const chatId = msg.chat.id;
597
620
 
598
- // Security: check whitelist (empty = deny all)
599
- if (!allowedIds.includes(chatId)) {
621
+ // Security: check whitelist (empty = deny all) โ€” read live config to support hot-reload
622
+ // Exception: /bind is allowed from any chat so users can self-register new groups
623
+ const allowedIds = (loadConfig().telegram && loadConfig().telegram.allowed_chat_ids) || [];
624
+ const isBindCmd = msg.text && msg.text.trim().startsWith('/bind');
625
+ if (!allowedIds.includes(chatId) && !isBindCmd) {
600
626
  log('WARN', `Rejected message from unauthorized chat: ${chatId}`);
601
627
  continue;
602
628
  }
@@ -670,9 +696,15 @@ async function startTelegramBridge(config, executeTaskByName) {
670
696
  };
671
697
  }
672
698
 
699
+ // โ”€โ”€ Timing constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
700
+ const CLAUDE_COOLDOWN_MS = 10000; // 10s between Claude calls per chat
701
+ const STATUS_THROTTLE_MS = 3000; // Min 3s between streaming status updates
702
+ const FALLBACK_THROTTLE_MS = 8000; // 8s between fallback status updates
703
+ const DEDUP_TTL_MS = 60000; // Feishu message dedup window (60s)
704
+ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
705
+
673
706
  // Rate limiter for /ask and /run โ€” prevents rapid-fire Claude calls
674
707
  const _lastClaudeCall = {};
675
- const CLAUDE_COOLDOWN_MS = 10000; // 10s between Claude calls per chat
676
708
 
677
709
  function checkCooldown(chatId) {
678
710
  const now = Date.now();
@@ -688,63 +720,126 @@ function checkCooldown(chatId) {
688
720
  // Path shortener โ€” imported from ./utils
689
721
  const { shortenPath, expandPath } = createPathMap();
690
722
 
723
+ /**
724
+ * Normalize a directory path: expand shortcuts and resolve ~
725
+ */
726
+ function normalizeCwd(p) {
727
+ return expandPath(p).replace(/^~/, HOME);
728
+ }
729
+
730
+ /**
731
+ * Parse [[FILE:...]] markers from Claude output.
732
+ * Returns { markedFiles, cleanOutput }
733
+ */
734
+ function parseFileMarkers(output) {
735
+ const markers = output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
736
+ const markedFiles = markers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
737
+ const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
738
+ return { markedFiles, cleanOutput };
739
+ }
740
+
741
+ /**
742
+ * Merge explicit [[FILE:...]] paths with auto-detected content files.
743
+ * Returns a Set of unique file paths.
744
+ */
745
+ function mergeFileCollections(markedFiles, sourceFiles) {
746
+ const result = new Set(markedFiles);
747
+ if (sourceFiles && sourceFiles.length > 0) {
748
+ for (const f of sourceFiles) { if (isContentFile(f)) result.add(f); }
749
+ }
750
+ return result;
751
+ }
752
+
753
+ /**
754
+ * Send file download buttons for a set of file paths.
755
+ */
756
+ async function sendFileButtons(bot, chatId, files) {
757
+ if (!bot.sendButtons || files.size === 0) return;
758
+ const validFiles = [...files].filter(f => fs.existsSync(f));
759
+ if (validFiles.length === 0) return;
760
+ const buttons = validFiles.map(filePath => {
761
+ const shortId = cacheFile(filePath);
762
+ return [{ text: `๐Ÿ“Ž ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
763
+ });
764
+ await bot.sendButtons(chatId, '๐Ÿ“‚ ๆ–‡ไปถ:', buttons);
765
+ }
766
+
767
+ /**
768
+ * Attach chatId to the most recent session in projCwd, or create a new one.
769
+ */
770
+ function attachOrCreateSession(chatId, projCwd, name) {
771
+ const state = loadState();
772
+ const recent = listRecentSessions(1, projCwd);
773
+ if (recent.length > 0 && recent[0].sessionId) {
774
+ state.sessions[chatId] = { id: recent[0].sessionId, cwd: projCwd, started: true };
775
+ } else {
776
+ const newSess = createSession(chatId, projCwd, name || '');
777
+ state.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
778
+ }
779
+ saveState(state);
780
+ }
781
+
691
782
  /**
692
783
  * Send directory picker: recent projects + Browse button
693
784
  * @param {string} mode - 'new' or 'cd' (determines callback command)
694
785
  */
695
786
  async function sendDirPicker(bot, chatId, mode, title) {
696
- const dirs = listProjectDirs();
697
- const cmd = mode === 'new' ? '/new' : '/cd';
698
- if (bot.sendButtons) {
699
- const buttons = dirs.map(d => [{ text: d.label, callback_data: `${cmd} ${shortenPath(d.path)}` }]);
700
- buttons.push([{ text: 'Browse...', callback_data: `/browse ${mode} ${shortenPath(HOME)}` }]);
701
- await bot.sendButtons(chatId, title, buttons);
702
- } else {
703
- let msg = `${title}\n`;
704
- dirs.forEach((d, i) => { msg += `${i + 1}. ${d.label}\n ${cmd} ${d.path}\n`; });
705
- msg += `\nOr type: ${cmd} /full/path`;
706
- await bot.sendMessage(chatId, msg);
707
- }
787
+ // Always open the file browser starting from HOME โ€” Finder-style navigation
788
+ await sendBrowse(bot, chatId, mode, HOME, title);
708
789
  }
709
790
 
710
791
  /**
711
- * Send directory browser: list subdirs of a path with .. parent nav
792
+ * Send directory browser: Finder-style navigation
793
+ * - Clicking a subdir ALWAYS navigates into it (never immediate select)
794
+ * - "โœ“ ้€‰ๆ‹ฉๆญค็›ฎๅฝ•" button at top confirms the current dir
795
+ * - Shows up to 12 subdirs per page with pagination
712
796
  */
713
- async function sendBrowse(bot, chatId, mode, dirPath) {
714
- const cmd = mode === 'new' ? '/new' : '/cd';
797
+ async function sendBrowse(bot, chatId, mode, dirPath, title, page = 0) {
798
+ const cmd = mode === 'new' ? '/new' : mode === 'bind' ? '/bind-dir' : '/cd';
799
+ const PAGE_SIZE = 10;
715
800
  try {
716
801
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
717
802
  const subdirs = entries
718
803
  .filter(e => e.isDirectory() && !e.name.startsWith('.'))
719
804
  .map(e => e.name)
720
- .sort()
721
- .slice(0, 8); // max 8 subdirs per screen
805
+ .sort();
806
+
807
+ const totalPages = Math.ceil(subdirs.length / PAGE_SIZE);
808
+ const pageSubdirs = subdirs.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
809
+ const parent = path.dirname(dirPath);
810
+ const displayPath = dirPath.replace(HOME, '~');
722
811
 
723
812
  if (bot.sendButtons) {
724
813
  const buttons = [];
725
- // Select this directory
726
- buttons.push([{ text: `>> Use this dir`, callback_data: `${cmd} ${shortenPath(dirPath)}` }]);
727
- // Subdirectories
728
- for (const name of subdirs) {
814
+ // โœ“ Confirm current dir
815
+ buttons.push([{ text: `โœ“ ้€‰ๆ‹ฉใ€Œ${displayPath}ใ€`, callback_data: `${cmd} ${shortenPath(dirPath)}` }]);
816
+ // Subdirectories โ€” click = navigate in
817
+ for (const name of pageSubdirs) {
729
818
  const full = path.join(dirPath, name);
730
- buttons.push([{ text: `${name}/`, callback_data: `/browse ${mode} ${shortenPath(full)}` }]);
819
+ buttons.push([{ text: `๐Ÿ“ ${name}`, callback_data: `/browse ${mode} ${shortenPath(full)}` }]);
731
820
  }
732
- // Parent
733
- const parent = path.dirname(dirPath);
821
+ // Pagination
822
+ const nav = [];
823
+ if (page > 0) nav.push({ text: 'โ† ไธŠ้กต', callback_data: `/browse ${mode} ${shortenPath(dirPath)} ${page - 1}` });
824
+ if (page < totalPages - 1) nav.push({ text: 'ไธ‹้กต โ†’', callback_data: `/browse ${mode} ${shortenPath(dirPath)} ${page + 1}` });
825
+ if (nav.length) buttons.push(nav);
826
+ // Parent dir
734
827
  if (parent !== dirPath) {
735
- buttons.push([{ text: '.. back', callback_data: `/browse ${mode} ${shortenPath(parent)}` }]);
828
+ buttons.push([{ text: 'โฌ† ไธŠ็บง็›ฎๅฝ•', callback_data: `/browse ${mode} ${shortenPath(parent)}` }]);
736
829
  }
737
- await bot.sendButtons(chatId, dirPath, buttons);
830
+ const header = title ? `${title}\n๐Ÿ“‚ ${displayPath}` : `๐Ÿ“‚ ${displayPath}`;
831
+ await bot.sendButtons(chatId, header, buttons);
738
832
  } else {
739
- let msg = `${dirPath}\n\n`;
740
- subdirs.forEach((name, i) => {
741
- msg += `${i + 1}. ${name}/\n /browse ${mode} ${path.join(dirPath, name)}\n`;
833
+ let msg = `๐Ÿ“‚ ${displayPath}\n\n`;
834
+ pageSubdirs.forEach((name, i) => {
835
+ msg += `${page * PAGE_SIZE + i + 1}. ${name}/\n /browse ${mode} ${path.join(dirPath, name)}\n`;
742
836
  });
743
- msg += `\nSelect: ${cmd} ${dirPath}\nBack: /browse ${mode} ${path.dirname(dirPath)}`;
837
+ msg += `\nโœ“ ้€‰ๆ‹ฉๆญค็›ฎๅฝ•: ${cmd} ${dirPath}`;
838
+ if (parent !== dirPath) msg += `\nโฌ† ไธŠ็บง: /browse ${mode} ${parent}`;
744
839
  await bot.sendMessage(chatId, msg);
745
840
  }
746
841
  } catch (e) {
747
- await bot.sendMessage(chatId, `Cannot read: ${dirPath}`);
842
+ await bot.sendMessage(chatId, `ๆ— ๆณ•่ฏปๅ–็›ฎๅฝ•: ${dirPath}`);
748
843
  }
749
844
  }
750
845
 
@@ -851,16 +946,132 @@ async function sendDirListing(bot, chatId, baseDir, arg) {
851
946
  /**
852
947
  * Unified command handler โ€” shared by Telegram & Feishu
853
948
  */
854
- async function handleCommand(bot, chatId, text, config, executeTaskByName) {
949
+
950
+ async function doBindAgent(bot, chatId, agentName, agentCwd) {
951
+ // /bind sets the session context (cwd, CLAUDE.md, project configs) for this chat.
952
+ // The agent can still read/write any path on the machine โ€” bind only defines
953
+ // which project directory Claude Code uses as its working directory.
954
+ // Calling /bind again overwrites the previous binding (rebind is always allowed).
955
+ try {
956
+ const cfg = loadConfig();
957
+ const isTg = typeof chatId === 'number';
958
+ const ak = isTg ? 'telegram' : 'feishu';
959
+ if (!cfg[ak]) cfg[ak] = {};
960
+ if (!cfg[ak].allowed_chat_ids) cfg[ak].allowed_chat_ids = [];
961
+ if (!cfg[ak].chat_agent_map) cfg[ak].chat_agent_map = {};
962
+ const idVal = isTg ? chatId : String(chatId);
963
+ if (!cfg[ak].allowed_chat_ids.includes(idVal)) cfg[ak].allowed_chat_ids.push(idVal);
964
+ const projectKey = agentName.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() || String(chatId);
965
+ cfg[ak].chat_agent_map[String(chatId)] = projectKey;
966
+ if (!cfg.projects) cfg.projects = {};
967
+ const isNew = !cfg.projects[projectKey];
968
+ if (isNew) {
969
+ cfg.projects[projectKey] = { name: agentName, cwd: agentCwd, nicknames: [agentName] };
970
+ } else {
971
+ cfg.projects[projectKey].name = agentName;
972
+ cfg.projects[projectKey].cwd = agentCwd;
973
+ }
974
+ fs.writeFileSync(CONFIG_FILE, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
975
+ backupConfig();
976
+
977
+ const proj = cfg.projects[projectKey];
978
+ const icon = proj.icon || '๐Ÿค–';
979
+ const color = proj.color || 'blue';
980
+ const action = isNew ? '็ป‘ๅฎšๆˆๅŠŸ' : '้‡ๆ–ฐ็ป‘ๅฎš';
981
+ const displayCwd = agentCwd.replace(HOME, '~');
982
+ if (bot.sendCard) {
983
+ await bot.sendCard(chatId, {
984
+ title: `${icon} ${agentName} โ€” ${action}`,
985
+ body: `**ๅทฅไฝœ็›ฎๅฝ•**\n${displayCwd}\n\n็›ดๆŽฅๅ‘ๆถˆๆฏๅณๅฏๅผ€ๅง‹ๅฏน่ฏ๏ผŒๆ— ้œ€ @bot`,
986
+ color,
987
+ });
988
+ } else {
989
+ await bot.sendMessage(chatId, `${icon} ${agentName} ${action}\n็›ฎๅฝ•: ${displayCwd}`);
990
+ }
991
+ } catch (e) {
992
+ await bot.sendMessage(chatId, `โŒ ็ป‘ๅฎšๅคฑ่ดฅ: ${e.message}`);
993
+ }
994
+ }
995
+
996
+ async function handleCommand(bot, chatId, text, config, executeTaskByName, senderId = null, readOnly = false) {
855
997
  const state = loadState();
856
998
 
999
+ // --- /chatid: reply with current chatId ---
1000
+ if (text === '/chatid') {
1001
+ await bot.sendMessage(chatId, `Chat ID: \`${chatId}\``);
1002
+ return;
1003
+ }
1004
+
1005
+ // --- /myid: reply with sender's user open_id (for configuring operator_ids) ---
1006
+ if (text === '/myid') {
1007
+ await bot.sendMessage(chatId, senderId ? `Your ID: \`${senderId}\`` : 'ID not available (Telegram not supported)');
1008
+ return;
1009
+ }
1010
+
1011
+ // --- /bind <name> [cwd]: register this chat as a dedicated agent channel ---
1012
+ // With cwd: /bind ๅฐ็พŽ ~/ โ†’ bind immediately
1013
+ // Without cwd: /bind ๆ•™ๆŽˆ โ†’ show directory picker
1014
+ if (text.startsWith('/bind ') || text === '/bind') {
1015
+ const args = text.slice(5).trim();
1016
+ const parts = args.split(/\s+/);
1017
+ const agentName = parts[0];
1018
+ const agentCwd = parts.slice(1).join(' ');
1019
+
1020
+ if (!agentName) {
1021
+ await bot.sendMessage(chatId, '็”จๆณ•: /bind <ๅ็งฐ> [ๅทฅไฝœ็›ฎๅฝ•]\nไพ‹: /bind ๅฐ็พŽ ~/\nๆˆ–: /bind ๆ•™ๆŽˆ (ๅผนๅ‡บ็›ฎๅฝ•้€‰ๆ‹ฉ)');
1022
+ return;
1023
+ }
1024
+
1025
+ if (!agentCwd) {
1026
+ // No cwd given โ€” show directory picker
1027
+ pendingBinds.set(String(chatId), agentName);
1028
+ await sendDirPicker(bot, chatId, 'bind', `ไธบใ€Œ${agentName}ใ€้€‰ๆ‹ฉๅทฅไฝœ็›ฎๅฝ•:`);
1029
+ return;
1030
+ }
1031
+
1032
+ await doBindAgent(bot, chatId, agentName, agentCwd);
1033
+ return;
1034
+ }
1035
+
1036
+ // --- /bind-dir <path>: called by directory picker to complete a pending bind ---
1037
+ if (text.startsWith('/bind-dir ')) {
1038
+ const dirPath = expandPath(text.slice(10).trim());
1039
+ const agentName = pendingBinds.get(String(chatId));
1040
+ if (!agentName) {
1041
+ await bot.sendMessage(chatId, 'โŒ ๆฒกๆœ‰ๅพ…ๅฎŒๆˆ็š„ /bind๏ผŒ่ฏท้‡ๆ–ฐๅ‘้€ /bind <ๅ็งฐ>');
1042
+ return;
1043
+ }
1044
+ pendingBinds.delete(String(chatId));
1045
+ await doBindAgent(bot, chatId, agentName, dirPath);
1046
+ return;
1047
+ }
1048
+
1049
+ // --- chat_agent_map: auto-switch agent based on dedicated chatId ---
1050
+ // Configure in daemon.yaml: feishu.chat_agent_map or telegram.chat_agent_map
1051
+ // e.g. chat_agent_map: { "oc_xxx": "personal", "oc_yyy": "metame" }
1052
+ const chatAgentMap = (config.feishu && config.feishu.chat_agent_map) ||
1053
+ (config.telegram && config.telegram.chat_agent_map) || {};
1054
+ const mappedKey = chatAgentMap[String(chatId)];
1055
+ if (mappedKey && config.projects && config.projects[mappedKey]) {
1056
+ const proj = config.projects[mappedKey];
1057
+ const projCwd = normalizeCwd(proj.cwd);
1058
+ const cur = loadState().sessions?.[chatId];
1059
+ if (!cur || cur.cwd !== projCwd) {
1060
+ attachOrCreateSession(chatId, projCwd, proj.name || mappedKey);
1061
+ }
1062
+ }
1063
+
857
1064
  // --- Browse handler (directory navigation) ---
858
1065
  if (text.startsWith('/browse ')) {
859
1066
  const parts = text.slice(8).trim().split(' ');
860
- const mode = parts[0]; // 'new' or 'cd'
861
- const dirPath = expandPath(parts.slice(1).join(' '));
1067
+ const mode = parts[0]; // 'new', 'cd', or 'bind'
1068
+ // Last token may be a page number
1069
+ const lastPart = parts[parts.length - 1];
1070
+ const page = /^\d+$/.test(lastPart) ? parseInt(lastPart, 10) : 0;
1071
+ const pathParts = /^\d+$/.test(lastPart) ? parts.slice(1, -1) : parts.slice(1);
1072
+ const dirPath = expandPath(pathParts.join(' '));
862
1073
  if (mode && dirPath && fs.existsSync(dirPath)) {
863
- await sendBrowse(bot, chatId, mode, dirPath);
1074
+ await sendBrowse(bot, chatId, mode, dirPath, null, page);
864
1075
  } else if (/^p\d+$/.test(dirPath)) {
865
1076
  await bot.sendMessage(chatId, 'โš ๏ธ Button expired. Pick again:');
866
1077
  await sendDirPicker(bot, chatId, mode || 'cd', 'Switch workdir:');
@@ -875,6 +1086,19 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
875
1086
  if (text === '/new' || text.startsWith('/new ')) {
876
1087
  const arg = text.slice(4).trim();
877
1088
  if (!arg) {
1089
+ // In a dedicated agent group, use the agent's bound cwd directly
1090
+ const newCfg = loadConfig();
1091
+ const agentMap = (newCfg.feishu && newCfg.feishu.chat_agent_map) ||
1092
+ (newCfg.telegram && newCfg.telegram.chat_agent_map) || {};
1093
+ const boundKey = agentMap[String(chatId)];
1094
+ const boundProj = boundKey && newCfg.projects && newCfg.projects[boundKey];
1095
+ if (boundProj && boundProj.cwd) {
1096
+ const boundCwd = normalizeCwd(boundProj.cwd);
1097
+ const session = createSession(chatId, boundCwd, '');
1098
+ await bot.sendMessage(chatId, `โœ… ๆ–ฐไผš่ฏๅทฒๅˆ›ๅปบ\nWorkdir: ${session.cwd}`);
1099
+ return;
1100
+ }
1101
+ // Non-dedicated group: show directory picker
878
1102
  await sendDirPicker(bot, chatId, 'new', 'Pick a workdir:');
879
1103
  return;
880
1104
  }
@@ -1128,7 +1352,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1128
1352
  const currentSession = getSession(chatId);
1129
1353
  const currentCwd = currentSession?.cwd ? path.resolve(expandPath(currentSession.cwd)) : null;
1130
1354
  const buttons = entries.map(([key, p]) => {
1131
- const projCwd = expandPath(p.cwd).replace(/^~/, HOME);
1355
+ const projCwd = normalizeCwd(p.cwd);
1132
1356
  const active = currentCwd && path.resolve(projCwd) === currentCwd ? ' โ—€' : '';
1133
1357
  return [{ text: `${p.icon || '๐Ÿค–'} ${p.name || key}${active}`, callback_data: `/cd ${projCwd}` }];
1134
1358
  });
@@ -1940,7 +2164,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1940
2164
  q.messages.push(text);
1941
2165
  // Only notify once (first message), subsequent ones silently queue
1942
2166
  if (isFirst) {
1943
- await bot.sendMessage(chatId, '๐Ÿ“ ๆ”ถๅˆฐ๏ผŒไธญๆ–ญๅฝ“ๅ‰ไปปๅŠกๅŽไธ€่ตทๅค„็†');
2167
+ await bot.sendMessage(chatId, '๐Ÿ“ ๆ”ถๅˆฐ๏ผŒ็จๅŽไธ€่ตทๅค„็†');
1944
2168
  }
1945
2169
  // Interrupt the running Claude process
1946
2170
  const proc = activeProcesses.get(chatId);
@@ -1972,16 +2196,8 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1972
2196
  const quickAgent = routeAgent(text, config);
1973
2197
  if (quickAgent && !quickAgent.rest) {
1974
2198
  const { key, proj } = quickAgent;
1975
- const projCwd = expandPath(proj.cwd).replace(/^~/, HOME);
1976
- const st = loadState();
1977
- const recentInDir = listRecentSessions(1, projCwd);
1978
- if (recentInDir.length > 0 && recentInDir[0].sessionId) {
1979
- st.sessions[chatId] = { id: recentInDir[0].sessionId, cwd: projCwd, started: true };
1980
- } else {
1981
- const newSess = createSession(chatId, projCwd, proj.name || key);
1982
- st.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
1983
- }
1984
- saveState(st);
2199
+ const projCwd = normalizeCwd(proj.cwd);
2200
+ attachOrCreateSession(chatId, projCwd, proj.name || key);
1985
2201
  log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
1986
2202
  await bot.sendMessage(chatId, `${proj.icon || '๐Ÿค–'} ${proj.name || key} ๅœจ็บฟ`);
1987
2203
  return;
@@ -2364,6 +2580,9 @@ const CONTENT_EXTENSIONS = new Set([
2364
2580
  // Active Claude processes per chat (for /stop)
2365
2581
  const activeProcesses = new Map(); // chatId -> { child, aborted }
2366
2582
 
2583
+ // Pending /bind flows: waiting for user to pick a directory
2584
+ const pendingBinds = new Map(); // chatId -> agentName
2585
+
2367
2586
  // Message queue for messages received while a task is running
2368
2587
  const messageQueue = new Map(); // chatId -> { messages: string[], notified: false }
2369
2588
 
@@ -2482,7 +2701,7 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2482
2701
  let killed = false;
2483
2702
  let finalResult = '';
2484
2703
  let lastStatusTime = 0;
2485
- const STATUS_THROTTLE = 3000; // Min 3s between status updates
2704
+ const STATUS_THROTTLE = STATUS_THROTTLE_MS;
2486
2705
  const writtenFiles = []; // Track files created/modified by Write tool
2487
2706
 
2488
2707
  const timer = setTimeout(() => {
@@ -2641,7 +2860,6 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
2641
2860
  }
2642
2861
 
2643
2862
  // Lazy distill: run distill.js in background on first message, then every 4 hours
2644
- let _lastDistillTime = 0;
2645
2863
  // Track outbound message_id โ†’ session for reply-based session restoration.
2646
2864
  // Keeps last 200 entries to avoid unbounded growth.
2647
2865
  function trackMsgSession(messageId, session) {
@@ -2658,14 +2876,17 @@ function trackMsgSession(messageId, session) {
2658
2876
 
2659
2877
  function lazyDistill() {
2660
2878
  const now = Date.now();
2661
- if (now - _lastDistillTime < 4 * 60 * 60 * 1000) return; // 4h cooldown
2879
+ const st = loadState();
2880
+ const lastDistillTime = st.last_distill_time || 0;
2881
+ if (now - lastDistillTime < 4 * 60 * 60 * 1000) return; // 4h cooldown
2662
2882
  const distillPath = path.join(HOME, '.metame', 'distill.js');
2663
2883
  const signalsPath = path.join(HOME, '.metame', 'raw_signals.jsonl');
2664
2884
  if (!fs.existsSync(distillPath)) return;
2665
2885
  if (!fs.existsSync(signalsPath)) return;
2666
2886
  const content = fs.readFileSync(signalsPath, 'utf8').trim();
2667
2887
  if (!content) return;
2668
- _lastDistillTime = now;
2888
+ st.last_distill_time = now;
2889
+ saveState(st);
2669
2890
  const lines = content.split('\n').filter(l => l.trim()).length;
2670
2891
  log('INFO', `Distilling ${lines} signal(s) in background...`);
2671
2892
  const bg = spawn('node', [distillPath], { detached: true, stdio: 'ignore' });
@@ -2697,16 +2918,8 @@ async function askClaude(bot, chatId, prompt, config) {
2697
2918
  const agentMatch = routeAgent(prompt, config);
2698
2919
  if (agentMatch) {
2699
2920
  const { key, proj, rest } = agentMatch;
2700
- const projCwd = expandPath(proj.cwd).replace(/^~/, HOME);
2701
- const st = loadState();
2702
- const recentInDir = listRecentSessions(1, projCwd);
2703
- if (recentInDir.length > 0 && recentInDir[0].sessionId) {
2704
- st.sessions[chatId] = { id: recentInDir[0].sessionId, cwd: projCwd, started: true };
2705
- } else {
2706
- const newSess = createSession(chatId, projCwd, proj.name || key);
2707
- st.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
2708
- }
2709
- saveState(st);
2921
+ const projCwd = normalizeCwd(proj.cwd);
2922
+ attachOrCreateSession(chatId, projCwd, proj.name || key);
2710
2923
  log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
2711
2924
  if (!rest) {
2712
2925
  // Pure nickname call โ€” confirm switch and stop
@@ -2770,7 +2983,11 @@ async function askClaude(bot, chatId, prompt, config) {
2770
2983
  const daemonCfg = loadConfig().daemon || {};
2771
2984
  const model = daemonCfg.model || 'opus';
2772
2985
  args.push('--model', model);
2773
- if (daemonCfg.dangerously_skip_permissions) {
2986
+ if (readOnly) {
2987
+ // Read-only mode for non-operator users: query/chat only, no write/edit/execute
2988
+ const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task'];
2989
+ for (const tool of READ_ONLY_TOOLS) args.push('--allowedTools', tool);
2990
+ } else if (daemonCfg.dangerously_skip_permissions) {
2774
2991
  args.push('--dangerously-skip-permissions');
2775
2992
  } else {
2776
2993
  const sessionAllowed = daemonCfg.session_allowed_tools || [];
@@ -2801,14 +3018,22 @@ async function askClaude(bot, chatId, prompt, config) {
2801
3018
  gitCheckpoint(session.cwd);
2802
3019
 
2803
3020
  // Use streaming mode to show progress
2804
- // Telegram: edit status msg in-place; Feishu/others: send new messages
3021
+ // Telegram: edit status msg in-place; Feishu: edit or fallback to new messages
3022
+ let editFailed = false;
3023
+ let lastFallbackStatus = 0;
3024
+ const FALLBACK_THROTTLE = FALLBACK_THROTTLE_MS;
2805
3025
  const onStatus = async (status) => {
2806
3026
  try {
2807
- if (statusMsgId && bot.editMessage) {
2808
- await bot.editMessage(chatId, statusMsgId, status);
2809
- } else {
2810
- await bot.sendMessage(chatId, status);
3027
+ if (statusMsgId && bot.editMessage && !editFailed) {
3028
+ const ok = await bot.editMessage(chatId, statusMsgId, status);
3029
+ if (ok !== false) return; // edit succeeded (true or undefined for Telegram)
3030
+ editFailed = true; // edit failed, switch to fallback permanently
2811
3031
  }
3032
+ // Fallback: send as new message with extra throttle to avoid spam
3033
+ const now = Date.now();
3034
+ if (now - lastFallbackStatus < FALLBACK_THROTTLE) return;
3035
+ lastFallbackStatus = now;
3036
+ await bot.sendMessage(chatId, status);
2812
3037
  } catch { /* ignore status update failures */ }
2813
3038
  };
2814
3039
 
@@ -2859,32 +3084,32 @@ async function askClaude(bot, chatId, prompt, config) {
2859
3084
  recordTokens(loadState(), estimated);
2860
3085
 
2861
3086
  // Parse [[FILE:...]] markers from output (Claude's explicit file sends)
2862
- const fileMarkers = output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
2863
- const markedFiles = fileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
2864
- const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
2865
-
2866
- const replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
2867
- if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session);
2868
-
2869
- // Combine: marked files + auto-detected content files from Write operations
2870
- const allFiles = new Set(markedFiles);
2871
- if (files && files.length > 0) {
2872
- for (const f of files) {
2873
- if (isContentFile(f)) allFiles.add(f);
3087
+ const { markedFiles, cleanOutput } = parseFileMarkers(output);
3088
+
3089
+ // Match current session to a project for colored card display
3090
+ let activeProject = null;
3091
+ if (session && session.cwd && config && config.projects) {
3092
+ const sessionCwd = path.resolve(normalizeCwd(session.cwd));
3093
+ for (const [, proj] of Object.entries(config.projects)) {
3094
+ if (!proj.cwd) continue;
3095
+ const projCwd = path.resolve(normalizeCwd(proj.cwd));
3096
+ if (sessionCwd === projCwd) { activeProject = proj; break; }
2874
3097
  }
2875
3098
  }
2876
3099
 
2877
- // Send file buttons
2878
- if (allFiles.size > 0 && bot.sendButtons) {
2879
- const validFiles = [...allFiles].filter(f => fs.existsSync(f));
2880
- if (validFiles.length > 0) {
2881
- const buttons = validFiles.map(filePath => {
2882
- const shortId = cacheFile(filePath);
2883
- return [{ text: `๐Ÿ“Ž ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
2884
- });
2885
- await bot.sendButtons(chatId, '๐Ÿ“‚ ๆ–‡ไปถ:', buttons);
2886
- }
3100
+ let replyMsg;
3101
+ if (activeProject && bot.sendCard) {
3102
+ replyMsg = await bot.sendCard(chatId, {
3103
+ title: `${activeProject.icon || '๐Ÿค–'} ${activeProject.name || ''}`,
3104
+ body: cleanOutput,
3105
+ color: activeProject.color || 'blue',
3106
+ });
3107
+ } else {
3108
+ replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
2887
3109
  }
3110
+ if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session);
3111
+
3112
+ await sendFileButtons(bot, chatId, mergeFileCollections(markedFiles, files));
2888
3113
 
2889
3114
  // Auto-name: if this was the first message and session has no name, generate one
2890
3115
  if (wasNew && !getSessionName(session.id)) {
@@ -2909,28 +3134,9 @@ async function askClaude(bot, chatId, prompt, config) {
2909
3134
  const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
2910
3135
  if (retry.output) {
2911
3136
  markSessionStarted(chatId);
2912
- // Parse [[FILE:...]] markers
2913
- const retryFileMarkers = retry.output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
2914
- const retryMarkedFiles = retryFileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
2915
- const retryCleanOutput = retry.output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
2916
- await bot.sendMarkdown(chatId, retryCleanOutput);
2917
- // Combine marked + auto-detected content files
2918
- const retryAllFiles = new Set(retryMarkedFiles);
2919
- if (retry.files && retry.files.length > 0) {
2920
- for (const f of retry.files) {
2921
- if (isContentFile(f)) retryAllFiles.add(f);
2922
- }
2923
- }
2924
- if (retryAllFiles.size > 0 && bot.sendButtons) {
2925
- const validFiles = [...retryAllFiles].filter(f => fs.existsSync(f));
2926
- if (validFiles.length > 0) {
2927
- const buttons = validFiles.map(filePath => {
2928
- const shortId = cacheFile(filePath);
2929
- return [{ text: `๐Ÿ“Ž ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
2930
- });
2931
- await bot.sendButtons(chatId, '๐Ÿ“‚ ๆ–‡ไปถ:', buttons);
2932
- }
2933
- }
3137
+ const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
3138
+ await bot.sendMarkdown(chatId, retryClean);
3139
+ await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
2934
3140
  } else {
2935
3141
  log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
2936
3142
  try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
@@ -2975,16 +3181,34 @@ async function startFeishuBridge(config, executeTaskByName) {
2975
3181
 
2976
3182
  const { createBot } = require(path.join(__dirname, 'feishu-adapter.js'));
2977
3183
  const bot = createBot(config.feishu);
2978
- const allowedIds = config.feishu.allowed_chat_ids || [];
2979
-
2980
3184
  try {
2981
- const receiver = await bot.startReceiving(async (chatId, text, event, fileInfo) => {
2982
- // Security: check whitelist (empty = deny all)
2983
- if (!allowedIds.includes(chatId)) {
3185
+ const receiver = await bot.startReceiving(async (chatId, text, event, fileInfo, senderId) => {
3186
+ // Security: check whitelist (empty = deny all) โ€” read live config to support hot-reload
3187
+ // Exception: /bind is allowed from any chat so users can self-register new groups
3188
+ const liveCfg = loadConfig();
3189
+ const allowedIds = (liveCfg.feishu && liveCfg.feishu.allowed_chat_ids) || [];
3190
+ const isBindCmd = text && text.trim().startsWith('/bind');
3191
+ if (!allowedIds.includes(chatId) && !isBindCmd) {
2984
3192
  log('WARN', `Feishu: rejected message from ${chatId}`);
2985
3193
  return;
2986
3194
  }
2987
3195
 
3196
+ // Operator check: if operator_ids configured, non-operators get read-only chat mode
3197
+ const operatorIds = (liveCfg.feishu && liveCfg.feishu.operator_ids) || [];
3198
+ if (operatorIds.length > 0 && senderId && !operatorIds.includes(senderId) && !isBindCmd) {
3199
+ log('INFO', `Feishu: read-only message from non-operator ${senderId} in ${chatId}: ${(text || '').slice(0, 50)}`);
3200
+ // Block slash commands for non-operators
3201
+ if (text && text.startsWith('/')) {
3202
+ await bot.sendMessage(chatId, 'โš ๏ธ ่ฏฅๆ“ไฝœ้œ€่ฆๆŽˆๆƒ๏ผŒ่ฏท่”็ณป็ฎก็†ๅ‘˜ใ€‚');
3203
+ return;
3204
+ }
3205
+ // Allow read-only chat (query/answer only, no write/edit/execute)
3206
+ if (text) {
3207
+ await handleCommand(bot, chatId, text, config, executeTaskByName, senderId, true);
3208
+ }
3209
+ return;
3210
+ }
3211
+
2988
3212
  // Handle file message
2989
3213
  if (fileInfo && fileInfo.fileKey) {
2990
3214
  log('INFO', `Feishu file from ${chatId}: ${fileInfo.fileName} (key: ${fileInfo.fileKey}, msgId: ${fileInfo.messageId}, type: ${fileInfo.msgType})`);
@@ -3027,7 +3251,7 @@ async function startFeishuBridge(config, executeTaskByName) {
3027
3251
  log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
3028
3252
  }
3029
3253
  }
3030
- await handleCommand(bot, chatId, text, config, executeTaskByName);
3254
+ await handleCommand(bot, chatId, text, config, executeTaskByName, senderId);
3031
3255
  }
3032
3256
  });
3033
3257
 
@@ -159,6 +159,7 @@ RULES:
159
159
  5. Episodic exceptions: context.anti_patterns (max 5, cross-project lessons only), context.milestones (max 3).
160
160
  6. Strong directives (ไปฅๅŽไธ€ๅพ‹/always/never/from now on) โ†’ _confidence: high. Otherwise: normal.
161
161
  7. Add _confidence and _source blocks mapping field keys to confidence level and triggering quote.
162
+ 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.
162
163
 
163
164
  BIAS PREVENTION:
164
165
  - Single observation = STATE, not TRAIT. T3 cognition needs 3+ observations.
@@ -51,13 +51,22 @@ function createBot(config) {
51
51
  return msgId ? { message_id: msgId } : null;
52
52
  },
53
53
 
54
+ _editBroken: false, // Set to true if patch API consistently fails
54
55
  async editMessage(chatId, messageId, text) {
56
+ if (this._editBroken) return false;
55
57
  try {
56
58
  await client.im.message.patch({
57
59
  path: { message_id: messageId },
58
60
  data: { content: JSON.stringify({ text }) },
59
61
  });
60
- } catch { /* non-fatal */ }
62
+ return true;
63
+ } catch (e) {
64
+ const code = e?.code || e?.response?.data?.code;
65
+ if (code === 230001 || code === 230002 || /permission|forbidden/i.test(String(e))) {
66
+ this._editBroken = true;
67
+ }
68
+ return false;
69
+ }
61
70
  },
62
71
 
63
72
  /**
@@ -69,13 +78,12 @@ function createBot(config) {
69
78
  .replace(/^(#{1,3})\s+(.+)$/gm, '**$2**') // headers โ†’ bold
70
79
  .replace(/^---+$/gm, 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'); // hr โ†’ unicode line
71
80
 
72
- // Split into chunks if too long (lark_md element limit ~4000 chars)
81
+ // Split into chunks if too long (element limit ~4000 chars)
73
82
  const MAX_CHUNK = 3800;
74
83
  const chunks = [];
75
84
  if (content.length <= MAX_CHUNK) {
76
85
  chunks.push(content);
77
86
  } else {
78
- // Split on double newlines to avoid breaking mid-paragraph
79
87
  const paragraphs = content.split(/\n\n/);
80
88
  let buf = '';
81
89
  for (const p of paragraphs) {
@@ -89,14 +97,16 @@ function createBot(config) {
89
97
  if (buf) chunks.push(buf);
90
98
  }
91
99
 
100
+ // V2 schema: markdown element with normal text size
92
101
  const elements = chunks.map(c => ({
93
- tag: 'div',
94
- text: { tag: 'lark_md', content: c },
102
+ tag: 'markdown',
103
+ content: c,
104
+ text_size: 'x-large',
95
105
  }));
96
106
 
97
107
  const card = {
98
- config: { wide_screen_mode: true },
99
- elements,
108
+ schema: '2.0',
109
+ body: { elements },
100
110
  };
101
111
 
102
112
  const res = await client.im.message.create({
@@ -119,11 +129,56 @@ function createBot(config) {
119
129
  * @param {string} color - header color: blue|orange|green|red|grey|purple|turquoise
120
130
  */
121
131
  async sendCard(chatId, { title, body, color = 'blue' }) {
122
- const elements = body ? [{ tag: 'div', text: { tag: 'lark_md', content: body } }] : [];
132
+ // Use card schema V2 for better text sizing
133
+ if (!body) {
134
+ const card = {
135
+ schema: '2.0',
136
+ header: { title: { tag: 'plain_text', content: title }, template: color },
137
+ body: { elements: [] },
138
+ };
139
+ const res = await client.im.message.create({
140
+ params: { receive_id_type: 'chat_id' },
141
+ data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
142
+ });
143
+ const msgId = res?.data?.message_id;
144
+ return msgId ? { message_id: msgId } : null;
145
+ }
146
+
147
+ // Convert standard markdown โ†’ lark_md
148
+ let content = body
149
+ .replace(/^(#{1,3})\s+(.+)$/gm, '**$2**')
150
+ .replace(/^---+$/gm, 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
151
+
152
+ // Split into chunks (lark_md element limit ~4000 chars)
153
+ const MAX_CHUNK = 3800;
154
+ const chunks = [];
155
+ if (content.length <= MAX_CHUNK) {
156
+ chunks.push(content);
157
+ } else {
158
+ const paragraphs = content.split(/\n\n/);
159
+ let buf = '';
160
+ for (const p of paragraphs) {
161
+ if (buf.length + p.length + 2 > MAX_CHUNK && buf) {
162
+ chunks.push(buf);
163
+ buf = p;
164
+ } else {
165
+ buf = buf ? buf + '\n\n' + p : p;
166
+ }
167
+ }
168
+ if (buf) chunks.push(buf);
169
+ }
170
+
171
+ // V2: use markdown element with text_size for readable font
172
+ const elements = chunks.map(c => ({
173
+ tag: 'markdown',
174
+ content: c,
175
+ text_size: 'x-large',
176
+ }));
177
+
123
178
  const card = {
124
- config: { wide_screen_mode: true },
179
+ schema: '2.0',
125
180
  header: { title: { tag: 'plain_text', content: title }, template: color },
126
- elements,
181
+ body: { elements },
127
182
  };
128
183
  const res = await client.im.message.create({
129
184
  params: { receive_id_type: 'chat_id' },
@@ -133,6 +188,15 @@ function createBot(config) {
133
188
  return msgId ? { message_id: msgId } : null;
134
189
  },
135
190
 
191
+ /**
192
+ * Delete a message by ID
193
+ */
194
+ async deleteMessage(chatId, messageId) {
195
+ try {
196
+ await client.im.message.delete({ path: { message_id: messageId } });
197
+ } catch { /* non-fatal โ€” message may already be deleted or expired */ }
198
+ },
199
+
136
200
  /**
137
201
  * Typing indicator (Feishu has no such API โ€” no-op)
138
202
  */
@@ -342,6 +406,7 @@ function createBot(config) {
342
406
  if (isDuplicate(msg.message_id)) return;
343
407
 
344
408
  const chatId = msg.chat_id;
409
+ const senderId = data.sender && data.sender.sender_id && data.sender.sender_id.open_id || null;
345
410
  let text = '';
346
411
  let fileInfo = null;
347
412
 
@@ -370,7 +435,7 @@ function createBot(config) {
370
435
 
371
436
  if (text || fileInfo) {
372
437
  // Fire-and-forget: don't block the event loop (SDK needs fast ack)
373
- Promise.resolve().then(() => onMessage(chatId, text, data, fileInfo)).catch(() => {});
438
+ Promise.resolve().then(() => onMessage(chatId, text, data, fileInfo, senderId)).catch(() => {});
374
439
  }
375
440
  } catch (e) {
376
441
  // Non-fatal
package/scripts/schema.js CHANGED
@@ -16,8 +16,7 @@
16
16
  */
17
17
 
18
18
  const SCHEMA = {
19
- // === T1: Identity ===
20
- 'identity.nickname': { tier: 'T1', type: 'string', locked: true },
19
+ // === T1: Identity (USER's identity, not agent's) ===
21
20
  'identity.role': { tier: 'T1', type: 'string', locked: false },
22
21
  'identity.locale': { tier: 'T1', type: 'string', locked: true },
23
22
 
@@ -65,6 +65,11 @@ process.stdin.on('end', () => {
65
65
  process.exit(0);
66
66
  }
67
67
 
68
+ // 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)) {
70
+ process.exit(0);
71
+ }
72
+
68
73
  // Skip pasted error logs / stack traces
69
74
  if (/^(Error|TypeError|SyntaxError|ReferenceError|at\s+\w+|Traceback|FATAL|WARN|ERR!)/i.test(prompt)) {
70
75
  process.exit(0);