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,420 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * daemon-wiki.js — /wiki command handler
5
+ *
6
+ * Subcommands (Phase 1):
7
+ * /wiki — list all wiki pages (title, staleness, last_built)
8
+ * /wiki research <query> — search wiki + facts, format answer (trackSearch: true)
9
+ * /wiki page <slug> — show full content of a page
10
+ * /wiki sync — force rebuild stale pages (staleness ≥ 0.4)
11
+ * /wiki pin <tag> [title] — manually register a topic (force=true, pinned=1)
12
+ * /wiki open — open Obsidian vault
13
+ *
14
+ * Exports:
15
+ * createWikiCommandHandler(deps) → { handleWikiCommand }
16
+ */
17
+
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const { execFileSync } = require('child_process');
21
+
22
+ const {
23
+ listWikiPages,
24
+ getWikiPageBySlug,
25
+ searchWikiAndFacts,
26
+ upsertWikiTopic,
27
+ } = require('./core/wiki-db');
28
+
29
+ const STALENESS_THRESHOLD = 0.4;
30
+ const DEFAULT_WIKI_DIR = path.join(os.homedir(), 'Documents', 'MetaMe-Wiki');
31
+
32
+ function createWikiCommandHandler(deps) {
33
+ const {
34
+ getDb, // () → DatabaseSync
35
+ providers, // { callHaiku, buildDistillEnv }
36
+ wikiOutputDir, // optional — path to Obsidian vault wiki folder
37
+ log = () => {},
38
+ } = deps;
39
+
40
+ const outputDir = wikiOutputDir || DEFAULT_WIKI_DIR;
41
+
42
+ /**
43
+ * Main entry point. Returns true if /wiki command was handled.
44
+ * @param {{ bot: object, chatId: string, text: string }} ctx
45
+ * @returns {Promise<boolean>}
46
+ */
47
+ async function handleWikiCommand(ctx) {
48
+ const { bot, chatId, text } = ctx;
49
+ if (typeof text !== 'string') return false;
50
+
51
+ const trimmed = text.trim();
52
+ if (trimmed === '/wiki') {
53
+ await _handleList(bot, chatId);
54
+ return true;
55
+ }
56
+ if (trimmed === '/wiki research' || trimmed.startsWith('/wiki research ')) {
57
+ const query = trimmed.slice(15).trim();
58
+ await _handleResearch(bot, chatId, query);
59
+ return true;
60
+ }
61
+ if (trimmed === '/wiki page' || trimmed.startsWith('/wiki page ')) {
62
+ const slug = trimmed.slice(11).trim();
63
+ await _handlePage(bot, chatId, slug);
64
+ return true;
65
+ }
66
+ if (trimmed === '/wiki sync') {
67
+ await _handleSync(bot, chatId);
68
+ return true;
69
+ }
70
+ if (trimmed === '/wiki pin' || trimmed.startsWith('/wiki pin ')) {
71
+ const args = trimmed.slice(10).trim();
72
+ await _handlePin(bot, chatId, args);
73
+ return true;
74
+ }
75
+ if (trimmed === '/wiki open') {
76
+ await _handleOpen(bot, chatId);
77
+ return true;
78
+ }
79
+ if (trimmed === '/wiki help' || trimmed === '/wiki ?') {
80
+ await _handleHelp(bot, chatId);
81
+ return true;
82
+ }
83
+ if (trimmed === '/wiki import' || trimmed.startsWith('/wiki import ')) {
84
+ const args = trimmed.slice(12).trim();
85
+ await _handleImport(bot, chatId, args);
86
+ return true;
87
+ }
88
+ // Unknown /wiki subcommand — show help
89
+ if (trimmed.startsWith('/wiki ')) {
90
+ await _handleHelp(bot, chatId);
91
+ return true;
92
+ }
93
+
94
+ return false;
95
+ }
96
+
97
+ // ── Subcommand handlers ──────────────────────────────────────────────────────
98
+
99
+ async function _handleList(bot, chatId) {
100
+ const db = getDb();
101
+ const pages = listWikiPages(db, { limit: 50, orderBy: 'title' });
102
+
103
+ if (pages.length === 0) {
104
+ await bot.sendMessage(chatId,
105
+ '📚 Wiki 暂无页面。\n\n使用 `/wiki pin <标签> [标题]` 手工注册第一个主题。'
106
+ );
107
+ return;
108
+ }
109
+
110
+ const lines = ['📚 **知识 Wiki**', ''];
111
+ for (const p of pages) {
112
+ const stalePct = Math.round((p.staleness || 0) * 100);
113
+ const built = p.last_built_at ? p.last_built_at.slice(0, 10) : '未建';
114
+ const staleFlag = p.staleness >= STALENESS_THRESHOLD ? ' ⚠️' : '';
115
+ lines.push(`• **${p.title}** \`${p.slug}\`${staleFlag}`);
116
+ lines.push(` 来源:${p.raw_source_count || 0} 条 · 陈旧度:${stalePct}% · 更新:${built}`);
117
+ }
118
+ lines.push('');
119
+ lines.push(`共 ${pages.length} 页 · \`/wiki research <关键词>\` 搜索`);
120
+
121
+ await bot.sendMessage(chatId, lines.join('\n'));
122
+ }
123
+
124
+ async function _handleResearch(bot, chatId, query) {
125
+ if (!query) {
126
+ await bot.sendMessage(chatId, '用法: `/wiki research <关键词>`');
127
+ return;
128
+ }
129
+
130
+ const db = getDb();
131
+ let wikiPages, facts;
132
+ try {
133
+ const { hybridSearchWiki } = require('./core/hybrid-search');
134
+ ({ wikiPages, facts } = await hybridSearchWiki(db, query, { trackSearch: true }));
135
+ } catch (err) {
136
+ log('WARN', `[wiki-research] hybrid search fallback to FTS: ${err.message}`);
137
+ ({ wikiPages, facts } = searchWikiAndFacts(db, query, { trackSearch: true }));
138
+ }
139
+
140
+ if (wikiPages.length === 0 && facts.length === 0) {
141
+ await bot.sendMessage(chatId,
142
+ `🔍 未找到与「${query}」相关的知识。\n\n可用 \`/wiki pin ${query}\` 手工注册主题,或等待记忆积累后自动建页。`
143
+ );
144
+ return;
145
+ }
146
+
147
+ const lines = [`🔍 **「${query}」相关知识**`, ''];
148
+
149
+ for (const wp of wikiPages.slice(0, 3)) {
150
+ const built = wp.last_built_at ? wp.last_built_at.slice(0, 10) : '—';
151
+ lines.push(`📖 **${wp.title}**`);
152
+ if (wp.excerpt) lines.push(wp.excerpt.replace(/<\/?b>/g, '**'));
153
+ lines.push(`来源: \`${wp.slug}\` · 更新于 ${built}`);
154
+ lines.push('');
155
+ }
156
+
157
+ if (facts.length > 0) {
158
+ lines.push(`📌 **相关事实** (${facts.length} 条)`);
159
+ for (const f of facts.slice(0, 5)) {
160
+ const title = f.title ? `**${f.title}** ` : '';
161
+ const excerpt = f.excerpt
162
+ ? f.excerpt.replace(/<\/?b>/g, '**').slice(0, 120)
163
+ : (f.content || '').slice(0, 120);
164
+ lines.push(`• ${title}${excerpt}`);
165
+ }
166
+ }
167
+
168
+ await bot.sendMessage(chatId, lines.join('\n'));
169
+ }
170
+
171
+ async function _handlePage(bot, chatId, slug) {
172
+ if (!slug) {
173
+ await bot.sendMessage(chatId, '用法: `/wiki page <slug>`');
174
+ return;
175
+ }
176
+
177
+ const db = getDb();
178
+ const page = getWikiPageBySlug(db, slug);
179
+
180
+ if (!page) {
181
+ await bot.sendMessage(chatId, `❌ 未找到页面 \`${slug}\`\n\n用 \`/wiki\` 查看所有页面。`);
182
+ return;
183
+ }
184
+
185
+ const built = page.last_built_at ? page.last_built_at.slice(0, 10) : '未建';
186
+ const stalePct = Math.round((page.staleness || 0) * 100);
187
+
188
+ const lines = [
189
+ `📄 **${page.title}**`,
190
+ `_标签: ${page.primary_topic} · 来源: ${page.raw_source_count || 0} 条 · 陈旧度: ${stalePct}% · 更新: ${built}_`,
191
+ '',
192
+ page.content,
193
+ ];
194
+
195
+ await bot.sendMessage(chatId, lines.join('\n'));
196
+ }
197
+
198
+ async function _handleSync(bot, chatId) {
199
+ const db = getDb();
200
+ const pages = listWikiPages(db, { limit: 200 });
201
+ const staleCount = pages.filter(p => (p.staleness || 0) >= STALENESS_THRESHOLD).length;
202
+
203
+ if (staleCount === 0) {
204
+ await bot.sendMessage(chatId, `✅ Wiki 已是最新状态,无需重建。`);
205
+ return;
206
+ }
207
+
208
+ await bot.sendMessage(chatId, `🔄 开始重建 ${staleCount} 个陈旧页面...`);
209
+
210
+ try {
211
+ const { runWikiReflect } = require('./wiki-reflect');
212
+ const result = await runWikiReflect(db, {
213
+ providers,
214
+ outputDir,
215
+ threshold: STALENESS_THRESHOLD,
216
+ });
217
+
218
+ const lines = ['✅ Wiki 重建完成'];
219
+ if (result.built.length > 0) {
220
+ lines.push(`• 重建: ${result.built.join(', ')}`);
221
+ try {
222
+ const { execFile } = require('child_process');
223
+ const embScript = path.join(os.homedir(), '.metame', 'daemon-embedding.js');
224
+ execFile('node', [embScript], { timeout: 120000, stdio: 'ignore' }, (err) => {
225
+ if (err) log('WARN', `[wiki-sync] embedding trigger failed: ${err.message}`);
226
+ });
227
+ } catch { }
228
+ }
229
+ if (result.failed.length > 0) {
230
+ lines.push(`• 失败: ${result.failed.map(f => f.slug).join(', ')}`);
231
+ }
232
+ if (result.exportFailed.length > 0) {
233
+ lines.push(`• 文件导出失败 (DB 已更新): ${result.exportFailed.join(', ')}`);
234
+ }
235
+ await bot.sendMessage(chatId, lines.join('\n'));
236
+
237
+ // Phased doc + cluster rebuild (wiki-import feature extension)
238
+ const { listStaleDocSources } = require('./core/wiki-db');
239
+ const { buildDocWikiPage } = require('./wiki-reflect-build');
240
+ const { extractText } = require('./wiki-extract');
241
+ const allDocSlugsForSync = db.prepare("SELECT slug FROM doc_sources WHERE status='active'").all().map(r => r.slug);
242
+ const staleDocSources = listStaleDocSources(db);
243
+ const builtDocSlugs = [];
244
+ for (const docSrc of staleDocSources) {
245
+ try {
246
+ const { text } = await extractText(docSrc.file_path);
247
+ const docResult = await buildDocWikiPage(db, docSrc, text, { allowedSlugs: allDocSlugsForSync, providers });
248
+ if (docResult) {
249
+ db.prepare("UPDATE doc_sources SET content_stale=0, built_at=? WHERE id=?")
250
+ .run(new Date().toISOString(), docSrc.id);
251
+ builtDocSlugs.push(docSrc.slug);
252
+ }
253
+ } catch (docErr) {
254
+ db.prepare("UPDATE doc_sources SET error_message=? WHERE id=?").run(docErr.message, docSrc.id);
255
+ log('WARN', `[wiki-sync] doc rebuild failed ${docSrc.slug}: ${docErr.message}`);
256
+ }
257
+ }
258
+ // Cascade stale cluster pages
259
+ if (builtDocSlugs.length > 0) {
260
+ const ph = builtDocSlugs.map(() => '?').join(',');
261
+ const affected = db.prepare(`SELECT DISTINCT page_slug FROM wiki_page_doc_sources
262
+ WHERE role='cluster_member' AND doc_source_id IN
263
+ (SELECT id FROM doc_sources WHERE slug IN (${ph}))`).all(...builtDocSlugs).map(r => r.page_slug);
264
+ if (affected.length) {
265
+ db.prepare(`UPDATE wiki_pages SET staleness=1 WHERE slug IN (${affected.map(() => '?').join(',')})`).run(...affected);
266
+ }
267
+ }
268
+ // Rebuild stale cluster pages after embedding drain
269
+ const { waitForEmbeddingDrain } = require('./wiki-import');
270
+ const phase1ChunkIds = builtDocSlugs.flatMap(slug =>
271
+ db.prepare("SELECT id FROM content_chunks WHERE page_slug=?").all(slug).map(c => c.id)
272
+ );
273
+ const drainedOk = await waitForEmbeddingDrain(db, phase1ChunkIds, (msg) => log('INFO', msg));
274
+ if (drainedOk) {
275
+ const { buildTopicClusterPage } = require('./wiki-reflect-build');
276
+ const { getClusterMemberIds } = require('./core/wiki-db');
277
+ const staleClusterPages = db.prepare("SELECT * FROM wiki_pages WHERE source_type='topic_cluster' AND staleness=1").all();
278
+ for (const cp of staleClusterPages) {
279
+ const memberIds = getClusterMemberIds(db, cp.slug);
280
+ const getDocSrcById = db.prepare("SELECT * FROM doc_sources WHERE id=?");
281
+ const docRows = memberIds.map(id => getDocSrcById.get(id)).filter(Boolean);
282
+ try {
283
+ await buildTopicClusterPage(db, docRows, { allowedSlugs: allDocSlugsForSync, providers, existingClusters: [] });
284
+ } catch (clErr) {
285
+ log('WARN', `[wiki-sync] cluster rebuild failed ${cp.slug}: ${clErr.message}`);
286
+ }
287
+ }
288
+ }
289
+ if (staleDocSources.length > 0) {
290
+ const docMsg = [];
291
+ if (builtDocSlugs.length > 0) docMsg.push(`• 文档页面重建: ${builtDocSlugs.join(', ')}`);
292
+ const docFailed = staleDocSources.length - builtDocSlugs.length;
293
+ if (docFailed > 0) docMsg.push(`• 文档重建失败: ${docFailed} 个(见日志)`);
294
+ if (docMsg.length > 0) {
295
+ await bot.sendMessage(chatId, `📄 文档页面同步\n\n${docMsg.join('\n')}`);
296
+ }
297
+ }
298
+ } catch (err) {
299
+ log('ERROR', `[wiki-sync] ${err.message}`);
300
+ if (err.message.includes('another instance')) {
301
+ await bot.sendMessage(chatId, '⚠️ Wiki 重建正在进行中,请稍后再试。');
302
+ } else {
303
+ await bot.sendMessage(chatId, `❌ Wiki 重建失败: ${err.message}`);
304
+ }
305
+ }
306
+ }
307
+
308
+ async function _handlePin(bot, chatId, args) {
309
+ if (!args) {
310
+ await bot.sendMessage(chatId, '用法: `/wiki pin <标签> [显示名称]`\n例: `/wiki pin session Session管理`');
311
+ return;
312
+ }
313
+
314
+ // Parse: first token = tag, rest = label
315
+ const parts = args.split(/\s+/);
316
+ const tag = parts[0];
317
+ const label = parts.slice(1).join(' ') || tag;
318
+
319
+ const db = getDb();
320
+ try {
321
+ const { slug, isNew } = upsertWikiTopic(db, tag, { label, pinned: 1, force: true });
322
+ if (isNew) {
323
+ await bot.sendMessage(chatId,
324
+ `📌 已注册主题 \`${tag}\` (slug: \`${slug}\`)\n\n使用 \`/wiki sync\` 构建页面,或等待每周自动重建。`
325
+ );
326
+ } else {
327
+ await bot.sendMessage(chatId,
328
+ `📌 主题 \`${tag}\` 已更新标题为「${label}」,pinned=1。`
329
+ );
330
+ }
331
+ } catch (err) {
332
+ log('ERROR', `[wiki-pin] ${err.message}`);
333
+ await bot.sendMessage(chatId, `❌ 注册失败: ${err.message}`);
334
+ }
335
+ }
336
+
337
+ async function _handleOpen(bot, chatId) {
338
+ try {
339
+ // Ensure the vault directory exists (may not yet have any pages)
340
+ const fs = require('fs');
341
+ if (!fs.existsSync(outputDir)) {
342
+ fs.mkdirSync(outputDir, { recursive: true });
343
+ }
344
+ // Try Obsidian URI first (opens vault by path if already configured)
345
+ const vaultName = path.basename(outputDir);
346
+ try {
347
+ execFileSync('open', [`obsidian://open?vault=${encodeURIComponent(vaultName)}`], { timeout: 5000 });
348
+ } catch {
349
+ // Fallback: open folder in Finder — user can then drag into Obsidian
350
+ execFileSync('open', [outputDir], { timeout: 5000 });
351
+ }
352
+ await bot.sendMessage(chatId,
353
+ `📂 已打开 Obsidian vault: \`${outputDir}\`\n\n` +
354
+ `如果是第一次打开,请在 Obsidian 里选 **Open folder as vault** 并选择该目录。\n` +
355
+ `之后用 \`/wiki sync\` 生成页面。`
356
+ );
357
+ } catch (err) {
358
+ log('ERROR', `[wiki-open] ${err.message}`);
359
+ await bot.sendMessage(chatId, `❌ 打开失败: ${err.message}\n\nVault 路径: \`${outputDir}\``);
360
+ }
361
+ }
362
+
363
+ async function _handleImport(bot, chatId, args) {
364
+ const noCluster = args.includes('--no-cluster');
365
+ const inputPath = args.replace('--no-cluster', '').trim();
366
+
367
+ if (!inputPath) {
368
+ await bot.sendMessage(chatId, '用法: `/wiki import <路径或文件>` [--no-cluster]\n\n示例:\n`/wiki import ~/Documents/notes`\n`/wiki import ~/report.pdf`');
369
+ return;
370
+ }
371
+
372
+ const resolvedPath = inputPath.replace(/^~/, require('node:os').homedir());
373
+
374
+ let stat;
375
+ try { stat = require('node:fs').statSync(resolvedPath); }
376
+ catch { await bot.sendMessage(chatId, `❌ 路径不存在: ${resolvedPath}`); return; }
377
+
378
+ const isDir = stat.isDirectory();
379
+ await bot.sendMessage(chatId, `⏳ 开始导入 ${isDir ? '目录' : '文件'}: \`${resolvedPath}\`\n${noCluster ? '(跳过聚类)' : '(含自动聚类)'}`);
380
+
381
+ const { runWikiImport } = require('./wiki-import');
382
+ const db = getDb();
383
+ const logFn = (msg) => { log('INFO', msg); };
384
+
385
+ try {
386
+ const stats = await runWikiImport(db, resolvedPath, {
387
+ providers, noCluster, log: logFn,
388
+ });
389
+ await bot.sendMessage(chatId,
390
+ `✅ 导入完成\n\n` +
391
+ `- 新建/更新页面: ${stats.imported}\n` +
392
+ `- 跳过 (未变更): ${stats.skipped}\n` +
393
+ `- 失败: ${stats.failed}\n` +
394
+ `- 聚类页面: ${stats.clusters}\n\n` +
395
+ `使用 \`/wiki\` 查看全部页面`
396
+ );
397
+ } catch (err) {
398
+ log('ERROR', `[wiki-import] ${err.message}`);
399
+ await bot.sendMessage(chatId, `❌ 导入失败: ${err.message}`);
400
+ }
401
+ }
402
+
403
+ async function _handleHelp(bot, chatId) {
404
+ await bot.sendMessage(chatId, [
405
+ '📚 **Wiki 命令**',
406
+ '',
407
+ '`/wiki` — 列出所有知识页',
408
+ '`/wiki research <关键词>` — 搜索知识',
409
+ '`/wiki page <slug>` — 查看页面全文',
410
+ '`/wiki sync` — 重建陈旧页面',
411
+ '`/wiki import <路径>` — 导入本地文档 (md/txt/PDF)',
412
+ '`/wiki pin <标签> [标题]` — 手工注册主题',
413
+ '`/wiki open` — 在 Obsidian 中打开 vault',
414
+ ].join('\n'));
415
+ }
416
+
417
+ return { handleWikiCommand };
418
+ }
419
+
420
+ module.exports = { createWikiCommandHandler };
package/scripts/daemon.js CHANGED
@@ -1992,6 +1992,10 @@ const { handleAdminCommand } = createAdminCommandHandler({
1992
1992
  getDistillModel,
1993
1993
  });
1994
1994
 
1995
+ // Warm process pool for eliminating Claude CLI cold-start latency
1996
+ // Must be created before createSessionCommandHandler so releaseWarmPool can be passed in.
1997
+ const warmPool = createWarmPool({ log });
1998
+
1995
1999
  const { handleSessionCommand } = createSessionCommandHandler({
1996
2000
  fs,
1997
2001
  path,
@@ -2021,14 +2025,12 @@ const { handleSessionCommand } = createSessionCommandHandler({
2021
2025
  getSessionRecentDialogue,
2022
2026
  sessionLabel,
2023
2027
  getDefaultEngine,
2028
+ releaseWarmPool: (key) => warmPool.releaseWarm(key),
2024
2029
  });
2025
2030
 
2026
2031
  // Message queue for messages received while a task is running
2027
2032
  const messageQueue = new Map(); // chatId -> { messages: string[], notified: false }
2028
2033
 
2029
- // Warm process pool for eliminating Claude CLI cold-start latency
2030
- const warmPool = createWarmPool({ log });
2031
-
2032
2034
  const { spawnClaudeAsync, askClaude } = createClaudeEngine({
2033
2035
  fs,
2034
2036
  path,
@@ -2258,6 +2260,7 @@ const { startTelegramBridge, startFeishuBridge, startWeixinBridge, startImessage
2258
2260
  saveState,
2259
2261
  getSession,
2260
2262
  restoreSessionFromReply,
2263
+ releaseWarmPool: (key) => warmPool.releaseWarm(key),
2261
2264
  handleCommand,
2262
2265
  pipeline,
2263
2266
  pendingActivations,
@@ -2384,7 +2387,7 @@ async function main() {
2384
2387
  }
2385
2388
 
2386
2389
  // Config validation: warn on unknown/suspect fields
2387
- const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'weixin', 'heartbeat', 'budget', 'projects', 'imessage', 'siri_bridge'];
2390
+ const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'weixin', 'heartbeat', 'budget', 'projects', 'imessage', 'siri_bridge', 'hooks'];
2388
2391
  const KNOWN_DAEMON = [
2389
2392
  'model', // legacy (still valid as fallback)
2390
2393
  'models', // per-engine model map: { claude, codex }
@@ -2399,6 +2402,8 @@ async function main() {
2399
2402
  'mac_control_mode',
2400
2403
  'enable_nl_mac_control',
2401
2404
  'enable_nl_mac_fallback',
2405
+ 'wiki_output_dir', // wiki export path (used by daemon-command-router)
2406
+ 'skill_evolution_notify', // whether to notify on skill evolution (used by daemon-task-scheduler)
2402
2407
  ];
2403
2408
  for (const key of Object.keys(config)) {
2404
2409
  if (!KNOWN_SECTIONS.includes(key)) log('WARN', `Config: unknown section "${key}" (typo?)`);
@@ -2507,7 +2512,7 @@ async function main() {
2507
2512
  };
2508
2513
 
2509
2514
  // Start heartbeat scheduler
2510
- let heartbeatTimer = startHeartbeat(config, notifyFn, notifyPersonalFn);
2515
+ let heartbeatTimer = startHeartbeat(config, notifyFn, notifyPersonalFn, adminNotifyFn);
2511
2516
 
2512
2517
  let shuttingDown = false;
2513
2518
  function spawnReplacementDaemon(reason) {
@@ -221,7 +221,7 @@ ${promptInput}
221
221
 
222
222
  fs.mkdirSync(POSTMORTEM_DIR, { recursive: true });
223
223
  const day = new Date().toISOString().slice(0, 10);
224
- const topicSlug = sanitizeSlug(title || skeleton.intent, `session-${String(skeleton.session_id || '').slice(0, 8)}`);
224
+ const topicSlug = sanitizeSlug(skeleton.intent || title, `session-${String(skeleton.session_id || '').slice(0, 8)}`);
225
225
  const filePath = path.join(POSTMORTEM_DIR, `${day}-${topicSlug}.md`);
226
226
  const markdown = [
227
227
  `# ${title}`,
@@ -30,4 +30,3 @@
30
30
  - **永远不要读取再复述文件内容**,直接用 `[[FILE:...]]` 标记发送
31
31
  - 路径必须是绝对路径
32
32
  - daemon 会自动解析标记并通过 bot 发送给用户
33
- - **⛔ 禁止发到 open_id**:即使系统上下文中存在用户的飞书 `ou_...` ID,也绝不用它发文件。`[[FILE:...]]` 会自动发到**当前对话群**,这才是正确行为。直接发 open_id 会导致文件出现在 bot 和用户的 1-on-1 私聊,而不在当前群里。
@@ -362,60 +362,7 @@ Claude 看到 hook 注入:
362
362
  - `/dispatch to windows:hunter <任务>`:手动跨设备派发
363
363
  - `/dispatch to 猎手 <任务>`:按昵称解析,自动检测 `member.peer` 走远端
364
364
 
365
- ## 12. 永续任务系统(Perpetual Task Engine)
366
-
367
- ### 概念
368
-
369
- 永续任务系统允许任何项目作为 reactive 永续循环运行。Agent 产出信号 → daemon 解析 → 门控检查 → 调度下一步。平台完全领域无关,科研、代码审计、文档维护等任何长期任务均可接入。
370
-
371
- ### 核心组件
372
-
373
- | 组件 | 文件 | 职责 |
374
- |------|------|------|
375
- | Reactive Lifecycle | `daemon-reactive-lifecycle.js` | 信号解析、budget/depth gate、事件溯源、verifier 调用、state 生成 |
376
- | Event Log | `~/.metame/events/<key>.jsonl` | 唯一 Source of Truth,daemon 独占写入 |
377
- | Manifest | `<cwd>/perpetual.yaml` | 可选项目清单(completion_signal、脚本路径、约束) |
378
- | Reconciliation | `reconcilePerpetualProjects()` | heartbeat 中零 token 停滞检测 |
379
- | Status 命令 | `/status perpetual` | 查看所有永续项目的 phase/depth/mission/status |
380
-
381
- ### 接入一个新永续项目
382
-
383
- 1. 在 `daemon.yaml` 中注册项目,添加 `reactive: true`
384
- 2. 在项目目录创建 `CLAUDE.md`(定义 agent 行为)
385
- 3. 可选:创建 `scripts/verifier.js`(阶段门控)
386
- 4. 可选:创建 `perpetual.yaml`(覆盖默认约定)
387
- 5. 可选:创建 `scripts/archiver.js` + `scripts/mission-queue.js`(归档与任务队列)
388
-
389
- 不创建 perpetual.yaml 时,平台使用默认约定:
390
- - Verifier: `scripts/verifier.js`
391
- - Archiver: `scripts/archiver.js`
392
- - Mission Queue: `scripts/mission-queue.js`
393
- - 完成信号: `MISSION_COMPLETE`
394
-
395
- ### 事件溯源协议
396
-
397
- 所有状态变更记录在 `~/.metame/events/<projectKey>.jsonl`,一行一个 JSON 事件。`now/<key>.md` 和 `workspace/progress.tsv` 都是 event log 的投影(Projection),可随时从 event log 重建。
398
-
399
- Event 类型:`MISSION_START` / `DISPATCH` / `MEMBER_COMPLETE` / `PHASE_GATE` / `DEPTH_LIMIT` / `BUDGET_LIMIT` / `MISSION_COMPLETE` / `ARCHIVE` / `STALE` / `INFRA_PAUSE`
400
-
401
- ### 设计契约
402
-
403
- 1. **Tolerant Reader**:`replayEventLog` 逐行解析,损坏行 WARN + skip,绝不 crash
404
- 2. **Error Semantic Isolation**:verifier L2b 区分 404(幻觉,打回 agent)和 50x(基建故障,挂起项目通知人类)
405
- 3. **State 由 daemon 生成**:agent 只读 `now/<key>.md`,不负责维护
406
-
407
- ### 故障排查
408
-
409
- | 症状 | 检查 |
410
- |------|------|
411
- | 永续项目不启动 | `daemon.yaml` 中是否有 `reactive: true`? |
412
- | 完成信号不触发 | 检查 `perpetual.yaml` 中的 `completion_signal` 是否与 CLAUDE.md 一致 |
413
- | Verifier 不运行 | 检查 `scripts/verifier.js` 路径(或 manifest 中的自定义路径)是否存在 |
414
- | 项目挂起 (infra_failure) | 外部 API 不可用,检查网络。非 agent 错误。 |
415
- | Event log 损坏 | 重启后 replay 会跳过损坏行。`progress.tsv` 和 `now/<key>.md` 可从 event log 重建 |
416
- | `/status perpetual` 无输出 | 确认项目配置了 `reactive: true` |
417
-
418
- ## 13. 私人配置保护(原 §12)
365
+ ## 12. 私人配置保护
419
366
 
420
367
  - `daemon.yaml` 是用户私人配置,包含 API keys、chat IDs、个人项目配置
421
368
  - **绝不上传**到代码仓库,已加入 `.gitignore`
@@ -424,7 +371,7 @@ Event 类型:`MISSION_START` / `DISPATCH` / `MEMBER_COMPLETE` / `PHASE_GATE` /
424
371
  - 同样不应上传的文件:`MEMORY.md`、`SOUL.md`、`.env*`
425
372
  - Agent 在执行任务时,**绝不能** `cp scripts/daemon.yaml ~/.metame/daemon.yaml`,这会覆盖用户私人配置
426
373
 
427
- ## 14. 变更后维护动作
374
+ ## 13. 变更后维护动作
428
375
 
429
376
  1. 测试:
430
377
  - `npm test`(全量)
@@ -140,39 +140,6 @@
140
140
  - `scripts/memory.js`:`saveFactLabels()` 原子写入 API
141
141
  - `scripts/memory-nightly-reflect.js`:`synthesized_insight` 回写、知识胶囊聚合与 `knowledge_capsule` 回写
142
142
 
143
- ## 永续任务系统(Perpetual Task Engine)
144
-
145
- - Reactive 生命周期引擎:
146
- - `scripts/daemon-reactive-lifecycle.js`
147
- - 关键点:`handleReactiveOutput()` 通用任务链引擎(领域无关);
148
- `parseReactiveSignals()` 信号解析(NEXT_DISPATCH + 可配完成信号);
149
- `reconcilePerpetualProjects()` 停滞检测(零 token,heartbeat 驱动);
150
- `replayEventLog()` 事件溯源状态重放;
151
- `generateStateFile()` 从 event log 生成 `now/<key>.md`(投影);
152
- `appendEvent()` 追加事件到 `~/.metame/events/<key>.jsonl`(唯一 SoT);
153
- `loadProjectManifest()` 读取项目 `perpetual.yaml`(convention over config);
154
- `resolveProjectScripts()` 按约定/manifest 发现 verifier/archiver/mission-queue 脚本
155
-
156
- - daemon.js 接入点:
157
- - `scripts/daemon.js` `outputHandler` 内 `handleReactiveOutput` 调用(try/catch 隔离)
158
- - `scripts/daemon.js` `physiologicalHeartbeat` 内 `reconcilePerpetualProjects` 调用
159
-
160
- - 可观测命令:
161
- - `scripts/daemon-admin-commands.js`
162
- - 关键点:`/status perpetual`(或 `/status reactive`)显示所有永续项目状态
163
-
164
- - 设计文档:
165
- - `docs/perpetual-task-system-design.md`(v4,含 4 份附录)
166
- - `docs/perpetual-task-system-plan.md`(实施计划,Phase A-C)
167
-
168
- - 项目清单协议:
169
- - `<cwd>/perpetual.yaml` — 可选,声明 completion_signal / verifier / archiver / mission_queue 路径
170
- - 不存在时使用默认约定:`scripts/verifier.js`、`scripts/archiver.js`、`scripts/mission-queue.js`、信号 `MISSION_COMPLETE`
171
-
172
- - 事件日志:
173
- - `~/.metame/events/<projectKey>.jsonl` — append-only,daemon 独占写入
174
- - Event 类型:MISSION_START / DISPATCH / MEMBER_COMPLETE / PHASE_GATE / DEPTH_LIMIT / BUDGET_LIMIT / MISSION_COMPLETE / ARCHIVE / STALE / INFRA_PAUSE
175
-
176
143
  ## 运行时数据位置
177
144
 
178
145
  - 画像:`~/.claude_profile.yaml`
@@ -184,7 +151,6 @@
184
151
  - 复盘文档:`~/.metame/memory/postmortems/`
185
152
  - Dispatch 队列:`~/.metame/dispatch/pending.jsonl`(本地 socket 降级)
186
153
  - 远端 Dispatch 队列:`~/.metame/dispatch/remote-pending.jsonl`(跨设备中继)
187
- - **永续任务事件日志**:`~/.metame/events/<projectKey>.jsonl`(唯一 SoT,append-only)
188
154
  - 共享进度白板:`~/.metame/memory/now/shared.md`
189
155
  - Agent 最新产出:`~/.metame/memory/agents/{key}_latest.md`
190
156
  - Agent 收件箱:`~/.metame/memory/inbox/{key}/`(未读),`read/`(已归档)
@@ -393,11 +393,14 @@ function createBot(config) {
393
393
  let stopped = false;
394
394
  let currentWs = null;
395
395
  let healthTimer = null;
396
+ let sleepWakeTimer = null;
396
397
  let reconnectTimer = null;
397
398
  let reconnectDelay = 5000; // start 5s, doubles up to 60s
398
399
  const MAX_RECONNECT_DELAY = 60000;
399
400
  const HEALTH_CHECK_INTERVAL = 90000; // check every 90s
400
401
  const SILENT_THRESHOLD = 300000; // 5 min no SDK activity → suspect dead
402
+ const SLEEP_DETECT_INTERVAL = 5000; // tick every 5s to detect clock jump
403
+ const SLEEP_JUMP_THRESHOLD = 30000; // clock jump >30s = system was sleeping
401
404
 
402
405
  // Track last SDK activity (any event received = alive)
403
406
  let _lastActivityAt = Date.now();
@@ -538,15 +541,37 @@ function createBot(config) {
538
541
  }, HEALTH_CHECK_INTERVAL);
539
542
  }
540
543
 
544
+ // Sleep/wake detector: if the JS clock jumps >30s, system was sleeping → force reconnect
545
+ function startSleepWakeDetector() {
546
+ let _lastTickAt = Date.now();
547
+ sleepWakeTimer = setInterval(() => {
548
+ if (stopped) return;
549
+ const now = Date.now();
550
+ const elapsed = now - _lastTickAt;
551
+ _lastTickAt = now;
552
+ if (elapsed > SLEEP_JUMP_THRESHOLD) {
553
+ _log('INFO', `System wake detected (${Math.round(elapsed / 1000)}s gap) — forcing reconnect`);
554
+ reconnectDelay = 5000;
555
+ clearTimeout(reconnectTimer);
556
+ try { currentWs?.stop?.(); } catch { /* ignore */ }
557
+ currentWs = null;
558
+ touchActivity(); // reset silence counter so health check doesn't double-fire
559
+ connect();
560
+ }
561
+ }, SLEEP_DETECT_INTERVAL);
562
+ }
563
+
541
564
  // Initial connect
542
565
  connect();
543
566
  startHealthCheck();
567
+ startSleepWakeDetector();
544
568
 
545
569
  return Promise.resolve({
546
570
  stop() {
547
571
  stopped = true;
548
572
  clearTimeout(reconnectTimer);
549
573
  clearInterval(healthTimer);
574
+ clearInterval(sleepWakeTimer);
550
575
  currentWs = null;
551
576
  },
552
577
  reconnect() {
@@ -35,10 +35,9 @@ module.exports = function detectFileTransfer(prompt) {
35
35
 
36
36
  if (isSend) {
37
37
  hints.push(
38
- '- **发送文件到手机**:在回复末尾加 `[[FILE:/absolute/path]]`,daemon 自动发到**当前对话群**',
38
+ '- **发送文件到手机**:在回复末尾加 `[[FILE:/absolute/path]]`,daemon 自动发送',
39
39
  '- 多个文件用多个 `[[FILE:...]]` 标记',
40
40
  '- **不要读取文件内容再复述**,直接用标记发送(省 token)',
41
- '- **⛔ 严禁发到 open_id**:即使上下文中有 `ou_...` 用户ID,也绝对不用它发文件——那会发到 bot 私聊而非当前群',
42
41
  );
43
42
  }
44
43