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.
- package/README.md +113 -30
- package/index.js +250 -1
- package/package.json +3 -2
- package/scripts/daemon-default.yaml +49 -0
- package/scripts/daemon.js +1082 -0
- package/scripts/feishu-adapter.js +189 -0
- package/scripts/telegram-adapter.js +196 -0
|
@@ -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 };
|