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.
- package/package.json +1 -1
- package/scripts/agent-layer.js +36 -0
- package/scripts/core/wiki-db.js +404 -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 +15 -0
- package/scripts/daemon-default.yaml +2 -3
- package/scripts/daemon-ops-commands.js +9 -18
- package/scripts/daemon-session-commands.js +4 -0
- package/scripts/daemon-warm-pool.js +15 -0
- package/scripts/daemon-wiki.js +298 -0
- package/scripts/daemon.js +6 -3
- 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/hooks/intent-file-transfer.js +1 -2
- package/scripts/memory-search.js +17 -2
- package/scripts/memory-wiki-schema.js +96 -0
- package/scripts/memory.js +88 -3
- package/scripts/signal-capture.js +1 -1
- package/scripts/skill-evolution.js +2 -11
- package/scripts/wiki-reflect-build.js +117 -0
- package/scripts/wiki-reflect-export.js +333 -0
- package/scripts/wiki-reflect-query.js +109 -0
- package/scripts/wiki-reflect.js +305 -0
|
@@ -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
|
|
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
|
|
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
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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,
|
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}`,
|