metame-cli 1.3.17 → 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 +125 -0
- package/index.js +79 -11
- package/package.json +1 -1
- package/scripts/daemon-default.yaml +19 -0
- package/scripts/daemon.js +817 -150
- package/scripts/distill.js +1 -0
- package/scripts/feishu-adapter.js +142 -6
- package/scripts/schema.js +1 -2
- package/scripts/signal-capture.js +5 -6
package/scripts/daemon.js
CHANGED
|
@@ -26,6 +26,37 @@ const PID_FILE = path.join(METAME_DIR, 'daemon.pid');
|
|
|
26
26
|
const LOG_FILE = path.join(METAME_DIR, 'daemon.log');
|
|
27
27
|
const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
|
|
28
28
|
|
|
29
|
+
// ---------------------------------------------------------
|
|
30
|
+
// SKILL ROUTING (keyword → /skillname prefix, like metame-desktop)
|
|
31
|
+
// ---------------------------------------------------------
|
|
32
|
+
const SKILL_ROUTES = [
|
|
33
|
+
{ name: 'macos-mail-calendar', pattern: /邮件|邮箱|收件箱|日历|日程|会议|schedule|email|mail|calendar|unread|inbox/i },
|
|
34
|
+
{ name: 'heartbeat-task-manager', pattern: /提醒|remind|闹钟|定时|每[天周月]/i },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function routeSkill(prompt) {
|
|
38
|
+
for (const r of SKILL_ROUTES) {
|
|
39
|
+
if (r.pattern.test(prompt)) return r.name;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Agent nickname routing: matches "贾维斯" or "贾维斯,帮我..." at message start
|
|
45
|
+
// Returns { key, proj, rest } or null
|
|
46
|
+
function routeAgent(prompt, config) {
|
|
47
|
+
for (const [key, proj] of Object.entries((config && config.projects) || {})) {
|
|
48
|
+
if (!proj.cwd || !proj.nicknames) continue;
|
|
49
|
+
const nicks = Array.isArray(proj.nicknames) ? proj.nicknames : [proj.nicknames];
|
|
50
|
+
for (const nick of nicks) {
|
|
51
|
+
const re = new RegExp(`^${nick}[,,、\\s]*`, 'i');
|
|
52
|
+
if (re.test(prompt.trim())) {
|
|
53
|
+
return { key, proj, rest: prompt.trim().replace(re, '').trim() };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
29
60
|
const yaml = require('./resolve-yaml');
|
|
30
61
|
const { parseInterval, formatRelativeTime, createPathMap } = require('./utils');
|
|
31
62
|
if (!yaml) {
|
|
@@ -95,12 +126,34 @@ function backupConfig() {
|
|
|
95
126
|
|
|
96
127
|
function restoreConfig() {
|
|
97
128
|
const bak = CONFIG_FILE + '.bak';
|
|
98
|
-
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 {
|
|
99
153
|
fs.copyFileSync(bak, CONFIG_FILE);
|
|
100
154
|
config = loadConfig();
|
|
101
155
|
return true;
|
|
102
156
|
}
|
|
103
|
-
return false;
|
|
104
157
|
}
|
|
105
158
|
|
|
106
159
|
let _cachedState = null;
|
|
@@ -221,6 +274,11 @@ function checkPrecondition(task) {
|
|
|
221
274
|
}
|
|
222
275
|
|
|
223
276
|
function executeTask(task, config) {
|
|
277
|
+
if (task.enabled === false) {
|
|
278
|
+
log('INFO', `Skipping disabled task: ${task.name}`);
|
|
279
|
+
return { success: true, output: '(disabled)', skipped: true };
|
|
280
|
+
}
|
|
281
|
+
|
|
224
282
|
const state = loadState();
|
|
225
283
|
|
|
226
284
|
if (!checkBudget(config, state)) {
|
|
@@ -285,39 +343,81 @@ function executeTask(task, config) {
|
|
|
285
343
|
}
|
|
286
344
|
const fullPrompt = preamble + taskPrompt;
|
|
287
345
|
|
|
288
|
-
const claudeArgs = ['-p', '--model', model];
|
|
346
|
+
const claudeArgs = ['-p', '--model', model, '--dangerously-skip-permissions'];
|
|
289
347
|
for (const t of (task.allowedTools || [])) claudeArgs.push('--allowedTools', t);
|
|
290
|
-
|
|
348
|
+
// Auto-detect MCP config in task cwd or project directory
|
|
349
|
+
const cwd = task.cwd ? task.cwd.replace(/^~/, HOME) : undefined;
|
|
350
|
+
const mcpConfig = task.mcp_config
|
|
351
|
+
? path.resolve(task.mcp_config.replace(/^~/, HOME))
|
|
352
|
+
: cwd && fs.existsSync(path.join(cwd, '.mcp.json'))
|
|
353
|
+
? path.join(cwd, '.mcp.json')
|
|
354
|
+
: null;
|
|
355
|
+
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
|
356
|
+
|
|
357
|
+
// Persistent session: reuse same session across runs (for tasks like weekly-review)
|
|
358
|
+
if (task.persistent_session) {
|
|
359
|
+
const savedSessionId = state.tasks[task.name]?.session_id;
|
|
360
|
+
if (savedSessionId) {
|
|
361
|
+
claudeArgs.push('--resume', savedSessionId);
|
|
362
|
+
log('INFO', `Executing task: ${task.name} (model: ${model}, resuming session ${savedSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
|
|
363
|
+
} else {
|
|
364
|
+
const newSessionId = crypto.randomUUID();
|
|
365
|
+
claudeArgs.push('--session-id', newSessionId);
|
|
366
|
+
if (!state.tasks[task.name]) state.tasks[task.name] = {};
|
|
367
|
+
state.tasks[task.name].session_id = newSessionId;
|
|
368
|
+
saveState(state);
|
|
369
|
+
log('INFO', `Executing task: ${task.name} (model: ${model}, new session ${newSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
log('INFO', `Executing task: ${task.name} (model: ${model}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
|
|
373
|
+
}
|
|
291
374
|
|
|
292
375
|
try {
|
|
293
376
|
const output = execFileSync('claude', claudeArgs, {
|
|
294
377
|
input: fullPrompt,
|
|
295
378
|
encoding: 'utf8',
|
|
296
|
-
timeout: 120000,
|
|
297
|
-
maxBuffer: 1024 * 1024,
|
|
298
|
-
|
|
379
|
+
timeout: task.timeout || 120000,
|
|
380
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
381
|
+
...(cwd && { cwd }),
|
|
382
|
+
env: { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined },
|
|
299
383
|
}).trim();
|
|
300
384
|
|
|
301
385
|
// Rough token estimate: ~4 chars per token for input + output
|
|
302
386
|
const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
|
|
303
387
|
recordTokens(state, estimatedTokens);
|
|
304
388
|
|
|
305
|
-
// Record task result
|
|
389
|
+
// Record task result (preserve session_id for persistent sessions)
|
|
390
|
+
const prevSessionId = state.tasks[task.name]?.session_id;
|
|
306
391
|
state.tasks[task.name] = {
|
|
307
392
|
last_run: new Date().toISOString(),
|
|
308
393
|
status: 'success',
|
|
309
394
|
output_preview: output.slice(0, 200),
|
|
395
|
+
...(prevSessionId && { session_id: prevSessionId }),
|
|
310
396
|
};
|
|
311
397
|
saveState(state);
|
|
312
398
|
|
|
313
399
|
log('INFO', `Task ${task.name} completed (est. ${estimatedTokens} tokens)`);
|
|
314
400
|
return { success: true, output, tokens: estimatedTokens };
|
|
315
401
|
} catch (e) {
|
|
316
|
-
|
|
402
|
+
const errMsg = e.message || '';
|
|
403
|
+
// If persistent session expired/not found, reset and let next run create fresh
|
|
404
|
+
if (task.persistent_session && (errMsg.includes('not found') || errMsg.includes('No session'))) {
|
|
405
|
+
log('WARN', `Persistent session for ${task.name} expired, will create new on next run`);
|
|
406
|
+
state.tasks[task.name] = {
|
|
407
|
+
last_run: new Date().toISOString(),
|
|
408
|
+
status: 'session_reset',
|
|
409
|
+
error: 'Session expired, will retry with new session',
|
|
410
|
+
};
|
|
411
|
+
saveState(state);
|
|
412
|
+
return { success: false, error: 'session_expired', output: '' };
|
|
413
|
+
}
|
|
414
|
+
log('ERROR', `Task ${task.name} failed: ${errMsg}`);
|
|
415
|
+
const prevSid = state.tasks[task.name]?.session_id;
|
|
317
416
|
state.tasks[task.name] = {
|
|
318
417
|
last_run: new Date().toISOString(),
|
|
319
418
|
status: 'error',
|
|
320
|
-
error:
|
|
419
|
+
error: errMsg.slice(0, 200),
|
|
420
|
+
...(prevSid && { session_id: prevSid }),
|
|
321
421
|
};
|
|
322
422
|
saveState(state);
|
|
323
423
|
return { success: false, error: e.message, output: '' };
|
|
@@ -350,21 +450,28 @@ function executeWorkflow(task, config) {
|
|
|
350
450
|
const outputs = [];
|
|
351
451
|
let totalTokens = 0;
|
|
352
452
|
const allowed = task.allowedTools || [];
|
|
453
|
+
// Auto-detect MCP config in task cwd
|
|
454
|
+
const mcpConfig = task.mcp_config
|
|
455
|
+
? path.resolve(task.mcp_config.replace(/^~/, HOME))
|
|
456
|
+
: fs.existsSync(path.join(cwd, '.mcp.json'))
|
|
457
|
+
? path.join(cwd, '.mcp.json')
|
|
458
|
+
: null;
|
|
353
459
|
|
|
354
|
-
log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}`);
|
|
460
|
+
log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''}`);
|
|
355
461
|
|
|
356
462
|
for (let i = 0; i < steps.length; i++) {
|
|
357
463
|
const step = steps[i];
|
|
358
464
|
let prompt = (step.skill ? `/${step.skill} ` : '') + (step.prompt || '');
|
|
359
465
|
if (i === 0 && precheck.context) prompt += `\n\n相关数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
|
|
360
|
-
const args = ['-p', '--model', model];
|
|
466
|
+
const args = ['-p', '--model', model, '--dangerously-skip-permissions'];
|
|
361
467
|
for (const tool of allowed) args.push('--allowedTools', tool);
|
|
468
|
+
if (mcpConfig) args.push('--mcp-config', mcpConfig);
|
|
362
469
|
args.push(i === 0 ? '--session-id' : '--resume', sessionId);
|
|
363
470
|
|
|
364
471
|
log('INFO', `Workflow ${task.name} step ${i + 1}/${steps.length}: ${step.skill || 'prompt'}`);
|
|
365
472
|
try {
|
|
366
|
-
const output =
|
|
367
|
-
input: prompt, encoding: 'utf8', timeout: step.timeout || 300000, maxBuffer: 5 * 1024 * 1024, cwd, env: { ...process.env, ...getDaemonProviderEnv() },
|
|
473
|
+
const output = execFileSync('claude', args, {
|
|
474
|
+
input: prompt, encoding: 'utf8', timeout: step.timeout || 300000, maxBuffer: 5 * 1024 * 1024, cwd, env: { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined },
|
|
368
475
|
}).trim();
|
|
369
476
|
const tk = Math.ceil((prompt.length + output.length) / 4);
|
|
370
477
|
totalTokens += tk;
|
|
@@ -394,21 +501,35 @@ function executeWorkflow(task, config) {
|
|
|
394
501
|
// HEARTBEAT SCHEDULER
|
|
395
502
|
// ---------------------------------------------------------
|
|
396
503
|
function startHeartbeat(config, notifyFn) {
|
|
397
|
-
const
|
|
504
|
+
const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
|
|
505
|
+
const projectTasks = [];
|
|
506
|
+
const legacyNames = new Set(legacyTasks.map(t => t.name));
|
|
507
|
+
for (const [key, proj] of Object.entries(config.projects || {})) {
|
|
508
|
+
for (const t of (proj.heartbeat_tasks || [])) {
|
|
509
|
+
if (legacyNames.has(t.name)) log('WARN', `Duplicate task name "${t.name}" in project "${key}" and legacy heartbeat — will run twice`);
|
|
510
|
+
projectTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const tasks = [...legacyTasks, ...projectTasks];
|
|
398
514
|
if (tasks.length === 0) {
|
|
399
515
|
log('INFO', 'No heartbeat tasks configured');
|
|
400
516
|
return;
|
|
401
517
|
}
|
|
402
518
|
|
|
519
|
+
const enabledTasks = tasks.filter(t => t.enabled !== false);
|
|
403
520
|
const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
|
|
404
|
-
log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${tasks.length} tasks)`);
|
|
521
|
+
log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${enabledTasks.length}/${tasks.length} tasks enabled)`);
|
|
522
|
+
|
|
523
|
+
if (enabledTasks.length === 0) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
405
526
|
|
|
406
527
|
// Track next run times
|
|
407
528
|
const nextRun = {};
|
|
408
529
|
const now = Date.now();
|
|
409
530
|
const state = loadState();
|
|
410
531
|
|
|
411
|
-
for (const task of
|
|
532
|
+
for (const task of enabledTasks) {
|
|
412
533
|
const intervalSec = parseInterval(task.interval);
|
|
413
534
|
const lastRun = state.tasks[task.name] && state.tasks[task.name].last_run;
|
|
414
535
|
if (lastRun) {
|
|
@@ -422,17 +543,18 @@ function startHeartbeat(config, notifyFn) {
|
|
|
422
543
|
|
|
423
544
|
const timer = setInterval(() => {
|
|
424
545
|
const currentTime = Date.now();
|
|
425
|
-
for (const task of
|
|
546
|
+
for (const task of enabledTasks) {
|
|
426
547
|
if (currentTime >= (nextRun[task.name] || 0)) {
|
|
427
548
|
const result = executeTask(task, config);
|
|
428
549
|
const intervalSec = parseInterval(task.interval);
|
|
429
550
|
nextRun[task.name] = currentTime + intervalSec * 1000;
|
|
430
551
|
|
|
431
552
|
if (task.notify && notifyFn && !result.skipped) {
|
|
553
|
+
const proj = task._project || null;
|
|
432
554
|
if (result.success) {
|
|
433
|
-
notifyFn(`✅ *${task.name}* completed\n\n${result.output}
|
|
555
|
+
notifyFn(`✅ *${task.name}* completed\n\n${result.output}`, proj);
|
|
434
556
|
} else {
|
|
435
|
-
notifyFn(`❌ *${task.name}* failed: ${result.error}
|
|
557
|
+
notifyFn(`❌ *${task.name}* failed: ${result.error}`, proj);
|
|
436
558
|
}
|
|
437
559
|
}
|
|
438
560
|
}
|
|
@@ -454,7 +576,7 @@ async function startTelegramBridge(config, executeTaskByName) {
|
|
|
454
576
|
|
|
455
577
|
const { createBot } = require(path.join(__dirname, 'telegram-adapter.js'));
|
|
456
578
|
const bot = createBot(config.telegram.bot_token);
|
|
457
|
-
|
|
579
|
+
// allowedIds read dynamically per-message to support hot-reload of daemon.yaml
|
|
458
580
|
|
|
459
581
|
// Verify bot
|
|
460
582
|
try {
|
|
@@ -481,6 +603,7 @@ async function startTelegramBridge(config, executeTaskByName) {
|
|
|
481
603
|
const chatId = cb.message && cb.message.chat.id;
|
|
482
604
|
bot.answerCallback(cb.id).catch(() => {});
|
|
483
605
|
if (chatId && cb.data) {
|
|
606
|
+
const allowedIds = (loadConfig().telegram && loadConfig().telegram.allowed_chat_ids) || [];
|
|
484
607
|
if (!allowedIds.includes(chatId)) continue;
|
|
485
608
|
// Fire-and-forget: don't block poll loop (enables message queue)
|
|
486
609
|
handleCommand(bot, chatId, cb.data, config, executeTaskByName).catch(e => {
|
|
@@ -495,8 +618,11 @@ async function startTelegramBridge(config, executeTaskByName) {
|
|
|
495
618
|
const msg = update.message;
|
|
496
619
|
const chatId = msg.chat.id;
|
|
497
620
|
|
|
498
|
-
// Security: check whitelist (empty = deny all)
|
|
499
|
-
|
|
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) {
|
|
500
626
|
log('WARN', `Rejected message from unauthorized chat: ${chatId}`);
|
|
501
627
|
continue;
|
|
502
628
|
}
|
|
@@ -570,9 +696,15 @@ async function startTelegramBridge(config, executeTaskByName) {
|
|
|
570
696
|
};
|
|
571
697
|
}
|
|
572
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
|
+
|
|
573
706
|
// Rate limiter for /ask and /run — prevents rapid-fire Claude calls
|
|
574
707
|
const _lastClaudeCall = {};
|
|
575
|
-
const CLAUDE_COOLDOWN_MS = 10000; // 10s between Claude calls per chat
|
|
576
708
|
|
|
577
709
|
function checkCooldown(chatId) {
|
|
578
710
|
const now = Date.now();
|
|
@@ -588,63 +720,126 @@ function checkCooldown(chatId) {
|
|
|
588
720
|
// Path shortener — imported from ./utils
|
|
589
721
|
const { shortenPath, expandPath } = createPathMap();
|
|
590
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
|
+
|
|
591
782
|
/**
|
|
592
783
|
* Send directory picker: recent projects + Browse button
|
|
593
784
|
* @param {string} mode - 'new' or 'cd' (determines callback command)
|
|
594
785
|
*/
|
|
595
786
|
async function sendDirPicker(bot, chatId, mode, title) {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
if (bot.sendButtons) {
|
|
599
|
-
const buttons = dirs.map(d => [{ text: d.label, callback_data: `${cmd} ${shortenPath(d.path)}` }]);
|
|
600
|
-
buttons.push([{ text: 'Browse...', callback_data: `/browse ${mode} ${shortenPath(HOME)}` }]);
|
|
601
|
-
await bot.sendButtons(chatId, title, buttons);
|
|
602
|
-
} else {
|
|
603
|
-
let msg = `${title}\n`;
|
|
604
|
-
dirs.forEach((d, i) => { msg += `${i + 1}. ${d.label}\n ${cmd} ${d.path}\n`; });
|
|
605
|
-
msg += `\nOr type: ${cmd} /full/path`;
|
|
606
|
-
await bot.sendMessage(chatId, msg);
|
|
607
|
-
}
|
|
787
|
+
// Always open the file browser starting from HOME — Finder-style navigation
|
|
788
|
+
await sendBrowse(bot, chatId, mode, HOME, title);
|
|
608
789
|
}
|
|
609
790
|
|
|
610
791
|
/**
|
|
611
|
-
* Send directory browser:
|
|
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
|
|
612
796
|
*/
|
|
613
|
-
async function sendBrowse(bot, chatId, mode, dirPath) {
|
|
614
|
-
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;
|
|
615
800
|
try {
|
|
616
801
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
617
802
|
const subdirs = entries
|
|
618
803
|
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
619
804
|
.map(e => e.name)
|
|
620
|
-
.sort()
|
|
621
|
-
|
|
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, '~');
|
|
622
811
|
|
|
623
812
|
if (bot.sendButtons) {
|
|
624
813
|
const buttons = [];
|
|
625
|
-
//
|
|
626
|
-
buttons.push([{ text:
|
|
627
|
-
// Subdirectories
|
|
628
|
-
for (const name of
|
|
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) {
|
|
629
818
|
const full = path.join(dirPath, name);
|
|
630
|
-
buttons.push([{ text:
|
|
819
|
+
buttons.push([{ text: `📁 ${name}`, callback_data: `/browse ${mode} ${shortenPath(full)}` }]);
|
|
631
820
|
}
|
|
632
|
-
//
|
|
633
|
-
const
|
|
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
|
|
634
827
|
if (parent !== dirPath) {
|
|
635
|
-
buttons.push([{ text: '
|
|
828
|
+
buttons.push([{ text: '⬆ 上级目录', callback_data: `/browse ${mode} ${shortenPath(parent)}` }]);
|
|
636
829
|
}
|
|
637
|
-
|
|
830
|
+
const header = title ? `${title}\n📂 ${displayPath}` : `📂 ${displayPath}`;
|
|
831
|
+
await bot.sendButtons(chatId, header, buttons);
|
|
638
832
|
} else {
|
|
639
|
-
let msg =
|
|
640
|
-
|
|
641
|
-
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`;
|
|
642
836
|
});
|
|
643
|
-
msg += `\
|
|
837
|
+
msg += `\n✓ 选择此目录: ${cmd} ${dirPath}`;
|
|
838
|
+
if (parent !== dirPath) msg += `\n⬆ 上级: /browse ${mode} ${parent}`;
|
|
644
839
|
await bot.sendMessage(chatId, msg);
|
|
645
840
|
}
|
|
646
841
|
} catch (e) {
|
|
647
|
-
await bot.sendMessage(chatId,
|
|
842
|
+
await bot.sendMessage(chatId, `无法读取目录: ${dirPath}`);
|
|
648
843
|
}
|
|
649
844
|
}
|
|
650
845
|
|
|
@@ -751,16 +946,132 @@ async function sendDirListing(bot, chatId, baseDir, arg) {
|
|
|
751
946
|
/**
|
|
752
947
|
* Unified command handler — shared by Telegram & Feishu
|
|
753
948
|
*/
|
|
754
|
-
|
|
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) {
|
|
755
997
|
const state = loadState();
|
|
756
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
|
+
|
|
757
1064
|
// --- Browse handler (directory navigation) ---
|
|
758
1065
|
if (text.startsWith('/browse ')) {
|
|
759
1066
|
const parts = text.slice(8).trim().split(' ');
|
|
760
|
-
const mode = parts[0]; // 'new' or '
|
|
761
|
-
|
|
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(' '));
|
|
762
1073
|
if (mode && dirPath && fs.existsSync(dirPath)) {
|
|
763
|
-
await sendBrowse(bot, chatId, mode, dirPath);
|
|
1074
|
+
await sendBrowse(bot, chatId, mode, dirPath, null, page);
|
|
764
1075
|
} else if (/^p\d+$/.test(dirPath)) {
|
|
765
1076
|
await bot.sendMessage(chatId, '⚠️ Button expired. Pick again:');
|
|
766
1077
|
await sendDirPicker(bot, chatId, mode || 'cd', 'Switch workdir:');
|
|
@@ -775,6 +1086,19 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
775
1086
|
if (text === '/new' || text.startsWith('/new ')) {
|
|
776
1087
|
const arg = text.slice(4).trim();
|
|
777
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
|
|
778
1102
|
await sendDirPicker(bot, chatId, 'new', 'Pick a workdir:');
|
|
779
1103
|
return;
|
|
780
1104
|
}
|
|
@@ -880,6 +1204,81 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
880
1204
|
return;
|
|
881
1205
|
}
|
|
882
1206
|
|
|
1207
|
+
// /sessions — compact list, tap to see details, then tap to switch
|
|
1208
|
+
if (text === '/sessions') {
|
|
1209
|
+
const allSessions = listRecentSessions(15);
|
|
1210
|
+
if (allSessions.length === 0) {
|
|
1211
|
+
await bot.sendMessage(chatId, 'No sessions found. Try /new first.');
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (bot.sendButtons) {
|
|
1215
|
+
const buttons = allSessions.map(s => {
|
|
1216
|
+
const proj = s.projectPath ? path.basename(s.projectPath) : '~';
|
|
1217
|
+
const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
|
|
1218
|
+
const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
|
|
1219
|
+
const ago = formatRelativeTime(new Date(timeMs).toISOString());
|
|
1220
|
+
const shortId = s.sessionId.slice(0, 6);
|
|
1221
|
+
const name = s.customTitle || (s.summary || '').slice(0, 18) || '';
|
|
1222
|
+
let label = `${ago} 📁${proj}`;
|
|
1223
|
+
if (name) label += ` ${name}`;
|
|
1224
|
+
label += ` #${shortId}`;
|
|
1225
|
+
return [{ text: label, callback_data: `/sess ${s.sessionId}` }];
|
|
1226
|
+
});
|
|
1227
|
+
await bot.sendButtons(chatId, '📋 Tap a session to view details:', buttons);
|
|
1228
|
+
} else {
|
|
1229
|
+
let msg = '📋 Recent sessions:\n\n';
|
|
1230
|
+
allSessions.forEach((s, i) => {
|
|
1231
|
+
const proj = s.projectPath ? path.basename(s.projectPath) : '~';
|
|
1232
|
+
const title = s.customTitle || s.summary || (s.firstPrompt || '').slice(0, 40) || '';
|
|
1233
|
+
const shortId = s.sessionId.slice(0, 8);
|
|
1234
|
+
msg += `${i + 1}. 📁${proj} | ${title}\n /resume ${shortId}\n`;
|
|
1235
|
+
});
|
|
1236
|
+
await bot.sendMessage(chatId, msg);
|
|
1237
|
+
}
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// /sess <id> — show session detail card with switch button
|
|
1242
|
+
if (text.startsWith('/sess ')) {
|
|
1243
|
+
const sid = text.slice(6).trim();
|
|
1244
|
+
const allSessions = listRecentSessions(50);
|
|
1245
|
+
const s = allSessions.find(x => x.sessionId === sid || x.sessionId.startsWith(sid));
|
|
1246
|
+
if (!s) {
|
|
1247
|
+
await bot.sendMessage(chatId, `Session not found: ${sid.slice(0, 8)}`);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
const proj = s.projectPath || '~';
|
|
1251
|
+
const projName = path.basename(proj);
|
|
1252
|
+
const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
|
|
1253
|
+
const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
|
|
1254
|
+
const ago = formatRelativeTime(new Date(timeMs).toISOString());
|
|
1255
|
+
const title = s.customTitle || '';
|
|
1256
|
+
const summary = s.summary || '';
|
|
1257
|
+
const firstMsg = (s.firstPrompt || '').replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '');
|
|
1258
|
+
const msgs = s.messageCount || '?';
|
|
1259
|
+
|
|
1260
|
+
let detail = `📋 Session Detail\n`;
|
|
1261
|
+
detail += `━━━━━━━━━━━━━━━━━━━━\n`;
|
|
1262
|
+
if (title) detail += `📝 Title: ${title}\n`;
|
|
1263
|
+
if (summary) detail += `💡 Summary: ${summary}\n`;
|
|
1264
|
+
detail += `📁 Project: ${projName}\n`;
|
|
1265
|
+
detail += `📂 Path: ${proj}\n`;
|
|
1266
|
+
detail += `💬 Messages: ${msgs}\n`;
|
|
1267
|
+
detail += `🕐 Last active: ${ago}\n`;
|
|
1268
|
+
detail += `🆔 ID: ${s.sessionId.slice(0, 8)}`;
|
|
1269
|
+
if (firstMsg && firstMsg !== summary) detail += `\n\n🗨️ First message:\n${firstMsg}`;
|
|
1270
|
+
|
|
1271
|
+
if (bot.sendButtons) {
|
|
1272
|
+
await bot.sendButtons(chatId, detail, [
|
|
1273
|
+
[{ text: '▶️ Switch to this session', callback_data: `/resume ${s.sessionId}` }],
|
|
1274
|
+
[{ text: '⬅️ Back to list', callback_data: '/sessions' }],
|
|
1275
|
+
]);
|
|
1276
|
+
} else {
|
|
1277
|
+
await bot.sendMessage(chatId, detail + `\n\n/resume ${s.sessionId.slice(0, 8)}`);
|
|
1278
|
+
}
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
883
1282
|
if (text === '/resume' || text.startsWith('/resume ')) {
|
|
884
1283
|
const arg = text.slice(7).trim();
|
|
885
1284
|
|
|
@@ -943,6 +1342,24 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
943
1342
|
return;
|
|
944
1343
|
}
|
|
945
1344
|
|
|
1345
|
+
if (text === '/agent') {
|
|
1346
|
+
const projects = config.projects || {};
|
|
1347
|
+
const entries = Object.entries(projects).filter(([, p]) => p.cwd);
|
|
1348
|
+
if (entries.length === 0) {
|
|
1349
|
+
await bot.sendMessage(chatId, 'No projects configured. Add projects with cwd to daemon.yaml.');
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
const currentSession = getSession(chatId);
|
|
1353
|
+
const currentCwd = currentSession?.cwd ? path.resolve(expandPath(currentSession.cwd)) : null;
|
|
1354
|
+
const buttons = entries.map(([key, p]) => {
|
|
1355
|
+
const projCwd = normalizeCwd(p.cwd);
|
|
1356
|
+
const active = currentCwd && path.resolve(projCwd) === currentCwd ? ' ◀' : '';
|
|
1357
|
+
return [{ text: `${p.icon || '🤖'} ${p.name || key}${active}`, callback_data: `/cd ${projCwd}` }];
|
|
1358
|
+
});
|
|
1359
|
+
await bot.sendButtons(chatId, '切换对话对象', buttons);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
946
1363
|
if (text === '/cd' || text.startsWith('/cd ')) {
|
|
947
1364
|
let newCwd = expandPath(text.slice(3).trim());
|
|
948
1365
|
if (!newCwd) {
|
|
@@ -1082,14 +1499,28 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1082
1499
|
}
|
|
1083
1500
|
|
|
1084
1501
|
if (text === '/tasks') {
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1502
|
+
let msg = '';
|
|
1503
|
+
// Legacy flat tasks
|
|
1504
|
+
const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
|
|
1505
|
+
if (legacyTasks.length > 0) {
|
|
1506
|
+
msg += '📋 General:\n';
|
|
1507
|
+
for (const t of legacyTasks) {
|
|
1508
|
+
const ts = state.tasks[t.name] || {};
|
|
1509
|
+
msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
|
|
1510
|
+
}
|
|
1091
1511
|
}
|
|
1092
|
-
|
|
1512
|
+
// Project tasks grouped
|
|
1513
|
+
for (const [, proj] of Object.entries(config.projects || {})) {
|
|
1514
|
+
const pTasks = proj.heartbeat_tasks || [];
|
|
1515
|
+
if (pTasks.length === 0) continue;
|
|
1516
|
+
msg += `\n${proj.icon || '🤖'} ${proj.name || proj}:\n`;
|
|
1517
|
+
for (const t of pTasks) {
|
|
1518
|
+
const ts = state.tasks[t.name] || {};
|
|
1519
|
+
msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
if (!msg) { await bot.sendMessage(chatId, 'No heartbeat tasks configured.'); return; }
|
|
1523
|
+
await bot.sendMessage(chatId, msg.trim());
|
|
1093
1524
|
return;
|
|
1094
1525
|
}
|
|
1095
1526
|
|
|
@@ -1101,8 +1532,13 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1101
1532
|
return;
|
|
1102
1533
|
}
|
|
1103
1534
|
const taskName = text.slice(5).trim();
|
|
1104
|
-
const
|
|
1105
|
-
const
|
|
1535
|
+
const allRunTasks = [...(config.heartbeat && config.heartbeat.tasks || [])];
|
|
1536
|
+
for (const [key, proj] of Object.entries(config.projects || {})) {
|
|
1537
|
+
for (const t of (proj.heartbeat_tasks || [])) {
|
|
1538
|
+
allRunTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const task = allRunTasks.find(t => t.name === taskName);
|
|
1106
1542
|
if (!task) { await bot.sendMessage(chatId, `❌ Task "${taskName}" not found`); return; }
|
|
1107
1543
|
|
|
1108
1544
|
// Script tasks: quick, run inline
|
|
@@ -1124,7 +1560,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1124
1560
|
if (precheck.context) taskPrompt += `\n\n以下是相关原始数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
|
|
1125
1561
|
const fullPrompt = preamble + taskPrompt;
|
|
1126
1562
|
const model = task.model || 'haiku';
|
|
1127
|
-
const claudeArgs = ['-p', '--model', model];
|
|
1563
|
+
const claudeArgs = ['-p', '--model', model, '--dangerously-skip-permissions'];
|
|
1128
1564
|
for (const t of (task.allowedTools || [])) claudeArgs.push('--allowedTools', t);
|
|
1129
1565
|
|
|
1130
1566
|
await bot.sendMessage(chatId, `Running: ${taskName} (${model})...`);
|
|
@@ -1189,6 +1625,106 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1189
1625
|
return;
|
|
1190
1626
|
}
|
|
1191
1627
|
|
|
1628
|
+
// /compact — compress current session context to save tokens
|
|
1629
|
+
if (text === '/compact') {
|
|
1630
|
+
const session = getSession(chatId);
|
|
1631
|
+
if (!session || !session.started) {
|
|
1632
|
+
await bot.sendMessage(chatId, '❌ No active session to compact.');
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
await bot.sendMessage(chatId, '🗜 Compacting session...');
|
|
1636
|
+
|
|
1637
|
+
// Step 1: Read conversation from JSONL (fast, no Claude needed)
|
|
1638
|
+
const jsonlPath = findSessionFile(session.id);
|
|
1639
|
+
if (!jsonlPath) {
|
|
1640
|
+
await bot.sendMessage(chatId, '❌ Session file not found.');
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
let messages = [];
|
|
1644
|
+
try {
|
|
1645
|
+
const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n').filter(Boolean);
|
|
1646
|
+
for (const line of lines) {
|
|
1647
|
+
try {
|
|
1648
|
+
const obj = JSON.parse(line);
|
|
1649
|
+
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
1650
|
+
const msg = obj.message || {};
|
|
1651
|
+
const content = msg.content;
|
|
1652
|
+
let text_content = '';
|
|
1653
|
+
if (typeof content === 'string') {
|
|
1654
|
+
text_content = content;
|
|
1655
|
+
} else if (Array.isArray(content)) {
|
|
1656
|
+
text_content = content
|
|
1657
|
+
.filter(c => c.type === 'text')
|
|
1658
|
+
.map(c => c.text || '')
|
|
1659
|
+
.join(' ');
|
|
1660
|
+
}
|
|
1661
|
+
if (text_content.trim()) {
|
|
1662
|
+
messages.push({ role: obj.type, text: text_content.trim() });
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
} catch { /* skip malformed lines */ }
|
|
1666
|
+
}
|
|
1667
|
+
} catch (e) {
|
|
1668
|
+
await bot.sendMessage(chatId, `❌ Cannot read session: ${e.message}`);
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
if (messages.length === 0) {
|
|
1673
|
+
await bot.sendMessage(chatId, '❌ No messages found in session.');
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// Step 2: Build a truncated conversation digest (keep under ~20k chars for haiku)
|
|
1678
|
+
const MAX_DIGEST = 20000;
|
|
1679
|
+
let digest = '';
|
|
1680
|
+
// Take messages from newest to oldest until we hit the limit
|
|
1681
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1682
|
+
const m = messages[i];
|
|
1683
|
+
const prefix = m.role === 'user' ? 'USER' : 'ASSISTANT';
|
|
1684
|
+
const entry = `[${prefix}]: ${m.text.slice(0, 800)}\n\n`;
|
|
1685
|
+
if (digest.length + entry.length > MAX_DIGEST) break;
|
|
1686
|
+
digest = entry + digest;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// Step 3: Summarize with haiku (new process, no --resume, fast)
|
|
1690
|
+
const daemonCfg = loadConfig().daemon || {};
|
|
1691
|
+
const compactArgs = ['-p', '--model', 'haiku', '--no-session-persistence'];
|
|
1692
|
+
if (daemonCfg.dangerously_skip_permissions) compactArgs.push('--dangerously-skip-permissions');
|
|
1693
|
+
const { output, error } = await spawnClaudeAsync(
|
|
1694
|
+
compactArgs,
|
|
1695
|
+
`Summarize the following conversation into a compact context document. Include: (1) what was being worked on, (2) key decisions made, (3) current state, (4) pending tasks. Be concise but preserve ALL important technical context (file names, function names, variable names, specific values). Output ONLY the summary.\n\n--- CONVERSATION ---\n${digest}`,
|
|
1696
|
+
session.cwd,
|
|
1697
|
+
60000
|
|
1698
|
+
);
|
|
1699
|
+
if (error || !output) {
|
|
1700
|
+
await bot.sendMessage(chatId, `❌ Compact failed: ${error || 'no output'}`);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Step 4: Create new session with the summary
|
|
1705
|
+
const model = daemonCfg.model || 'opus';
|
|
1706
|
+
const oldName = getSessionName(session.id);
|
|
1707
|
+
const newSession = createSession(chatId, session.cwd, oldName ? oldName + ' (compacted)' : '');
|
|
1708
|
+
const initArgs = ['-p', '--session-id', newSession.id, '--model', model];
|
|
1709
|
+
if (daemonCfg.dangerously_skip_permissions) initArgs.push('--dangerously-skip-permissions');
|
|
1710
|
+
const preamble = buildProfilePreamble();
|
|
1711
|
+
const initPrompt = preamble + `Here is the context from our previous session (compacted):\n\n${output}\n\nContext loaded. Ready to continue.`;
|
|
1712
|
+
const { error: initErr } = await spawnClaudeAsync(initArgs, initPrompt, session.cwd, 60000);
|
|
1713
|
+
if (initErr) {
|
|
1714
|
+
await bot.sendMessage(chatId, `⚠️ Summary saved but new session init failed: ${initErr}`);
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
// Mark as started
|
|
1718
|
+
const state = loadState();
|
|
1719
|
+
if (state.sessions[chatId]) {
|
|
1720
|
+
state.sessions[chatId].started = true;
|
|
1721
|
+
saveState(state);
|
|
1722
|
+
}
|
|
1723
|
+
const tokenEst = Math.round(output.length / 3.5);
|
|
1724
|
+
await bot.sendMessage(chatId, `✅ Compacted! ~${tokenEst} tokens of context carried over.\nNew session: ${newSession.id.slice(0, 8)}`);
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1192
1728
|
// /publish <otp> — npm publish with OTP (zero latency, no Claude)
|
|
1193
1729
|
if (text.startsWith('/publish ')) {
|
|
1194
1730
|
const otp = text.slice(9).trim();
|
|
@@ -1595,8 +2131,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1595
2131
|
'/last — 继续电脑上最近的对话',
|
|
1596
2132
|
'/cd last — 切到电脑最近的项目目录',
|
|
1597
2133
|
'',
|
|
2134
|
+
'🤖 Agent 切换:',
|
|
2135
|
+
'/agent — 选择对话的项目/Agent',
|
|
2136
|
+
'',
|
|
1598
2137
|
'📂 Session 管理:',
|
|
1599
2138
|
'/new [path] [name] — 新建会话',
|
|
2139
|
+
'/sessions — 浏览所有最近会话',
|
|
1600
2140
|
'/resume [name] — 选择/恢复会话',
|
|
1601
2141
|
'/name <name> — 命名当前会话',
|
|
1602
2142
|
'/cd <path> — 切换工作目录',
|
|
@@ -1624,7 +2164,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1624
2164
|
q.messages.push(text);
|
|
1625
2165
|
// Only notify once (first message), subsequent ones silently queue
|
|
1626
2166
|
if (isFirst) {
|
|
1627
|
-
await bot.sendMessage(chatId, '📝
|
|
2167
|
+
await bot.sendMessage(chatId, '📝 收到,稍后一起处理');
|
|
1628
2168
|
}
|
|
1629
2169
|
// Interrupt the running Claude process
|
|
1630
2170
|
const proc = activeProcesses.get(chatId);
|
|
@@ -1652,13 +2192,24 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1652
2192
|
}, 5000);
|
|
1653
2193
|
return;
|
|
1654
2194
|
}
|
|
2195
|
+
// Nickname-only switch: bypass cooldown + budget (no Claude call)
|
|
2196
|
+
const quickAgent = routeAgent(text, config);
|
|
2197
|
+
if (quickAgent && !quickAgent.rest) {
|
|
2198
|
+
const { key, proj } = quickAgent;
|
|
2199
|
+
const projCwd = normalizeCwd(proj.cwd);
|
|
2200
|
+
attachOrCreateSession(chatId, projCwd, proj.name || key);
|
|
2201
|
+
log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
|
|
2202
|
+
await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
1655
2206
|
const cd = checkCooldown(chatId);
|
|
1656
2207
|
if (!cd.ok) { await bot.sendMessage(chatId, `${cd.wait}s`); return; }
|
|
1657
2208
|
if (!checkBudget(loadConfig(), loadState())) {
|
|
1658
2209
|
await bot.sendMessage(chatId, 'Daily token budget exceeded.');
|
|
1659
2210
|
return;
|
|
1660
2211
|
}
|
|
1661
|
-
await askClaude(bot, chatId, text);
|
|
2212
|
+
await askClaude(bot, chatId, text, config);
|
|
1662
2213
|
}
|
|
1663
2214
|
|
|
1664
2215
|
// ---------------------------------------------------------
|
|
@@ -1858,6 +2409,7 @@ function createSession(chatId, cwd, name) {
|
|
|
1858
2409
|
saveState(state);
|
|
1859
2410
|
invalidateSessionCache();
|
|
1860
2411
|
|
|
2412
|
+
|
|
1861
2413
|
// If name provided, write to Claude's session file (same as /rename on desktop)
|
|
1862
2414
|
if (name) {
|
|
1863
2415
|
writeSessionName(sessionId, cwd || HOME, name);
|
|
@@ -1958,7 +2510,7 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
|
|
|
1958
2510
|
const child = spawn('claude', args, {
|
|
1959
2511
|
cwd,
|
|
1960
2512
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1961
|
-
env: { ...process.env, ...getActiveProviderEnv() },
|
|
2513
|
+
env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
|
|
1962
2514
|
});
|
|
1963
2515
|
|
|
1964
2516
|
let stdout = '';
|
|
@@ -2028,6 +2580,9 @@ const CONTENT_EXTENSIONS = new Set([
|
|
|
2028
2580
|
// Active Claude processes per chat (for /stop)
|
|
2029
2581
|
const activeProcesses = new Map(); // chatId -> { child, aborted }
|
|
2030
2582
|
|
|
2583
|
+
// Pending /bind flows: waiting for user to pick a directory
|
|
2584
|
+
const pendingBinds = new Map(); // chatId -> agentName
|
|
2585
|
+
|
|
2031
2586
|
// Message queue for messages received while a task is running
|
|
2032
2587
|
const messageQueue = new Map(); // chatId -> { messages: string[], notified: false }
|
|
2033
2588
|
|
|
@@ -2133,7 +2688,7 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
|
|
|
2133
2688
|
const child = spawn('claude', streamArgs, {
|
|
2134
2689
|
cwd,
|
|
2135
2690
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2136
|
-
env: { ...process.env, ...getActiveProviderEnv() },
|
|
2691
|
+
env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
|
|
2137
2692
|
});
|
|
2138
2693
|
|
|
2139
2694
|
// Track active process for /stop
|
|
@@ -2146,7 +2701,7 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
|
|
|
2146
2701
|
let killed = false;
|
|
2147
2702
|
let finalResult = '';
|
|
2148
2703
|
let lastStatusTime = 0;
|
|
2149
|
-
const STATUS_THROTTLE =
|
|
2704
|
+
const STATUS_THROTTLE = STATUS_THROTTLE_MS;
|
|
2150
2705
|
const writtenFiles = []; // Track files created/modified by Write tool
|
|
2151
2706
|
|
|
2152
2707
|
const timer = setTimeout(() => {
|
|
@@ -2305,17 +2860,33 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
|
|
|
2305
2860
|
}
|
|
2306
2861
|
|
|
2307
2862
|
// Lazy distill: run distill.js in background on first message, then every 4 hours
|
|
2308
|
-
|
|
2863
|
+
// Track outbound message_id → session for reply-based session restoration.
|
|
2864
|
+
// Keeps last 200 entries to avoid unbounded growth.
|
|
2865
|
+
function trackMsgSession(messageId, session) {
|
|
2866
|
+
if (!messageId || !session || !session.id) return;
|
|
2867
|
+
const st = loadState();
|
|
2868
|
+
if (!st.msg_sessions) st.msg_sessions = {};
|
|
2869
|
+
st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd };
|
|
2870
|
+
const keys = Object.keys(st.msg_sessions);
|
|
2871
|
+
if (keys.length > 200) {
|
|
2872
|
+
for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
|
|
2873
|
+
}
|
|
2874
|
+
saveState(st);
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2309
2877
|
function lazyDistill() {
|
|
2310
2878
|
const now = Date.now();
|
|
2311
|
-
|
|
2879
|
+
const st = loadState();
|
|
2880
|
+
const lastDistillTime = st.last_distill_time || 0;
|
|
2881
|
+
if (now - lastDistillTime < 4 * 60 * 60 * 1000) return; // 4h cooldown
|
|
2312
2882
|
const distillPath = path.join(HOME, '.metame', 'distill.js');
|
|
2313
2883
|
const signalsPath = path.join(HOME, '.metame', 'raw_signals.jsonl');
|
|
2314
2884
|
if (!fs.existsSync(distillPath)) return;
|
|
2315
2885
|
if (!fs.existsSync(signalsPath)) return;
|
|
2316
2886
|
const content = fs.readFileSync(signalsPath, 'utf8').trim();
|
|
2317
2887
|
if (!content) return;
|
|
2318
|
-
|
|
2888
|
+
st.last_distill_time = now;
|
|
2889
|
+
saveState(st);
|
|
2319
2890
|
const lines = content.split('\n').filter(l => l.trim()).length;
|
|
2320
2891
|
log('INFO', `Distilling ${lines} signal(s) in background...`);
|
|
2321
2892
|
const bg = spawn('node', [distillPath], { detached: true, stdio: 'ignore' });
|
|
@@ -2326,7 +2897,7 @@ function lazyDistill() {
|
|
|
2326
2897
|
* Shared ask logic — full Claude Code session (stateful, with tools)
|
|
2327
2898
|
* Now uses spawn (async) instead of execSync to allow parallel requests.
|
|
2328
2899
|
*/
|
|
2329
|
-
async function askClaude(bot, chatId, prompt) {
|
|
2900
|
+
async function askClaude(bot, chatId, prompt, config) {
|
|
2330
2901
|
log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
|
|
2331
2902
|
// Trigger background distill on first message / every 4h
|
|
2332
2903
|
try { lazyDistill(); } catch { /* non-fatal */ }
|
|
@@ -2343,8 +2914,52 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2343
2914
|
bot.sendTyping(chatId).catch(() => {});
|
|
2344
2915
|
}, 4000);
|
|
2345
2916
|
|
|
2917
|
+
// Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
|
|
2918
|
+
const agentMatch = routeAgent(prompt, config);
|
|
2919
|
+
if (agentMatch) {
|
|
2920
|
+
const { key, proj, rest } = agentMatch;
|
|
2921
|
+
const projCwd = normalizeCwd(proj.cwd);
|
|
2922
|
+
attachOrCreateSession(chatId, projCwd, proj.name || key);
|
|
2923
|
+
log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
|
|
2924
|
+
if (!rest) {
|
|
2925
|
+
// Pure nickname call — confirm switch and stop
|
|
2926
|
+
clearInterval(typingTimer);
|
|
2927
|
+
await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
// Nickname + content — strip nickname, continue with rest as prompt
|
|
2931
|
+
prompt = rest;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
// Skill routing: detect skill first, then decide session
|
|
2935
|
+
const skill = routeSkill(prompt);
|
|
2936
|
+
|
|
2937
|
+
// Skills with dedicated pinned sessions (reused across days, no re-injection needed)
|
|
2938
|
+
const PINNED_SKILL_SESSIONS = new Set(['macos-mail-calendar', 'skill-manager']);
|
|
2939
|
+
|
|
2346
2940
|
let session = getSession(chatId);
|
|
2347
|
-
|
|
2941
|
+
|
|
2942
|
+
if (skill && PINNED_SKILL_SESSIONS.has(skill)) {
|
|
2943
|
+
// Use a dedicated long-lived session per skill
|
|
2944
|
+
const state = loadState();
|
|
2945
|
+
if (!state.pinned_sessions) state.pinned_sessions = {};
|
|
2946
|
+
const pinned = state.pinned_sessions[skill];
|
|
2947
|
+
if (pinned) {
|
|
2948
|
+
// Reuse existing pinned session
|
|
2949
|
+
state.sessions[chatId] = { id: pinned.id, cwd: pinned.cwd, started: true };
|
|
2950
|
+
saveState(state);
|
|
2951
|
+
session = state.sessions[chatId];
|
|
2952
|
+
log('INFO', `Pinned session reused for skill ${skill}: ${pinned.id.slice(0, 8)}`);
|
|
2953
|
+
} else {
|
|
2954
|
+
// First time — create session and pin it
|
|
2955
|
+
session = createSession(chatId, HOME, skill);
|
|
2956
|
+
const st2 = loadState();
|
|
2957
|
+
if (!st2.pinned_sessions) st2.pinned_sessions = {};
|
|
2958
|
+
st2.pinned_sessions[skill] = { id: session.id, cwd: session.cwd };
|
|
2959
|
+
saveState(st2);
|
|
2960
|
+
log('INFO', `Pinned session created for skill ${skill}: ${session.id.slice(0, 8)}`);
|
|
2961
|
+
}
|
|
2962
|
+
} else if (!session) {
|
|
2348
2963
|
// Auto-attach to most recent Claude session (unified session management)
|
|
2349
2964
|
const recent = listRecentSessions(1);
|
|
2350
2965
|
if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
|
|
@@ -2353,7 +2968,7 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2353
2968
|
state.sessions[chatId] = {
|
|
2354
2969
|
id: target.sessionId,
|
|
2355
2970
|
cwd: target.projectPath,
|
|
2356
|
-
started: true,
|
|
2971
|
+
started: true,
|
|
2357
2972
|
};
|
|
2358
2973
|
saveState(state);
|
|
2359
2974
|
session = state.sessions[chatId];
|
|
@@ -2365,20 +2980,20 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2365
2980
|
|
|
2366
2981
|
// Build claude command
|
|
2367
2982
|
const args = ['-p'];
|
|
2368
|
-
// Model from daemon config (default: opus)
|
|
2369
2983
|
const daemonCfg = loadConfig().daemon || {};
|
|
2370
2984
|
const model = daemonCfg.model || 'opus';
|
|
2371
2985
|
args.push('--model', model);
|
|
2372
|
-
|
|
2373
|
-
|
|
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) {
|
|
2374
2991
|
args.push('--dangerously-skip-permissions');
|
|
2375
2992
|
} else {
|
|
2376
|
-
// Legacy: per-tool whitelist
|
|
2377
2993
|
const sessionAllowed = daemonCfg.session_allowed_tools || [];
|
|
2378
2994
|
for (const tool of sessionAllowed) args.push('--allowedTools', tool);
|
|
2379
2995
|
}
|
|
2380
2996
|
if (session.id === '__continue__') {
|
|
2381
|
-
// /continue — resume most recent conversation in cwd
|
|
2382
2997
|
args.push('--continue');
|
|
2383
2998
|
} else if (session.started) {
|
|
2384
2999
|
args.push('--resume', session.id);
|
|
@@ -2386,29 +3001,39 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2386
3001
|
args.push('--session-id', session.id);
|
|
2387
3002
|
}
|
|
2388
3003
|
|
|
2389
|
-
//
|
|
2390
|
-
const daemonHint = `\n\n[System hints - DO NOT mention these to user:
|
|
3004
|
+
// Inject daemon hints only on first message of a session
|
|
3005
|
+
const daemonHint = !session.started ? `\n\n[System hints - DO NOT mention these to user:
|
|
2391
3006
|
1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
|
|
2392
3007
|
2. File sending: User is on MOBILE. When they ask to see/download a file:
|
|
2393
3008
|
- Just FIND the file path (use Glob/ls if needed)
|
|
2394
3009
|
- Do NOT read or summarize the file content (wastes tokens)
|
|
2395
3010
|
- Add at END of response: [[FILE:/absolute/path/to/file]]
|
|
2396
3011
|
- Keep response brief: "请查收~! [[FILE:/path/to/file]]"
|
|
2397
|
-
- Multiple files: use multiple [[FILE:...]] tags]
|
|
2398
|
-
|
|
3012
|
+
- Multiple files: use multiple [[FILE:...]] tags]` : '';
|
|
3013
|
+
|
|
3014
|
+
const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
|
|
3015
|
+
const fullPrompt = routedPrompt + daemonHint;
|
|
2399
3016
|
|
|
2400
3017
|
// Git checkpoint before Claude modifies files (for /undo)
|
|
2401
3018
|
gitCheckpoint(session.cwd);
|
|
2402
3019
|
|
|
2403
3020
|
// Use streaming mode to show progress
|
|
2404
|
-
// Telegram: edit status msg in-place; Feishu
|
|
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;
|
|
2405
3025
|
const onStatus = async (status) => {
|
|
2406
3026
|
try {
|
|
2407
|
-
if (statusMsgId && bot.editMessage) {
|
|
2408
|
-
await bot.editMessage(chatId, statusMsgId, status);
|
|
2409
|
-
|
|
2410
|
-
|
|
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
|
|
2411
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);
|
|
2412
3037
|
} catch { /* ignore status update failures */ }
|
|
2413
3038
|
};
|
|
2414
3039
|
|
|
@@ -2419,6 +3044,16 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2419
3044
|
bot.deleteMessage(chatId, statusMsgId).catch(() => {});
|
|
2420
3045
|
}
|
|
2421
3046
|
|
|
3047
|
+
// When Claude completes with no text output (pure tool work), send a done notice
|
|
3048
|
+
if (output === '' && !error) {
|
|
3049
|
+
const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
|
|
3050
|
+
const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
|
|
3051
|
+
if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
|
|
3052
|
+
const wasNew = !session.started;
|
|
3053
|
+
if (wasNew) markSessionStarted(chatId);
|
|
3054
|
+
return;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
2422
3057
|
if (output) {
|
|
2423
3058
|
// Detect provider/model errors disguised as output (e.g., "model not found", API errors)
|
|
2424
3059
|
const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
|
|
@@ -2449,31 +3084,32 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2449
3084
|
recordTokens(loadState(), estimated);
|
|
2450
3085
|
|
|
2451
3086
|
// Parse [[FILE:...]] markers from output (Claude's explicit file sends)
|
|
2452
|
-
const
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
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; }
|
|
2463
3097
|
}
|
|
2464
3098
|
}
|
|
2465
3099
|
|
|
2466
|
-
|
|
2467
|
-
if (
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
}
|
|
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);
|
|
2476
3109
|
}
|
|
3110
|
+
if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session);
|
|
3111
|
+
|
|
3112
|
+
await sendFileButtons(bot, chatId, mergeFileCollections(markedFiles, files));
|
|
2477
3113
|
|
|
2478
3114
|
// Auto-name: if this was the first message and session has no name, generate one
|
|
2479
3115
|
if (wasNew && !getSessionName(session.id)) {
|
|
@@ -2498,28 +3134,9 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2498
3134
|
const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
|
|
2499
3135
|
if (retry.output) {
|
|
2500
3136
|
markSessionStarted(chatId);
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
const retryCleanOutput = retry.output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
|
|
2505
|
-
await bot.sendMarkdown(chatId, retryCleanOutput);
|
|
2506
|
-
// Combine marked + auto-detected content files
|
|
2507
|
-
const retryAllFiles = new Set(retryMarkedFiles);
|
|
2508
|
-
if (retry.files && retry.files.length > 0) {
|
|
2509
|
-
for (const f of retry.files) {
|
|
2510
|
-
if (isContentFile(f)) retryAllFiles.add(f);
|
|
2511
|
-
}
|
|
2512
|
-
}
|
|
2513
|
-
if (retryAllFiles.size > 0 && bot.sendButtons) {
|
|
2514
|
-
const validFiles = [...retryAllFiles].filter(f => fs.existsSync(f));
|
|
2515
|
-
if (validFiles.length > 0) {
|
|
2516
|
-
const buttons = validFiles.map(filePath => {
|
|
2517
|
-
const shortId = cacheFile(filePath);
|
|
2518
|
-
return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
|
|
2519
|
-
});
|
|
2520
|
-
await bot.sendButtons(chatId, '📂 文件:', buttons);
|
|
2521
|
-
}
|
|
2522
|
-
}
|
|
3137
|
+
const { markedFiles: retryMarked, cleanOutput: retryClean } = parseFileMarkers(retry.output);
|
|
3138
|
+
await bot.sendMarkdown(chatId, retryClean);
|
|
3139
|
+
await sendFileButtons(bot, chatId, mergeFileCollections(retryMarked, retry.files));
|
|
2523
3140
|
} else {
|
|
2524
3141
|
log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
|
|
2525
3142
|
try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
|
|
@@ -2564,16 +3181,34 @@ async function startFeishuBridge(config, executeTaskByName) {
|
|
|
2564
3181
|
|
|
2565
3182
|
const { createBot } = require(path.join(__dirname, 'feishu-adapter.js'));
|
|
2566
3183
|
const bot = createBot(config.feishu);
|
|
2567
|
-
const allowedIds = config.feishu.allowed_chat_ids || [];
|
|
2568
|
-
|
|
2569
3184
|
try {
|
|
2570
|
-
const receiver = await bot.startReceiving(async (chatId, text, event, fileInfo) => {
|
|
2571
|
-
// Security: check whitelist (empty = deny all)
|
|
2572
|
-
|
|
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) {
|
|
2573
3192
|
log('WARN', `Feishu: rejected message from ${chatId}`);
|
|
2574
3193
|
return;
|
|
2575
3194
|
}
|
|
2576
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
|
+
|
|
2577
3212
|
// Handle file message
|
|
2578
3213
|
if (fileInfo && fileInfo.fileKey) {
|
|
2579
3214
|
log('INFO', `Feishu file from ${chatId}: ${fileInfo.fileName} (key: ${fileInfo.fileKey}, msgId: ${fileInfo.messageId}, type: ${fileInfo.msgType})`);
|
|
@@ -2604,7 +3239,19 @@ async function startFeishuBridge(config, executeTaskByName) {
|
|
|
2604
3239
|
// Handle text message
|
|
2605
3240
|
if (text) {
|
|
2606
3241
|
log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
|
|
2607
|
-
|
|
3242
|
+
// Reply-based session restoration: if user replied to a bot message,
|
|
3243
|
+
// restore the session that sent that message before processing.
|
|
3244
|
+
const parentId = event?.message?.parent_id;
|
|
3245
|
+
if (parentId) {
|
|
3246
|
+
const st = loadState();
|
|
3247
|
+
const mapped = st.msg_sessions && st.msg_sessions[parentId];
|
|
3248
|
+
if (mapped) {
|
|
3249
|
+
st.sessions[chatId] = { id: mapped.id, cwd: mapped.cwd, started: true };
|
|
3250
|
+
saveState(st);
|
|
3251
|
+
log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
await handleCommand(bot, chatId, text, config, executeTaskByName, senderId);
|
|
2608
3255
|
}
|
|
2609
3256
|
});
|
|
2610
3257
|
|
|
@@ -2669,7 +3316,7 @@ async function main() {
|
|
|
2669
3316
|
}
|
|
2670
3317
|
|
|
2671
3318
|
// Config validation: warn on unknown/suspect fields
|
|
2672
|
-
const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget'];
|
|
3319
|
+
const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects'];
|
|
2673
3320
|
const KNOWN_DAEMON = ['model', 'log_max_size', 'heartbeat_check_interval', 'session_allowed_tools', 'dangerously_skip_permissions', 'cooldown_seconds'];
|
|
2674
3321
|
const VALID_MODELS = ['sonnet', 'opus', 'haiku'];
|
|
2675
3322
|
for (const key of Object.keys(config)) {
|
|
@@ -2702,8 +3349,14 @@ async function main() {
|
|
|
2702
3349
|
|
|
2703
3350
|
// Task executor lookup (always reads fresh config)
|
|
2704
3351
|
function executeTaskByName(name) {
|
|
2705
|
-
const
|
|
2706
|
-
|
|
3352
|
+
const legacy = (config.heartbeat && config.heartbeat.tasks) || [];
|
|
3353
|
+
let task = legacy.find(t => t.name === name);
|
|
3354
|
+
if (!task) {
|
|
3355
|
+
for (const [key, proj] of Object.entries(config.projects || {})) {
|
|
3356
|
+
const found = (proj.heartbeat_tasks || []).find(t => t.name === name);
|
|
3357
|
+
if (found) { task = { ...found, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } }; break; }
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
2707
3360
|
if (!task) return { success: false, error: `Task "${name}" not found` };
|
|
2708
3361
|
return executeTask(task, config);
|
|
2709
3362
|
}
|
|
@@ -2713,7 +3366,8 @@ async function main() {
|
|
|
2713
3366
|
let feishuBridge = null;
|
|
2714
3367
|
|
|
2715
3368
|
// Notification function (sends to all enabled channels)
|
|
2716
|
-
|
|
3369
|
+
// project: optional { key, name, color, icon } — triggers colored card on Feishu
|
|
3370
|
+
const notifyFn = async (message, project = null) => {
|
|
2717
3371
|
if (telegramBridge && telegramBridge.bot) {
|
|
2718
3372
|
const tgIds = (config.telegram && config.telegram.allowed_chat_ids) || [];
|
|
2719
3373
|
for (const chatId of tgIds) {
|
|
@@ -2725,7 +3379,17 @@ async function main() {
|
|
|
2725
3379
|
if (feishuBridge && feishuBridge.bot) {
|
|
2726
3380
|
const fsIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
|
|
2727
3381
|
for (const chatId of fsIds) {
|
|
2728
|
-
try {
|
|
3382
|
+
try {
|
|
3383
|
+
if (project && feishuBridge.bot.sendCard) {
|
|
3384
|
+
await feishuBridge.bot.sendCard(chatId, {
|
|
3385
|
+
title: `${project.icon} ${project.name}`,
|
|
3386
|
+
body: message,
|
|
3387
|
+
color: project.color,
|
|
3388
|
+
});
|
|
3389
|
+
} else {
|
|
3390
|
+
await feishuBridge.bot.sendMessage(chatId, message);
|
|
3391
|
+
}
|
|
3392
|
+
} catch (e) {
|
|
2729
3393
|
log('ERROR', `Feishu notify failed ${chatId}: ${e.message}`);
|
|
2730
3394
|
}
|
|
2731
3395
|
}
|
|
@@ -2743,8 +3407,11 @@ async function main() {
|
|
|
2743
3407
|
refreshLogMaxSize(config);
|
|
2744
3408
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
2745
3409
|
heartbeatTimer = startHeartbeat(config, notifyFn);
|
|
2746
|
-
|
|
2747
|
-
|
|
3410
|
+
const legacyCount = (config.heartbeat && config.heartbeat.tasks || []).length;
|
|
3411
|
+
const projectCount = Object.values(config.projects || {}).reduce((n, p) => n + (p.heartbeat_tasks || []).length, 0);
|
|
3412
|
+
const totalCount = legacyCount + projectCount;
|
|
3413
|
+
log('INFO', `Config reloaded: ${totalCount} tasks (${projectCount} in projects)`);
|
|
3414
|
+
return { success: true, tasks: totalCount };
|
|
2748
3415
|
}
|
|
2749
3416
|
// Expose reloadConfig to handleCommand via closure
|
|
2750
3417
|
global._metameReload = reloadConfig;
|