metame-cli 1.3.17 → 1.3.18
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 +48 -0
- package/index.js +60 -7
- package/package.json +1 -1
- package/scripts/daemon-default.yaml +19 -0
- package/scripts/daemon.js +493 -50
- package/scripts/feishu-adapter.js +76 -5
- package/scripts/signal-capture.js +0 -6
package/README.md
CHANGED
|
@@ -399,6 +399,53 @@ Each step runs in the same Claude Code session. Step outputs automatically becom
|
|
|
399
399
|
* `~/.metame/` directory set to mode 700
|
|
400
400
|
* Bot tokens stored locally, never transmitted
|
|
401
401
|
|
|
402
|
+
### Multi-Agent Projects — Context Isolation & Nickname Routing (v1.3.18)
|
|
403
|
+
|
|
404
|
+
Organize your work into named agents, each tied to a project directory. Switch between them instantly from your phone — no commands needed, just say their name.
|
|
405
|
+
|
|
406
|
+
**How it works:**
|
|
407
|
+
|
|
408
|
+
Each `project` entry in `daemon.yaml` defines an agent with a working directory, display name, notification color, and optional nicknames. When you send a message starting with a nickname, the daemon instantly switches to that project's last session — no Claude call, no token cost.
|
|
409
|
+
|
|
410
|
+
**Setup via conversation:**
|
|
411
|
+
|
|
412
|
+
The easiest way to add an agent is to tell the bot:
|
|
413
|
+
|
|
414
|
+
> *"Add a project called 'work' pointing to ~/my-project, nickname is '工作'"*
|
|
415
|
+
|
|
416
|
+
Or edit `~/.metame/daemon.yaml` directly:
|
|
417
|
+
|
|
418
|
+
```yaml
|
|
419
|
+
projects:
|
|
420
|
+
my_agent:
|
|
421
|
+
name: "My Agent" # Display name in notifications
|
|
422
|
+
icon: "🤖" # Emoji shown in Feishu cards
|
|
423
|
+
color: "blue" # Feishu card color: blue|orange|green|purple|red|grey
|
|
424
|
+
cwd: "~/my-project" # Working directory for this agent
|
|
425
|
+
nicknames: [nickname1, nickname2] # Wake words (matched at message start)
|
|
426
|
+
heartbeat_tasks: [] # Scheduled tasks for this project (optional)
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
**Available colors:** `blue` · `orange` · `green` · `purple` · `red` · `grey` · `turquoise`
|
|
430
|
+
|
|
431
|
+
**Phone commands:**
|
|
432
|
+
|
|
433
|
+
| Action | How |
|
|
434
|
+
|--------|-----|
|
|
435
|
+
| Switch agent | Send the nickname alone: `贾维斯` → `🤖 Jarvis 在线` |
|
|
436
|
+
| Switch + ask | `贾维斯, what's the status?` → switches then asks Claude |
|
|
437
|
+
| Pick from list | `/agent` → tap button to switch |
|
|
438
|
+
| Continue a reply | Reply to any bot message → session auto-restored |
|
|
439
|
+
| View all tasks | `/tasks` → grouped by project |
|
|
440
|
+
| Run a task | `/run <task-name>` |
|
|
441
|
+
|
|
442
|
+
**Nickname routing rules:**
|
|
443
|
+
- Matched at **message start only** — mentioning a nickname mid-sentence never triggers a switch
|
|
444
|
+
- Pure nickname (no content after) → instant switch, zero token cost, bypasses cooldown
|
|
445
|
+
- Nickname + content → switch then send content to Claude
|
|
446
|
+
|
|
447
|
+
**Heartbeat task notifications** arrive as colored Feishu cards — each project's color is distinct, so you can tell at a glance which agent sent the update.
|
|
448
|
+
|
|
402
449
|
### Provider Relay — Third-Party Model Support (v1.3.11)
|
|
403
450
|
|
|
404
451
|
MetaMe supports any Anthropic-compatible API relay as a backend. This means you can route Claude Code through a third-party relay that maps `sonnet`/`opus`/`haiku` to any model (GPT-4, DeepSeek, Gemini, etc.) — MetaMe passes standard model names and the relay handles translation.
|
|
@@ -602,6 +649,7 @@ A: No. Your profile stays local at `~/.claude_profile.yaml`. MetaMe simply passe
|
|
|
602
649
|
|
|
603
650
|
| Version | Highlights |
|
|
604
651
|
|---------|------------|
|
|
652
|
+
| **v1.3.18** | **Multi-agent project isolation** — `projects` in `daemon.yaml` with per-project heartbeat tasks, Feishu colored cards per project, `/agent` picker button, nickname routing (say agent name to switch instantly), reply-to-message session restoration, fix `~` expansion in project cwd |
|
|
605
653
|
| **v1.3.17** | **Windows support** (WSL one-command installer), `install-systemd` for Linux/WSL daemon auto-start. Fix onboarding (Genesis interview was never injected, CLAUDE.md accumulated across runs). Marker-based cleanup, unified protocols, `--append-system-prompt` guarantees interview activation, Feishu auto-fetch chat ID, full mobile permissions, fix `/publish` false-success, auto-restart daemon on script update |
|
|
606
654
|
| **v1.3.16** | Git-based `/undo` (auto-checkpoint before each turn, `git reset --hard` rollback), `/nosleep` toggle (macOS caffeinate), custom provider model passthrough (`/model` accepts any name for non-anthropic providers), auto-fallback to anthropic/opus on provider failure, message queue works on Telegram (fire-and-forget poll loop), lazy background distill |
|
|
607
655
|
| **v1.3.15** | Native Playwright MCP (browser automation for all users), `/list` interactive file browser with buttons, Feishu image download fix, Skill/MCP/Agent status push, hot restart reliability (single notification, no double instance) |
|
package/index.js
CHANGED
|
@@ -28,9 +28,22 @@ if (!fs.existsSync(METAME_DIR)) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// Auto-deploy bundled scripts to ~/.metame/
|
|
31
|
+
// IMPORTANT: daemon.yaml is USER CONFIG — never overwrite it. Only daemon-default.yaml (template) is synced.
|
|
31
32
|
const BUNDLED_SCRIPTS = ['signal-capture.js', 'distill.js', 'schema.js', 'pending-traits.js', 'migrate-v2.js', 'daemon.js', 'telegram-adapter.js', 'feishu-adapter.js', 'daemon-default.yaml', 'providers.js', 'session-analytics.js', 'resolve-yaml.js', 'utils.js'];
|
|
32
33
|
const scriptsDir = path.join(__dirname, 'scripts');
|
|
33
34
|
|
|
35
|
+
// Protect daemon.yaml: create backup before any sync operation
|
|
36
|
+
const DAEMON_YAML_BACKUP = path.join(METAME_DIR, 'daemon.yaml.bak');
|
|
37
|
+
try {
|
|
38
|
+
if (fs.existsSync(DAEMON_CONFIG_FILE)) {
|
|
39
|
+
const content = fs.readFileSync(DAEMON_CONFIG_FILE, 'utf8');
|
|
40
|
+
// Only backup if it has real config (not just the default template)
|
|
41
|
+
if (content.includes('enabled: true') || content.includes('bot_token:') && !content.includes('bot_token: null')) {
|
|
42
|
+
fs.copyFileSync(DAEMON_CONFIG_FILE, DAEMON_YAML_BACKUP);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch { /* non-fatal */ }
|
|
46
|
+
|
|
34
47
|
let scriptsUpdated = false;
|
|
35
48
|
for (const script of BUNDLED_SCRIPTS) {
|
|
36
49
|
const src = path.join(scriptsDir, script);
|
|
@@ -63,7 +76,11 @@ if (scriptsUpdated) {
|
|
|
63
76
|
}, 2000);
|
|
64
77
|
const DAEMON_SCRIPT = path.join(METAME_DIR, 'daemon.js');
|
|
65
78
|
setTimeout(() => {
|
|
66
|
-
|
|
79
|
+
// Use caffeinate on macOS to prevent sleep while daemon is running
|
|
80
|
+
const isNotWindows = process.platform !== 'win32';
|
|
81
|
+
const cmd = isNotWindows ? 'caffeinate' : process.execPath;
|
|
82
|
+
const args = isNotWindows ? ['-i', process.execPath, DAEMON_SCRIPT] : [DAEMON_SCRIPT];
|
|
83
|
+
const bg = spawn(cmd, args, {
|
|
67
84
|
detached: true,
|
|
68
85
|
stdio: 'ignore',
|
|
69
86
|
env: { ...process.env, HOME: HOME_DIR, METAME_ROOT: __dirname },
|
|
@@ -77,11 +94,27 @@ if (scriptsUpdated) {
|
|
|
77
94
|
}
|
|
78
95
|
}
|
|
79
96
|
|
|
80
|
-
//
|
|
97
|
+
// Load daemon config for local launch flags
|
|
98
|
+
let daemonCfg = {};
|
|
99
|
+
try {
|
|
100
|
+
if (fs.existsSync(DAEMON_CONFIG_FILE)) {
|
|
101
|
+
const _yaml = require(path.join(__dirname, 'node_modules', 'js-yaml'));
|
|
102
|
+
const raw = _yaml.load(fs.readFileSync(DAEMON_CONFIG_FILE, 'utf8')) || {};
|
|
103
|
+
daemonCfg = raw.daemon || {};
|
|
104
|
+
}
|
|
105
|
+
} catch { /* non-fatal */ }
|
|
106
|
+
|
|
107
|
+
// Ensure daemon.yaml exists (restore backup or copy from template)
|
|
81
108
|
if (!fs.existsSync(DAEMON_CONFIG_FILE)) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
fs.copyFileSync(
|
|
109
|
+
if (fs.existsSync(DAEMON_YAML_BACKUP)) {
|
|
110
|
+
// Restore from backup — user had real config that was lost
|
|
111
|
+
fs.copyFileSync(DAEMON_YAML_BACKUP, DAEMON_CONFIG_FILE);
|
|
112
|
+
console.log('⚠️ daemon.yaml was missing — restored from backup.');
|
|
113
|
+
} else {
|
|
114
|
+
const daemonTemplate = path.join(scriptsDir, 'daemon-default.yaml');
|
|
115
|
+
if (fs.existsSync(daemonTemplate)) {
|
|
116
|
+
fs.copyFileSync(daemonTemplate, DAEMON_CONFIG_FILE);
|
|
117
|
+
}
|
|
85
118
|
}
|
|
86
119
|
}
|
|
87
120
|
|
|
@@ -161,6 +194,15 @@ function spawnDistillBackground() {
|
|
|
161
194
|
const distillPath = path.join(METAME_DIR, 'distill.js');
|
|
162
195
|
if (!fs.existsSync(distillPath)) return;
|
|
163
196
|
|
|
197
|
+
// Early exit if distillation already in progress (prevents duplicate spawns across terminals)
|
|
198
|
+
const lockFile = path.join(METAME_DIR, 'distill.lock');
|
|
199
|
+
if (fs.existsSync(lockFile)) {
|
|
200
|
+
try {
|
|
201
|
+
const lockAge = Date.now() - fs.statSync(lockFile).mtimeMs;
|
|
202
|
+
if (lockAge < 120000) return;
|
|
203
|
+
} catch { /* stale lock, proceed */ }
|
|
204
|
+
}
|
|
205
|
+
|
|
164
206
|
const hasSignals = shouldDistill();
|
|
165
207
|
const bootstrap = needsBootstrap();
|
|
166
208
|
|
|
@@ -1086,6 +1128,8 @@ if (isDaemon) {
|
|
|
1086
1128
|
<string>com.metame.daemon</string>
|
|
1087
1129
|
<key>ProgramArguments</key>
|
|
1088
1130
|
<array>
|
|
1131
|
+
<string>/usr/bin/caffeinate</string>
|
|
1132
|
+
<string>-i</string>
|
|
1089
1133
|
<string>${nodePath}</string>
|
|
1090
1134
|
<string>${DAEMON_SCRIPT}</string>
|
|
1091
1135
|
</array>
|
|
@@ -1207,7 +1251,11 @@ WantedBy=default.target
|
|
|
1207
1251
|
console.error("❌ daemon.js not found. Reinstall MetaMe.");
|
|
1208
1252
|
process.exit(1);
|
|
1209
1253
|
}
|
|
1210
|
-
|
|
1254
|
+
// Use caffeinate on macOS/Linux to prevent sleep while daemon is running
|
|
1255
|
+
const isNotWindows = process.platform !== 'win32';
|
|
1256
|
+
const cmd = isNotWindows ? 'caffeinate' : process.execPath;
|
|
1257
|
+
const args = isNotWindows ? ['-i', process.execPath, DAEMON_SCRIPT] : [DAEMON_SCRIPT];
|
|
1258
|
+
const bg = spawn(cmd, args, {
|
|
1211
1259
|
detached: true,
|
|
1212
1260
|
stdio: 'ignore',
|
|
1213
1261
|
env: { ...process.env, HOME: HOME_DIR, METAME_ROOT: __dirname },
|
|
@@ -1363,7 +1411,9 @@ if (isSync) {
|
|
|
1363
1411
|
|
|
1364
1412
|
console.log(`\n🔄 Resuming session ${bestSession.id.slice(0, 8)}...\n`);
|
|
1365
1413
|
const providerEnv = (() => { try { return require(path.join(__dirname, 'scripts', 'providers.js')).buildActiveEnv(); } catch { return {}; } })();
|
|
1366
|
-
const
|
|
1414
|
+
const resumeArgs = ['--resume', bestSession.id];
|
|
1415
|
+
if (daemonCfg.dangerously_skip_permissions) resumeArgs.push('--dangerously-skip-permissions');
|
|
1416
|
+
const syncChild = spawn('claude', resumeArgs, {
|
|
1367
1417
|
stdio: 'inherit',
|
|
1368
1418
|
env: { ...process.env, ...providerEnv, METAME_ACTIVE_SESSION: 'true' }
|
|
1369
1419
|
});
|
|
@@ -1398,6 +1448,9 @@ if (activeProviderName !== 'anthropic') {
|
|
|
1398
1448
|
|
|
1399
1449
|
// Build launch args — inject system prompt for new users
|
|
1400
1450
|
const launchArgs = process.argv.slice(2);
|
|
1451
|
+
if (daemonCfg.dangerously_skip_permissions && !launchArgs.includes('--dangerously-skip-permissions')) {
|
|
1452
|
+
launchArgs.push('--dangerously-skip-permissions');
|
|
1453
|
+
}
|
|
1401
1454
|
if (!isKnownUser) {
|
|
1402
1455
|
launchArgs.push(
|
|
1403
1456
|
'--append-system-prompt',
|
package/package.json
CHANGED
|
@@ -12,8 +12,27 @@ feishu:
|
|
|
12
12
|
app_secret: null
|
|
13
13
|
allowed_chat_ids: []
|
|
14
14
|
|
|
15
|
+
projects:
|
|
16
|
+
# Per-project heartbeat tasks. Each project's tasks are isolated and
|
|
17
|
+
# notifications arrive as colored Feishu cards (visually distinct).
|
|
18
|
+
#
|
|
19
|
+
# example_project:
|
|
20
|
+
# name: "My Project"
|
|
21
|
+
# icon: "🎬"
|
|
22
|
+
# color: "orange" # blue|orange|green|red|grey|purple|turquoise
|
|
23
|
+
# heartbeat_tasks:
|
|
24
|
+
# - name: "daily-task"
|
|
25
|
+
# cwd: "~/AGI/MyProject"
|
|
26
|
+
# model: "sonnet"
|
|
27
|
+
# interval: "24h"
|
|
28
|
+
# notify: true
|
|
29
|
+
# enabled: true
|
|
30
|
+
# prompt: "..."
|
|
31
|
+
# allowedTools: [Read, Write, WebSearch]
|
|
32
|
+
|
|
15
33
|
heartbeat:
|
|
16
34
|
tasks: []
|
|
35
|
+
# Legacy flat tasks (no project isolation). New tasks should go under projects: above.
|
|
17
36
|
# Examples — uncomment or add your own:
|
|
18
37
|
#
|
|
19
38
|
# Scheduled task (calls claude -p with your profile context):
|
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) {
|
|
@@ -221,6 +252,11 @@ function checkPrecondition(task) {
|
|
|
221
252
|
}
|
|
222
253
|
|
|
223
254
|
function executeTask(task, config) {
|
|
255
|
+
if (task.enabled === false) {
|
|
256
|
+
log('INFO', `Skipping disabled task: ${task.name}`);
|
|
257
|
+
return { success: true, output: '(disabled)', skipped: true };
|
|
258
|
+
}
|
|
259
|
+
|
|
224
260
|
const state = loadState();
|
|
225
261
|
|
|
226
262
|
if (!checkBudget(config, state)) {
|
|
@@ -285,39 +321,81 @@ function executeTask(task, config) {
|
|
|
285
321
|
}
|
|
286
322
|
const fullPrompt = preamble + taskPrompt;
|
|
287
323
|
|
|
288
|
-
const claudeArgs = ['-p', '--model', model];
|
|
324
|
+
const claudeArgs = ['-p', '--model', model, '--dangerously-skip-permissions'];
|
|
289
325
|
for (const t of (task.allowedTools || [])) claudeArgs.push('--allowedTools', t);
|
|
290
|
-
|
|
326
|
+
// Auto-detect MCP config in task cwd or project directory
|
|
327
|
+
const cwd = task.cwd ? task.cwd.replace(/^~/, HOME) : undefined;
|
|
328
|
+
const mcpConfig = task.mcp_config
|
|
329
|
+
? path.resolve(task.mcp_config.replace(/^~/, HOME))
|
|
330
|
+
: cwd && fs.existsSync(path.join(cwd, '.mcp.json'))
|
|
331
|
+
? path.join(cwd, '.mcp.json')
|
|
332
|
+
: null;
|
|
333
|
+
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
|
334
|
+
|
|
335
|
+
// Persistent session: reuse same session across runs (for tasks like weekly-review)
|
|
336
|
+
if (task.persistent_session) {
|
|
337
|
+
const savedSessionId = state.tasks[task.name]?.session_id;
|
|
338
|
+
if (savedSessionId) {
|
|
339
|
+
claudeArgs.push('--resume', savedSessionId);
|
|
340
|
+
log('INFO', `Executing task: ${task.name} (model: ${model}, resuming session ${savedSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
|
|
341
|
+
} else {
|
|
342
|
+
const newSessionId = crypto.randomUUID();
|
|
343
|
+
claudeArgs.push('--session-id', newSessionId);
|
|
344
|
+
if (!state.tasks[task.name]) state.tasks[task.name] = {};
|
|
345
|
+
state.tasks[task.name].session_id = newSessionId;
|
|
346
|
+
saveState(state);
|
|
347
|
+
log('INFO', `Executing task: ${task.name} (model: ${model}, new session ${newSessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
log('INFO', `Executing task: ${task.name} (model: ${model}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
|
|
351
|
+
}
|
|
291
352
|
|
|
292
353
|
try {
|
|
293
354
|
const output = execFileSync('claude', claudeArgs, {
|
|
294
355
|
input: fullPrompt,
|
|
295
356
|
encoding: 'utf8',
|
|
296
|
-
timeout: 120000,
|
|
297
|
-
maxBuffer: 1024 * 1024,
|
|
298
|
-
|
|
357
|
+
timeout: task.timeout || 120000,
|
|
358
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
359
|
+
...(cwd && { cwd }),
|
|
360
|
+
env: { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined },
|
|
299
361
|
}).trim();
|
|
300
362
|
|
|
301
363
|
// Rough token estimate: ~4 chars per token for input + output
|
|
302
364
|
const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
|
|
303
365
|
recordTokens(state, estimatedTokens);
|
|
304
366
|
|
|
305
|
-
// Record task result
|
|
367
|
+
// Record task result (preserve session_id for persistent sessions)
|
|
368
|
+
const prevSessionId = state.tasks[task.name]?.session_id;
|
|
306
369
|
state.tasks[task.name] = {
|
|
307
370
|
last_run: new Date().toISOString(),
|
|
308
371
|
status: 'success',
|
|
309
372
|
output_preview: output.slice(0, 200),
|
|
373
|
+
...(prevSessionId && { session_id: prevSessionId }),
|
|
310
374
|
};
|
|
311
375
|
saveState(state);
|
|
312
376
|
|
|
313
377
|
log('INFO', `Task ${task.name} completed (est. ${estimatedTokens} tokens)`);
|
|
314
378
|
return { success: true, output, tokens: estimatedTokens };
|
|
315
379
|
} catch (e) {
|
|
316
|
-
|
|
380
|
+
const errMsg = e.message || '';
|
|
381
|
+
// If persistent session expired/not found, reset and let next run create fresh
|
|
382
|
+
if (task.persistent_session && (errMsg.includes('not found') || errMsg.includes('No session'))) {
|
|
383
|
+
log('WARN', `Persistent session for ${task.name} expired, will create new on next run`);
|
|
384
|
+
state.tasks[task.name] = {
|
|
385
|
+
last_run: new Date().toISOString(),
|
|
386
|
+
status: 'session_reset',
|
|
387
|
+
error: 'Session expired, will retry with new session',
|
|
388
|
+
};
|
|
389
|
+
saveState(state);
|
|
390
|
+
return { success: false, error: 'session_expired', output: '' };
|
|
391
|
+
}
|
|
392
|
+
log('ERROR', `Task ${task.name} failed: ${errMsg}`);
|
|
393
|
+
const prevSid = state.tasks[task.name]?.session_id;
|
|
317
394
|
state.tasks[task.name] = {
|
|
318
395
|
last_run: new Date().toISOString(),
|
|
319
396
|
status: 'error',
|
|
320
|
-
error:
|
|
397
|
+
error: errMsg.slice(0, 200),
|
|
398
|
+
...(prevSid && { session_id: prevSid }),
|
|
321
399
|
};
|
|
322
400
|
saveState(state);
|
|
323
401
|
return { success: false, error: e.message, output: '' };
|
|
@@ -350,21 +428,28 @@ function executeWorkflow(task, config) {
|
|
|
350
428
|
const outputs = [];
|
|
351
429
|
let totalTokens = 0;
|
|
352
430
|
const allowed = task.allowedTools || [];
|
|
431
|
+
// Auto-detect MCP config in task cwd
|
|
432
|
+
const mcpConfig = task.mcp_config
|
|
433
|
+
? path.resolve(task.mcp_config.replace(/^~/, HOME))
|
|
434
|
+
: fs.existsSync(path.join(cwd, '.mcp.json'))
|
|
435
|
+
? path.join(cwd, '.mcp.json')
|
|
436
|
+
: null;
|
|
353
437
|
|
|
354
|
-
log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}`);
|
|
438
|
+
log('INFO', `Workflow ${task.name}: ${steps.length} steps, session ${sessionId.slice(0, 8)}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''}`);
|
|
355
439
|
|
|
356
440
|
for (let i = 0; i < steps.length; i++) {
|
|
357
441
|
const step = steps[i];
|
|
358
442
|
let prompt = (step.skill ? `/${step.skill} ` : '') + (step.prompt || '');
|
|
359
443
|
if (i === 0 && precheck.context) prompt += `\n\n相关数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
|
|
360
|
-
const args = ['-p', '--model', model];
|
|
444
|
+
const args = ['-p', '--model', model, '--dangerously-skip-permissions'];
|
|
361
445
|
for (const tool of allowed) args.push('--allowedTools', tool);
|
|
446
|
+
if (mcpConfig) args.push('--mcp-config', mcpConfig);
|
|
362
447
|
args.push(i === 0 ? '--session-id' : '--resume', sessionId);
|
|
363
448
|
|
|
364
449
|
log('INFO', `Workflow ${task.name} step ${i + 1}/${steps.length}: ${step.skill || 'prompt'}`);
|
|
365
450
|
try {
|
|
366
|
-
const output =
|
|
367
|
-
input: prompt, encoding: 'utf8', timeout: step.timeout || 300000, maxBuffer: 5 * 1024 * 1024, cwd, env: { ...process.env, ...getDaemonProviderEnv() },
|
|
451
|
+
const output = execFileSync('claude', args, {
|
|
452
|
+
input: prompt, encoding: 'utf8', timeout: step.timeout || 300000, maxBuffer: 5 * 1024 * 1024, cwd, env: { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined },
|
|
368
453
|
}).trim();
|
|
369
454
|
const tk = Math.ceil((prompt.length + output.length) / 4);
|
|
370
455
|
totalTokens += tk;
|
|
@@ -394,21 +479,35 @@ function executeWorkflow(task, config) {
|
|
|
394
479
|
// HEARTBEAT SCHEDULER
|
|
395
480
|
// ---------------------------------------------------------
|
|
396
481
|
function startHeartbeat(config, notifyFn) {
|
|
397
|
-
const
|
|
482
|
+
const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
|
|
483
|
+
const projectTasks = [];
|
|
484
|
+
const legacyNames = new Set(legacyTasks.map(t => t.name));
|
|
485
|
+
for (const [key, proj] of Object.entries(config.projects || {})) {
|
|
486
|
+
for (const t of (proj.heartbeat_tasks || [])) {
|
|
487
|
+
if (legacyNames.has(t.name)) log('WARN', `Duplicate task name "${t.name}" in project "${key}" and legacy heartbeat — will run twice`);
|
|
488
|
+
projectTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const tasks = [...legacyTasks, ...projectTasks];
|
|
398
492
|
if (tasks.length === 0) {
|
|
399
493
|
log('INFO', 'No heartbeat tasks configured');
|
|
400
494
|
return;
|
|
401
495
|
}
|
|
402
496
|
|
|
497
|
+
const enabledTasks = tasks.filter(t => t.enabled !== false);
|
|
403
498
|
const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
|
|
404
|
-
log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${tasks.length} tasks)`);
|
|
499
|
+
log('INFO', `Heartbeat scheduler started (check every ${checkIntervalSec}s, ${enabledTasks.length}/${tasks.length} tasks enabled)`);
|
|
500
|
+
|
|
501
|
+
if (enabledTasks.length === 0) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
405
504
|
|
|
406
505
|
// Track next run times
|
|
407
506
|
const nextRun = {};
|
|
408
507
|
const now = Date.now();
|
|
409
508
|
const state = loadState();
|
|
410
509
|
|
|
411
|
-
for (const task of
|
|
510
|
+
for (const task of enabledTasks) {
|
|
412
511
|
const intervalSec = parseInterval(task.interval);
|
|
413
512
|
const lastRun = state.tasks[task.name] && state.tasks[task.name].last_run;
|
|
414
513
|
if (lastRun) {
|
|
@@ -422,17 +521,18 @@ function startHeartbeat(config, notifyFn) {
|
|
|
422
521
|
|
|
423
522
|
const timer = setInterval(() => {
|
|
424
523
|
const currentTime = Date.now();
|
|
425
|
-
for (const task of
|
|
524
|
+
for (const task of enabledTasks) {
|
|
426
525
|
if (currentTime >= (nextRun[task.name] || 0)) {
|
|
427
526
|
const result = executeTask(task, config);
|
|
428
527
|
const intervalSec = parseInterval(task.interval);
|
|
429
528
|
nextRun[task.name] = currentTime + intervalSec * 1000;
|
|
430
529
|
|
|
431
530
|
if (task.notify && notifyFn && !result.skipped) {
|
|
531
|
+
const proj = task._project || null;
|
|
432
532
|
if (result.success) {
|
|
433
|
-
notifyFn(`✅ *${task.name}* completed\n\n${result.output}
|
|
533
|
+
notifyFn(`✅ *${task.name}* completed\n\n${result.output}`, proj);
|
|
434
534
|
} else {
|
|
435
|
-
notifyFn(`❌ *${task.name}* failed: ${result.error}
|
|
535
|
+
notifyFn(`❌ *${task.name}* failed: ${result.error}`, proj);
|
|
436
536
|
}
|
|
437
537
|
}
|
|
438
538
|
}
|
|
@@ -880,6 +980,81 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
880
980
|
return;
|
|
881
981
|
}
|
|
882
982
|
|
|
983
|
+
// /sessions — compact list, tap to see details, then tap to switch
|
|
984
|
+
if (text === '/sessions') {
|
|
985
|
+
const allSessions = listRecentSessions(15);
|
|
986
|
+
if (allSessions.length === 0) {
|
|
987
|
+
await bot.sendMessage(chatId, 'No sessions found. Try /new first.');
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (bot.sendButtons) {
|
|
991
|
+
const buttons = allSessions.map(s => {
|
|
992
|
+
const proj = s.projectPath ? path.basename(s.projectPath) : '~';
|
|
993
|
+
const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
|
|
994
|
+
const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
|
|
995
|
+
const ago = formatRelativeTime(new Date(timeMs).toISOString());
|
|
996
|
+
const shortId = s.sessionId.slice(0, 6);
|
|
997
|
+
const name = s.customTitle || (s.summary || '').slice(0, 18) || '';
|
|
998
|
+
let label = `${ago} 📁${proj}`;
|
|
999
|
+
if (name) label += ` ${name}`;
|
|
1000
|
+
label += ` #${shortId}`;
|
|
1001
|
+
return [{ text: label, callback_data: `/sess ${s.sessionId}` }];
|
|
1002
|
+
});
|
|
1003
|
+
await bot.sendButtons(chatId, '📋 Tap a session to view details:', buttons);
|
|
1004
|
+
} else {
|
|
1005
|
+
let msg = '📋 Recent sessions:\n\n';
|
|
1006
|
+
allSessions.forEach((s, i) => {
|
|
1007
|
+
const proj = s.projectPath ? path.basename(s.projectPath) : '~';
|
|
1008
|
+
const title = s.customTitle || s.summary || (s.firstPrompt || '').slice(0, 40) || '';
|
|
1009
|
+
const shortId = s.sessionId.slice(0, 8);
|
|
1010
|
+
msg += `${i + 1}. 📁${proj} | ${title}\n /resume ${shortId}\n`;
|
|
1011
|
+
});
|
|
1012
|
+
await bot.sendMessage(chatId, msg);
|
|
1013
|
+
}
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// /sess <id> — show session detail card with switch button
|
|
1018
|
+
if (text.startsWith('/sess ')) {
|
|
1019
|
+
const sid = text.slice(6).trim();
|
|
1020
|
+
const allSessions = listRecentSessions(50);
|
|
1021
|
+
const s = allSessions.find(x => x.sessionId === sid || x.sessionId.startsWith(sid));
|
|
1022
|
+
if (!s) {
|
|
1023
|
+
await bot.sendMessage(chatId, `Session not found: ${sid.slice(0, 8)}`);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const proj = s.projectPath || '~';
|
|
1027
|
+
const projName = path.basename(proj);
|
|
1028
|
+
const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
|
|
1029
|
+
const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
|
|
1030
|
+
const ago = formatRelativeTime(new Date(timeMs).toISOString());
|
|
1031
|
+
const title = s.customTitle || '';
|
|
1032
|
+
const summary = s.summary || '';
|
|
1033
|
+
const firstMsg = (s.firstPrompt || '').replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '');
|
|
1034
|
+
const msgs = s.messageCount || '?';
|
|
1035
|
+
|
|
1036
|
+
let detail = `📋 Session Detail\n`;
|
|
1037
|
+
detail += `━━━━━━━━━━━━━━━━━━━━\n`;
|
|
1038
|
+
if (title) detail += `📝 Title: ${title}\n`;
|
|
1039
|
+
if (summary) detail += `💡 Summary: ${summary}\n`;
|
|
1040
|
+
detail += `📁 Project: ${projName}\n`;
|
|
1041
|
+
detail += `📂 Path: ${proj}\n`;
|
|
1042
|
+
detail += `💬 Messages: ${msgs}\n`;
|
|
1043
|
+
detail += `🕐 Last active: ${ago}\n`;
|
|
1044
|
+
detail += `🆔 ID: ${s.sessionId.slice(0, 8)}`;
|
|
1045
|
+
if (firstMsg && firstMsg !== summary) detail += `\n\n🗨️ First message:\n${firstMsg}`;
|
|
1046
|
+
|
|
1047
|
+
if (bot.sendButtons) {
|
|
1048
|
+
await bot.sendButtons(chatId, detail, [
|
|
1049
|
+
[{ text: '▶️ Switch to this session', callback_data: `/resume ${s.sessionId}` }],
|
|
1050
|
+
[{ text: '⬅️ Back to list', callback_data: '/sessions' }],
|
|
1051
|
+
]);
|
|
1052
|
+
} else {
|
|
1053
|
+
await bot.sendMessage(chatId, detail + `\n\n/resume ${s.sessionId.slice(0, 8)}`);
|
|
1054
|
+
}
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
883
1058
|
if (text === '/resume' || text.startsWith('/resume ')) {
|
|
884
1059
|
const arg = text.slice(7).trim();
|
|
885
1060
|
|
|
@@ -943,6 +1118,24 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
943
1118
|
return;
|
|
944
1119
|
}
|
|
945
1120
|
|
|
1121
|
+
if (text === '/agent') {
|
|
1122
|
+
const projects = config.projects || {};
|
|
1123
|
+
const entries = Object.entries(projects).filter(([, p]) => p.cwd);
|
|
1124
|
+
if (entries.length === 0) {
|
|
1125
|
+
await bot.sendMessage(chatId, 'No projects configured. Add projects with cwd to daemon.yaml.');
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
const currentSession = getSession(chatId);
|
|
1129
|
+
const currentCwd = currentSession?.cwd ? path.resolve(expandPath(currentSession.cwd)) : null;
|
|
1130
|
+
const buttons = entries.map(([key, p]) => {
|
|
1131
|
+
const projCwd = expandPath(p.cwd).replace(/^~/, HOME);
|
|
1132
|
+
const active = currentCwd && path.resolve(projCwd) === currentCwd ? ' ◀' : '';
|
|
1133
|
+
return [{ text: `${p.icon || '🤖'} ${p.name || key}${active}`, callback_data: `/cd ${projCwd}` }];
|
|
1134
|
+
});
|
|
1135
|
+
await bot.sendButtons(chatId, '切换对话对象', buttons);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
946
1139
|
if (text === '/cd' || text.startsWith('/cd ')) {
|
|
947
1140
|
let newCwd = expandPath(text.slice(3).trim());
|
|
948
1141
|
if (!newCwd) {
|
|
@@ -1082,14 +1275,28 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1082
1275
|
}
|
|
1083
1276
|
|
|
1084
1277
|
if (text === '/tasks') {
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1278
|
+
let msg = '';
|
|
1279
|
+
// Legacy flat tasks
|
|
1280
|
+
const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
|
|
1281
|
+
if (legacyTasks.length > 0) {
|
|
1282
|
+
msg += '📋 General:\n';
|
|
1283
|
+
for (const t of legacyTasks) {
|
|
1284
|
+
const ts = state.tasks[t.name] || {};
|
|
1285
|
+
msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
|
|
1286
|
+
}
|
|
1091
1287
|
}
|
|
1092
|
-
|
|
1288
|
+
// Project tasks grouped
|
|
1289
|
+
for (const [, proj] of Object.entries(config.projects || {})) {
|
|
1290
|
+
const pTasks = proj.heartbeat_tasks || [];
|
|
1291
|
+
if (pTasks.length === 0) continue;
|
|
1292
|
+
msg += `\n${proj.icon || '🤖'} ${proj.name || proj}:\n`;
|
|
1293
|
+
for (const t of pTasks) {
|
|
1294
|
+
const ts = state.tasks[t.name] || {};
|
|
1295
|
+
msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (!msg) { await bot.sendMessage(chatId, 'No heartbeat tasks configured.'); return; }
|
|
1299
|
+
await bot.sendMessage(chatId, msg.trim());
|
|
1093
1300
|
return;
|
|
1094
1301
|
}
|
|
1095
1302
|
|
|
@@ -1101,8 +1308,13 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1101
1308
|
return;
|
|
1102
1309
|
}
|
|
1103
1310
|
const taskName = text.slice(5).trim();
|
|
1104
|
-
const
|
|
1105
|
-
const
|
|
1311
|
+
const allRunTasks = [...(config.heartbeat && config.heartbeat.tasks || [])];
|
|
1312
|
+
for (const [key, proj] of Object.entries(config.projects || {})) {
|
|
1313
|
+
for (const t of (proj.heartbeat_tasks || [])) {
|
|
1314
|
+
allRunTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const task = allRunTasks.find(t => t.name === taskName);
|
|
1106
1318
|
if (!task) { await bot.sendMessage(chatId, `❌ Task "${taskName}" not found`); return; }
|
|
1107
1319
|
|
|
1108
1320
|
// Script tasks: quick, run inline
|
|
@@ -1124,7 +1336,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1124
1336
|
if (precheck.context) taskPrompt += `\n\n以下是相关原始数据:\n\`\`\`\n${precheck.context}\n\`\`\``;
|
|
1125
1337
|
const fullPrompt = preamble + taskPrompt;
|
|
1126
1338
|
const model = task.model || 'haiku';
|
|
1127
|
-
const claudeArgs = ['-p', '--model', model];
|
|
1339
|
+
const claudeArgs = ['-p', '--model', model, '--dangerously-skip-permissions'];
|
|
1128
1340
|
for (const t of (task.allowedTools || [])) claudeArgs.push('--allowedTools', t);
|
|
1129
1341
|
|
|
1130
1342
|
await bot.sendMessage(chatId, `Running: ${taskName} (${model})...`);
|
|
@@ -1189,6 +1401,106 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1189
1401
|
return;
|
|
1190
1402
|
}
|
|
1191
1403
|
|
|
1404
|
+
// /compact — compress current session context to save tokens
|
|
1405
|
+
if (text === '/compact') {
|
|
1406
|
+
const session = getSession(chatId);
|
|
1407
|
+
if (!session || !session.started) {
|
|
1408
|
+
await bot.sendMessage(chatId, '❌ No active session to compact.');
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
await bot.sendMessage(chatId, '🗜 Compacting session...');
|
|
1412
|
+
|
|
1413
|
+
// Step 1: Read conversation from JSONL (fast, no Claude needed)
|
|
1414
|
+
const jsonlPath = findSessionFile(session.id);
|
|
1415
|
+
if (!jsonlPath) {
|
|
1416
|
+
await bot.sendMessage(chatId, '❌ Session file not found.');
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
let messages = [];
|
|
1420
|
+
try {
|
|
1421
|
+
const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n').filter(Boolean);
|
|
1422
|
+
for (const line of lines) {
|
|
1423
|
+
try {
|
|
1424
|
+
const obj = JSON.parse(line);
|
|
1425
|
+
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
1426
|
+
const msg = obj.message || {};
|
|
1427
|
+
const content = msg.content;
|
|
1428
|
+
let text_content = '';
|
|
1429
|
+
if (typeof content === 'string') {
|
|
1430
|
+
text_content = content;
|
|
1431
|
+
} else if (Array.isArray(content)) {
|
|
1432
|
+
text_content = content
|
|
1433
|
+
.filter(c => c.type === 'text')
|
|
1434
|
+
.map(c => c.text || '')
|
|
1435
|
+
.join(' ');
|
|
1436
|
+
}
|
|
1437
|
+
if (text_content.trim()) {
|
|
1438
|
+
messages.push({ role: obj.type, text: text_content.trim() });
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
} catch { /* skip malformed lines */ }
|
|
1442
|
+
}
|
|
1443
|
+
} catch (e) {
|
|
1444
|
+
await bot.sendMessage(chatId, `❌ Cannot read session: ${e.message}`);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (messages.length === 0) {
|
|
1449
|
+
await bot.sendMessage(chatId, '❌ No messages found in session.');
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Step 2: Build a truncated conversation digest (keep under ~20k chars for haiku)
|
|
1454
|
+
const MAX_DIGEST = 20000;
|
|
1455
|
+
let digest = '';
|
|
1456
|
+
// Take messages from newest to oldest until we hit the limit
|
|
1457
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1458
|
+
const m = messages[i];
|
|
1459
|
+
const prefix = m.role === 'user' ? 'USER' : 'ASSISTANT';
|
|
1460
|
+
const entry = `[${prefix}]: ${m.text.slice(0, 800)}\n\n`;
|
|
1461
|
+
if (digest.length + entry.length > MAX_DIGEST) break;
|
|
1462
|
+
digest = entry + digest;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Step 3: Summarize with haiku (new process, no --resume, fast)
|
|
1466
|
+
const daemonCfg = loadConfig().daemon || {};
|
|
1467
|
+
const compactArgs = ['-p', '--model', 'haiku', '--no-session-persistence'];
|
|
1468
|
+
if (daemonCfg.dangerously_skip_permissions) compactArgs.push('--dangerously-skip-permissions');
|
|
1469
|
+
const { output, error } = await spawnClaudeAsync(
|
|
1470
|
+
compactArgs,
|
|
1471
|
+
`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}`,
|
|
1472
|
+
session.cwd,
|
|
1473
|
+
60000
|
|
1474
|
+
);
|
|
1475
|
+
if (error || !output) {
|
|
1476
|
+
await bot.sendMessage(chatId, `❌ Compact failed: ${error || 'no output'}`);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// Step 4: Create new session with the summary
|
|
1481
|
+
const model = daemonCfg.model || 'opus';
|
|
1482
|
+
const oldName = getSessionName(session.id);
|
|
1483
|
+
const newSession = createSession(chatId, session.cwd, oldName ? oldName + ' (compacted)' : '');
|
|
1484
|
+
const initArgs = ['-p', '--session-id', newSession.id, '--model', model];
|
|
1485
|
+
if (daemonCfg.dangerously_skip_permissions) initArgs.push('--dangerously-skip-permissions');
|
|
1486
|
+
const preamble = buildProfilePreamble();
|
|
1487
|
+
const initPrompt = preamble + `Here is the context from our previous session (compacted):\n\n${output}\n\nContext loaded. Ready to continue.`;
|
|
1488
|
+
const { error: initErr } = await spawnClaudeAsync(initArgs, initPrompt, session.cwd, 60000);
|
|
1489
|
+
if (initErr) {
|
|
1490
|
+
await bot.sendMessage(chatId, `⚠️ Summary saved but new session init failed: ${initErr}`);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
// Mark as started
|
|
1494
|
+
const state = loadState();
|
|
1495
|
+
if (state.sessions[chatId]) {
|
|
1496
|
+
state.sessions[chatId].started = true;
|
|
1497
|
+
saveState(state);
|
|
1498
|
+
}
|
|
1499
|
+
const tokenEst = Math.round(output.length / 3.5);
|
|
1500
|
+
await bot.sendMessage(chatId, `✅ Compacted! ~${tokenEst} tokens of context carried over.\nNew session: ${newSession.id.slice(0, 8)}`);
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1192
1504
|
// /publish <otp> — npm publish with OTP (zero latency, no Claude)
|
|
1193
1505
|
if (text.startsWith('/publish ')) {
|
|
1194
1506
|
const otp = text.slice(9).trim();
|
|
@@ -1595,8 +1907,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1595
1907
|
'/last — 继续电脑上最近的对话',
|
|
1596
1908
|
'/cd last — 切到电脑最近的项目目录',
|
|
1597
1909
|
'',
|
|
1910
|
+
'🤖 Agent 切换:',
|
|
1911
|
+
'/agent — 选择对话的项目/Agent',
|
|
1912
|
+
'',
|
|
1598
1913
|
'📂 Session 管理:',
|
|
1599
1914
|
'/new [path] [name] — 新建会话',
|
|
1915
|
+
'/sessions — 浏览所有最近会话',
|
|
1600
1916
|
'/resume [name] — 选择/恢复会话',
|
|
1601
1917
|
'/name <name> — 命名当前会话',
|
|
1602
1918
|
'/cd <path> — 切换工作目录',
|
|
@@ -1652,13 +1968,32 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
1652
1968
|
}, 5000);
|
|
1653
1969
|
return;
|
|
1654
1970
|
}
|
|
1971
|
+
// Nickname-only switch: bypass cooldown + budget (no Claude call)
|
|
1972
|
+
const quickAgent = routeAgent(text, config);
|
|
1973
|
+
if (quickAgent && !quickAgent.rest) {
|
|
1974
|
+
const { key, proj } = quickAgent;
|
|
1975
|
+
const projCwd = expandPath(proj.cwd).replace(/^~/, HOME);
|
|
1976
|
+
const st = loadState();
|
|
1977
|
+
const recentInDir = listRecentSessions(1, projCwd);
|
|
1978
|
+
if (recentInDir.length > 0 && recentInDir[0].sessionId) {
|
|
1979
|
+
st.sessions[chatId] = { id: recentInDir[0].sessionId, cwd: projCwd, started: true };
|
|
1980
|
+
} else {
|
|
1981
|
+
const newSess = createSession(chatId, projCwd, proj.name || key);
|
|
1982
|
+
st.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
|
|
1983
|
+
}
|
|
1984
|
+
saveState(st);
|
|
1985
|
+
log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
|
|
1986
|
+
await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1655
1990
|
const cd = checkCooldown(chatId);
|
|
1656
1991
|
if (!cd.ok) { await bot.sendMessage(chatId, `${cd.wait}s`); return; }
|
|
1657
1992
|
if (!checkBudget(loadConfig(), loadState())) {
|
|
1658
1993
|
await bot.sendMessage(chatId, 'Daily token budget exceeded.');
|
|
1659
1994
|
return;
|
|
1660
1995
|
}
|
|
1661
|
-
await askClaude(bot, chatId, text);
|
|
1996
|
+
await askClaude(bot, chatId, text, config);
|
|
1662
1997
|
}
|
|
1663
1998
|
|
|
1664
1999
|
// ---------------------------------------------------------
|
|
@@ -1858,6 +2193,7 @@ function createSession(chatId, cwd, name) {
|
|
|
1858
2193
|
saveState(state);
|
|
1859
2194
|
invalidateSessionCache();
|
|
1860
2195
|
|
|
2196
|
+
|
|
1861
2197
|
// If name provided, write to Claude's session file (same as /rename on desktop)
|
|
1862
2198
|
if (name) {
|
|
1863
2199
|
writeSessionName(sessionId, cwd || HOME, name);
|
|
@@ -1958,7 +2294,7 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
|
|
|
1958
2294
|
const child = spawn('claude', args, {
|
|
1959
2295
|
cwd,
|
|
1960
2296
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1961
|
-
env: { ...process.env, ...getActiveProviderEnv() },
|
|
2297
|
+
env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
|
|
1962
2298
|
});
|
|
1963
2299
|
|
|
1964
2300
|
let stdout = '';
|
|
@@ -2133,7 +2469,7 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
|
|
|
2133
2469
|
const child = spawn('claude', streamArgs, {
|
|
2134
2470
|
cwd,
|
|
2135
2471
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2136
|
-
env: { ...process.env, ...getActiveProviderEnv() },
|
|
2472
|
+
env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
|
|
2137
2473
|
});
|
|
2138
2474
|
|
|
2139
2475
|
// Track active process for /stop
|
|
@@ -2306,6 +2642,20 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
|
|
|
2306
2642
|
|
|
2307
2643
|
// Lazy distill: run distill.js in background on first message, then every 4 hours
|
|
2308
2644
|
let _lastDistillTime = 0;
|
|
2645
|
+
// Track outbound message_id → session for reply-based session restoration.
|
|
2646
|
+
// Keeps last 200 entries to avoid unbounded growth.
|
|
2647
|
+
function trackMsgSession(messageId, session) {
|
|
2648
|
+
if (!messageId || !session || !session.id) return;
|
|
2649
|
+
const st = loadState();
|
|
2650
|
+
if (!st.msg_sessions) st.msg_sessions = {};
|
|
2651
|
+
st.msg_sessions[messageId] = { id: session.id, cwd: session.cwd };
|
|
2652
|
+
const keys = Object.keys(st.msg_sessions);
|
|
2653
|
+
if (keys.length > 200) {
|
|
2654
|
+
for (const k of keys.slice(0, keys.length - 200)) delete st.msg_sessions[k];
|
|
2655
|
+
}
|
|
2656
|
+
saveState(st);
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2309
2659
|
function lazyDistill() {
|
|
2310
2660
|
const now = Date.now();
|
|
2311
2661
|
if (now - _lastDistillTime < 4 * 60 * 60 * 1000) return; // 4h cooldown
|
|
@@ -2326,7 +2676,7 @@ function lazyDistill() {
|
|
|
2326
2676
|
* Shared ask logic — full Claude Code session (stateful, with tools)
|
|
2327
2677
|
* Now uses spawn (async) instead of execSync to allow parallel requests.
|
|
2328
2678
|
*/
|
|
2329
|
-
async function askClaude(bot, chatId, prompt) {
|
|
2679
|
+
async function askClaude(bot, chatId, prompt, config) {
|
|
2330
2680
|
log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
|
|
2331
2681
|
// Trigger background distill on first message / every 4h
|
|
2332
2682
|
try { lazyDistill(); } catch { /* non-fatal */ }
|
|
@@ -2343,8 +2693,60 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2343
2693
|
bot.sendTyping(chatId).catch(() => {});
|
|
2344
2694
|
}, 4000);
|
|
2345
2695
|
|
|
2696
|
+
// Agent nickname routing: "贾维斯" / "小美,帮我..." → switch project session
|
|
2697
|
+
const agentMatch = routeAgent(prompt, config);
|
|
2698
|
+
if (agentMatch) {
|
|
2699
|
+
const { key, proj, rest } = agentMatch;
|
|
2700
|
+
const projCwd = expandPath(proj.cwd).replace(/^~/, HOME);
|
|
2701
|
+
const st = loadState();
|
|
2702
|
+
const recentInDir = listRecentSessions(1, projCwd);
|
|
2703
|
+
if (recentInDir.length > 0 && recentInDir[0].sessionId) {
|
|
2704
|
+
st.sessions[chatId] = { id: recentInDir[0].sessionId, cwd: projCwd, started: true };
|
|
2705
|
+
} else {
|
|
2706
|
+
const newSess = createSession(chatId, projCwd, proj.name || key);
|
|
2707
|
+
st.sessions[chatId] = { id: newSess.id, cwd: projCwd, started: false };
|
|
2708
|
+
}
|
|
2709
|
+
saveState(st);
|
|
2710
|
+
log('INFO', `Agent switch via nickname: ${key} (${projCwd})`);
|
|
2711
|
+
if (!rest) {
|
|
2712
|
+
// Pure nickname call — confirm switch and stop
|
|
2713
|
+
clearInterval(typingTimer);
|
|
2714
|
+
await bot.sendMessage(chatId, `${proj.icon || '🤖'} ${proj.name || key} 在线`);
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
// Nickname + content — strip nickname, continue with rest as prompt
|
|
2718
|
+
prompt = rest;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// Skill routing: detect skill first, then decide session
|
|
2722
|
+
const skill = routeSkill(prompt);
|
|
2723
|
+
|
|
2724
|
+
// Skills with dedicated pinned sessions (reused across days, no re-injection needed)
|
|
2725
|
+
const PINNED_SKILL_SESSIONS = new Set(['macos-mail-calendar', 'skill-manager']);
|
|
2726
|
+
|
|
2346
2727
|
let session = getSession(chatId);
|
|
2347
|
-
|
|
2728
|
+
|
|
2729
|
+
if (skill && PINNED_SKILL_SESSIONS.has(skill)) {
|
|
2730
|
+
// Use a dedicated long-lived session per skill
|
|
2731
|
+
const state = loadState();
|
|
2732
|
+
if (!state.pinned_sessions) state.pinned_sessions = {};
|
|
2733
|
+
const pinned = state.pinned_sessions[skill];
|
|
2734
|
+
if (pinned) {
|
|
2735
|
+
// Reuse existing pinned session
|
|
2736
|
+
state.sessions[chatId] = { id: pinned.id, cwd: pinned.cwd, started: true };
|
|
2737
|
+
saveState(state);
|
|
2738
|
+
session = state.sessions[chatId];
|
|
2739
|
+
log('INFO', `Pinned session reused for skill ${skill}: ${pinned.id.slice(0, 8)}`);
|
|
2740
|
+
} else {
|
|
2741
|
+
// First time — create session and pin it
|
|
2742
|
+
session = createSession(chatId, HOME, skill);
|
|
2743
|
+
const st2 = loadState();
|
|
2744
|
+
if (!st2.pinned_sessions) st2.pinned_sessions = {};
|
|
2745
|
+
st2.pinned_sessions[skill] = { id: session.id, cwd: session.cwd };
|
|
2746
|
+
saveState(st2);
|
|
2747
|
+
log('INFO', `Pinned session created for skill ${skill}: ${session.id.slice(0, 8)}`);
|
|
2748
|
+
}
|
|
2749
|
+
} else if (!session) {
|
|
2348
2750
|
// Auto-attach to most recent Claude session (unified session management)
|
|
2349
2751
|
const recent = listRecentSessions(1);
|
|
2350
2752
|
if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
|
|
@@ -2353,7 +2755,7 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2353
2755
|
state.sessions[chatId] = {
|
|
2354
2756
|
id: target.sessionId,
|
|
2355
2757
|
cwd: target.projectPath,
|
|
2356
|
-
started: true,
|
|
2758
|
+
started: true,
|
|
2357
2759
|
};
|
|
2358
2760
|
saveState(state);
|
|
2359
2761
|
session = state.sessions[chatId];
|
|
@@ -2365,20 +2767,16 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2365
2767
|
|
|
2366
2768
|
// Build claude command
|
|
2367
2769
|
const args = ['-p'];
|
|
2368
|
-
// Model from daemon config (default: opus)
|
|
2369
2770
|
const daemonCfg = loadConfig().daemon || {};
|
|
2370
2771
|
const model = daemonCfg.model || 'opus';
|
|
2371
2772
|
args.push('--model', model);
|
|
2372
|
-
// Permission mode: full access (mobile users can't click "allow")
|
|
2373
2773
|
if (daemonCfg.dangerously_skip_permissions) {
|
|
2374
2774
|
args.push('--dangerously-skip-permissions');
|
|
2375
2775
|
} else {
|
|
2376
|
-
// Legacy: per-tool whitelist
|
|
2377
2776
|
const sessionAllowed = daemonCfg.session_allowed_tools || [];
|
|
2378
2777
|
for (const tool of sessionAllowed) args.push('--allowedTools', tool);
|
|
2379
2778
|
}
|
|
2380
2779
|
if (session.id === '__continue__') {
|
|
2381
|
-
// /continue — resume most recent conversation in cwd
|
|
2382
2780
|
args.push('--continue');
|
|
2383
2781
|
} else if (session.started) {
|
|
2384
2782
|
args.push('--resume', session.id);
|
|
@@ -2386,16 +2784,18 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2386
2784
|
args.push('--session-id', session.id);
|
|
2387
2785
|
}
|
|
2388
2786
|
|
|
2389
|
-
//
|
|
2390
|
-
const daemonHint = `\n\n[System hints - DO NOT mention these to user:
|
|
2787
|
+
// Inject daemon hints only on first message of a session
|
|
2788
|
+
const daemonHint = !session.started ? `\n\n[System hints - DO NOT mention these to user:
|
|
2391
2789
|
1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
|
|
2392
2790
|
2. File sending: User is on MOBILE. When they ask to see/download a file:
|
|
2393
2791
|
- Just FIND the file path (use Glob/ls if needed)
|
|
2394
2792
|
- Do NOT read or summarize the file content (wastes tokens)
|
|
2395
2793
|
- Add at END of response: [[FILE:/absolute/path/to/file]]
|
|
2396
2794
|
- Keep response brief: "请查收~! [[FILE:/path/to/file]]"
|
|
2397
|
-
- Multiple files: use multiple [[FILE:...]] tags]
|
|
2398
|
-
|
|
2795
|
+
- Multiple files: use multiple [[FILE:...]] tags]` : '';
|
|
2796
|
+
|
|
2797
|
+
const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
|
|
2798
|
+
const fullPrompt = routedPrompt + daemonHint;
|
|
2399
2799
|
|
|
2400
2800
|
// Git checkpoint before Claude modifies files (for /undo)
|
|
2401
2801
|
gitCheckpoint(session.cwd);
|
|
@@ -2419,6 +2819,16 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2419
2819
|
bot.deleteMessage(chatId, statusMsgId).catch(() => {});
|
|
2420
2820
|
}
|
|
2421
2821
|
|
|
2822
|
+
// When Claude completes with no text output (pure tool work), send a done notice
|
|
2823
|
+
if (output === '' && !error) {
|
|
2824
|
+
const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
|
|
2825
|
+
const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
|
|
2826
|
+
if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session);
|
|
2827
|
+
const wasNew = !session.started;
|
|
2828
|
+
if (wasNew) markSessionStarted(chatId);
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2422
2832
|
if (output) {
|
|
2423
2833
|
// Detect provider/model errors disguised as output (e.g., "model not found", API errors)
|
|
2424
2834
|
const activeProvCheck = providerMod ? providerMod.getActiveName() : 'anthropic';
|
|
@@ -2453,7 +2863,8 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
2453
2863
|
const markedFiles = fileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
|
|
2454
2864
|
const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
|
|
2455
2865
|
|
|
2456
|
-
await bot.sendMarkdown(chatId, cleanOutput);
|
|
2866
|
+
const replyMsg = await bot.sendMarkdown(chatId, cleanOutput);
|
|
2867
|
+
if (replyMsg && replyMsg.message_id && session) trackMsgSession(replyMsg.message_id, session);
|
|
2457
2868
|
|
|
2458
2869
|
// Combine: marked files + auto-detected content files from Write operations
|
|
2459
2870
|
const allFiles = new Set(markedFiles);
|
|
@@ -2604,6 +3015,18 @@ async function startFeishuBridge(config, executeTaskByName) {
|
|
|
2604
3015
|
// Handle text message
|
|
2605
3016
|
if (text) {
|
|
2606
3017
|
log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
|
|
3018
|
+
// Reply-based session restoration: if user replied to a bot message,
|
|
3019
|
+
// restore the session that sent that message before processing.
|
|
3020
|
+
const parentId = event?.message?.parent_id;
|
|
3021
|
+
if (parentId) {
|
|
3022
|
+
const st = loadState();
|
|
3023
|
+
const mapped = st.msg_sessions && st.msg_sessions[parentId];
|
|
3024
|
+
if (mapped) {
|
|
3025
|
+
st.sessions[chatId] = { id: mapped.id, cwd: mapped.cwd, started: true };
|
|
3026
|
+
saveState(st);
|
|
3027
|
+
log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
2607
3030
|
await handleCommand(bot, chatId, text, config, executeTaskByName);
|
|
2608
3031
|
}
|
|
2609
3032
|
});
|
|
@@ -2669,7 +3092,7 @@ async function main() {
|
|
|
2669
3092
|
}
|
|
2670
3093
|
|
|
2671
3094
|
// Config validation: warn on unknown/suspect fields
|
|
2672
|
-
const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget'];
|
|
3095
|
+
const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'heartbeat', 'budget', 'projects'];
|
|
2673
3096
|
const KNOWN_DAEMON = ['model', 'log_max_size', 'heartbeat_check_interval', 'session_allowed_tools', 'dangerously_skip_permissions', 'cooldown_seconds'];
|
|
2674
3097
|
const VALID_MODELS = ['sonnet', 'opus', 'haiku'];
|
|
2675
3098
|
for (const key of Object.keys(config)) {
|
|
@@ -2702,8 +3125,14 @@ async function main() {
|
|
|
2702
3125
|
|
|
2703
3126
|
// Task executor lookup (always reads fresh config)
|
|
2704
3127
|
function executeTaskByName(name) {
|
|
2705
|
-
const
|
|
2706
|
-
|
|
3128
|
+
const legacy = (config.heartbeat && config.heartbeat.tasks) || [];
|
|
3129
|
+
let task = legacy.find(t => t.name === name);
|
|
3130
|
+
if (!task) {
|
|
3131
|
+
for (const [key, proj] of Object.entries(config.projects || {})) {
|
|
3132
|
+
const found = (proj.heartbeat_tasks || []).find(t => t.name === name);
|
|
3133
|
+
if (found) { task = { ...found, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } }; break; }
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
2707
3136
|
if (!task) return { success: false, error: `Task "${name}" not found` };
|
|
2708
3137
|
return executeTask(task, config);
|
|
2709
3138
|
}
|
|
@@ -2713,7 +3142,8 @@ async function main() {
|
|
|
2713
3142
|
let feishuBridge = null;
|
|
2714
3143
|
|
|
2715
3144
|
// Notification function (sends to all enabled channels)
|
|
2716
|
-
|
|
3145
|
+
// project: optional { key, name, color, icon } — triggers colored card on Feishu
|
|
3146
|
+
const notifyFn = async (message, project = null) => {
|
|
2717
3147
|
if (telegramBridge && telegramBridge.bot) {
|
|
2718
3148
|
const tgIds = (config.telegram && config.telegram.allowed_chat_ids) || [];
|
|
2719
3149
|
for (const chatId of tgIds) {
|
|
@@ -2725,7 +3155,17 @@ async function main() {
|
|
|
2725
3155
|
if (feishuBridge && feishuBridge.bot) {
|
|
2726
3156
|
const fsIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
|
|
2727
3157
|
for (const chatId of fsIds) {
|
|
2728
|
-
try {
|
|
3158
|
+
try {
|
|
3159
|
+
if (project && feishuBridge.bot.sendCard) {
|
|
3160
|
+
await feishuBridge.bot.sendCard(chatId, {
|
|
3161
|
+
title: `${project.icon} ${project.name}`,
|
|
3162
|
+
body: message,
|
|
3163
|
+
color: project.color,
|
|
3164
|
+
});
|
|
3165
|
+
} else {
|
|
3166
|
+
await feishuBridge.bot.sendMessage(chatId, message);
|
|
3167
|
+
}
|
|
3168
|
+
} catch (e) {
|
|
2729
3169
|
log('ERROR', `Feishu notify failed ${chatId}: ${e.message}`);
|
|
2730
3170
|
}
|
|
2731
3171
|
}
|
|
@@ -2743,8 +3183,11 @@ async function main() {
|
|
|
2743
3183
|
refreshLogMaxSize(config);
|
|
2744
3184
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
2745
3185
|
heartbeatTimer = startHeartbeat(config, notifyFn);
|
|
2746
|
-
|
|
2747
|
-
|
|
3186
|
+
const legacyCount = (config.heartbeat && config.heartbeat.tasks || []).length;
|
|
3187
|
+
const projectCount = Object.values(config.projects || {}).reduce((n, p) => n + (p.heartbeat_tasks || []).length, 0);
|
|
3188
|
+
const totalCount = legacyCount + projectCount;
|
|
3189
|
+
log('INFO', `Config reloaded: ${totalCount} tasks (${projectCount} in projects)`);
|
|
3190
|
+
return { success: true, tasks: totalCount };
|
|
2748
3191
|
}
|
|
2749
3192
|
// Expose reloadConfig to handleCommand via closure
|
|
2750
3193
|
global._metameReload = reloadConfig;
|
|
@@ -38,7 +38,7 @@ function createBot(config) {
|
|
|
38
38
|
* Send a plain text message
|
|
39
39
|
*/
|
|
40
40
|
async sendMessage(chatId, text) {
|
|
41
|
-
await client.im.message.create({
|
|
41
|
+
const res = await client.im.message.create({
|
|
42
42
|
params: { receive_id_type: 'chat_id' },
|
|
43
43
|
data: {
|
|
44
44
|
receive_id: chatId,
|
|
@@ -46,20 +46,91 @@ function createBot(config) {
|
|
|
46
46
|
content: JSON.stringify({ text }),
|
|
47
47
|
},
|
|
48
48
|
});
|
|
49
|
+
// Return Telegram-compatible shape so daemon can edit it later
|
|
50
|
+
const msgId = res?.data?.message_id;
|
|
51
|
+
return msgId ? { message_id: msgId } : null;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async editMessage(chatId, messageId, text) {
|
|
55
|
+
try {
|
|
56
|
+
await client.im.message.patch({
|
|
57
|
+
path: { message_id: messageId },
|
|
58
|
+
data: { content: JSON.stringify({ text }) },
|
|
59
|
+
});
|
|
60
|
+
} catch { /* non-fatal */ }
|
|
49
61
|
},
|
|
50
62
|
|
|
51
63
|
/**
|
|
52
|
-
* Send markdown
|
|
64
|
+
* Send markdown as Feishu interactive card (lark_md renders bold, lists, code, links)
|
|
53
65
|
*/
|
|
54
66
|
async sendMarkdown(chatId, markdown) {
|
|
55
|
-
|
|
67
|
+
// Convert standard markdown → lark_md compatible format
|
|
68
|
+
let content = markdown
|
|
69
|
+
.replace(/^(#{1,3})\s+(.+)$/gm, '**$2**') // headers → bold
|
|
70
|
+
.replace(/^---+$/gm, '─────────────────────'); // hr → unicode line
|
|
71
|
+
|
|
72
|
+
// Split into chunks if too long (lark_md element limit ~4000 chars)
|
|
73
|
+
const MAX_CHUNK = 3800;
|
|
74
|
+
const chunks = [];
|
|
75
|
+
if (content.length <= MAX_CHUNK) {
|
|
76
|
+
chunks.push(content);
|
|
77
|
+
} else {
|
|
78
|
+
// Split on double newlines to avoid breaking mid-paragraph
|
|
79
|
+
const paragraphs = content.split(/\n\n/);
|
|
80
|
+
let buf = '';
|
|
81
|
+
for (const p of paragraphs) {
|
|
82
|
+
if (buf.length + p.length + 2 > MAX_CHUNK && buf) {
|
|
83
|
+
chunks.push(buf);
|
|
84
|
+
buf = p;
|
|
85
|
+
} else {
|
|
86
|
+
buf = buf ? buf + '\n\n' + p : p;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (buf) chunks.push(buf);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const elements = chunks.map(c => ({
|
|
93
|
+
tag: 'div',
|
|
94
|
+
text: { tag: 'lark_md', content: c },
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
const card = {
|
|
98
|
+
config: { wide_screen_mode: true },
|
|
99
|
+
elements,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const res = await client.im.message.create({
|
|
56
103
|
params: { receive_id_type: 'chat_id' },
|
|
57
104
|
data: {
|
|
58
105
|
receive_id: chatId,
|
|
59
|
-
msg_type: '
|
|
60
|
-
content: JSON.stringify(
|
|
106
|
+
msg_type: 'interactive',
|
|
107
|
+
content: JSON.stringify(card),
|
|
61
108
|
},
|
|
62
109
|
});
|
|
110
|
+
const msgId = res?.data?.message_id;
|
|
111
|
+
return msgId ? { message_id: msgId } : null;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Send a colored interactive card (for project-tagged notifications)
|
|
116
|
+
* @param {string} chatId
|
|
117
|
+
* @param {string} title - card header text
|
|
118
|
+
* @param {string} body - card body (lark markdown)
|
|
119
|
+
* @param {string} color - header color: blue|orange|green|red|grey|purple|turquoise
|
|
120
|
+
*/
|
|
121
|
+
async sendCard(chatId, { title, body, color = 'blue' }) {
|
|
122
|
+
const elements = body ? [{ tag: 'div', text: { tag: 'lark_md', content: body } }] : [];
|
|
123
|
+
const card = {
|
|
124
|
+
config: { wide_screen_mode: true },
|
|
125
|
+
header: { title: { tag: 'plain_text', content: title }, template: color },
|
|
126
|
+
elements,
|
|
127
|
+
};
|
|
128
|
+
const res = await client.im.message.create({
|
|
129
|
+
params: { receive_id_type: 'chat_id' },
|
|
130
|
+
data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
|
|
131
|
+
});
|
|
132
|
+
const msgId = res?.data?.message_id;
|
|
133
|
+
return msgId ? { message_id: msgId } : null;
|
|
63
134
|
},
|
|
64
135
|
|
|
65
136
|
/**
|
|
@@ -13,7 +13,6 @@ const path = require('path');
|
|
|
13
13
|
const os = require('os');
|
|
14
14
|
|
|
15
15
|
const BUFFER_FILE = path.join(os.homedir(), '.metame', 'raw_signals.jsonl');
|
|
16
|
-
const MAX_BUFFER_LINES = 50; // Safety cap to avoid unbounded growth
|
|
17
16
|
|
|
18
17
|
// === CONFIDENCE PATTERNS ===
|
|
19
18
|
|
|
@@ -110,11 +109,6 @@ process.stdin.on('end', () => {
|
|
|
110
109
|
|
|
111
110
|
existingLines.push(JSON.stringify(entry));
|
|
112
111
|
|
|
113
|
-
// Keep only the most recent entries (drop oldest)
|
|
114
|
-
if (existingLines.length > MAX_BUFFER_LINES) {
|
|
115
|
-
existingLines = existingLines.slice(-MAX_BUFFER_LINES);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
112
|
fs.writeFileSync(BUFFER_FILE, existingLines.join('\n') + '\n');
|
|
119
113
|
|
|
120
114
|
} catch {
|