metame-cli 1.5.26 → 1.6.1

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.
Files changed (46) hide show
  1. package/index.js +4 -1
  2. package/package.json +1 -1
  3. package/scripts/agent-layer.js +36 -0
  4. package/scripts/core/chunker.js +100 -0
  5. package/scripts/core/embedding.js +225 -0
  6. package/scripts/core/hybrid-search.js +296 -0
  7. package/scripts/core/wiki-db.js +545 -0
  8. package/scripts/core/wiki-prompt.js +88 -0
  9. package/scripts/core/wiki-slug.js +66 -0
  10. package/scripts/core/wiki-staleness.js +18 -0
  11. package/scripts/daemon-agent-commands.js +10 -4
  12. package/scripts/daemon-bridges.js +16 -0
  13. package/scripts/daemon-claude-engine.js +62 -8
  14. package/scripts/daemon-command-router.js +40 -1
  15. package/scripts/daemon-default.yaml +33 -3
  16. package/scripts/daemon-embedding.js +162 -0
  17. package/scripts/daemon-engine-runtime.js +1 -1
  18. package/scripts/daemon-health-scan.js +185 -0
  19. package/scripts/daemon-ops-commands.js +9 -18
  20. package/scripts/daemon-runtime-lifecycle.js +1 -1
  21. package/scripts/daemon-session-commands.js +4 -0
  22. package/scripts/daemon-task-scheduler.js +5 -3
  23. package/scripts/daemon-warm-pool.js +15 -0
  24. package/scripts/daemon-wiki.js +420 -0
  25. package/scripts/daemon.js +10 -5
  26. package/scripts/distill.js +1 -1
  27. package/scripts/docs/file-transfer.md +0 -1
  28. package/scripts/docs/maintenance-manual.md +2 -55
  29. package/scripts/docs/pointer-map.md +0 -34
  30. package/scripts/feishu-adapter.js +25 -0
  31. package/scripts/hooks/intent-file-transfer.js +1 -2
  32. package/scripts/memory-backfill-chunks.js +92 -0
  33. package/scripts/memory-search.js +49 -6
  34. package/scripts/memory-wiki-schema.js +255 -0
  35. package/scripts/memory.js +103 -3
  36. package/scripts/signal-capture.js +1 -1
  37. package/scripts/skill-evolution.js +2 -11
  38. package/scripts/wiki-cluster.js +121 -0
  39. package/scripts/wiki-extract.js +171 -0
  40. package/scripts/wiki-facts.js +351 -0
  41. package/scripts/wiki-import.js +256 -0
  42. package/scripts/wiki-reflect-build.js +441 -0
  43. package/scripts/wiki-reflect-export.js +448 -0
  44. package/scripts/wiki-reflect-query.js +109 -0
  45. package/scripts/wiki-reflect.js +338 -0
  46. package/scripts/wiki-synthesis.js +224 -0
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * daemon-health-scan.js — Daily Daemon Health Report
5
+ *
6
+ * Reads ~/.metame/daemon.log for last 24h ERROR/WARN entries,
7
+ * calls LLM (Haiku) to analyze root causes and propose fixes,
8
+ * saves report to ~/.metame/health-report-latest.json,
9
+ * then prints a formatted summary to stdout.
10
+ *
11
+ * Heartbeat: daily at 08:30 via daemon.yaml
12
+ * notify: true → daemon sends stdout to Feishu automatically.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+ const { callHaiku, buildDistillEnv } = require('./providers');
19
+
20
+ const HOME = os.homedir();
21
+ const LOG_FILE = path.join(HOME, '.metame', 'daemon.log');
22
+ const REPORT_FILE = path.join(HOME, '.metame', 'health-report-latest.json');
23
+ const WINDOW_MS = 24 * 60 * 60 * 1000;
24
+ const MAX_UNIQUE_ERRORS = 8;
25
+ const MAX_LINE_LEN = 280;
26
+
27
+ // Match log lines that contain an ERROR or WARN level tag
28
+ const LEVEL_PATTERN = /\[(ERROR|WARN)\]/;
29
+ // Extract ISO timestamp from log line prefix like [2026-04-10T08:00:00
30
+ const TS_PATTERN = /^\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})/;
31
+
32
+ function readRecentErrors(logFile, windowMs) {
33
+ let content;
34
+ try {
35
+ content = fs.readFileSync(logFile, 'utf8');
36
+ } catch {
37
+ return [];
38
+ }
39
+
40
+ const cutoff = Date.now() - windowMs;
41
+ const lines = content.split('\n').filter(Boolean);
42
+ const result = [];
43
+
44
+ for (const line of lines) {
45
+ if (!LEVEL_PATTERN.test(line)) continue;
46
+ const tsMatch = line.match(TS_PATTERN);
47
+ if (tsMatch) {
48
+ const ts = new Date(tsMatch[1]).getTime();
49
+ if (ts < cutoff) continue;
50
+ }
51
+ result.push(line.slice(0, MAX_LINE_LEN));
52
+ }
53
+
54
+ return result;
55
+ }
56
+
57
+ function groupErrors(lines) {
58
+ const counts = new Map();
59
+ for (const line of lines) {
60
+ // Normalize numbers to reduce noise, use first 100 chars as bucket key
61
+ const key = line.slice(0, 100).replace(/\d+/g, 'N').replace(/[a-f0-9]{8,}/gi, 'HASH');
62
+ counts.set(key, (counts.get(key) || 0) + 1);
63
+ }
64
+ // Sort by frequency descending
65
+ return Array.from(counts.entries())
66
+ .sort((a, b) => b[1] - a[1])
67
+ .slice(0, MAX_UNIQUE_ERRORS)
68
+ .map(([key, count]) => ({ key: key.trim(), count }));
69
+ }
70
+
71
+ async function analyzeWithLLM(grouped, totalCount) {
72
+ const errorList = grouped
73
+ .map(({ key, count }) => `[×${count}] ${key}`)
74
+ .join('\n');
75
+
76
+ const prompt = `你是 MetaMe daemon 的健康分析师。以下是过去24小时的错误/警告日志(已去重,按频次排序):
77
+
78
+ ${errorList}
79
+
80
+ 请分析并以 JSON 格式回复:
81
+ {
82
+ "summary": "一句话总结(20字以内)",
83
+ "severity": "low|medium|high",
84
+ "issues": [
85
+ {
86
+ "name": "问题名称(10字以内)",
87
+ "count": 频次,
88
+ "cause": "根因(30字以内)",
89
+ "fix": "修复建议(50字以内)"
90
+ }
91
+ ],
92
+ "action": "最紧迫的下一步行动(30字以内)"
93
+ }
94
+
95
+ severity 判断:high=影响功能/数据/重复崩溃,medium=有异常但仍可运行,low=轻微警告。
96
+ 只输出 JSON,不要解释。`;
97
+
98
+ let distillEnv = {};
99
+ try { distillEnv = buildDistillEnv(); } catch { /* ignore */ }
100
+
101
+ try {
102
+ const raw = await Promise.race([
103
+ callHaiku(prompt, distillEnv, 60000),
104
+ new Promise((_, reject) => setTimeout(() => reject(new Error('llm_timeout')), 90000)),
105
+ ]);
106
+ const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
107
+ const parsed = JSON.parse(cleaned);
108
+ // Basic validation
109
+ if (!parsed.summary || !parsed.severity || !Array.isArray(parsed.issues)) {
110
+ throw new Error('invalid structure');
111
+ }
112
+ return parsed;
113
+ } catch {
114
+ // Fallback: no LLM
115
+ return {
116
+ summary: `发现 ${totalCount} 条错误/警告`,
117
+ severity: 'medium',
118
+ issues: grouped.slice(0, 5).map(({ key, count }) => ({
119
+ name: key.slice(0, 30),
120
+ count,
121
+ cause: '待分析',
122
+ fix: '手动检查 daemon.log',
123
+ })),
124
+ action: '手动检查 ~/.metame/daemon.log',
125
+ };
126
+ }
127
+ }
128
+
129
+ function formatReport(analysis, totalCount, uniqueTypes) {
130
+ const emoji = { low: '🟡', medium: '🟠', high: '🔴' }[analysis.severity] || '🟠';
131
+ const date = new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' });
132
+
133
+ const issueLines = (analysis.issues || []).slice(0, 5).map(issue =>
134
+ `• ${issue.name}(×${issue.count})\n 根因:${issue.cause}\n 建议:${issue.fix}`
135
+ ).join('\n\n');
136
+
137
+ return [
138
+ `${emoji} Daemon 健康报告 · ${date}`,
139
+ ``,
140
+ `📊 过去24h:${totalCount} 条错误/警告,${uniqueTypes} 种类型`,
141
+ `📝 摘要:${analysis.summary}`,
142
+ ``,
143
+ `🔍 问题详情:`,
144
+ issueLines,
145
+ ``,
146
+ `⚡ 建议:${analysis.action}`,
147
+ ``,
148
+ `---`,
149
+ `需要修复?回复「修」,我来处理。`,
150
+ ].join('\n');
151
+ }
152
+
153
+ async function run() {
154
+ const errorLines = readRecentErrors(LOG_FILE, WINDOW_MS);
155
+
156
+ if (errorLines.length === 0) {
157
+ console.log('✅ Daemon 健康正常 · 过去24小时无错误/警告');
158
+ return;
159
+ }
160
+
161
+ const grouped = groupErrors(errorLines);
162
+ const analysis = await analyzeWithLLM(grouped, errorLines.length);
163
+
164
+ // Save full report for "修" handler to load
165
+ const report = {
166
+ generated_at: new Date().toISOString(),
167
+ total_errors: errorLines.length,
168
+ unique_types: grouped.length,
169
+ analysis,
170
+ raw_grouped: grouped,
171
+ };
172
+
173
+ try {
174
+ fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2), 'utf8');
175
+ } catch (e) {
176
+ process.stderr.write(`[health-scan] failed to write report: ${e.message}\n`);
177
+ }
178
+
179
+ console.log(formatReport(analysis, errorLines.length, grouped.length));
180
+ }
181
+
182
+ run().catch(e => {
183
+ process.stderr.write(`[daemon-health-scan] fatal: ${e.message}\n`);
184
+ process.exit(1);
185
+ });
@@ -87,30 +87,22 @@ function createOpsCommandHandler(deps) {
87
87
  }
88
88
  try {
89
89
  let diffFiles = '';
90
- let diffFailed = false;
91
90
  const _wh = process.platform === 'win32' ? { windowsHide: true } : {};
92
- try { diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000, ..._wh }).trim(); } catch { diffFailed = true; }
91
+ try { diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000, ..._wh }).trim(); } catch { }
93
92
  const changedFiles = diffFiles ? diffFiles.split('\n').filter(Boolean) : [];
94
- if (changedFiles.length > 0 || diffFailed) {
95
- // Save current state before rollback (safety net)
96
- gitCheckpoint(cwd, '[metame-safety] before rollback');
97
- // Reset HEAD to checkpoint's parent (removes any commits Claude made)
98
- if (match.parentHash) {
99
- execSync(`git reset --hard ${match.parentHash}`, { cwd, stdio: 'ignore', timeout: 10000, ..._wh });
100
- }
101
- // Restore only changed files (not entire worktree) to preserve user's manual edits
102
- if (changedFiles.length > 0) {
103
- execFileSync('git', ['checkout', match.hash, '--', ...changedFiles], { cwd, stdio: 'ignore', timeout: 10000 });
104
- } else {
105
- // diff failed but we still reset — fallback to full restore
106
- execFileSync('git', ['checkout', match.hash, '--', '.'], { cwd, stdio: 'ignore', timeout: 10000 });
107
- }
93
+ // Reset HEAD to checkpoint's parent (removes any commits Claude made)
94
+ if (match.parentHash) {
95
+ execSync(`git reset --hard ${match.parentHash}`, { cwd, stdio: 'ignore', timeout: 10000, ..._wh });
96
+ }
97
+ // Restore only files touched between the checkpoint and HEAD; unrelated user edits must survive.
98
+ if (changedFiles.length > 0) {
99
+ execFileSync('git', ['checkout', match.hash, '--', ...changedFiles], { cwd, stdio: 'ignore', timeout: 10000 });
108
100
  }
109
101
  // Truncate context to checkpoint time (covers multi-turn rollback)
110
102
  truncateSessionToCheckpoint(session.id, match.message);
103
+ const fileList = changedFiles.map(f => path.basename(f)).join(', ');
111
104
  const fileCount = changedFiles.length;
112
105
  let msg = `⏪ 已回退到 ${cpDisplayLabel(match.message)}`;
113
- const fileList = changedFiles.map(f => path.basename(f)).join(', ');
114
106
  if (fileCount > 0) msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
115
107
  log('INFO', `/undo <hash> executed for ${chatId}: reset to ${match.hash.slice(0, 8)}, files=${fileCount}`);
116
108
  await bot.sendMessage(chatId, msg);
@@ -251,7 +243,6 @@ function createOpsCommandHandler(deps) {
251
243
  if (cpMatch.parentHash) {
252
244
  execSync(`git reset --hard ${cpMatch.parentHash}`, { cwd: cwd2, stdio: 'ignore', timeout: 10000, ..._wh2 });
253
245
  }
254
- // Restore only changed files (not entire worktree) to preserve user's manual edits
255
246
  execFileSync('git', ['checkout', cpMatch.hash, '--', ...changedFiles2], { cwd: cwd2, stdio: 'ignore', timeout: 10000 });
256
247
  gitMsg2 = `\n📁 ${changedFiles2.length} 个文件已恢复`;
257
248
  cleanupCheckpoints(cwd2);
@@ -153,7 +153,7 @@ function setupRuntimeWatchers(deps) {
153
153
  refreshLogMaxSize(newConfig);
154
154
  const timer = getHeartbeatTimer();
155
155
  if (timer) clearInterval(timer);
156
- setHeartbeatTimer(startHeartbeat(newConfig, notifyFn, notifyPersonalFn));
156
+ setHeartbeatTimer(startHeartbeat(newConfig, notifyFn, notifyPersonalFn, adminNotifyFn));
157
157
  const { general, project } = getAllTasks(newConfig);
158
158
  const totalCount = general.length + project.length;
159
159
  log('INFO', `Config reloaded: ${totalCount} tasks (${project.length} in projects)`);
@@ -36,6 +36,7 @@ function createSessionCommandHandler(deps) {
36
36
  getSessionRecentContext,
37
37
  getSessionRecentDialogue,
38
38
  getDefaultEngine = () => 'claude',
39
+ releaseWarmPool,
39
40
  } = deps;
40
41
 
41
42
  function normalizeEngineName(name) {
@@ -412,10 +413,13 @@ function createSessionCommandHandler(deps) {
412
413
  return true;
413
414
  }
414
415
 
416
+ const sessionKeyForWarm = getSessionRoute(chatId).sessionChatId;
415
417
  const state2 = loadState();
416
418
  const targetEngine = normalizeEngineName(target.engine) || getCurrentEngine(chatId);
417
419
  const attached = attachResolvedTarget(state2, chatId, targetEngine, target, target.projectPath || HOME);
418
420
  saveState(state2);
421
+ // Evict warm process so next spawn uses --resume <new-session-id>
422
+ if (typeof releaseWarmPool === 'function') releaseWarmPool(sessionKeyForWarm);
419
423
 
420
424
  const recentCtx = typeof getSessionRecentContext === 'function'
421
425
  ? getSessionRecentContext(target.sessionId)
@@ -660,7 +660,7 @@ function createTaskScheduler(deps) {
660
660
  return found || null;
661
661
  }
662
662
 
663
- function startHeartbeat(config, notifyFn, notifyPersonalFn) {
663
+ function startHeartbeat(config, notifyFn, notifyPersonalFn, adminNotifyFn) {
664
664
  const { all: tasks } = getAllTasks(config);
665
665
 
666
666
  const enabledTasks = tasks.filter(t => t.enabled !== false);
@@ -787,10 +787,12 @@ function createTaskScheduler(deps) {
787
787
  }
788
788
  if (task.notify && notifyFn && !result.skipped) {
789
789
  const proj = task._project || null;
790
+ // Tasks without a project are system-level — send to admin chat only
791
+ const sendFn = proj ? notifyFn : (adminNotifyFn || notifyFn);
790
792
  if (result.success) {
791
- notifyFn(`✅ *${task.name}* completed\n\n${result.output}`, proj);
793
+ sendFn(`✅ *${task.name}* completed\n\n${result.output}`, proj);
792
794
  } else {
793
- notifyFn(`❌ *${task.name}* failed: ${result.error}`, proj);
795
+ sendFn(`❌ *${task.name}* failed: ${result.error}`, proj);
794
796
  }
795
797
  }
796
798
  })
@@ -161,6 +161,20 @@ function createWarmPool(deps) {
161
161
  }
162
162
  }
163
163
 
164
+ /**
165
+ * Non-destructive validity check. Returns true if a live warm process exists for the key.
166
+ * Mirrors acquireWarm's dead-process checks without consuming the entry.
167
+ */
168
+ function hasWarm(sessionKey) {
169
+ const entry = pool.get(sessionKey);
170
+ if (!entry) return false;
171
+ if (entry.child.killed || entry.child.exitCode !== null) {
172
+ _cleanup(sessionKey);
173
+ return false;
174
+ }
175
+ return true;
176
+ }
177
+
164
178
  /**
165
179
  * Build the stream-json user message for stdin.
166
180
  */
@@ -179,6 +193,7 @@ function createWarmPool(deps) {
179
193
  releaseWarm,
180
194
  releaseAll,
181
195
  buildStreamMessage,
196
+ hasWarm,
182
197
  _pool: pool,
183
198
  };
184
199
  }