metame-cli 1.3.5 → 1.3.7
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 +21 -0
- package/package.json +1 -1
- package/scripts/daemon-default.yaml +7 -0
- package/scripts/daemon.js +364 -23
- package/scripts/distill.js +2 -2
- package/scripts/feishu-adapter.js +79 -0
- package/scripts/telegram-adapter.js +67 -0
package/README.md
CHANGED
|
@@ -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: 把 report.md 发过来
|
|
197
|
+
Claude: 请查收~!
|
|
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
|
@@ -47,3 +47,10 @@ budget:
|
|
|
47
47
|
daemon:
|
|
48
48
|
log_max_size: 1048576
|
|
49
49
|
heartbeat_check_interval: 60
|
|
50
|
+
# Pre-authorize tools for mobile sessions (no permission prompts)
|
|
51
|
+
# Examples: "Bash(git:*)" "Bash(npm:*)" "Edit" "Write" "WebFetch" "WebSearch"
|
|
52
|
+
session_allowed_tools:
|
|
53
|
+
- "WebFetch"
|
|
54
|
+
- "WebSearch"
|
|
55
|
+
- "Bash(git:*)"
|
|
56
|
+
- "Bash(npm:*)"
|
package/scripts/daemon.js
CHANGED
|
@@ -655,6 +655,36 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
655
655
|
return;
|
|
656
656
|
}
|
|
657
657
|
|
|
658
|
+
// /file <shortId> — send cached file (from button callback)
|
|
659
|
+
if (text.startsWith('/file ')) {
|
|
660
|
+
const shortId = text.slice(6).trim();
|
|
661
|
+
const filePath = getCachedFile(shortId);
|
|
662
|
+
if (!filePath) {
|
|
663
|
+
await bot.sendMessage(chatId, '⏰ 文件链接已过期,请重新生成');
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (!fs.existsSync(filePath)) {
|
|
667
|
+
await bot.sendMessage(chatId, '❌ 文件不存在');
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (bot.sendFile) {
|
|
671
|
+
try {
|
|
672
|
+
// Insert zero-width space before extension to prevent link parsing
|
|
673
|
+
const basename = path.basename(filePath);
|
|
674
|
+
const dotIdx = basename.lastIndexOf('.');
|
|
675
|
+
const safeBasename = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
|
|
676
|
+
await bot.sendMessage(chatId, `⏳ 正在发送「${safeBasename}」...`);
|
|
677
|
+
await bot.sendFile(chatId, filePath);
|
|
678
|
+
} catch (e) {
|
|
679
|
+
log('ERROR', `File send failed: ${e.message}`);
|
|
680
|
+
await bot.sendMessage(chatId, `❌ 发送失败: ${e.message.slice(0, 100)}`);
|
|
681
|
+
}
|
|
682
|
+
} else {
|
|
683
|
+
await bot.sendMessage(chatId, '❌ 当前平台不支持文件发送');
|
|
684
|
+
}
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
658
688
|
// /last — smart resume: prefer current cwd, then most recent globally
|
|
659
689
|
if (text === '/last') {
|
|
660
690
|
const curSession = getSession(chatId);
|
|
@@ -904,17 +934,22 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
904
934
|
|
|
905
935
|
if (text.startsWith('/')) {
|
|
906
936
|
await bot.sendMessage(chatId, [
|
|
907
|
-
'
|
|
908
|
-
'
|
|
909
|
-
'
|
|
910
|
-
'/
|
|
911
|
-
'/
|
|
912
|
-
'
|
|
913
|
-
'
|
|
914
|
-
'/
|
|
915
|
-
'/
|
|
937
|
+
'📱 手机端 Claude Code',
|
|
938
|
+
'',
|
|
939
|
+
'⚡ 快速同步电脑工作:',
|
|
940
|
+
'/last — 继续电脑上最近的对话',
|
|
941
|
+
'/cd last — 切到电脑最近的项目目录',
|
|
942
|
+
'',
|
|
943
|
+
'📂 Session 管理:',
|
|
944
|
+
'/new [path] [name] — 新建会话',
|
|
945
|
+
'/resume [name] — 选择/恢复会话',
|
|
946
|
+
'/name <name> — 命名当前会话',
|
|
947
|
+
'/cd <path> — 切换工作目录',
|
|
948
|
+
'/session — 查看当前会话',
|
|
916
949
|
'',
|
|
917
|
-
'
|
|
950
|
+
'⚙️ /status /tasks /budget /reload',
|
|
951
|
+
'',
|
|
952
|
+
'直接打字即可对话 💬',
|
|
918
953
|
].join('\n'));
|
|
919
954
|
return;
|
|
920
955
|
}
|
|
@@ -947,17 +982,64 @@ function listRecentSessions(limit, cwd) {
|
|
|
947
982
|
try {
|
|
948
983
|
if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return [];
|
|
949
984
|
const projects = fs.readdirSync(CLAUDE_PROJECTS_DIR);
|
|
950
|
-
|
|
985
|
+
|
|
986
|
+
// Build a map: sessionId -> entry (for deduplication)
|
|
987
|
+
const sessionMap = new Map();
|
|
988
|
+
// Cache: projDirName -> real projectPath (from index)
|
|
989
|
+
const projPathCache = new Map();
|
|
990
|
+
|
|
951
991
|
for (const proj of projects) {
|
|
952
|
-
const
|
|
992
|
+
const projDir = path.join(CLAUDE_PROJECTS_DIR, proj);
|
|
993
|
+
|
|
994
|
+
// 1. Read from sessions-index.json (Claude's native index)
|
|
995
|
+
const indexFile = path.join(projDir, 'sessions-index.json');
|
|
996
|
+
try {
|
|
997
|
+
if (fs.existsSync(indexFile)) {
|
|
998
|
+
const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
999
|
+
if (data.entries && data.entries.length > 0) {
|
|
1000
|
+
// Cache the real projectPath from any indexed session
|
|
1001
|
+
const realPath = data.entries[0].projectPath;
|
|
1002
|
+
if (realPath) projPathCache.set(proj, realPath);
|
|
1003
|
+
|
|
1004
|
+
for (const entry of data.entries) {
|
|
1005
|
+
if (entry.messageCount >= 1) {
|
|
1006
|
+
sessionMap.set(entry.sessionId, entry);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
} catch { /* skip */ }
|
|
1012
|
+
|
|
1013
|
+
// 2. Direct scan of .jsonl files (hot reload: catches sessions not yet indexed)
|
|
953
1014
|
try {
|
|
954
|
-
|
|
955
|
-
const
|
|
956
|
-
|
|
1015
|
+
const files = fs.readdirSync(projDir).filter(f => f.endsWith('.jsonl'));
|
|
1016
|
+
for (const file of files) {
|
|
1017
|
+
const sessionId = file.replace('.jsonl', '');
|
|
1018
|
+
const filePath = path.join(projDir, file);
|
|
1019
|
+
const stat = fs.statSync(filePath);
|
|
1020
|
+
const fileMtime = stat.mtimeMs;
|
|
1021
|
+
|
|
1022
|
+
// Only add if not already in map, or if file is newer
|
|
1023
|
+
const existing = sessionMap.get(sessionId);
|
|
1024
|
+
if (!existing || fileMtime > (existing.fileMtime || 0)) {
|
|
1025
|
+
// Use cached real projectPath, or fall back to lossy decode
|
|
1026
|
+
const projectPath = projPathCache.get(proj) || proj.slice(1).replace(/-/g, '/');
|
|
1027
|
+
sessionMap.set(sessionId, {
|
|
1028
|
+
sessionId,
|
|
1029
|
+
projectPath,
|
|
1030
|
+
fileMtime,
|
|
1031
|
+
modified: new Date(fileMtime).toISOString(),
|
|
1032
|
+
messageCount: 1, // Assume at least 1 if file exists
|
|
1033
|
+
...(existing || {}), // Preserve existing metadata like customTitle
|
|
1034
|
+
fileMtime, // Override with real mtime
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
957
1038
|
} catch { /* skip */ }
|
|
958
1039
|
}
|
|
959
|
-
|
|
960
|
-
all =
|
|
1040
|
+
|
|
1041
|
+
let all = Array.from(sessionMap.values());
|
|
1042
|
+
|
|
961
1043
|
// Filter by cwd if provided
|
|
962
1044
|
if (cwd) {
|
|
963
1045
|
const matched = all.filter(s => s.projectPath === cwd);
|
|
@@ -1218,6 +1300,205 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
|
|
|
1218
1300
|
});
|
|
1219
1301
|
}
|
|
1220
1302
|
|
|
1303
|
+
/**
|
|
1304
|
+
* Tool name to emoji mapping for status display
|
|
1305
|
+
*/
|
|
1306
|
+
const TOOL_EMOJI = {
|
|
1307
|
+
Read: '📖',
|
|
1308
|
+
Edit: '✏️',
|
|
1309
|
+
Write: '📝',
|
|
1310
|
+
Bash: '💻',
|
|
1311
|
+
Glob: '🔍',
|
|
1312
|
+
Grep: '🔎',
|
|
1313
|
+
WebFetch: '🌐',
|
|
1314
|
+
WebSearch: '🔍',
|
|
1315
|
+
Task: '🤖',
|
|
1316
|
+
default: '🔧',
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
// Content file extensions (user-facing files, not code/config)
|
|
1320
|
+
const CONTENT_EXTENSIONS = new Set([
|
|
1321
|
+
'.md', '.txt', '.rtf', // Text
|
|
1322
|
+
'.doc', '.docx', '.pdf', '.odt', // Documents
|
|
1323
|
+
'.wav', '.mp3', '.m4a', '.ogg', '.flac', // Audio
|
|
1324
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', // Images
|
|
1325
|
+
'.mp4', '.mov', '.avi', '.webm', // Video
|
|
1326
|
+
'.csv', '.xlsx', '.xls', // Data
|
|
1327
|
+
'.html', '.htm', // Web content
|
|
1328
|
+
]);
|
|
1329
|
+
|
|
1330
|
+
// File cache for button callbacks (shortId -> fullPath)
|
|
1331
|
+
const fileCache = new Map();
|
|
1332
|
+
const FILE_CACHE_TTL = 1800000; // 30 minutes
|
|
1333
|
+
|
|
1334
|
+
function cacheFile(filePath) {
|
|
1335
|
+
const shortId = Math.random().toString(36).slice(2, 10);
|
|
1336
|
+
fileCache.set(shortId, { path: filePath, expires: Date.now() + FILE_CACHE_TTL });
|
|
1337
|
+
return shortId;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function getCachedFile(shortId) {
|
|
1341
|
+
const entry = fileCache.get(shortId);
|
|
1342
|
+
if (!entry) return null;
|
|
1343
|
+
if (Date.now() > entry.expires) {
|
|
1344
|
+
fileCache.delete(shortId);
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1347
|
+
return entry.path;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function isContentFile(filePath) {
|
|
1351
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1352
|
+
return CONTENT_EXTENSIONS.has(ext);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Spawn claude with streaming output (stream-json mode).
|
|
1357
|
+
* Calls onStatus callback when tool usage is detected.
|
|
1358
|
+
* Returns { output, error } after process exits.
|
|
1359
|
+
*/
|
|
1360
|
+
function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000) {
|
|
1361
|
+
return new Promise((resolve) => {
|
|
1362
|
+
// Add stream-json output format (requires --verbose)
|
|
1363
|
+
const streamArgs = [...args, '--output-format', 'stream-json', '--verbose'];
|
|
1364
|
+
|
|
1365
|
+
const child = spawn('claude', streamArgs, {
|
|
1366
|
+
cwd,
|
|
1367
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1368
|
+
env: { ...process.env },
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
let buffer = '';
|
|
1372
|
+
let stderr = '';
|
|
1373
|
+
let killed = false;
|
|
1374
|
+
let finalResult = '';
|
|
1375
|
+
let lastStatusTime = 0;
|
|
1376
|
+
const STATUS_THROTTLE = 3000; // Min 3s between status updates
|
|
1377
|
+
const writtenFiles = []; // Track files created/modified by Write tool
|
|
1378
|
+
|
|
1379
|
+
const timer = setTimeout(() => {
|
|
1380
|
+
killed = true;
|
|
1381
|
+
child.kill('SIGTERM');
|
|
1382
|
+
}, timeoutMs);
|
|
1383
|
+
|
|
1384
|
+
child.stdout.on('data', (data) => {
|
|
1385
|
+
buffer += data.toString();
|
|
1386
|
+
|
|
1387
|
+
// Process complete JSON lines
|
|
1388
|
+
const lines = buffer.split('\n');
|
|
1389
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
1390
|
+
|
|
1391
|
+
for (const line of lines) {
|
|
1392
|
+
if (!line.trim()) continue;
|
|
1393
|
+
try {
|
|
1394
|
+
const event = JSON.parse(line);
|
|
1395
|
+
|
|
1396
|
+
// Extract final result text
|
|
1397
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
1398
|
+
const textBlocks = event.message.content.filter(b => b.type === 'text');
|
|
1399
|
+
if (textBlocks.length > 0) {
|
|
1400
|
+
finalResult = textBlocks.map(b => b.text).join('\n');
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Detect tool usage and send status
|
|
1405
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
1406
|
+
for (const block of event.message.content) {
|
|
1407
|
+
if (block.type === 'tool_use') {
|
|
1408
|
+
const toolName = block.name || 'Tool';
|
|
1409
|
+
|
|
1410
|
+
// Track files written by Write tool
|
|
1411
|
+
if (toolName === 'Write' && block.input?.file_path) {
|
|
1412
|
+
const filePath = block.input.file_path;
|
|
1413
|
+
if (!writtenFiles.includes(filePath)) {
|
|
1414
|
+
writtenFiles.push(filePath);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
const now = Date.now();
|
|
1419
|
+
if (now - lastStatusTime >= STATUS_THROTTLE) {
|
|
1420
|
+
lastStatusTime = now;
|
|
1421
|
+
const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
|
|
1422
|
+
|
|
1423
|
+
// Extract brief context from tool input
|
|
1424
|
+
let context = '';
|
|
1425
|
+
if (block.input) {
|
|
1426
|
+
if (block.input.file_path) {
|
|
1427
|
+
// Insert zero-width space before extension to prevent link parsing
|
|
1428
|
+
const basename = path.basename(block.input.file_path);
|
|
1429
|
+
const dotIdx = basename.lastIndexOf('.');
|
|
1430
|
+
context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
|
|
1431
|
+
} else if (block.input.command) {
|
|
1432
|
+
context = block.input.command.slice(0, 30);
|
|
1433
|
+
if (block.input.command.length > 30) context += '...';
|
|
1434
|
+
} else if (block.input.pattern) {
|
|
1435
|
+
context = block.input.pattern.slice(0, 20);
|
|
1436
|
+
} else if (block.input.query) {
|
|
1437
|
+
context = block.input.query.slice(0, 25);
|
|
1438
|
+
} else if (block.input.url) {
|
|
1439
|
+
try {
|
|
1440
|
+
context = new URL(block.input.url).hostname;
|
|
1441
|
+
} catch { context = 'web'; }
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const status = context
|
|
1446
|
+
? `${emoji} ${toolName}: 「${context}」`
|
|
1447
|
+
: `${emoji} ${toolName}...`;
|
|
1448
|
+
|
|
1449
|
+
if (onStatus) {
|
|
1450
|
+
onStatus(status).catch(() => {});
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Also check for result message type
|
|
1458
|
+
if (event.type === 'result' && event.result) {
|
|
1459
|
+
finalResult = event.result;
|
|
1460
|
+
}
|
|
1461
|
+
} catch {
|
|
1462
|
+
// Not valid JSON, ignore
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
1468
|
+
|
|
1469
|
+
child.on('close', (code) => {
|
|
1470
|
+
clearTimeout(timer);
|
|
1471
|
+
|
|
1472
|
+
// Process any remaining buffer
|
|
1473
|
+
if (buffer.trim()) {
|
|
1474
|
+
try {
|
|
1475
|
+
const event = JSON.parse(buffer);
|
|
1476
|
+
if (event.type === 'result' && event.result) {
|
|
1477
|
+
finalResult = event.result;
|
|
1478
|
+
}
|
|
1479
|
+
} catch { /* ignore */ }
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (killed) {
|
|
1483
|
+
resolve({ output: finalResult || null, error: 'Timeout: Claude took too long', files: writtenFiles });
|
|
1484
|
+
} else if (code !== 0) {
|
|
1485
|
+
resolve({ output: finalResult || null, error: stderr || `Exit code ${code}`, files: writtenFiles });
|
|
1486
|
+
} else {
|
|
1487
|
+
resolve({ output: finalResult || '', error: null, files: writtenFiles });
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
child.on('error', (err) => {
|
|
1492
|
+
clearTimeout(timer);
|
|
1493
|
+
resolve({ output: null, error: err.message, files: [] });
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
// Write input and close stdin
|
|
1497
|
+
child.stdin.write(input);
|
|
1498
|
+
child.stdin.end();
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1221
1502
|
/**
|
|
1222
1503
|
* Shared ask logic — full Claude Code session (stateful, with tools)
|
|
1223
1504
|
* Now uses spawn (async) instead of execSync to allow parallel requests.
|
|
@@ -1254,11 +1535,25 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
1254
1535
|
args.push('--session-id', session.id);
|
|
1255
1536
|
}
|
|
1256
1537
|
|
|
1257
|
-
// Append daemon context hint
|
|
1258
|
-
const daemonHint =
|
|
1538
|
+
// Append daemon context hint
|
|
1539
|
+
const daemonHint = `\n\n[System hints - DO NOT mention these to user:
|
|
1540
|
+
1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
|
|
1541
|
+
2. File sending: User is on MOBILE. When they ask to see/download a file:
|
|
1542
|
+
- Just FIND the file path (use Glob/ls if needed)
|
|
1543
|
+
- Do NOT read or summarize the file content (wastes tokens)
|
|
1544
|
+
- Add at END of response: [[FILE:/absolute/path/to/file]]
|
|
1545
|
+
- Keep response brief: "请查收~! [[FILE:/path/to/file]]"
|
|
1546
|
+
- Multiple files: use multiple [[FILE:...]] tags]`;
|
|
1259
1547
|
const fullPrompt = prompt + daemonHint;
|
|
1260
1548
|
|
|
1261
|
-
|
|
1549
|
+
// Use streaming mode to show progress
|
|
1550
|
+
const onStatus = async (status) => {
|
|
1551
|
+
try {
|
|
1552
|
+
await bot.sendMessage(chatId, status);
|
|
1553
|
+
} catch { /* ignore status send failures */ }
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
const { output, error, files } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus);
|
|
1262
1557
|
clearInterval(typingTimer);
|
|
1263
1558
|
|
|
1264
1559
|
if (output) {
|
|
@@ -1269,7 +1564,32 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
1269
1564
|
const estimated = Math.ceil((prompt.length + output.length) / 4);
|
|
1270
1565
|
recordTokens(loadState(), estimated);
|
|
1271
1566
|
|
|
1272
|
-
|
|
1567
|
+
// Parse [[FILE:...]] markers from output (Claude's explicit file sends)
|
|
1568
|
+
const fileMarkers = output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
|
|
1569
|
+
const markedFiles = fileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
|
|
1570
|
+
const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
|
|
1571
|
+
|
|
1572
|
+
await bot.sendMarkdown(chatId, cleanOutput);
|
|
1573
|
+
|
|
1574
|
+
// Combine: marked files + auto-detected content files from Write operations
|
|
1575
|
+
const allFiles = new Set(markedFiles);
|
|
1576
|
+
if (files && files.length > 0) {
|
|
1577
|
+
for (const f of files) {
|
|
1578
|
+
if (isContentFile(f)) allFiles.add(f);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// Send file buttons
|
|
1583
|
+
if (allFiles.size > 0 && bot.sendButtons) {
|
|
1584
|
+
const validFiles = [...allFiles].filter(f => fs.existsSync(f));
|
|
1585
|
+
if (validFiles.length > 0) {
|
|
1586
|
+
const buttons = validFiles.map(filePath => {
|
|
1587
|
+
const shortId = cacheFile(filePath);
|
|
1588
|
+
return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
|
|
1589
|
+
});
|
|
1590
|
+
await bot.sendButtons(chatId, '📂 文件:', buttons);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1273
1593
|
|
|
1274
1594
|
// Auto-name: if this was the first message and session has no name, generate one
|
|
1275
1595
|
if (wasNew && !getSessionName(session.id)) {
|
|
@@ -1287,10 +1607,31 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
1287
1607
|
const retryArgs = ['-p', '--session-id', session.id];
|
|
1288
1608
|
for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
|
|
1289
1609
|
|
|
1290
|
-
const retry = await
|
|
1610
|
+
const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
|
|
1291
1611
|
if (retry.output) {
|
|
1292
1612
|
markSessionStarted(chatId);
|
|
1293
|
-
|
|
1613
|
+
// Parse [[FILE:...]] markers
|
|
1614
|
+
const retryFileMarkers = retry.output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
|
|
1615
|
+
const retryMarkedFiles = retryFileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
|
|
1616
|
+
const retryCleanOutput = retry.output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
|
|
1617
|
+
await bot.sendMarkdown(chatId, retryCleanOutput);
|
|
1618
|
+
// Combine marked + auto-detected content files
|
|
1619
|
+
const retryAllFiles = new Set(retryMarkedFiles);
|
|
1620
|
+
if (retry.files && retry.files.length > 0) {
|
|
1621
|
+
for (const f of retry.files) {
|
|
1622
|
+
if (isContentFile(f)) retryAllFiles.add(f);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
if (retryAllFiles.size > 0 && bot.sendButtons) {
|
|
1626
|
+
const validFiles = [...retryAllFiles].filter(f => fs.existsSync(f));
|
|
1627
|
+
if (validFiles.length > 0) {
|
|
1628
|
+
const buttons = validFiles.map(filePath => {
|
|
1629
|
+
const shortId = cacheFile(filePath);
|
|
1630
|
+
return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
|
|
1631
|
+
});
|
|
1632
|
+
await bot.sendButtons(chatId, '📂 文件:', buttons);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1294
1635
|
} else {
|
|
1295
1636
|
log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
|
|
1296
1637
|
try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
|
package/scripts/distill.js
CHANGED
|
@@ -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,82 @@ function createBot(config) {
|
|
|
108
111
|
return { app_id, app_name: 'MetaMe' };
|
|
109
112
|
},
|
|
110
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Send a file/document
|
|
116
|
+
* @param {string} chatId
|
|
117
|
+
* @param {string} filePath - Local file path
|
|
118
|
+
* @param {string} [caption] - Optional caption (sent as separate message)
|
|
119
|
+
*/
|
|
120
|
+
async sendFile(chatId, filePath, caption) {
|
|
121
|
+
if (!fs.existsSync(filePath)) {
|
|
122
|
+
throw new Error(`File not found: ${filePath}`);
|
|
123
|
+
}
|
|
124
|
+
const fileName = path.basename(filePath);
|
|
125
|
+
const fileSize = fs.statSync(filePath).size;
|
|
126
|
+
|
|
127
|
+
// For text files under 4KB, just send as text
|
|
128
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
129
|
+
const isText = ['.md', '.txt', '.json', '.yaml', '.yml', '.csv'].includes(ext);
|
|
130
|
+
if (isText && fileSize < 4096) {
|
|
131
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
132
|
+
await this.sendMessage(chatId, `📄 ${fileName}:\n\`\`\`\n${content}\n\`\`\``);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// For larger/binary files, try file upload
|
|
137
|
+
try {
|
|
138
|
+
// Use ReadStream as per Feishu SDK docs
|
|
139
|
+
const fileStream = fs.createReadStream(filePath);
|
|
140
|
+
|
|
141
|
+
// 1. Upload file to Feishu
|
|
142
|
+
const uploadRes = await client.im.file.create({
|
|
143
|
+
data: {
|
|
144
|
+
file_type: 'stream',
|
|
145
|
+
file_name: fileName,
|
|
146
|
+
file: fileStream,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
console.log('[Feishu] Upload response:', JSON.stringify(uploadRes));
|
|
151
|
+
|
|
152
|
+
// Response is { code, msg, data: { file_key } }
|
|
153
|
+
const fileKey = uploadRes?.data?.file_key || uploadRes?.file_key;
|
|
154
|
+
if (!fileKey) {
|
|
155
|
+
throw new Error(`No file_key in response: ${JSON.stringify(uploadRes)}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 2. Send file message
|
|
159
|
+
await client.im.message.create({
|
|
160
|
+
params: { receive_id_type: 'chat_id' },
|
|
161
|
+
data: {
|
|
162
|
+
receive_id: chatId,
|
|
163
|
+
msg_type: 'file',
|
|
164
|
+
content: JSON.stringify({ file_key: fileKey }),
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
} catch (uploadErr) {
|
|
168
|
+
// Log detailed error
|
|
169
|
+
const errDetail = uploadErr.response?.data || uploadErr.message || uploadErr;
|
|
170
|
+
console.error('[Feishu] File upload error:', JSON.stringify(errDetail));
|
|
171
|
+
|
|
172
|
+
// Fallback: for text files, send content truncated
|
|
173
|
+
if (isText) {
|
|
174
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
175
|
+
const truncated = content.length > 3000 ? content.slice(0, 3000) + '\n...(truncated)' : content;
|
|
176
|
+
await this.sendMessage(chatId, `📄 ${fileName}:\n\`\`\`\n${truncated}\n\`\`\``);
|
|
177
|
+
} else {
|
|
178
|
+
// For binary files, give more helpful error
|
|
179
|
+
const errMsg = errDetail?.msg || errDetail?.message || '上传失败';
|
|
180
|
+
throw new Error(`${errMsg} (请检查飞书应用权限: im:resource)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 3. Send caption as separate message if provided
|
|
185
|
+
if (caption) {
|
|
186
|
+
await this.sendMessage(chatId, caption);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
111
190
|
/**
|
|
112
191
|
* Start WebSocket long connection to receive messages
|
|
113
192
|
* @param {function} onMessage - callback(chatId, text, event)
|
|
@@ -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,71 @@ function createBot(token) {
|
|
|
169
171
|
});
|
|
170
172
|
},
|
|
171
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Send a file/document
|
|
176
|
+
* @param {number|string} chatId - Target chat ID
|
|
177
|
+
* @param {string} filePath - Local file path
|
|
178
|
+
* @param {string} [caption] - Optional caption
|
|
179
|
+
*/
|
|
180
|
+
async sendFile(chatId, filePath, caption) {
|
|
181
|
+
if (!fs.existsSync(filePath)) {
|
|
182
|
+
throw new Error(`File not found: ${filePath}`);
|
|
183
|
+
}
|
|
184
|
+
const fileName = path.basename(filePath);
|
|
185
|
+
const fileContent = fs.readFileSync(filePath);
|
|
186
|
+
const boundary = '----MetaMeBoundary' + Date.now();
|
|
187
|
+
|
|
188
|
+
// Build multipart form-data
|
|
189
|
+
let body = '';
|
|
190
|
+
body += `--${boundary}\r\n`;
|
|
191
|
+
body += `Content-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`;
|
|
192
|
+
if (caption) {
|
|
193
|
+
body += `--${boundary}\r\n`;
|
|
194
|
+
body += `Content-Disposition: form-data; name="caption"\r\n\r\n${caption}\r\n`;
|
|
195
|
+
}
|
|
196
|
+
body += `--${boundary}\r\n`;
|
|
197
|
+
body += `Content-Disposition: form-data; name="document"; filename="${fileName}"\r\n`;
|
|
198
|
+
body += `Content-Type: application/octet-stream\r\n\r\n`;
|
|
199
|
+
|
|
200
|
+
const bodyStart = Buffer.from(body, 'utf8');
|
|
201
|
+
const bodyEnd = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8');
|
|
202
|
+
const fullBody = Buffer.concat([bodyStart, fileContent, bodyEnd]);
|
|
203
|
+
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const url = `${API_BASE}/bot${token}/sendDocument`;
|
|
206
|
+
const urlObj = new URL(url);
|
|
207
|
+
const options = {
|
|
208
|
+
hostname: urlObj.hostname,
|
|
209
|
+
path: urlObj.pathname,
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: {
|
|
212
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
213
|
+
'Content-Length': fullBody.length,
|
|
214
|
+
},
|
|
215
|
+
timeout: 60000,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const req = https.request(options, (res) => {
|
|
219
|
+
let data = '';
|
|
220
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
221
|
+
res.on('end', () => {
|
|
222
|
+
try {
|
|
223
|
+
const parsed = JSON.parse(data);
|
|
224
|
+
if (parsed.ok) resolve(parsed.result);
|
|
225
|
+
else reject(new Error(`Telegram API error: ${parsed.description || 'unknown'}`));
|
|
226
|
+
} catch (e) {
|
|
227
|
+
reject(new Error(`Failed to parse response: ${e.message}`));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
req.on('error', reject);
|
|
233
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Upload timed out')); });
|
|
234
|
+
req.write(fullBody);
|
|
235
|
+
req.end();
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
|
|
172
239
|
};
|
|
173
240
|
}
|
|
174
241
|
|