metame-cli 1.5.26 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,7 @@ function createBridgeStarter(deps) {
24
24
  saveState,
25
25
  getSession,
26
26
  restoreSessionFromReply,
27
+ releaseWarmPool,
27
28
  handleCommand,
28
29
  pipeline, // message pipeline for per-chatId serial execution
29
30
  pendingActivations, // optional — used to show smart activation hint
@@ -877,6 +878,11 @@ function createBridgeStarter(deps) {
877
878
  }
878
879
  if (mapped.id) {
879
880
  log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
881
+ // Evict warm process so next spawn uses --resume with the restored session
882
+ if (typeof releaseWarmPool === 'function') {
883
+ const _logicalKey = String(mapped.logicalChatId || '').trim();
884
+ if (_logicalKey) releaseWarmPool(_logicalKey);
885
+ }
880
886
  }
881
887
  _replyAgentKey = mapped.agentKey || null;
882
888
  } else {
@@ -886,6 +892,16 @@ function createBridgeStarter(deps) {
886
892
  _replyMappingFound = true;
887
893
  _replyAgentKey = _parentMapping.agentKey || null;
888
894
  log('INFO', `Feishu topic inherited root mapping agentKey=${_replyAgentKey || 'main'} parentId=${parentId}`);
895
+ // Restore session from topic root (same as quoted reply) so 指定回复 resumes context
896
+ if (_parentMapping.id && typeof restoreSessionFromReply === 'function') {
897
+ restoreSessionFromReply(chatId, _parentMapping);
898
+ log('INFO', `Session restored via topic root: ${_parentMapping.id.slice(0, 8)} (${path.basename(_parentMapping.cwd || '~')})`);
899
+ // Evict warm process so next spawn uses --resume with the restored session
900
+ if (typeof releaseWarmPool === 'function') {
901
+ const _logicalKey = String(_parentMapping.logicalChatId || '').trim();
902
+ if (_logicalKey) releaseWarmPool(_logicalKey);
903
+ }
904
+ }
889
905
  }
890
906
 
891
907
  // Helper: set/clear sticky on shared state object and persist
@@ -10,7 +10,7 @@ const {
10
10
  _private: { resolveCodexPermissionProfile },
11
11
  } = require('./daemon-engine-runtime');
12
12
  const { rawChatId } = require('./core/thread-chat-id');
13
- const { buildAgentContextForEngine, buildMemorySnapshotContent, refreshMemorySnapshot } = require('./agent-layer');
13
+ const { buildAgentContextForEngine, buildMemorySnapshotContent, selectSnapshotContext, refreshMemorySnapshot } = require('./agent-layer');
14
14
  const {
15
15
  adaptDaemonHintForEngine,
16
16
  buildAgentHint,
@@ -84,6 +84,7 @@ function createClaudeEngine(deps) {
84
84
  isContentFile,
85
85
  sendFileButtons,
86
86
  findSessionFile,
87
+ listRecentSessions,
87
88
  getSession,
88
89
  getSessionForEngine,
89
90
  createSession,
@@ -102,6 +103,7 @@ function createClaudeEngine(deps) {
102
103
  touchInteraction,
103
104
  statusThrottleMs = 3000,
104
105
  fallbackThrottleMs = 8000,
106
+ autoSyncMinGapMs = 60_000,
105
107
  getEngineRuntime: injectedGetEngineRuntime,
106
108
  getDefaultEngine: _getDefaultEngine,
107
109
  warmPool,
@@ -1362,6 +1364,36 @@ function createClaudeEngine(deps) {
1362
1364
  await patchSessionSerialized(sessionChatId, (cur) => ({ ...cur, cwd: effectiveCwd }));
1363
1365
  }
1364
1366
 
1367
+ // Auto-sync: when daemon is idle (no warm process), check if a newer Claude session exists
1368
+ // in the project directory (e.g., created from Claude Code CLI on the same computer).
1369
+ // This keeps phone (Feishu/mobile) and computer (Claude Code CLI) sessions in sync.
1370
+ const _hasWarm = !!(warmPool && typeof warmPool.hasWarm === 'function' && warmPool.hasWarm(sessionChatId));
1371
+ if (runtime.name === 'claude' && !String(sessionChatId).startsWith('_agent_') &&
1372
+ !_hasWarm && session.started && session.id && session.id !== '__continue__' && session.cwd) {
1373
+ const _recentInProject = typeof listRecentSessions === 'function'
1374
+ ? listRecentSessions(2, session.cwd, 'claude') : [];
1375
+ const _currentMtime = (() => {
1376
+ try {
1377
+ const _f = typeof findSessionFile === 'function' ? findSessionFile(session.id) : null;
1378
+ return _f ? fs.statSync(_f).mtimeMs : 0;
1379
+ } catch { return 0; }
1380
+ })();
1381
+ const _newerSession = _recentInProject.find(
1382
+ s => s.sessionId !== session.id && (s.fileMtime || 0) - _currentMtime > autoSyncMinGapMs
1383
+ );
1384
+ if (_newerSession) {
1385
+ const _gapSec = Math.round(((_newerSession.fileMtime || 0) - _currentMtime) / 1000);
1386
+ log('INFO', `[AutoSync] ${String(sessionChatId).slice(-8)}: switching ${session.id.slice(0, 8)} -> ${_newerSession.sessionId.slice(0, 8)} (newer by ${_gapSec}s)`);
1387
+ await patchSessionSerialized(sessionChatId, (cur) => {
1388
+ const _engines = { ...(cur.engines || {}) };
1389
+ _engines.claude = { ...(_engines.claude || {}), id: _newerSession.sessionId, started: true };
1390
+ return { ...cur, engines: _engines };
1391
+ });
1392
+ session = { ...session, id: _newerSession.sessionId };
1393
+ bot.sendMessage(chatId, `🔄 已自动同步到最新 session:\`${_newerSession.sessionId.slice(0, 8)}\``).catch(() => { });
1394
+ }
1395
+ }
1396
+
1365
1397
  // Warm pool: check if a persistent process is available for this session (Claude only).
1366
1398
  // Declared early so downstream logic can skip expensive operations when reusing warm process.
1367
1399
  const _warmSessionKey = sessionChatId;
@@ -2148,14 +2180,30 @@ function createClaudeEngine(deps) {
2148
2180
  if (dispatchedTargets.length > 0) {
2149
2181
  const allProjects = (config && config.projects) || {};
2150
2182
  const names = dispatchedTargets.map(k => (allProjects[k] && allProjects[k].name) || k).join('、');
2151
- const doneMsg = await bot.sendMessage(chatId, `✉️ 已转达给 ${names},处理中…`);
2183
+ const fwdText = `✉️ 已转达给 ${names},处理中…`;
2184
+ let doneMsg;
2185
+ if (statusMsgId && bot.editMessage) {
2186
+ await bot.editMessage(chatId, statusMsgId, fwdText, _ackCardHeader).catch(() => {});
2187
+ doneMsg = { message_id: statusMsgId };
2188
+ } else {
2189
+ if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => {});
2190
+ doneMsg = await bot.sendMessage(chatId, fwdText);
2191
+ }
2152
2192
  if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session, projectKeyFromVirtualChatId(chatId));
2153
2193
  const wasNew = !session.started;
2154
2194
  if (wasNew) markSessionStarted(sessionChatId, engineName);
2155
2195
  return { ok: true };
2156
2196
  }
2157
2197
  const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
2158
- const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
2198
+ const doneText = `✅ 完成${filesDesc}`;
2199
+ let doneMsg;
2200
+ if (statusMsgId && bot.editMessage) {
2201
+ await bot.editMessage(chatId, statusMsgId, doneText, _ackCardHeader).catch(() => {});
2202
+ doneMsg = { message_id: statusMsgId };
2203
+ } else {
2204
+ if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => {});
2205
+ doneMsg = await bot.sendMessage(chatId, doneText);
2206
+ }
2159
2207
  if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session, projectKeyFromVirtualChatId(chatId));
2160
2208
  const wasNew = !session.started;
2161
2209
  if (wasNew) markSessionStarted(sessionChatId, engineName);
@@ -2338,12 +2386,18 @@ function createClaudeEngine(deps) {
2338
2386
  setImmediate(async () => {
2339
2387
  try {
2340
2388
  const memory = require('./memory');
2341
- const pKey = boundProjectKey || '';
2342
- const sessions = memory.recentSessions({ limit: 5, project: pKey });
2343
- const factsRaw = memory.searchFacts('', { limit: 10, project: pKey });
2344
- const facts = Array.isArray(factsRaw) ? factsRaw : [];
2389
+ const snapshotCtx = selectSnapshotContext(memory, {
2390
+ projectHints: [
2391
+ boundProjectKey,
2392
+ boundProject.project_key,
2393
+ boundProject.name,
2394
+ boundProject.agent_id,
2395
+ ],
2396
+ sessionLimit: 5,
2397
+ factLimit: 10,
2398
+ });
2345
2399
  memory.close();
2346
- const snapshotContent = buildMemorySnapshotContent(sessions, facts);
2400
+ const snapshotContent = buildMemorySnapshotContent(snapshotCtx.sessions, snapshotCtx.facts);
2347
2401
  const agentId = boundProject.agent_id;
2348
2402
  if (refreshMemorySnapshot(agentId, snapshotContent, HOME)) {
2349
2403
  log('DEBUG', `[AGENT] Memory snapshot refreshed for ${agentId}`);
@@ -3,6 +3,7 @@
3
3
  const { resolveEngineModel } = require('./daemon-engine-runtime');
4
4
  const { createAgentIntentHandler } = require('./daemon-agent-intent');
5
5
  const { rawChatId: extractOriginalChatId, isThreadChatId } = require('./core/thread-chat-id');
6
+ const { createWikiCommandHandler } = require('./daemon-wiki');
6
7
 
7
8
  function createCommandRouter(deps) {
8
9
  const {
@@ -30,6 +31,7 @@ function createCommandRouter(deps) {
30
31
  pendingActivations,
31
32
  agentFlowTtlMs,
32
33
  getDefaultEngine,
34
+ getDb, // optional — () → DatabaseSync (for wiki commands)
33
35
  } = deps;
34
36
 
35
37
 
@@ -462,6 +464,19 @@ function createCommandRouter(deps) {
462
464
  return;
463
465
  }
464
466
 
467
+ // /wiki — knowledge wiki commands
468
+ if (text.startsWith('/wiki') && getDb) {
469
+ const wikiProviders = providerMod ? {
470
+ callHaiku: (...args) => providerMod.callHaiku(...args),
471
+ buildDistillEnv: (...args) => providerMod.buildDistillEnv(...args),
472
+ } : null;
473
+ const wikiOutputDir = config && config.daemon && config.daemon.wiki_output_dir
474
+ ? config.daemon.wiki_output_dir.replace(/^~/, process.env.HOME || '')
475
+ : null;
476
+ const { handleWikiCommand } = createWikiCommandHandler({ getDb, providers: wikiProviders, wikiOutputDir, log });
477
+ if (await handleWikiCommand({ bot, chatId, text })) return;
478
+ }
479
+
465
480
  // /btw — quick side question (read-only, concise, bypasses cooldown)
466
481
  if (/^\/btw(\s|$)/i.test(text)) {
467
482
  const btwQuestion = text.replace(/^\/btw\s*/i, '').trim();
@@ -55,7 +55,6 @@ heartbeat:
55
55
  type: script
56
56
  command: node ~/.metame/distill.js
57
57
  interval: 4h
58
- timeout: 5m
59
58
  precondition: "test -s ~/.metame/raw_signals.jsonl"
60
59
  require_idle: true
61
60
  notify: false
@@ -85,7 +84,6 @@ heartbeat:
85
84
  type: script
86
85
  command: node ~/.metame/skill-evolution.js
87
86
  interval: 12h
88
- timeout: 5m
89
87
  precondition: "test -s ~/.metame/skill_signals.jsonl"
90
88
  require_idle: true
91
89
  notify: false
@@ -116,7 +114,7 @@ heartbeat:
116
114
  at: "01:30"
117
115
  require_idle: true
118
116
  notify: false
119
- enabled: false
117
+ enabled: true
120
118
 
121
119
  # Legacy flat tasks (no project isolation). New tasks should go under projects: above.
122
120
  # Examples — uncomment or add your own:
@@ -166,6 +164,7 @@ budget:
166
164
  daemon:
167
165
  model: sonnet # sonnet, opus, haiku — model for mobile sessions
168
166
  log_max_size: 1048576
167
+ wiki_output_dir: ~/Documents/ObsidianVault/MetaMe/wiki # Obsidian vault wiki subdirectory
169
168
  heartbeat_check_interval: 60
170
169
  # Skip all permission prompts for mobile sessions (full access)
171
170
  # Mobile users can't click "allow" — so we pre-authorize everything.
@@ -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);
@@ -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)
@@ -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
  }
@@ -0,0 +1,298 @@
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 { execSync } = 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
+ // Unknown /wiki subcommand — show help
84
+ if (trimmed.startsWith('/wiki ')) {
85
+ await _handleHelp(bot, chatId);
86
+ return true;
87
+ }
88
+
89
+ return false;
90
+ }
91
+
92
+ // ── Subcommand handlers ──────────────────────────────────────────────────────
93
+
94
+ async function _handleList(bot, chatId) {
95
+ const db = getDb();
96
+ const pages = listWikiPages(db, { limit: 50, orderBy: 'title' });
97
+
98
+ if (pages.length === 0) {
99
+ await bot.sendMessage(chatId,
100
+ '📚 Wiki 暂无页面。\n\n使用 `/wiki pin <标签> [标题]` 手工注册第一个主题。'
101
+ );
102
+ return;
103
+ }
104
+
105
+ const lines = ['📚 **知识 Wiki**', ''];
106
+ for (const p of pages) {
107
+ const stalePct = Math.round((p.staleness || 0) * 100);
108
+ const built = p.last_built_at ? p.last_built_at.slice(0, 10) : '未建';
109
+ const staleFlag = p.staleness >= STALENESS_THRESHOLD ? ' ⚠️' : '';
110
+ lines.push(`• **${p.title}** \`${p.slug}\`${staleFlag}`);
111
+ lines.push(` 来源:${p.raw_source_count || 0} 条 · 陈旧度:${stalePct}% · 更新:${built}`);
112
+ }
113
+ lines.push('');
114
+ lines.push(`共 ${pages.length} 页 · \`/wiki research <关键词>\` 搜索`);
115
+
116
+ await bot.sendMessage(chatId, lines.join('\n'));
117
+ }
118
+
119
+ async function _handleResearch(bot, chatId, query) {
120
+ if (!query) {
121
+ await bot.sendMessage(chatId, '用法: `/wiki research <关键词>`');
122
+ return;
123
+ }
124
+
125
+ const db = getDb();
126
+ const { wikiPages, facts } = searchWikiAndFacts(db, query, { trackSearch: true });
127
+
128
+ if (wikiPages.length === 0 && facts.length === 0) {
129
+ await bot.sendMessage(chatId,
130
+ `🔍 未找到与「${query}」相关的知识。\n\n可用 \`/wiki pin ${query}\` 手工注册主题,或等待记忆积累后自动建页。`
131
+ );
132
+ return;
133
+ }
134
+
135
+ const lines = [`🔍 **「${query}」相关知识**`, ''];
136
+
137
+ for (const wp of wikiPages.slice(0, 3)) {
138
+ const built = wp.last_built_at ? wp.last_built_at.slice(0, 10) : '—';
139
+ lines.push(`📖 **${wp.title}**`);
140
+ if (wp.excerpt) lines.push(wp.excerpt.replace(/<\/?b>/g, '**'));
141
+ lines.push(`来源: \`${wp.slug}\` · 更新于 ${built}`);
142
+ lines.push('');
143
+ }
144
+
145
+ if (facts.length > 0) {
146
+ lines.push(`📌 **相关事实** (${facts.length} 条)`);
147
+ for (const f of facts.slice(0, 5)) {
148
+ const title = f.title ? `**${f.title}** ` : '';
149
+ const excerpt = f.excerpt
150
+ ? f.excerpt.replace(/<\/?b>/g, '**').slice(0, 120)
151
+ : (f.content || '').slice(0, 120);
152
+ lines.push(`• ${title}${excerpt}`);
153
+ }
154
+ }
155
+
156
+ await bot.sendMessage(chatId, lines.join('\n'));
157
+ }
158
+
159
+ async function _handlePage(bot, chatId, slug) {
160
+ if (!slug) {
161
+ await bot.sendMessage(chatId, '用法: `/wiki page <slug>`');
162
+ return;
163
+ }
164
+
165
+ const db = getDb();
166
+ const page = getWikiPageBySlug(db, slug);
167
+
168
+ if (!page) {
169
+ await bot.sendMessage(chatId, `❌ 未找到页面 \`${slug}\`\n\n用 \`/wiki\` 查看所有页面。`);
170
+ return;
171
+ }
172
+
173
+ const built = page.last_built_at ? page.last_built_at.slice(0, 10) : '未建';
174
+ const stalePct = Math.round((page.staleness || 0) * 100);
175
+
176
+ const lines = [
177
+ `📄 **${page.title}**`,
178
+ `_标签: ${page.primary_topic} · 来源: ${page.raw_source_count || 0} 条 · 陈旧度: ${stalePct}% · 更新: ${built}_`,
179
+ '',
180
+ page.content,
181
+ ];
182
+
183
+ await bot.sendMessage(chatId, lines.join('\n'));
184
+ }
185
+
186
+ async function _handleSync(bot, chatId) {
187
+ const db = getDb();
188
+ const pages = listWikiPages(db, { limit: 200 });
189
+ const staleCount = pages.filter(p => (p.staleness || 0) >= STALENESS_THRESHOLD).length;
190
+
191
+ if (staleCount === 0) {
192
+ await bot.sendMessage(chatId, `✅ Wiki 已是最新状态,无需重建。`);
193
+ return;
194
+ }
195
+
196
+ await bot.sendMessage(chatId, `🔄 开始重建 ${staleCount} 个陈旧页面...`);
197
+
198
+ try {
199
+ const { runWikiReflect } = require('./wiki-reflect');
200
+ const result = await runWikiReflect(db, {
201
+ providers,
202
+ outputDir,
203
+ threshold: STALENESS_THRESHOLD,
204
+ });
205
+
206
+ const lines = ['✅ Wiki 重建完成'];
207
+ if (result.built.length > 0) {
208
+ lines.push(`• 重建: ${result.built.join(', ')}`);
209
+ }
210
+ if (result.failed.length > 0) {
211
+ lines.push(`• 失败: ${result.failed.map(f => f.slug).join(', ')}`);
212
+ }
213
+ if (result.exportFailed.length > 0) {
214
+ lines.push(`• 文件导出失败 (DB 已更新): ${result.exportFailed.join(', ')}`);
215
+ }
216
+ await bot.sendMessage(chatId, lines.join('\n'));
217
+ } catch (err) {
218
+ log('ERROR', `[wiki-sync] ${err.message}`);
219
+ if (err.message.includes('another instance')) {
220
+ await bot.sendMessage(chatId, '⚠️ Wiki 重建正在进行中,请稍后再试。');
221
+ } else {
222
+ await bot.sendMessage(chatId, `❌ Wiki 重建失败: ${err.message}`);
223
+ }
224
+ }
225
+ }
226
+
227
+ async function _handlePin(bot, chatId, args) {
228
+ if (!args) {
229
+ await bot.sendMessage(chatId, '用法: `/wiki pin <标签> [显示名称]`\n例: `/wiki pin session Session管理`');
230
+ return;
231
+ }
232
+
233
+ // Parse: first token = tag, rest = label
234
+ const parts = args.split(/\s+/);
235
+ const tag = parts[0];
236
+ const label = parts.slice(1).join(' ') || tag;
237
+
238
+ const db = getDb();
239
+ try {
240
+ const { slug, isNew } = upsertWikiTopic(db, tag, { label, pinned: 1, force: true });
241
+ if (isNew) {
242
+ await bot.sendMessage(chatId,
243
+ `📌 已注册主题 \`${tag}\` (slug: \`${slug}\`)\n\n使用 \`/wiki sync\` 构建页面,或等待每周自动重建。`
244
+ );
245
+ } else {
246
+ await bot.sendMessage(chatId,
247
+ `📌 主题 \`${tag}\` 已更新标题为「${label}」,pinned=1。`
248
+ );
249
+ }
250
+ } catch (err) {
251
+ log('ERROR', `[wiki-pin] ${err.message}`);
252
+ await bot.sendMessage(chatId, `❌ 注册失败: ${err.message}`);
253
+ }
254
+ }
255
+
256
+ async function _handleOpen(bot, chatId) {
257
+ try {
258
+ // Ensure the vault directory exists (may not yet have any pages)
259
+ const fs = require('fs');
260
+ if (!fs.existsSync(outputDir)) {
261
+ fs.mkdirSync(outputDir, { recursive: true });
262
+ }
263
+ // Try Obsidian URI first (opens vault by path if already configured)
264
+ const vaultName = path.basename(outputDir);
265
+ try {
266
+ execSync(`open "obsidian://open?vault=${encodeURIComponent(vaultName)}"`, { timeout: 5000 });
267
+ } catch {
268
+ // Fallback: open folder in Finder — user can then drag into Obsidian
269
+ execSync(`open "${outputDir}"`, { timeout: 5000 });
270
+ }
271
+ await bot.sendMessage(chatId,
272
+ `📂 已打开 Obsidian vault: \`${outputDir}\`\n\n` +
273
+ `如果是第一次打开,请在 Obsidian 里选 **Open folder as vault** 并选择该目录。\n` +
274
+ `之后用 \`/wiki sync\` 生成页面。`
275
+ );
276
+ } catch (err) {
277
+ log('ERROR', `[wiki-open] ${err.message}`);
278
+ await bot.sendMessage(chatId, `❌ 打开失败: ${err.message}\n\nVault 路径: \`${outputDir}\``);
279
+ }
280
+ }
281
+
282
+ async function _handleHelp(bot, chatId) {
283
+ await bot.sendMessage(chatId, [
284
+ '📚 **Wiki 命令**',
285
+ '',
286
+ '`/wiki` — 列出所有知识页',
287
+ '`/wiki research <关键词>` — 搜索知识',
288
+ '`/wiki page <slug>` — 查看页面全文',
289
+ '`/wiki sync` — 重建陈旧页面',
290
+ '`/wiki pin <标签> [标题]` — 手工注册主题',
291
+ '`/wiki open` — 在 Obsidian 中打开 vault',
292
+ ].join('\n'));
293
+ }
294
+
295
+ return { handleWikiCommand };
296
+ }
297
+
298
+ 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,
@@ -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 私聊,而不在当前群里。