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.
- package/index.js +4 -1
- package/package.json +1 -1
- package/scripts/agent-layer.js +36 -0
- package/scripts/core/chunker.js +100 -0
- package/scripts/core/embedding.js +225 -0
- package/scripts/core/hybrid-search.js +296 -0
- package/scripts/core/wiki-db.js +545 -0
- package/scripts/core/wiki-prompt.js +88 -0
- package/scripts/core/wiki-slug.js +66 -0
- package/scripts/core/wiki-staleness.js +18 -0
- package/scripts/daemon-agent-commands.js +10 -4
- package/scripts/daemon-bridges.js +16 -0
- package/scripts/daemon-claude-engine.js +62 -8
- package/scripts/daemon-command-router.js +40 -1
- package/scripts/daemon-default.yaml +33 -3
- package/scripts/daemon-embedding.js +162 -0
- package/scripts/daemon-engine-runtime.js +1 -1
- package/scripts/daemon-health-scan.js +185 -0
- package/scripts/daemon-ops-commands.js +9 -18
- package/scripts/daemon-runtime-lifecycle.js +1 -1
- package/scripts/daemon-session-commands.js +4 -0
- package/scripts/daemon-task-scheduler.js +5 -3
- package/scripts/daemon-warm-pool.js +15 -0
- package/scripts/daemon-wiki.js +420 -0
- package/scripts/daemon.js +10 -5
- package/scripts/distill.js +1 -1
- package/scripts/docs/file-transfer.md +0 -1
- package/scripts/docs/maintenance-manual.md +2 -55
- package/scripts/docs/pointer-map.md +0 -34
- package/scripts/feishu-adapter.js +25 -0
- package/scripts/hooks/intent-file-transfer.js +1 -2
- package/scripts/memory-backfill-chunks.js +92 -0
- package/scripts/memory-search.js +49 -6
- package/scripts/memory-wiki-schema.js +255 -0
- package/scripts/memory.js +103 -3
- package/scripts/signal-capture.js +1 -1
- package/scripts/skill-evolution.js +2 -11
- package/scripts/wiki-cluster.js +121 -0
- package/scripts/wiki-extract.js +171 -0
- package/scripts/wiki-facts.js +351 -0
- package/scripts/wiki-import.js +256 -0
- package/scripts/wiki-reflect-build.js +441 -0
- package/scripts/wiki-reflect-export.js +448 -0
- package/scripts/wiki-reflect-query.js +109 -0
- package/scripts/wiki-reflect.js +338 -0
- 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) {
|
package/scripts/distill.js
CHANGED
|
@@ -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(
|
|
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}`,
|
|
@@ -362,60 +362,7 @@ Claude 看到 hook 注入:
|
|
|
362
362
|
- `/dispatch to windows:hunter <任务>`:手动跨设备派发
|
|
363
363
|
- `/dispatch to 猎手 <任务>`:按昵称解析,自动检测 `member.peer` 走远端
|
|
364
364
|
|
|
365
|
-
## 12.
|
|
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
|
-
##
|
|
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
|
|