metame-cli 1.3.6 → 1.3.8

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
@@ -19,7 +19,7 @@ It is not a memory system; it is a **Cognitive Mirror** .
19
19
  ## ✨ Key Features
20
20
 
21
21
  * **🧠 Global Brain (`~/.claude_profile.yaml`):** A single, portable source of truth — your identity, cognitive traits, and preferences travel with you across every project.
22
- * **🧬 Cognitive Evolution Engine:** MetaMe learns how you think through three channels: (1) **Passive** — silently captures your messages and distills cognitive traits via Haiku on next launch; (2) **Manual** — `!metame evolve` for explicit teaching; (3) **Confidence gates** — strong directives ("always"/"以后一律") write immediately, normal observations need 3+ consistent sightings before promotion. Schema-enforced (41 fields, 5 tiers, 800 token budget) to prevent bloat.
22
+ * **🧬 Cognitive Evolution Engine:** MetaMe learns how you think through three channels: (1) **Passive** — silently captures your messages and distills cognitive traits via Haiku on next launch; (2) **Manual** — `!metame evolve` for explicit teaching; (3) **Confidence gates** — strong directives ("always"/"from now on") write immediately, normal observations need 3+ consistent sightings before promotion. Schema-enforced (41 fields, 5 tiers, 800 token budget) to prevent bloat.
23
23
  * **🛡️ Auto-Lock:** Mark any value with `# [LOCKED]` — treated as a constitution, never auto-modified.
24
24
  * **🪞 Metacognition Layer (v1.3):** MetaMe now observes *how* you think, not just *what* you say. Behavioral pattern detection runs inside the existing Haiku distill call (zero extra cost). It tracks decision patterns, cognitive load, comfort zones, and avoidance topics across sessions. When persistent patterns emerge, MetaMe injects a one-line mirror observation — e.g., *"You tend to avoid testing until forced"* — with a 14-day cooldown per pattern. Conditional reflection prompts appear only when triggered (every 7th distill or 3x consecutive comfort zone). All injection logic runs in Node.js; Claude receives only pre-decided directives, never rules to self-evaluate.
25
25
  * **📱 Remote Claude Code (v1.3):** Full Claude Code from your phone via Telegram or Feishu (Lark). Stateful sessions with `--resume` — same conversation history, tool use, and file editing as your terminal. Interactive buttons for project/session picking, directory browser, and macOS launchd auto-start.
@@ -93,7 +93,7 @@ metame interview
93
93
 
94
94
  MetaMe learns who you are through two paths:
95
95
 
96
- **Automatic (zero effort):** A global hook captures your messages. On next launch, Haiku distills cognitive traits in the background. Strong directives ("always"/"以后一律") write immediately; normal observations need 3+ consistent sightings. All writes are schema-validated (41 fields, 800 token budget). You'll see:
96
+ **Automatic (zero effort):** A global hook captures your messages. On next launch, Haiku distills cognitive traits in the background. Strong directives ("always"/"from now on") write immediately; normal observations need 3+ consistent sightings. All writes are schema-validated (41 fields, 800 token budget). You'll see:
97
97
 
98
98
  ```
99
99
  🧠 MetaMe: Distilling 7 moments in background...
@@ -161,9 +161,9 @@ metame daemon install-launchd # macOS auto-start (RunAtLoad + KeepAlive)
161
161
 
162
162
  | Command | Description |
163
163
  |---------|-------------|
164
- | `/last` | **Quick resume** — 优先当前目录最近 session,否则全局最近 |
164
+ | `/last` | **Quick resume** — prefers current directory's recent session, falls back to global recent |
165
165
  | `/new` | Start new session — pick project directory from button list |
166
- | `/new <name>` | Start new session with a name (e.g., `/new API重构`) |
166
+ | `/new <name>` | Start new session with a name (e.g., `/new API Refactor`) |
167
167
  | `/resume` | Resume a session — clickable list, shows session names + real-time timestamps |
168
168
  | `/resume <name>` | Resume by name (supports partial match, cross-project) |
169
169
  | `/name <name>` | Name the current session (syncs with computer's `/rename`) |
@@ -182,6 +182,27 @@ Each chat gets a persistent session via `claude -p --resume <session-id>`. This
182
182
 
183
183
  **Parallel request handling:** The daemon uses async spawning, so multiple users or overlapping requests don't block each other. Each Claude call runs in a non-blocking subprocess.
184
184
 
185
+ **Streaming status (v1.3.7):** See Claude's work progress in real-time on your phone:
186
+
187
+ ```
188
+ 📖 Read: 「config.yaml」
189
+ ✏️ Edit: 「daemon.js」
190
+ 💻 Bash: 「git status」
191
+ ```
192
+
193
+ **File sending (v1.3.7):** Ask Claude to send any file to your phone:
194
+
195
+ ```
196
+ You: Send me report.md
197
+ Claude: Here you go!
198
+ [📎 report.md] ← tap to download
199
+ ```
200
+
201
+ Works for documents, audio, images, etc. Click button to download. Links valid for 30 minutes.
202
+
203
+ - **Telegram:** Works out of the box
204
+ - **Feishu:** Requires `im:resource` permission in app settings
205
+
185
206
  **Other commands:**
186
207
 
187
208
  | Command | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
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": {
@@ -12,7 +12,7 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "start": "node index.js",
15
- "sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/daemon.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml plugin/scripts/ && echo '✅ Plugin scripts synced'",
15
+ "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 plugin/scripts/ && echo '✅ Plugin scripts synced'",
16
16
  "precommit": "npm run sync:plugin"
17
17
  },
18
18
  "keywords": [
package/scripts/daemon.js CHANGED
@@ -492,6 +492,36 @@ async function startTelegramBridge(config, executeTaskByName) {
492
492
  continue;
493
493
  }
494
494
 
495
+ // File/document message → download and pass to Claude
496
+ if (msg.document || msg.photo) {
497
+ const fileId = msg.document ? msg.document.file_id : msg.photo[msg.photo.length - 1].file_id;
498
+ const fileName = msg.document ? msg.document.file_name : `photo_${Date.now()}.jpg`;
499
+ const caption = msg.caption || '';
500
+
501
+ // Save to project's upload/ folder
502
+ const session = getSession(chatId);
503
+ const cwd = session?.cwd || HOME;
504
+ const uploadDir = path.join(cwd, 'upload');
505
+ if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
506
+ const destPath = path.join(uploadDir, fileName);
507
+
508
+ try {
509
+ await bot.downloadFile(fileId, destPath);
510
+ await bot.sendMessage(chatId, `📥 Saved: ${fileName}`);
511
+
512
+ // Build prompt - don't ask Claude to read large files automatically
513
+ const prompt = caption
514
+ ? `User uploaded a file to the project: ${destPath}\nUser says: "${caption}"`
515
+ : `User uploaded a file to the project: ${destPath}\nAcknowledge receipt. Only read the file if the user asks you to.`;
516
+
517
+ await handleCommand(bot, chatId, prompt, config, executeTaskByName);
518
+ } catch (err) {
519
+ log('ERROR', `File download failed: ${err.message}`);
520
+ await bot.sendMessage(chatId, `❌ Download failed: ${err.message}`);
521
+ }
522
+ continue;
523
+ }
524
+
495
525
  // Text message (commands or natural language)
496
526
  if (msg.text) {
497
527
  await handleCommand(bot, chatId, msg.text.trim(), config, executeTaskByName);
@@ -655,6 +685,36 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
655
685
  return;
656
686
  }
657
687
 
688
+ // /file <shortId> — send cached file (from button callback)
689
+ if (text.startsWith('/file ')) {
690
+ const shortId = text.slice(6).trim();
691
+ const filePath = getCachedFile(shortId);
692
+ if (!filePath) {
693
+ await bot.sendMessage(chatId, '⏰ 文件链接已过期,请重新生成');
694
+ return;
695
+ }
696
+ if (!fs.existsSync(filePath)) {
697
+ await bot.sendMessage(chatId, '❌ 文件不存在');
698
+ return;
699
+ }
700
+ if (bot.sendFile) {
701
+ try {
702
+ // Insert zero-width space before extension to prevent link parsing
703
+ const basename = path.basename(filePath);
704
+ const dotIdx = basename.lastIndexOf('.');
705
+ const safeBasename = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
706
+ await bot.sendMessage(chatId, `⏳ 正在发送「${safeBasename}」...`);
707
+ await bot.sendFile(chatId, filePath);
708
+ } catch (e) {
709
+ log('ERROR', `File send failed: ${e.message}`);
710
+ await bot.sendMessage(chatId, `❌ 发送失败: ${e.message.slice(0, 100)}`);
711
+ }
712
+ } else {
713
+ await bot.sendMessage(chatId, '❌ 当前平台不支持文件发送');
714
+ }
715
+ return;
716
+ }
717
+
658
718
  // /last — smart resume: prefer current cwd, then most recent globally
659
719
  if (text === '/last') {
660
720
  const curSession = getSession(chatId);
@@ -772,11 +832,26 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
772
832
  await sendDirPicker(bot, chatId, 'cd', 'Switch workdir:');
773
833
  return;
774
834
  }
775
- // /cd last — jump to the most recent session's directory globally
835
+ // /cd last — sync to computer: switch to most recent session AND its directory
776
836
  if (newCwd === 'last') {
777
- const recent = listRecentSessions(1);
778
- if (recent.length > 0 && recent[0].projectPath) {
779
- newCwd = recent[0].projectPath;
837
+ const currentSession = getSession(chatId);
838
+ const excludeId = currentSession?.id;
839
+ const recent = listRecentSessions(10);
840
+ const filtered = excludeId ? recent.filter(s => s.sessionId !== excludeId) : recent;
841
+ if (filtered.length > 0 && filtered[0].projectPath) {
842
+ const target = filtered[0];
843
+ // Switch to that session (like /resume) AND its directory
844
+ const state2 = loadState();
845
+ state2.sessions[chatId] = {
846
+ id: target.sessionId,
847
+ cwd: target.projectPath,
848
+ started: true,
849
+ };
850
+ saveState(state2);
851
+ const name = target.customTitle || target.summary || '';
852
+ const label = name ? name.slice(0, 40) : target.sessionId.slice(0, 8);
853
+ await bot.sendMessage(chatId, `🔄 Synced to: ${label}\n📁 ${path.basename(target.projectPath)}`);
854
+ return;
780
855
  } else {
781
856
  await bot.sendMessage(chatId, 'No recent session found.');
782
857
  return;
@@ -787,13 +862,27 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
787
862
  return;
788
863
  }
789
864
  const state2 = loadState();
790
- if (!state2.sessions[chatId]) {
865
+ // Try to find existing session in this directory
866
+ const recentInDir = listRecentSessions(1, newCwd);
867
+ if (recentInDir.length > 0 && recentInDir[0].sessionId) {
868
+ // Attach to existing session in this directory
869
+ const target = recentInDir[0];
870
+ state2.sessions[chatId] = {
871
+ id: target.sessionId,
872
+ cwd: newCwd,
873
+ started: true,
874
+ };
875
+ saveState(state2);
876
+ const label = target.customTitle || target.summary?.slice(0, 30) || target.sessionId.slice(0, 8);
877
+ await bot.sendMessage(chatId, `📁 ${path.basename(newCwd)}\n🔄 Attached: ${label}`);
878
+ } else if (!state2.sessions[chatId]) {
791
879
  createSession(chatId, newCwd);
880
+ await bot.sendMessage(chatId, `📁 ${path.basename(newCwd)} (new session)`);
792
881
  } else {
793
882
  state2.sessions[chatId].cwd = newCwd;
794
883
  saveState(state2);
884
+ await bot.sendMessage(chatId, `📁 ${path.basename(newCwd)}`);
795
885
  }
796
- await bot.sendMessage(chatId, `Workdir: ${newCwd}`);
797
886
  return;
798
887
  }
799
888
 
@@ -904,17 +993,22 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
904
993
 
905
994
  if (text.startsWith('/')) {
906
995
  await bot.sendMessage(chatId, [
907
- 'Commands:',
908
- '/last — ⚡ 一键继续最近的 session',
909
- '/new [path] [name] — new session',
910
- '/resume [name] 选择/搜索 session',
911
- '/continue — resume last in current dir',
912
- '/name <name> — name current session',
913
- '/cd <path|last> — change workdir (last=最近目录)',
914
- '/session — current session info',
915
- '/status /tasks /budget /reload',
996
+ '📱 手机端 Claude Code',
997
+ '',
998
+ ' 快速同步电脑工作:',
999
+ '/last继续电脑上最近的对话',
1000
+ '/cd last 切到电脑最近的项目目录',
916
1001
  '',
917
- 'Or just type naturally.',
1002
+ '📂 Session 管理:',
1003
+ '/new [path] [name] — 新建会话',
1004
+ '/resume [name] — 选择/恢复会话',
1005
+ '/name <name> — 命名当前会话',
1006
+ '/cd <path> — 切换工作目录',
1007
+ '/session — 查看当前会话',
1008
+ '',
1009
+ '⚙️ /status /tasks /budget /reload',
1010
+ '',
1011
+ '直接打字即可对话 💬',
918
1012
  ].join('\n'));
919
1013
  return;
920
1014
  }
@@ -1265,6 +1359,205 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
1265
1359
  });
1266
1360
  }
1267
1361
 
1362
+ /**
1363
+ * Tool name to emoji mapping for status display
1364
+ */
1365
+ const TOOL_EMOJI = {
1366
+ Read: '📖',
1367
+ Edit: '✏️',
1368
+ Write: '📝',
1369
+ Bash: '💻',
1370
+ Glob: '🔍',
1371
+ Grep: '🔎',
1372
+ WebFetch: '🌐',
1373
+ WebSearch: '🔍',
1374
+ Task: '🤖',
1375
+ default: '🔧',
1376
+ };
1377
+
1378
+ // Content file extensions (user-facing files, not code/config)
1379
+ const CONTENT_EXTENSIONS = new Set([
1380
+ '.md', '.txt', '.rtf', // Text
1381
+ '.doc', '.docx', '.pdf', '.odt', // Documents
1382
+ '.wav', '.mp3', '.m4a', '.ogg', '.flac', // Audio
1383
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', // Images
1384
+ '.mp4', '.mov', '.avi', '.webm', // Video
1385
+ '.csv', '.xlsx', '.xls', // Data
1386
+ '.html', '.htm', // Web content
1387
+ ]);
1388
+
1389
+ // File cache for button callbacks (shortId -> fullPath)
1390
+ const fileCache = new Map();
1391
+ const FILE_CACHE_TTL = 1800000; // 30 minutes
1392
+
1393
+ function cacheFile(filePath) {
1394
+ const shortId = Math.random().toString(36).slice(2, 10);
1395
+ fileCache.set(shortId, { path: filePath, expires: Date.now() + FILE_CACHE_TTL });
1396
+ return shortId;
1397
+ }
1398
+
1399
+ function getCachedFile(shortId) {
1400
+ const entry = fileCache.get(shortId);
1401
+ if (!entry) return null;
1402
+ if (Date.now() > entry.expires) {
1403
+ fileCache.delete(shortId);
1404
+ return null;
1405
+ }
1406
+ return entry.path;
1407
+ }
1408
+
1409
+ function isContentFile(filePath) {
1410
+ const ext = path.extname(filePath).toLowerCase();
1411
+ return CONTENT_EXTENSIONS.has(ext);
1412
+ }
1413
+
1414
+ /**
1415
+ * Spawn claude with streaming output (stream-json mode).
1416
+ * Calls onStatus callback when tool usage is detected.
1417
+ * Returns { output, error } after process exits.
1418
+ */
1419
+ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000) {
1420
+ return new Promise((resolve) => {
1421
+ // Add stream-json output format (requires --verbose)
1422
+ const streamArgs = [...args, '--output-format', 'stream-json', '--verbose'];
1423
+
1424
+ const child = spawn('claude', streamArgs, {
1425
+ cwd,
1426
+ stdio: ['pipe', 'pipe', 'pipe'],
1427
+ env: { ...process.env },
1428
+ });
1429
+
1430
+ let buffer = '';
1431
+ let stderr = '';
1432
+ let killed = false;
1433
+ let finalResult = '';
1434
+ let lastStatusTime = 0;
1435
+ const STATUS_THROTTLE = 3000; // Min 3s between status updates
1436
+ const writtenFiles = []; // Track files created/modified by Write tool
1437
+
1438
+ const timer = setTimeout(() => {
1439
+ killed = true;
1440
+ child.kill('SIGTERM');
1441
+ }, timeoutMs);
1442
+
1443
+ child.stdout.on('data', (data) => {
1444
+ buffer += data.toString();
1445
+
1446
+ // Process complete JSON lines
1447
+ const lines = buffer.split('\n');
1448
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
1449
+
1450
+ for (const line of lines) {
1451
+ if (!line.trim()) continue;
1452
+ try {
1453
+ const event = JSON.parse(line);
1454
+
1455
+ // Extract final result text
1456
+ if (event.type === 'assistant' && event.message?.content) {
1457
+ const textBlocks = event.message.content.filter(b => b.type === 'text');
1458
+ if (textBlocks.length > 0) {
1459
+ finalResult = textBlocks.map(b => b.text).join('\n');
1460
+ }
1461
+ }
1462
+
1463
+ // Detect tool usage and send status
1464
+ if (event.type === 'assistant' && event.message?.content) {
1465
+ for (const block of event.message.content) {
1466
+ if (block.type === 'tool_use') {
1467
+ const toolName = block.name || 'Tool';
1468
+
1469
+ // Track files written by Write tool
1470
+ if (toolName === 'Write' && block.input?.file_path) {
1471
+ const filePath = block.input.file_path;
1472
+ if (!writtenFiles.includes(filePath)) {
1473
+ writtenFiles.push(filePath);
1474
+ }
1475
+ }
1476
+
1477
+ const now = Date.now();
1478
+ if (now - lastStatusTime >= STATUS_THROTTLE) {
1479
+ lastStatusTime = now;
1480
+ const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
1481
+
1482
+ // Extract brief context from tool input
1483
+ let context = '';
1484
+ if (block.input) {
1485
+ if (block.input.file_path) {
1486
+ // Insert zero-width space before extension to prevent link parsing
1487
+ const basename = path.basename(block.input.file_path);
1488
+ const dotIdx = basename.lastIndexOf('.');
1489
+ context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
1490
+ } else if (block.input.command) {
1491
+ context = block.input.command.slice(0, 30);
1492
+ if (block.input.command.length > 30) context += '...';
1493
+ } else if (block.input.pattern) {
1494
+ context = block.input.pattern.slice(0, 20);
1495
+ } else if (block.input.query) {
1496
+ context = block.input.query.slice(0, 25);
1497
+ } else if (block.input.url) {
1498
+ try {
1499
+ context = new URL(block.input.url).hostname;
1500
+ } catch { context = 'web'; }
1501
+ }
1502
+ }
1503
+
1504
+ const status = context
1505
+ ? `${emoji} ${toolName}: 「${context}」`
1506
+ : `${emoji} ${toolName}...`;
1507
+
1508
+ if (onStatus) {
1509
+ onStatus(status).catch(() => {});
1510
+ }
1511
+ }
1512
+ }
1513
+ }
1514
+ }
1515
+
1516
+ // Also check for result message type
1517
+ if (event.type === 'result' && event.result) {
1518
+ finalResult = event.result;
1519
+ }
1520
+ } catch {
1521
+ // Not valid JSON, ignore
1522
+ }
1523
+ }
1524
+ });
1525
+
1526
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
1527
+
1528
+ child.on('close', (code) => {
1529
+ clearTimeout(timer);
1530
+
1531
+ // Process any remaining buffer
1532
+ if (buffer.trim()) {
1533
+ try {
1534
+ const event = JSON.parse(buffer);
1535
+ if (event.type === 'result' && event.result) {
1536
+ finalResult = event.result;
1537
+ }
1538
+ } catch { /* ignore */ }
1539
+ }
1540
+
1541
+ if (killed) {
1542
+ resolve({ output: finalResult || null, error: 'Timeout: Claude took too long', files: writtenFiles });
1543
+ } else if (code !== 0) {
1544
+ resolve({ output: finalResult || null, error: stderr || `Exit code ${code}`, files: writtenFiles });
1545
+ } else {
1546
+ resolve({ output: finalResult || '', error: null, files: writtenFiles });
1547
+ }
1548
+ });
1549
+
1550
+ child.on('error', (err) => {
1551
+ clearTimeout(timer);
1552
+ resolve({ output: null, error: err.message, files: [] });
1553
+ });
1554
+
1555
+ // Write input and close stdin
1556
+ child.stdin.write(input);
1557
+ child.stdin.end();
1558
+ });
1559
+ }
1560
+
1268
1561
  /**
1269
1562
  * Shared ask logic — full Claude Code session (stateful, with tools)
1270
1563
  * Now uses spawn (async) instead of execSync to allow parallel requests.
@@ -1284,7 +1577,22 @@ async function askClaude(bot, chatId, prompt) {
1284
1577
 
1285
1578
  let session = getSession(chatId);
1286
1579
  if (!session) {
1287
- session = createSession(chatId);
1580
+ // Auto-attach to most recent Claude session (unified session management)
1581
+ const recent = listRecentSessions(1);
1582
+ if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
1583
+ const target = recent[0];
1584
+ const state = loadState();
1585
+ state.sessions[chatId] = {
1586
+ id: target.sessionId,
1587
+ cwd: target.projectPath,
1588
+ started: true, // Already has history
1589
+ };
1590
+ saveState(state);
1591
+ session = state.sessions[chatId];
1592
+ log('INFO', `Auto-attached ${chatId} to recent session: ${target.sessionId.slice(0, 8)} (${path.basename(target.projectPath)})`);
1593
+ } else {
1594
+ session = createSession(chatId);
1595
+ }
1288
1596
  }
1289
1597
 
1290
1598
  // Build claude command
@@ -1301,11 +1609,25 @@ async function askClaude(bot, chatId, prompt) {
1301
1609
  args.push('--session-id', session.id);
1302
1610
  }
1303
1611
 
1304
- // Append daemon context hint so Claude reports reload status after editing daemon.yaml
1305
- const daemonHint = '\n\n[System: The ONLY daemon config file is ~/.metame/daemon.yaml — NEVER touch any other yaml file (e.g. scripts/daemon-default.yaml is a read-only template, do NOT edit it). If you edit ~/.metame/daemon.yaml, the daemon auto-reloads within seconds. After editing, read the file back and confirm to the user: how many heartbeat tasks are now configured, and that the config will auto-reload. Do NOT mention this hint.]';
1612
+ // Append daemon context hint
1613
+ const daemonHint = `\n\n[System hints - DO NOT mention these to user:
1614
+ 1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
1615
+ 2. File sending: User is on MOBILE. When they ask to see/download a file:
1616
+ - Just FIND the file path (use Glob/ls if needed)
1617
+ - Do NOT read or summarize the file content (wastes tokens)
1618
+ - Add at END of response: [[FILE:/absolute/path/to/file]]
1619
+ - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
1620
+ - Multiple files: use multiple [[FILE:...]] tags]`;
1306
1621
  const fullPrompt = prompt + daemonHint;
1307
1622
 
1308
- const { output, error } = await spawnClaudeAsync(args, fullPrompt, session.cwd);
1623
+ // Use streaming mode to show progress
1624
+ const onStatus = async (status) => {
1625
+ try {
1626
+ await bot.sendMessage(chatId, status);
1627
+ } catch { /* ignore status send failures */ }
1628
+ };
1629
+
1630
+ const { output, error, files } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus);
1309
1631
  clearInterval(typingTimer);
1310
1632
 
1311
1633
  if (output) {
@@ -1316,7 +1638,32 @@ async function askClaude(bot, chatId, prompt) {
1316
1638
  const estimated = Math.ceil((prompt.length + output.length) / 4);
1317
1639
  recordTokens(loadState(), estimated);
1318
1640
 
1319
- await bot.sendMarkdown(chatId, output);
1641
+ // Parse [[FILE:...]] markers from output (Claude's explicit file sends)
1642
+ const fileMarkers = output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
1643
+ const markedFiles = fileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
1644
+ const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
1645
+
1646
+ await bot.sendMarkdown(chatId, cleanOutput);
1647
+
1648
+ // Combine: marked files + auto-detected content files from Write operations
1649
+ const allFiles = new Set(markedFiles);
1650
+ if (files && files.length > 0) {
1651
+ for (const f of files) {
1652
+ if (isContentFile(f)) allFiles.add(f);
1653
+ }
1654
+ }
1655
+
1656
+ // Send file buttons
1657
+ if (allFiles.size > 0 && bot.sendButtons) {
1658
+ const validFiles = [...allFiles].filter(f => fs.existsSync(f));
1659
+ if (validFiles.length > 0) {
1660
+ const buttons = validFiles.map(filePath => {
1661
+ const shortId = cacheFile(filePath);
1662
+ return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
1663
+ });
1664
+ await bot.sendButtons(chatId, '📂 文件:', buttons);
1665
+ }
1666
+ }
1320
1667
 
1321
1668
  // Auto-name: if this was the first message and session has no name, generate one
1322
1669
  if (wasNew && !getSessionName(session.id)) {
@@ -1334,10 +1681,31 @@ async function askClaude(bot, chatId, prompt) {
1334
1681
  const retryArgs = ['-p', '--session-id', session.id];
1335
1682
  for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
1336
1683
 
1337
- const retry = await spawnClaudeAsync(retryArgs, prompt, session.cwd);
1684
+ const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
1338
1685
  if (retry.output) {
1339
1686
  markSessionStarted(chatId);
1340
- await bot.sendMarkdown(chatId, retry.output);
1687
+ // Parse [[FILE:...]] markers
1688
+ const retryFileMarkers = retry.output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
1689
+ const retryMarkedFiles = retryFileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
1690
+ const retryCleanOutput = retry.output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
1691
+ await bot.sendMarkdown(chatId, retryCleanOutput);
1692
+ // Combine marked + auto-detected content files
1693
+ const retryAllFiles = new Set(retryMarkedFiles);
1694
+ if (retry.files && retry.files.length > 0) {
1695
+ for (const f of retry.files) {
1696
+ if (isContentFile(f)) retryAllFiles.add(f);
1697
+ }
1698
+ }
1699
+ if (retryAllFiles.size > 0 && bot.sendButtons) {
1700
+ const validFiles = [...retryAllFiles].filter(f => fs.existsSync(f));
1701
+ if (validFiles.length > 0) {
1702
+ const buttons = validFiles.map(filePath => {
1703
+ const shortId = cacheFile(filePath);
1704
+ return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
1705
+ });
1706
+ await bot.sendButtons(chatId, '📂 文件:', buttons);
1707
+ }
1708
+ }
1341
1709
  } else {
1342
1710
  log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
1343
1711
  try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
@@ -1363,15 +1731,45 @@ async function startFeishuBridge(config, executeTaskByName) {
1363
1731
  const allowedIds = config.feishu.allowed_chat_ids || [];
1364
1732
 
1365
1733
  try {
1366
- const receiver = await bot.startReceiving((chatId, text, event) => {
1734
+ const receiver = await bot.startReceiving(async (chatId, text, event, fileInfo) => {
1367
1735
  // Security: check whitelist (empty = allow all)
1368
1736
  if (allowedIds.length > 0 && !allowedIds.includes(chatId)) {
1369
1737
  log('WARN', `Feishu: rejected message from ${chatId}`);
1370
1738
  return;
1371
1739
  }
1372
1740
 
1373
- log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
1374
- handleCommand(bot, chatId, text, config, executeTaskByName);
1741
+ // Handle file message
1742
+ if (fileInfo && fileInfo.fileKey) {
1743
+ log('INFO', `Feishu file from ${chatId}: ${fileInfo.fileName} (key: ${fileInfo.fileKey}, msgId: ${fileInfo.messageId}, type: ${fileInfo.msgType})`);
1744
+ // Save to project's upload/ folder
1745
+ const session = getSession(chatId);
1746
+ const cwd = session?.cwd || HOME;
1747
+ const uploadDir = path.join(cwd, 'upload');
1748
+ if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
1749
+ const destPath = path.join(uploadDir, fileInfo.fileName);
1750
+
1751
+ try {
1752
+ await bot.downloadFile(fileInfo.messageId, fileInfo.fileKey, destPath, fileInfo.msgType);
1753
+ await bot.sendMessage(chatId, `📥 Saved: ${fileInfo.fileName}`);
1754
+
1755
+ // Build prompt - don't ask Claude to read large files automatically
1756
+ const prompt = text
1757
+ ? `User uploaded a file to the project: ${destPath}\nUser says: "${text}"`
1758
+ : `User uploaded a file to the project: ${destPath}\nAcknowledge receipt. Only read the file if the user asks you to.`;
1759
+
1760
+ handleCommand(bot, chatId, prompt, config, executeTaskByName);
1761
+ } catch (err) {
1762
+ log('ERROR', `Feishu file download failed: ${err.message}`);
1763
+ await bot.sendMessage(chatId, `❌ Download failed: ${err.message}`);
1764
+ }
1765
+ return;
1766
+ }
1767
+
1768
+ // Handle text message
1769
+ if (text) {
1770
+ log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
1771
+ handleCommand(bot, chatId, text, config, executeTaskByName);
1772
+ }
1375
1773
  });
1376
1774
 
1377
1775
  log('INFO', 'Feishu bot connected (WebSocket long connection)');
@@ -174,7 +174,7 @@ Do NOT repeat existing unchanged values. Only output NEW or CHANGED fields.`;
174
174
  let result;
175
175
  try {
176
176
  result = execSync(
177
- `claude -p --model haiku`,
177
+ `claude -p --model haiku --no-session-persistence`,
178
178
  {
179
179
  input: distillPrompt,
180
180
  encoding: 'utf8',
@@ -673,7 +673,7 @@ If no clear patterns found: respond with exactly NO_PATTERNS`;
673
673
 
674
674
  try {
675
675
  const result = execSync(
676
- `claude -p --model haiku`,
676
+ `claude -p --model haiku --no-session-persistence`,
677
677
  {
678
678
  input: patternPrompt,
679
679
  encoding: 'utf8',
@@ -6,6 +6,9 @@
6
6
 
7
7
  'use strict';
8
8
 
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
9
12
  let Lark;
10
13
  try {
11
14
  Lark = require('@larksuiteoapi/node-sdk');
@@ -108,6 +111,131 @@ function createBot(config) {
108
111
  return { app_id, app_name: 'MetaMe' };
109
112
  },
110
113
 
114
+ /**
115
+ * Download a file from Feishu to local disk
116
+ * @param {string} messageId - Message ID containing the file
117
+ * @param {string} fileKey - File key from message content
118
+ * @param {string} destPath - Local destination path
119
+ * @returns {Promise<string>} The destination path
120
+ */
121
+ async downloadFile(messageId, fileKey, destPath, msgType = 'file') {
122
+ try {
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
+ }
136
+
137
+ // SDK returns writeFile method or getReadableStream
138
+ if (res && res.writeFile) {
139
+ await res.writeFile(destPath);
140
+ return destPath;
141
+ } else if (res && res.getReadableStream) {
142
+ const stream = res.getReadableStream();
143
+ const fileStream = fs.createWriteStream(destPath);
144
+ return new Promise((resolve, reject) => {
145
+ stream.pipe(fileStream);
146
+ fileStream.on('finish', () => {
147
+ fileStream.close();
148
+ resolve(destPath);
149
+ });
150
+ fileStream.on('error', (err) => {
151
+ fs.unlink(destPath, () => {});
152
+ reject(err);
153
+ });
154
+ });
155
+ }
156
+ throw new Error('No writeFile or stream in response');
157
+ } catch (err) {
158
+ const detail = err.message || String(err);
159
+ throw new Error(detail);
160
+ }
161
+ },
162
+
163
+ /**
164
+ * Send a file/document
165
+ * @param {string} chatId
166
+ * @param {string} filePath - Local file path
167
+ * @param {string} [caption] - Optional caption (sent as separate message)
168
+ */
169
+ async sendFile(chatId, filePath, caption) {
170
+ if (!fs.existsSync(filePath)) {
171
+ throw new Error(`File not found: ${filePath}`);
172
+ }
173
+ const fileName = path.basename(filePath);
174
+ const fileSize = fs.statSync(filePath).size;
175
+
176
+ // For text files under 4KB, just send as text
177
+ const ext = path.extname(filePath).toLowerCase();
178
+ const isText = ['.md', '.txt', '.json', '.yaml', '.yml', '.csv'].includes(ext);
179
+ if (isText && fileSize < 4096) {
180
+ const content = fs.readFileSync(filePath, 'utf8');
181
+ await this.sendMessage(chatId, `📄 ${fileName}:\n\`\`\`\n${content}\n\`\`\``);
182
+ return;
183
+ }
184
+
185
+ // For larger/binary files, try file upload
186
+ try {
187
+ // Use ReadStream as per Feishu SDK docs
188
+ const fileStream = fs.createReadStream(filePath);
189
+
190
+ // 1. Upload file to Feishu
191
+ const uploadRes = await client.im.file.create({
192
+ data: {
193
+ file_type: 'stream',
194
+ file_name: fileName,
195
+ file: fileStream,
196
+ },
197
+ });
198
+
199
+ console.log('[Feishu] Upload response:', JSON.stringify(uploadRes));
200
+
201
+ // Response is { code, msg, data: { file_key } }
202
+ const fileKey = uploadRes?.data?.file_key || uploadRes?.file_key;
203
+ if (!fileKey) {
204
+ throw new Error(`No file_key in response: ${JSON.stringify(uploadRes)}`);
205
+ }
206
+
207
+ // 2. Send file message
208
+ await client.im.message.create({
209
+ params: { receive_id_type: 'chat_id' },
210
+ data: {
211
+ receive_id: chatId,
212
+ msg_type: 'file',
213
+ content: JSON.stringify({ file_key: fileKey }),
214
+ },
215
+ });
216
+ } catch (uploadErr) {
217
+ // Log detailed error
218
+ const errDetail = uploadErr.response?.data || uploadErr.message || uploadErr;
219
+ console.error('[Feishu] File upload error:', JSON.stringify(errDetail));
220
+
221
+ // Fallback: for text files, send content truncated
222
+ if (isText) {
223
+ const content = fs.readFileSync(filePath, 'utf8');
224
+ const truncated = content.length > 3000 ? content.slice(0, 3000) + '\n...(truncated)' : content;
225
+ await this.sendMessage(chatId, `📄 ${fileName}:\n\`\`\`\n${truncated}\n\`\`\``);
226
+ } else {
227
+ // For binary files, give more helpful error
228
+ const errMsg = errDetail?.msg || errDetail?.message || '上传失败';
229
+ throw new Error(`${errMsg} (请检查飞书应用权限: im:resource)`);
230
+ }
231
+ }
232
+
233
+ // 3. Send caption as separate message if provided
234
+ if (caption) {
235
+ await this.sendMessage(chatId, caption);
236
+ }
237
+ },
238
+
111
239
  /**
112
240
  * Start WebSocket long connection to receive messages
113
241
  * @param {function} onMessage - callback(chatId, text, event)
@@ -149,6 +277,7 @@ function createBot(config) {
149
277
 
150
278
  const chatId = msg.chat_id;
151
279
  let text = '';
280
+ let fileInfo = null;
152
281
 
153
282
  if (msg.message_type === 'text') {
154
283
  try {
@@ -157,14 +286,25 @@ function createBot(config) {
157
286
  } catch {
158
287
  text = msg.content || '';
159
288
  }
289
+ } else if (msg.message_type === 'file' || msg.message_type === 'image' || msg.message_type === 'media') {
290
+ // File, image or media (video) message
291
+ try {
292
+ const content = JSON.parse(msg.content);
293
+ fileInfo = {
294
+ messageId: msg.message_id,
295
+ fileKey: content.file_key || content.image_key,
296
+ fileName: content.file_name || content.image_key || `file_${Date.now()}`,
297
+ msgType: msg.message_type, // 'file', 'image', or 'media'
298
+ };
299
+ } catch {}
160
300
  }
161
301
 
162
302
  // Strip @mention prefix if present
163
303
  text = text.replace(/@_user_\d+\s*/g, '').trim();
164
304
 
165
- if (text) {
305
+ if (text || fileInfo) {
166
306
  // Fire-and-forget: don't block the event loop (SDK needs fast ack)
167
- Promise.resolve().then(() => onMessage(chatId, text, data)).catch(() => {});
307
+ Promise.resolve().then(() => onMessage(chatId, text, data, fileInfo)).catch(() => {});
168
308
  }
169
309
  } catch (e) {
170
310
  // Non-fatal
@@ -7,6 +7,8 @@
7
7
  'use strict';
8
8
 
9
9
  const https = require('https');
10
+ const fs = require('fs');
11
+ const path = require('path');
10
12
 
11
13
  const API_BASE = 'https://api.telegram.org';
12
14
 
@@ -169,6 +171,111 @@ function createBot(token) {
169
171
  });
170
172
  },
171
173
 
174
+ /**
175
+ * Download a file from Telegram to local disk
176
+ * @param {string} fileId - Telegram file_id
177
+ * @param {string} destPath - Local destination path
178
+ * @returns {Promise<string>} The destination path
179
+ */
180
+ async downloadFile(fileId, destPath) {
181
+ // 1. Get file path from Telegram
182
+ const fileInfo = await apiRequest(token, 'getFile', { file_id: fileId });
183
+ if (!fileInfo.file_path) {
184
+ throw new Error('Failed to get file path from Telegram');
185
+ }
186
+
187
+ // 2. Download file using stream (zero memory overhead)
188
+ const fileUrl = `${API_BASE}/file/bot${token}/${fileInfo.file_path}`;
189
+ return new Promise((resolve, reject) => {
190
+ const urlObj = new URL(fileUrl);
191
+ https.get({
192
+ hostname: urlObj.hostname,
193
+ path: urlObj.pathname,
194
+ timeout: 60000,
195
+ }, (res) => {
196
+ if (res.statusCode !== 200) {
197
+ reject(new Error(`Download failed: ${res.statusCode}`));
198
+ return;
199
+ }
200
+ const fileStream = fs.createWriteStream(destPath);
201
+ res.pipe(fileStream);
202
+ fileStream.on('finish', () => {
203
+ fileStream.close();
204
+ resolve(destPath);
205
+ });
206
+ fileStream.on('error', (err) => {
207
+ fs.unlink(destPath, () => {});
208
+ reject(err);
209
+ });
210
+ }).on('error', reject);
211
+ });
212
+ },
213
+
214
+ /**
215
+ * Send a file/document
216
+ * @param {number|string} chatId - Target chat ID
217
+ * @param {string} filePath - Local file path
218
+ * @param {string} [caption] - Optional caption
219
+ */
220
+ async sendFile(chatId, filePath, caption) {
221
+ if (!fs.existsSync(filePath)) {
222
+ throw new Error(`File not found: ${filePath}`);
223
+ }
224
+ const fileName = path.basename(filePath);
225
+ const fileContent = fs.readFileSync(filePath);
226
+ const boundary = '----MetaMeBoundary' + Date.now();
227
+
228
+ // Build multipart form-data
229
+ let body = '';
230
+ body += `--${boundary}\r\n`;
231
+ body += `Content-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`;
232
+ if (caption) {
233
+ body += `--${boundary}\r\n`;
234
+ body += `Content-Disposition: form-data; name="caption"\r\n\r\n${caption}\r\n`;
235
+ }
236
+ body += `--${boundary}\r\n`;
237
+ body += `Content-Disposition: form-data; name="document"; filename="${fileName}"\r\n`;
238
+ body += `Content-Type: application/octet-stream\r\n\r\n`;
239
+
240
+ const bodyStart = Buffer.from(body, 'utf8');
241
+ const bodyEnd = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8');
242
+ const fullBody = Buffer.concat([bodyStart, fileContent, bodyEnd]);
243
+
244
+ return new Promise((resolve, reject) => {
245
+ const url = `${API_BASE}/bot${token}/sendDocument`;
246
+ const urlObj = new URL(url);
247
+ const options = {
248
+ hostname: urlObj.hostname,
249
+ path: urlObj.pathname,
250
+ method: 'POST',
251
+ headers: {
252
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
253
+ 'Content-Length': fullBody.length,
254
+ },
255
+ timeout: 60000,
256
+ };
257
+
258
+ const req = https.request(options, (res) => {
259
+ let data = '';
260
+ res.on('data', (chunk) => { data += chunk; });
261
+ res.on('end', () => {
262
+ try {
263
+ const parsed = JSON.parse(data);
264
+ if (parsed.ok) resolve(parsed.result);
265
+ else reject(new Error(`Telegram API error: ${parsed.description || 'unknown'}`));
266
+ } catch (e) {
267
+ reject(new Error(`Failed to parse response: ${e.message}`));
268
+ }
269
+ });
270
+ });
271
+
272
+ req.on('error', reject);
273
+ req.on('timeout', () => { req.destroy(); reject(new Error('Upload timed out')); });
274
+ req.write(fullBody);
275
+ req.end();
276
+ });
277
+ },
278
+
172
279
  };
173
280
  }
174
281