metame-cli 1.2.2 → 1.3.0

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.
@@ -0,0 +1,1082 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * daemon.js — MetaMe Heartbeat Daemon
5
+ *
6
+ * Single-process daemon that runs:
7
+ * - Scheduled heartbeat tasks (via claude -p)
8
+ * - Telegram bot bridge (optional, long-polling)
9
+ * - Budget tracking (daily token counter)
10
+ *
11
+ * Usage: node daemon.js (launched by `metame daemon start`)
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const { execSync, spawn } = require('child_process');
20
+
21
+ const HOME = os.homedir();
22
+ const METAME_DIR = path.join(HOME, '.metame');
23
+ const CONFIG_FILE = path.join(METAME_DIR, 'daemon.yaml');
24
+ const STATE_FILE = path.join(METAME_DIR, 'daemon_state.json');
25
+ const PID_FILE = path.join(METAME_DIR, 'daemon.pid');
26
+ const LOG_FILE = path.join(METAME_DIR, 'daemon.log');
27
+ const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
28
+
29
+ let yaml;
30
+ try {
31
+ yaml = require('js-yaml');
32
+ } catch {
33
+ // When deployed to ~/.metame/, resolve js-yaml via METAME_ROOT env
34
+ const metameRoot = process.env.METAME_ROOT;
35
+ if (metameRoot) {
36
+ try { yaml = require(path.join(metameRoot, 'node_modules', 'js-yaml')); } catch { /* fallthrough */ }
37
+ }
38
+ if (!yaml) {
39
+ // Try common paths
40
+ const candidates = [
41
+ path.resolve(__dirname, '..', 'node_modules', 'js-yaml'),
42
+ path.resolve(__dirname, 'node_modules', 'js-yaml'),
43
+ ];
44
+ for (const p of candidates) {
45
+ try { yaml = require(p); break; } catch { /* next */ }
46
+ }
47
+ }
48
+ if (!yaml) {
49
+ console.error('Cannot find js-yaml module. Ensure metame-cli is installed.');
50
+ process.exit(1);
51
+ }
52
+ }
53
+
54
+ // ---------------------------------------------------------
55
+ // LOGGING
56
+ // ---------------------------------------------------------
57
+ function log(level, msg) {
58
+ const ts = new Date().toISOString();
59
+ const line = `[${ts}] [${level}] ${msg}\n`;
60
+ try {
61
+ // Rotate if over max size
62
+ if (fs.existsSync(LOG_FILE)) {
63
+ const stat = fs.statSync(LOG_FILE);
64
+ const config = loadConfig();
65
+ const maxSize = (config.daemon && config.daemon.log_max_size) || 1048576;
66
+ if (stat.size > maxSize) {
67
+ const bakFile = LOG_FILE + '.bak';
68
+ if (fs.existsSync(bakFile)) fs.unlinkSync(bakFile);
69
+ fs.renameSync(LOG_FILE, bakFile);
70
+ }
71
+ }
72
+ fs.appendFileSync(LOG_FILE, line, 'utf8');
73
+ } catch {
74
+ // Last resort
75
+ process.stderr.write(line);
76
+ }
77
+ }
78
+
79
+ // ---------------------------------------------------------
80
+ // CONFIG & STATE
81
+ // ---------------------------------------------------------
82
+ function loadConfig() {
83
+ try {
84
+ return yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
85
+ } catch {
86
+ return {};
87
+ }
88
+ }
89
+
90
+ function loadState() {
91
+ try {
92
+ const s = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
93
+ if (!s.sessions) s.sessions = {};
94
+ return s;
95
+ } catch {
96
+ return {
97
+ pid: null,
98
+ budget: { date: null, tokens_used: 0 },
99
+ tasks: {},
100
+ sessions: {},
101
+ started_at: null,
102
+ };
103
+ }
104
+ }
105
+
106
+ function saveState(state) {
107
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
108
+ }
109
+
110
+ // ---------------------------------------------------------
111
+ // PROFILE PREAMBLE (lightweight — only core fields for daemon)
112
+ // ---------------------------------------------------------
113
+ const CORE_PROFILE_KEYS = ['identity', 'preferences', 'communication', 'context', 'cognition'];
114
+
115
+ function buildProfilePreamble() {
116
+ try {
117
+ if (!fs.existsSync(BRAIN_FILE)) return '';
118
+ const full = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8'));
119
+ if (!full || typeof full !== 'object') return '';
120
+
121
+ // Extract only core fields — skip evolution.log, growth.patterns, etc.
122
+ const slim = {};
123
+ for (const key of CORE_PROFILE_KEYS) {
124
+ if (full[key] !== undefined) slim[key] = full[key];
125
+ }
126
+
127
+ const slimYaml = yaml.dump(slim, { lineWidth: -1 });
128
+ return `You are an AI assistant. User profile:\n\`\`\`yaml\n${slimYaml}\`\`\`\nAdapt style to match preferences.\n\n`;
129
+ } catch {
130
+ return '';
131
+ }
132
+ }
133
+
134
+ // ---------------------------------------------------------
135
+ // BUDGET TRACKING
136
+ // ---------------------------------------------------------
137
+ function checkBudget(config, state) {
138
+ const today = new Date().toISOString().slice(0, 10);
139
+ if (state.budget.date !== today) {
140
+ state.budget.date = today;
141
+ state.budget.tokens_used = 0;
142
+ saveState(state);
143
+ }
144
+ const limit = (config.budget && config.budget.daily_limit) || 50000;
145
+ return state.budget.tokens_used < limit;
146
+ }
147
+
148
+ function recordTokens(state, tokens) {
149
+ const today = new Date().toISOString().slice(0, 10);
150
+ if (state.budget.date !== today) {
151
+ state.budget.date = today;
152
+ state.budget.tokens_used = 0;
153
+ }
154
+ state.budget.tokens_used += tokens;
155
+ saveState(state);
156
+ }
157
+
158
+ function getBudgetWarning(config, state) {
159
+ const limit = (config.budget && config.budget.daily_limit) || 50000;
160
+ const threshold = (config.budget && config.budget.warning_threshold) || 0.8;
161
+ const ratio = state.budget.tokens_used / limit;
162
+ if (ratio >= 1) return 'exceeded';
163
+ if (ratio >= threshold) return 'warning';
164
+ return 'ok';
165
+ }
166
+
167
+ // ---------------------------------------------------------
168
+ // TASK EXECUTION (claude -p)
169
+ // ---------------------------------------------------------
170
+ function checkPrecondition(task) {
171
+ if (!task.precondition) return { pass: true, context: '' };
172
+
173
+ try {
174
+ const output = execSync(task.precondition, {
175
+ encoding: 'utf8',
176
+ timeout: 15000,
177
+ maxBuffer: 64 * 1024,
178
+ }).trim();
179
+
180
+ if (!output) {
181
+ log('INFO', `Precondition empty for ${task.name}, skipping (zero tokens)`);
182
+ return { pass: false, context: '' };
183
+ }
184
+
185
+ log('INFO', `Precondition passed for ${task.name} (${output.split('\n').length} lines)`);
186
+ return { pass: true, context: output };
187
+ } catch (e) {
188
+ // Non-zero exit = precondition failed
189
+ log('INFO', `Precondition failed for ${task.name}: ${e.message.slice(0, 100)}`);
190
+ return { pass: false, context: '' };
191
+ }
192
+ }
193
+
194
+ function executeTask(task, config) {
195
+ const state = loadState();
196
+
197
+ if (!checkBudget(config, state)) {
198
+ log('WARN', `Budget exceeded, skipping task: ${task.name}`);
199
+ return { success: false, error: 'budget_exceeded', output: '' };
200
+ }
201
+
202
+ // Precondition gate: run a cheap shell check before burning tokens
203
+ const precheck = checkPrecondition(task);
204
+ if (!precheck.pass) {
205
+ state.tasks[task.name] = {
206
+ last_run: new Date().toISOString(),
207
+ status: 'skipped',
208
+ output_preview: 'Precondition not met — no activity',
209
+ };
210
+ saveState(state);
211
+ return { success: true, output: '(skipped — no activity)', skipped: true };
212
+ }
213
+
214
+ // Script tasks: run a local script directly (e.g. distill.js), no claude -p
215
+ if (task.type === 'script') {
216
+ log('INFO', `Executing script task: ${task.name} → ${task.command}`);
217
+ try {
218
+ const output = execSync(task.command, {
219
+ encoding: 'utf8',
220
+ timeout: 120000,
221
+ maxBuffer: 1024 * 1024,
222
+ env: { ...process.env, METAME_ROOT: process.env.METAME_ROOT || '' },
223
+ }).trim();
224
+
225
+ state.tasks[task.name] = {
226
+ last_run: new Date().toISOString(),
227
+ status: 'success',
228
+ output_preview: output.slice(0, 200),
229
+ };
230
+ saveState(state);
231
+ log('INFO', `Script task ${task.name} completed`);
232
+ return { success: true, output, tokens: 0 };
233
+ } catch (e) {
234
+ log('ERROR', `Script task ${task.name} failed: ${e.message}`);
235
+ state.tasks[task.name] = {
236
+ last_run: new Date().toISOString(),
237
+ status: 'error',
238
+ error: e.message.slice(0, 200),
239
+ };
240
+ saveState(state);
241
+ return { success: false, error: e.message, output: '' };
242
+ }
243
+ }
244
+
245
+ const preamble = buildProfilePreamble();
246
+ const model = task.model || 'haiku';
247
+ // If precondition returned context data, append it to the prompt
248
+ let taskPrompt = task.prompt;
249
+ if (precheck.context) {
250
+ taskPrompt += `\n\n以下是相关原始数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
251
+ }
252
+ const fullPrompt = preamble + taskPrompt;
253
+
254
+ log('INFO', `Executing task: ${task.name} (model: ${model})`);
255
+
256
+ try {
257
+ const output = execSync(
258
+ `claude -p --model ${model}`,
259
+ {
260
+ input: fullPrompt,
261
+ encoding: 'utf8',
262
+ timeout: 120000, // 2 min timeout
263
+ maxBuffer: 1024 * 1024,
264
+ }
265
+ ).trim();
266
+
267
+ // Rough token estimate: ~4 chars per token for input + output
268
+ const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
269
+ recordTokens(state, estimatedTokens);
270
+
271
+ // Record task result
272
+ state.tasks[task.name] = {
273
+ last_run: new Date().toISOString(),
274
+ status: 'success',
275
+ output_preview: output.slice(0, 200),
276
+ };
277
+ saveState(state);
278
+
279
+ log('INFO', `Task ${task.name} completed (est. ${estimatedTokens} tokens)`);
280
+ return { success: true, output, tokens: estimatedTokens };
281
+ } catch (e) {
282
+ log('ERROR', `Task ${task.name} failed: ${e.message}`);
283
+ state.tasks[task.name] = {
284
+ last_run: new Date().toISOString(),
285
+ status: 'error',
286
+ error: e.message.slice(0, 200),
287
+ };
288
+ saveState(state);
289
+ return { success: false, error: e.message, output: '' };
290
+ }
291
+ }
292
+
293
+ // ---------------------------------------------------------
294
+ // INTERVAL PARSING
295
+ // ---------------------------------------------------------
296
+ function parseInterval(str) {
297
+ const match = String(str).match(/^(\d+)(s|m|h|d)$/);
298
+ if (!match) return 3600; // default 1h
299
+ const val = parseInt(match[1], 10);
300
+ const unit = match[2];
301
+ switch (unit) {
302
+ case 's': return val;
303
+ case 'm': return val * 60;
304
+ case 'h': return val * 3600;
305
+ case 'd': return val * 86400;
306
+ default: return 3600;
307
+ }
308
+ }
309
+
310
+ // ---------------------------------------------------------
311
+ // HEARTBEAT SCHEDULER
312
+ // ---------------------------------------------------------
313
+ function startHeartbeat(config, notifyFn) {
314
+ const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
315
+ if (tasks.length === 0) {
316
+ log('INFO', 'No heartbeat tasks configured');
317
+ return;
318
+ }
319
+
320
+ const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
321
+ log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${tasks.length} tasks)`);
322
+
323
+ // Track next run times
324
+ const nextRun = {};
325
+ const now = Date.now();
326
+ const state = loadState();
327
+
328
+ for (const task of tasks) {
329
+ const intervalSec = parseInterval(task.interval);
330
+ const lastRun = state.tasks[task.name] && state.tasks[task.name].last_run;
331
+ if (lastRun) {
332
+ const elapsed = (now - new Date(lastRun).getTime()) / 1000;
333
+ nextRun[task.name] = now + Math.max(0, (intervalSec - elapsed)) * 1000;
334
+ } else {
335
+ // First run: execute after one check interval
336
+ nextRun[task.name] = now + checkIntervalSec * 1000;
337
+ }
338
+ }
339
+
340
+ const timer = setInterval(() => {
341
+ const currentTime = Date.now();
342
+ for (const task of tasks) {
343
+ if (currentTime >= (nextRun[task.name] || 0)) {
344
+ const result = executeTask(task, config);
345
+ const intervalSec = parseInterval(task.interval);
346
+ nextRun[task.name] = currentTime + intervalSec * 1000;
347
+
348
+ if (task.notify && notifyFn && !result.skipped) {
349
+ if (result.success) {
350
+ notifyFn(`✅ *${task.name}* completed\n\n${result.output}`);
351
+ } else {
352
+ notifyFn(`❌ *${task.name}* failed: ${result.error}`);
353
+ }
354
+ }
355
+ }
356
+ }
357
+ }, checkIntervalSec * 1000);
358
+
359
+ return timer;
360
+ }
361
+
362
+ // ---------------------------------------------------------
363
+ // TELEGRAM BOT BRIDGE
364
+ // ---------------------------------------------------------
365
+ async function startTelegramBridge(config, executeTaskByName) {
366
+ if (!config.telegram || !config.telegram.enabled) return null;
367
+ if (!config.telegram.bot_token) {
368
+ log('WARN', 'Telegram enabled but no bot_token configured');
369
+ return null;
370
+ }
371
+
372
+ const { createBot } = require(path.join(__dirname, 'telegram-adapter.js'));
373
+ const bot = createBot(config.telegram.bot_token);
374
+ const allowedIds = config.telegram.allowed_chat_ids || [];
375
+
376
+ // Verify bot
377
+ try {
378
+ const me = await bot.getMe();
379
+ log('INFO', `Telegram bot connected: @${me.username}`);
380
+ } catch (e) {
381
+ log('ERROR', `Telegram bot auth failed: ${e.message}`);
382
+ return null;
383
+ }
384
+
385
+ let offset = 0;
386
+ let running = true;
387
+
388
+ const pollLoop = async () => {
389
+ while (running) {
390
+ try {
391
+ const updates = await bot.getUpdates(offset, 30);
392
+ for (const update of updates) {
393
+ offset = update.update_id + 1;
394
+
395
+ // Handle inline keyboard button presses
396
+ if (update.callback_query) {
397
+ const cb = update.callback_query;
398
+ const chatId = cb.message && cb.message.chat.id;
399
+ bot.answerCallback(cb.id).catch(() => {});
400
+ if (chatId && cb.data) {
401
+ if (allowedIds.length > 0 && !allowedIds.includes(chatId)) continue;
402
+ // callback_data is a command string, e.g. "/resume <session-id>"
403
+ await handleCommand(bot, chatId, cb.data, config, executeTaskByName);
404
+ }
405
+ continue;
406
+ }
407
+
408
+ if (!update.message) continue;
409
+
410
+ const msg = update.message;
411
+ const chatId = msg.chat.id;
412
+
413
+ // Security: check whitelist
414
+ if (allowedIds.length > 0 && !allowedIds.includes(chatId)) {
415
+ log('WARN', `Rejected message from unauthorized chat: ${chatId}`);
416
+ continue;
417
+ }
418
+
419
+ // Voice/audio without text → hint user
420
+ if ((msg.voice || msg.audio) && !msg.text) {
421
+ await bot.sendMessage(chatId, '🎤 Use Telegram voice-to-text (long press → Transcribe), then send as text.');
422
+ continue;
423
+ }
424
+
425
+ // Text message (commands or natural language)
426
+ if (msg.text) {
427
+ await handleCommand(bot, chatId, msg.text.trim(), config, executeTaskByName);
428
+ }
429
+ }
430
+ } catch (e) {
431
+ log('ERROR', `Telegram poll error: ${e.message}`);
432
+ // Wait before retry
433
+ await sleep(5000);
434
+ }
435
+ }
436
+ };
437
+
438
+ pollLoop();
439
+
440
+ return {
441
+ stop() { running = false; },
442
+ bot,
443
+ };
444
+ }
445
+
446
+ // Rate limiter for /ask and /run — prevents rapid-fire Claude calls
447
+ const _lastClaudeCall = {};
448
+ const CLAUDE_COOLDOWN_MS = 10000; // 10s between Claude calls per chat
449
+
450
+ function checkCooldown(chatId) {
451
+ const now = Date.now();
452
+ const last = _lastClaudeCall[chatId] || 0;
453
+ if (now - last < CLAUDE_COOLDOWN_MS) {
454
+ const wait = Math.ceil((CLAUDE_COOLDOWN_MS - (now - last)) / 1000);
455
+ return { ok: false, wait };
456
+ }
457
+ _lastClaudeCall[chatId] = now;
458
+ return { ok: true };
459
+ }
460
+
461
+ /**
462
+ * Send directory picker: recent projects + Browse button
463
+ * @param {string} mode - 'new' or 'cd' (determines callback command)
464
+ */
465
+ async function sendDirPicker(bot, chatId, mode, title) {
466
+ const dirs = listProjectDirs();
467
+ const cmd = mode === 'new' ? '/new' : '/cd';
468
+ if (bot.sendButtons) {
469
+ const buttons = dirs.map(d => [{ text: d.label, callback_data: `${cmd} ${d.path}` }]);
470
+ buttons.push([{ text: 'Browse...', callback_data: `/browse ${mode} ${HOME}` }]);
471
+ await bot.sendButtons(chatId, title, buttons);
472
+ } else {
473
+ let msg = `${title}\n`;
474
+ dirs.forEach((d, i) => { msg += `${i + 1}. ${d.label}\n ${cmd} ${d.path}\n`; });
475
+ msg += `\nOr type: ${cmd} /full/path`;
476
+ await bot.sendMessage(chatId, msg);
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Send directory browser: list subdirs of a path with .. parent nav
482
+ */
483
+ async function sendBrowse(bot, chatId, mode, dirPath) {
484
+ const cmd = mode === 'new' ? '/new' : '/cd';
485
+ try {
486
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
487
+ const subdirs = entries
488
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
489
+ .map(e => e.name)
490
+ .sort()
491
+ .slice(0, 8); // max 8 subdirs per screen
492
+
493
+ if (bot.sendButtons) {
494
+ const buttons = [];
495
+ // Select this directory
496
+ buttons.push([{ text: `>> Use this dir`, callback_data: `${cmd} ${dirPath}` }]);
497
+ // Subdirectories
498
+ for (const name of subdirs) {
499
+ const full = path.join(dirPath, name);
500
+ buttons.push([{ text: `${name}/`, callback_data: `/browse ${mode} ${full}` }]);
501
+ }
502
+ // Parent
503
+ const parent = path.dirname(dirPath);
504
+ if (parent !== dirPath) {
505
+ buttons.push([{ text: '.. back', callback_data: `/browse ${mode} ${parent}` }]);
506
+ }
507
+ await bot.sendButtons(chatId, dirPath, buttons);
508
+ } else {
509
+ let msg = `${dirPath}\n\n`;
510
+ subdirs.forEach((name, i) => {
511
+ msg += `${i + 1}. ${name}/\n /browse ${mode} ${path.join(dirPath, name)}\n`;
512
+ });
513
+ msg += `\nSelect: ${cmd} ${dirPath}\nBack: /browse ${mode} ${path.dirname(dirPath)}`;
514
+ await bot.sendMessage(chatId, msg);
515
+ }
516
+ } catch (e) {
517
+ await bot.sendMessage(chatId, `Cannot read: ${dirPath}`);
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Unified command handler — shared by Telegram & Feishu
523
+ */
524
+ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
525
+ const state = loadState();
526
+
527
+ // --- Browse handler (directory navigation) ---
528
+ if (text.startsWith('/browse ')) {
529
+ const parts = text.slice(8).trim().split(' ');
530
+ const mode = parts[0]; // 'new' or 'cd'
531
+ const dirPath = parts.slice(1).join(' ');
532
+ if (mode && dirPath && fs.existsSync(dirPath)) {
533
+ await sendBrowse(bot, chatId, mode, dirPath);
534
+ } else {
535
+ await bot.sendMessage(chatId, 'Invalid browse path.');
536
+ }
537
+ return;
538
+ }
539
+
540
+ // --- Session commands ---
541
+
542
+ if (text === '/new' || text.startsWith('/new ')) {
543
+ const arg = text.slice(4).trim();
544
+ if (!arg) {
545
+ await sendDirPicker(bot, chatId, 'new', 'Pick a workdir:');
546
+ return;
547
+ }
548
+ if (!fs.existsSync(arg)) {
549
+ await bot.sendMessage(chatId, `Path not found: ${arg}`);
550
+ return;
551
+ }
552
+ const session = createSession(chatId, arg);
553
+ await bot.sendMessage(chatId, `New session.\nWorkdir: ${session.cwd}`);
554
+ return;
555
+ }
556
+
557
+ if (text === '/continue') {
558
+ // Continue the most recent conversation in current workdir
559
+ const session = getSession(chatId);
560
+ const cwd = session ? session.cwd : HOME;
561
+ const state2 = loadState();
562
+ state2.sessions[chatId] = {
563
+ id: '__continue__',
564
+ cwd,
565
+ created: new Date().toISOString(),
566
+ started: true,
567
+ };
568
+ saveState(state2);
569
+ await bot.sendMessage(chatId, `Resuming last conversation in ${cwd}`);
570
+ return;
571
+ }
572
+
573
+ if (text === '/resume' || text.startsWith('/resume ')) {
574
+ const arg = text.slice(7).trim();
575
+
576
+ // Get current workdir to scope session list
577
+ const curSession = getSession(chatId);
578
+ const curCwd = curSession ? curSession.cwd : null;
579
+ const recentSessions = listRecentSessions(5, curCwd);
580
+
581
+ if (!arg) {
582
+ if (recentSessions.length === 0) {
583
+ await bot.sendMessage(chatId, `No sessions found${curCwd ? ' in ' + path.basename(curCwd) : ''}. Try /new first.`);
584
+ return;
585
+ }
586
+ const title = curCwd ? `Sessions in ${path.basename(curCwd)}:` : 'Recent sessions:';
587
+ if (bot.sendButtons) {
588
+ const buttons = recentSessions.map(s => {
589
+ return [{ text: sessionLabel(s), callback_data: `/resume ${s.sessionId}` }];
590
+ });
591
+ await bot.sendButtons(chatId, title, buttons);
592
+ } else {
593
+ let msg = `${title}\n`;
594
+ recentSessions.forEach((s, i) => {
595
+ msg += `${i + 1}. ${sessionLabel(s)}\n /resume ${s.sessionId.slice(0, 8)}\n`;
596
+ });
597
+ await bot.sendMessage(chatId, msg);
598
+ }
599
+ return;
600
+ }
601
+
602
+ // Argument given → match by prefix or full ID
603
+ const match = recentSessions.length > 0
604
+ ? recentSessions.find(s => s.sessionId.startsWith(arg))
605
+ : null;
606
+ const fullMatch = match || listRecentSessions(50).find(s => s.sessionId.startsWith(arg));
607
+ const sessionId = fullMatch ? fullMatch.sessionId : arg;
608
+ const cwd = (fullMatch && fullMatch.projectPath) || (getSession(chatId) && getSession(chatId).cwd) || HOME;
609
+
610
+ const state2 = loadState();
611
+ state2.sessions[chatId] = {
612
+ id: sessionId,
613
+ cwd,
614
+ created: new Date().toISOString(),
615
+ started: true,
616
+ };
617
+ saveState(state2);
618
+ const label = fullMatch ? (fullMatch.summary || fullMatch.firstPrompt || '').slice(0, 40) : sessionId.slice(0, 8);
619
+ await bot.sendMessage(chatId, `Resumed: ${label}\nWorkdir: ${cwd}`);
620
+ return;
621
+ }
622
+
623
+ if (text === '/cd' || text.startsWith('/cd ')) {
624
+ const newCwd = text.slice(3).trim();
625
+ if (!newCwd) {
626
+ await sendDirPicker(bot, chatId, 'cd', 'Switch workdir:');
627
+ return;
628
+ }
629
+ if (!fs.existsSync(newCwd)) {
630
+ await bot.sendMessage(chatId, `Path not found: ${newCwd}`);
631
+ return;
632
+ }
633
+ const state2 = loadState();
634
+ if (!state2.sessions[chatId]) {
635
+ createSession(chatId, newCwd);
636
+ } else {
637
+ state2.sessions[chatId].cwd = newCwd;
638
+ saveState(state2);
639
+ }
640
+ await bot.sendMessage(chatId, `Workdir: ${newCwd}`);
641
+ return;
642
+ }
643
+
644
+ if (text === '/session') {
645
+ const session = getSession(chatId);
646
+ if (!session) {
647
+ await bot.sendMessage(chatId, 'No active session. Send any message to start one.');
648
+ } else {
649
+ await bot.sendMessage(chatId, `Session: ${session.id.slice(0, 8)}...\nWorkdir: ${session.cwd}\nStarted: ${session.created}`);
650
+ }
651
+ return;
652
+ }
653
+
654
+ // --- Daemon commands ---
655
+
656
+ if (text === '/status') {
657
+ const session = getSession(chatId);
658
+ let msg = `MetaMe Daemon\nStatus: Running\nStarted: ${state.started_at || 'unknown'}\n`;
659
+ msg += `Budget: ${state.budget.tokens_used}/${(config.budget && config.budget.daily_limit) || 50000} tokens`;
660
+ if (session) msg += `\nSession: ${session.id.slice(0, 8)}... (${session.cwd})`;
661
+ try {
662
+ if (fs.existsSync(BRAIN_FILE)) {
663
+ const doc = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
664
+ if (doc.identity) msg += `\nProfile: ${doc.identity.nickname || 'unknown'}`;
665
+ if (doc.context && doc.context.focus) msg += `\nFocus: ${doc.context.focus}`;
666
+ }
667
+ } catch { /* ignore */ }
668
+ await bot.sendMessage(chatId, msg);
669
+ return;
670
+ }
671
+
672
+ if (text === '/tasks') {
673
+ const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
674
+ if (tasks.length === 0) { await bot.sendMessage(chatId, 'No heartbeat tasks configured.'); return; }
675
+ let msg = 'Heartbeat Tasks:\n';
676
+ for (const t of tasks) {
677
+ const ts = state.tasks[t.name] || {};
678
+ msg += `- ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
679
+ }
680
+ await bot.sendMessage(chatId, msg);
681
+ return;
682
+ }
683
+
684
+ if (text.startsWith('/run ')) {
685
+ const cd = checkCooldown(chatId);
686
+ if (!cd.ok) { await bot.sendMessage(chatId, `Cooldown: ${cd.wait}s`); return; }
687
+ const taskName = text.slice(5).trim();
688
+ await bot.sendMessage(chatId, `Running: ${taskName}...`);
689
+ const result = executeTaskByName(taskName);
690
+ await bot.sendMessage(chatId, result.success ? `${taskName}\n\n${result.output}` : `Error: ${result.error}`);
691
+ return;
692
+ }
693
+
694
+ if (text === '/budget') {
695
+ const limit = (config.budget && config.budget.daily_limit) || 50000;
696
+ const used = state.budget.tokens_used;
697
+ await bot.sendMessage(chatId, `Budget: ${used}/${limit} tokens (${((used/limit)*100).toFixed(1)}%)`);
698
+ return;
699
+ }
700
+
701
+ if (text === '/quiet') {
702
+ try {
703
+ const doc = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
704
+ if (!doc.growth) doc.growth = {};
705
+ doc.growth.quiet_until = new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString();
706
+ fs.writeFileSync(BRAIN_FILE, yaml.dump(doc, { lineWidth: -1 }), 'utf8');
707
+ await bot.sendMessage(chatId, 'Mirror & reflections silenced for 48h.');
708
+ } catch (e) { await bot.sendMessage(chatId, `Error: ${e.message}`); }
709
+ return;
710
+ }
711
+
712
+ if (text.startsWith('/')) {
713
+ await bot.sendMessage(chatId, [
714
+ 'Commands:',
715
+ '/new [path] — new session',
716
+ '/continue — resume last computer session',
717
+ '/resume <id> — resume specific session',
718
+ '/cd <path> — change workdir',
719
+ '/session — current session info',
720
+ '/status /tasks /run /budget /quiet',
721
+ '',
722
+ 'Or just type naturally.',
723
+ ].join('\n'));
724
+ return;
725
+ }
726
+
727
+ // --- Natural language → Claude Code session ---
728
+ const cd = checkCooldown(chatId);
729
+ if (!cd.ok) { await bot.sendMessage(chatId, `${cd.wait}s`); return; }
730
+ if (!checkBudget(loadConfig(), loadState())) {
731
+ await bot.sendMessage(chatId, 'Daily token budget exceeded.');
732
+ return;
733
+ }
734
+ await askClaude(bot, chatId, text);
735
+ }
736
+
737
+ // ---------------------------------------------------------
738
+ // SESSION MANAGEMENT (persistent Claude Code conversations)
739
+ // ---------------------------------------------------------
740
+ const crypto = require('crypto');
741
+ const CLAUDE_PROJECTS_DIR = path.join(HOME, '.claude', 'projects');
742
+
743
+ /**
744
+ * Scan all project session indexes, return most recent N sessions.
745
+ * Filters out trivial sessions (no summary, < 3 messages).
746
+ */
747
+ /**
748
+ * @param {number} limit
749
+ * @param {string} [cwd] - if provided, only return sessions whose projectPath matches
750
+ */
751
+ function listRecentSessions(limit, cwd) {
752
+ try {
753
+ if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return [];
754
+ const projects = fs.readdirSync(CLAUDE_PROJECTS_DIR);
755
+ let all = [];
756
+ for (const proj of projects) {
757
+ const indexFile = path.join(CLAUDE_PROJECTS_DIR, proj, 'sessions-index.json');
758
+ try {
759
+ if (!fs.existsSync(indexFile)) continue;
760
+ const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
761
+ if (data.entries) all = all.concat(data.entries);
762
+ } catch { /* skip */ }
763
+ }
764
+ // Filter: must have summary and at least 3 messages
765
+ all = all.filter(s => s.summary && s.messageCount >= 3);
766
+ // Filter by cwd if provided
767
+ if (cwd) {
768
+ const matched = all.filter(s => s.projectPath === cwd);
769
+ if (matched.length > 0) all = matched;
770
+ // else fallback to all projects
771
+ }
772
+ // Sort by modified desc, take top N
773
+ all.sort((a, b) => new Date(b.modified) - new Date(a.modified));
774
+ return all.slice(0, limit || 10);
775
+ } catch {
776
+ return [];
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Format a session entry into a short, readable label for buttons
782
+ */
783
+ function sessionLabel(s) {
784
+ const proj = s.projectPath ? path.basename(s.projectPath) : '';
785
+ const date = new Date(s.modified).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
786
+ const title = (s.summary || '').slice(0, 28);
787
+ return `${date} ${proj ? proj + ': ' : ''}${title}`;
788
+ }
789
+
790
+ /**
791
+ * Extract unique project directories from session history, sorted by most recent activity.
792
+ * Returns [{path, label}] for button display.
793
+ */
794
+ function listProjectDirs() {
795
+ try {
796
+ const all = listRecentSessions(50);
797
+ const seen = new Map(); // path → latest modified
798
+ for (const s of all) {
799
+ if (!s.projectPath || !fs.existsSync(s.projectPath)) continue;
800
+ const prev = seen.get(s.projectPath);
801
+ if (!prev || new Date(s.modified) > new Date(prev)) {
802
+ seen.set(s.projectPath, s.modified);
803
+ }
804
+ }
805
+ // Sort by most recent, take top 6
806
+ return [...seen.entries()]
807
+ .sort((a, b) => new Date(b[1]) - new Date(a[1]))
808
+ .slice(0, 6)
809
+ .map(([p]) => ({ path: p, label: path.basename(p) }));
810
+ } catch {
811
+ return [];
812
+ }
813
+ }
814
+
815
+ function getSession(chatId) {
816
+ const state = loadState();
817
+ return state.sessions[chatId] || null;
818
+ }
819
+
820
+ function createSession(chatId, cwd) {
821
+ const state = loadState();
822
+ const sessionId = crypto.randomUUID();
823
+ state.sessions[chatId] = {
824
+ id: sessionId,
825
+ cwd: cwd || HOME,
826
+ created: new Date().toISOString(),
827
+ started: false, // true after first message sent
828
+ };
829
+ saveState(state);
830
+ log('INFO', `New session for ${chatId}: ${sessionId} (cwd: ${state.sessions[chatId].cwd})`);
831
+ return state.sessions[chatId];
832
+ }
833
+
834
+ function markSessionStarted(chatId) {
835
+ const state = loadState();
836
+ if (state.sessions[chatId]) {
837
+ state.sessions[chatId].started = true;
838
+ saveState(state);
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Shared ask logic — full Claude Code session (stateful, with tools)
844
+ */
845
+ async function askClaude(bot, chatId, prompt) {
846
+ log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
847
+ try {
848
+ await bot.sendMessage(chatId, '🤔');
849
+ } catch (e) {
850
+ log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`);
851
+ }
852
+ // Send typing immediately (await to ensure it registers), then refresh every 4s
853
+ await bot.sendTyping(chatId).catch(() => {});
854
+ const typingTimer = setInterval(() => {
855
+ bot.sendTyping(chatId).catch(() => {});
856
+ }, 4000);
857
+
858
+ let session = getSession(chatId);
859
+ if (!session) {
860
+ session = createSession(chatId);
861
+ }
862
+
863
+ // Build claude command
864
+ const args = ['-p'];
865
+ if (session.id === '__continue__') {
866
+ // /continue — resume most recent conversation in cwd
867
+ args.push('--continue');
868
+ } else if (session.started) {
869
+ args.push('--resume', session.id);
870
+ } else {
871
+ args.push('--session-id', session.id);
872
+ }
873
+
874
+ try {
875
+ const output = execSync(`claude ${args.join(' ')}`, {
876
+ input: prompt,
877
+ encoding: 'utf8',
878
+ timeout: 300000, // 5 min (Claude Code may use tools)
879
+ maxBuffer: 5 * 1024 * 1024,
880
+ cwd: session.cwd,
881
+ }).trim();
882
+ clearInterval(typingTimer);
883
+
884
+ // Mark session as started after first successful call
885
+ if (!session.started) markSessionStarted(chatId);
886
+
887
+ const estimated = Math.ceil((prompt.length + output.length) / 4);
888
+ recordTokens(loadState(), estimated);
889
+
890
+ await bot.sendMarkdown(chatId, output);
891
+ } catch (e) {
892
+ clearInterval(typingTimer);
893
+ const errMsg = e.message || '';
894
+ log('ERROR', `askClaude failed for ${chatId}: ${errMsg.slice(0, 300)}`);
895
+ // If session not found (expired/deleted), create new and retry once
896
+ if (errMsg.includes('not found') || errMsg.includes('No session')) {
897
+ log('WARN', `Session ${session.id} not found, creating new`);
898
+ session = createSession(chatId, session.cwd);
899
+ try {
900
+ const output = execSync(`claude -p --session-id ${session.id}`, {
901
+ input: prompt,
902
+ encoding: 'utf8',
903
+ timeout: 300000,
904
+ maxBuffer: 5 * 1024 * 1024,
905
+ cwd: session.cwd,
906
+ }).trim();
907
+ markSessionStarted(chatId);
908
+ await bot.sendMarkdown(chatId, output);
909
+ } catch (e2) {
910
+ log('ERROR', `askClaude retry failed: ${(e2.message || '').slice(0, 200)}`);
911
+ try { await bot.sendMessage(chatId, `Error: ${(e2.message || '').slice(0, 200)}`); } catch { /* */ }
912
+ }
913
+ } else {
914
+ try { await bot.sendMessage(chatId, `Error: ${errMsg.slice(0, 200)}`); } catch { /* */ }
915
+ }
916
+ }
917
+ }
918
+
919
+ // ---------------------------------------------------------
920
+ // FEISHU BOT BRIDGE
921
+ // ---------------------------------------------------------
922
+ async function startFeishuBridge(config, executeTaskByName) {
923
+ if (!config.feishu || !config.feishu.enabled) return null;
924
+ if (!config.feishu.app_id || !config.feishu.app_secret) {
925
+ log('WARN', 'Feishu enabled but app_id/app_secret missing');
926
+ return null;
927
+ }
928
+
929
+ const { createBot } = require(path.join(__dirname, 'feishu-adapter.js'));
930
+ const bot = createBot(config.feishu);
931
+ const allowedIds = config.feishu.allowed_chat_ids || [];
932
+
933
+ try {
934
+ const receiver = await bot.startReceiving((chatId, text, event) => {
935
+ // Security: check whitelist (empty = allow all)
936
+ if (allowedIds.length > 0 && !allowedIds.includes(chatId)) {
937
+ log('WARN', `Feishu: rejected message from ${chatId}`);
938
+ return;
939
+ }
940
+
941
+ log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
942
+ handleCommand(bot, chatId, text, config, executeTaskByName);
943
+ });
944
+
945
+ log('INFO', 'Feishu bot connected (WebSocket long connection)');
946
+ return { stop: () => receiver.stop(), bot };
947
+ } catch (e) {
948
+ log('ERROR', `Feishu bridge failed: ${e.message}`);
949
+ return null;
950
+ }
951
+ }
952
+
953
+ // ---------------------------------------------------------
954
+ // PID MANAGEMENT
955
+ // ---------------------------------------------------------
956
+ function writePid() {
957
+ fs.writeFileSync(PID_FILE, String(process.pid), 'utf8');
958
+ }
959
+
960
+ function cleanPid() {
961
+ try {
962
+ if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
963
+ } catch { /* ignore */ }
964
+ }
965
+
966
+ // ---------------------------------------------------------
967
+ // UTILITY
968
+ // ---------------------------------------------------------
969
+ function sleep(ms) {
970
+ return new Promise(resolve => setTimeout(resolve, ms));
971
+ }
972
+
973
+ // ---------------------------------------------------------
974
+ // MAIN
975
+ // ---------------------------------------------------------
976
+ async function main() {
977
+ const config = loadConfig();
978
+ if (!config || Object.keys(config).length === 0) {
979
+ console.error('No daemon config found. Run: metame daemon init');
980
+ process.exit(1);
981
+ }
982
+
983
+ writePid();
984
+ const state = loadState();
985
+ state.pid = process.pid;
986
+ state.started_at = new Date().toISOString();
987
+ saveState(state);
988
+
989
+ log('INFO', `MetaMe daemon started (PID: ${process.pid})`);
990
+
991
+ // Task executor lookup
992
+ function executeTaskByName(name) {
993
+ const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
994
+ const task = tasks.find(t => t.name === name);
995
+ if (!task) return { success: false, error: `Task "${name}" not found` };
996
+ return executeTask(task, config);
997
+ }
998
+
999
+ // Bridges
1000
+ let telegramBridge = null;
1001
+ let feishuBridge = null;
1002
+
1003
+ // Notification function (sends to all enabled channels)
1004
+ const notifyFn = async (message) => {
1005
+ if (telegramBridge && telegramBridge.bot) {
1006
+ const tgIds = (config.telegram && config.telegram.allowed_chat_ids) || [];
1007
+ for (const chatId of tgIds) {
1008
+ try { await telegramBridge.bot.sendMarkdown(chatId, message); } catch (e) {
1009
+ log('ERROR', `Telegram notify failed ${chatId}: ${e.message}`);
1010
+ }
1011
+ }
1012
+ }
1013
+ if (feishuBridge && feishuBridge.bot) {
1014
+ const fsIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
1015
+ for (const chatId of fsIds) {
1016
+ try { await feishuBridge.bot.sendMessage(chatId, message); } catch (e) {
1017
+ log('ERROR', `Feishu notify failed ${chatId}: ${e.message}`);
1018
+ }
1019
+ }
1020
+ }
1021
+ };
1022
+
1023
+ // Start heartbeat scheduler
1024
+ const heartbeatTimer = startHeartbeat(config, notifyFn);
1025
+
1026
+ // Start bridges (both can run simultaneously)
1027
+ telegramBridge = await startTelegramBridge(config, executeTaskByName);
1028
+ feishuBridge = await startFeishuBridge(config, executeTaskByName);
1029
+
1030
+ // Graceful shutdown
1031
+ const shutdown = () => {
1032
+ log('INFO', 'Daemon shutting down...');
1033
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
1034
+ if (telegramBridge) telegramBridge.stop();
1035
+ if (feishuBridge) feishuBridge.stop();
1036
+ cleanPid();
1037
+ const s = loadState();
1038
+ s.pid = null;
1039
+ saveState(s);
1040
+ process.exit(0);
1041
+ };
1042
+
1043
+ process.on('SIGTERM', shutdown);
1044
+ process.on('SIGINT', shutdown);
1045
+
1046
+ // Keep alive
1047
+ log('INFO', 'Daemon running. Send SIGTERM to stop.');
1048
+ }
1049
+
1050
+ // Single-task mode: `node daemon.js --run <taskname>`
1051
+ if (process.argv.includes('--run')) {
1052
+ const idx = process.argv.indexOf('--run');
1053
+ const taskName = process.argv[idx + 1];
1054
+ if (!taskName) {
1055
+ console.error('Usage: node daemon.js --run <task-name>');
1056
+ process.exit(1);
1057
+ }
1058
+ const config = loadConfig();
1059
+ const tasks = (config.heartbeat && config.heartbeat.tasks) || [];
1060
+ const task = tasks.find(t => t.name === taskName);
1061
+ if (!task) {
1062
+ console.error(`Task "${taskName}" not found in daemon.yaml`);
1063
+ console.error(`Available: ${tasks.map(t => t.name).join(', ') || '(none)'}`);
1064
+ process.exit(1);
1065
+ }
1066
+ const result = executeTask(task, config);
1067
+ if (result.success) {
1068
+ console.log(result.output);
1069
+ } else {
1070
+ console.error(`Error: ${result.error}`);
1071
+ process.exit(1);
1072
+ }
1073
+ } else {
1074
+ main().catch(e => {
1075
+ log('ERROR', `Fatal: ${e.message}`);
1076
+ cleanPid();
1077
+ process.exit(1);
1078
+ });
1079
+ }
1080
+
1081
+ // Export for testing
1082
+ module.exports = { executeTask, loadConfig, loadState, buildProfilePreamble, parseInterval };