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,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* calcStaleness — pure function, no I/O, no DB.
|
|
5
|
+
*
|
|
6
|
+
* @param {number} newFacts number of new facts discovered
|
|
7
|
+
* @param {number} rawSourceCount number of already-indexed raw sources
|
|
8
|
+
* @returns {number} staleness in [0, 1]
|
|
9
|
+
* formula: newFacts / (rawSourceCount + newFacts)
|
|
10
|
+
* special: both zero → 0 (avoids division by zero)
|
|
11
|
+
*/
|
|
12
|
+
function calcStaleness(newFacts, rawSourceCount) {
|
|
13
|
+
const denominator = rawSourceCount + newFacts;
|
|
14
|
+
if (denominator === 0) return 0;
|
|
15
|
+
return newFacts / denominator;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { calcStaleness };
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { normalizeEngineName: _normalizeEngine } = require('./daemon-utils');
|
|
4
|
+
|
|
5
|
+
function stripMd(s) {
|
|
6
|
+
return String(s || '')
|
|
7
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // [text](url) → text
|
|
8
|
+
.replace(/[*_`#>~|]/g, ''); // inline markers
|
|
9
|
+
}
|
|
4
10
|
const {
|
|
5
11
|
getBoundProject,
|
|
6
12
|
createWorkspaceAgent,
|
|
@@ -420,16 +426,16 @@ function createAgentCommandHandler(deps) {
|
|
|
420
426
|
msg += '\n\n最近对话:';
|
|
421
427
|
for (const item of recentDialogue) {
|
|
422
428
|
const marker = item.role === 'assistant' ? '🤖' : '👤';
|
|
423
|
-
const snippet = String(item.text || '').replace(/\n/g, ' ').slice(0, 120);
|
|
429
|
+
const snippet = stripMd(String(item.text || '').replace(/\n/g, ' ')).slice(0, 120);
|
|
424
430
|
if (snippet) msg += `\n${marker} ${snippet}`;
|
|
425
431
|
}
|
|
426
432
|
} else if (recentCtx) {
|
|
427
433
|
if (recentCtx.lastUser) {
|
|
428
|
-
const snippet = recentCtx.lastUser.replace(/\n/g, ' ').slice(0, 80);
|
|
429
|
-
msg += `\n\n💬 上次你说:
|
|
434
|
+
const snippet = stripMd(recentCtx.lastUser.replace(/\n/g, ' ')).slice(0, 80);
|
|
435
|
+
msg += `\n\n💬 上次你说: ${snippet}${recentCtx.lastUser.length > 80 ? '…' : ''}`;
|
|
430
436
|
}
|
|
431
437
|
if (recentCtx.lastAssistant) {
|
|
432
|
-
const snippet = recentCtx.lastAssistant.replace(/\n/g, ' ').slice(0, 80);
|
|
438
|
+
const snippet = stripMd(recentCtx.lastAssistant.replace(/\n/g, ' ')).slice(0, 80);
|
|
433
439
|
msg += `\n🤖 上次回复: ${snippet}${recentCtx.lastAssistant.length > 80 ? '…' : ''}`;
|
|
434
440
|
}
|
|
435
441
|
}
|
|
@@ -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();
|
|
@@ -598,7 +613,31 @@ function createCommandRouter(deps) {
|
|
|
598
613
|
await bot.sendMessage(chatId, 'Daily token budget exceeded.');
|
|
599
614
|
return;
|
|
600
615
|
}
|
|
601
|
-
|
|
616
|
+
|
|
617
|
+
// --- "修" shortcut: inject latest health report as context ---
|
|
618
|
+
let effectiveText = text;
|
|
619
|
+
if (text.trim() === '修') {
|
|
620
|
+
const reportFile = require('path').join(require('os').homedir(), '.metame', 'health-report-latest.json');
|
|
621
|
+
try {
|
|
622
|
+
const raw = require('fs').readFileSync(reportFile, 'utf8');
|
|
623
|
+
const report = JSON.parse(raw);
|
|
624
|
+
const ageMs = Date.now() - new Date(report.generated_at).getTime();
|
|
625
|
+
if (ageMs < 48 * 60 * 60 * 1000) { // only if report is within 48h
|
|
626
|
+
const issues = (report.analysis && report.analysis.issues || [])
|
|
627
|
+
.map(i => `- ${i.name}(×${i.count}):${i.fix}`)
|
|
628
|
+
.join('\n');
|
|
629
|
+
effectiveText = `请根据以下 daemon 健康报告修复问题:\n\n` +
|
|
630
|
+
`摘要:${report.analysis.summary}\n` +
|
|
631
|
+
`建议行动:${report.analysis.action}\n\n` +
|
|
632
|
+
`问题清单:\n${issues}\n\n` +
|
|
633
|
+
`报告时间:${report.generated_at}\n` +
|
|
634
|
+
`请先读取 ~/.metame/daemon.log 确认问题,然后修复 /Users/yaron/AGI/MetaMe/scripts/ 中的对应代码。`;
|
|
635
|
+
log('INFO', `[health-scan] "修" shortcut: injected health report (${report.total_errors} errors)`);
|
|
636
|
+
}
|
|
637
|
+
} catch { /* no report or stale — fall through with original text */ }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const claudeResult = await askClaude(bot, chatId, effectiveText, config, readOnly, senderId);
|
|
602
641
|
const claudeFailed = !!(claudeResult && claudeResult.ok === false);
|
|
603
642
|
const claudeAborted = !!(claudeResult && claudeResult.error === 'Stopped by user');
|
|
604
643
|
if (claudeFailed && !claudeAborted && !macLocalFirst && macFallbackEnabled && allowLocalMacControl) {
|
|
@@ -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
|
|
@@ -109,6 +107,27 @@ heartbeat:
|
|
|
109
107
|
notify: false
|
|
110
108
|
enabled: true
|
|
111
109
|
|
|
110
|
+
# wiki-sync: 每天 02:30 把所有 wiki 页面(记忆主题、doc/cluster 页、decisions/lessons)同步到 Obsidian vault
|
|
111
|
+
# 前置条件: daemon.wiki_output_dir 已配置,如: ~/Documents/ObsidianVault/MetaMe/wiki
|
|
112
|
+
- name: wiki-sync
|
|
113
|
+
type: script
|
|
114
|
+
command: >-
|
|
115
|
+
node -e "
|
|
116
|
+
const path=require('path'),os=require('os');
|
|
117
|
+
const {DatabaseSync}=require('node:sqlite');
|
|
118
|
+
const {runWikiReflect}=require(path.join(os.homedir(),'.metame','wiki-reflect'));
|
|
119
|
+
const providers=require(path.join(os.homedir(),'.metame','providers'));
|
|
120
|
+
const db=new DatabaseSync(path.join(os.homedir(),'.metame','memory.db'));
|
|
121
|
+
const outDir=path.join(os.homedir(),'Documents/ObsidianVault/MetaMe/wiki');
|
|
122
|
+
runWikiReflect(db,{providers,outputDir:outDir}).then(r=>{
|
|
123
|
+
console.log('wiki-sync done',JSON.stringify(r));db.close();
|
|
124
|
+
}).catch(e=>{console.error(e.message);db.close();process.exit(1);});
|
|
125
|
+
"
|
|
126
|
+
at: "02:30"
|
|
127
|
+
require_idle: true
|
|
128
|
+
notify: false
|
|
129
|
+
enabled: false # set to true after verifying wiki_output_dir is correct
|
|
130
|
+
|
|
112
131
|
# 记忆索引:每天 01:30 更新 ~/.metame/memory/INDEX.md
|
|
113
132
|
- name: memory-index
|
|
114
133
|
type: script
|
|
@@ -116,7 +135,17 @@ heartbeat:
|
|
|
116
135
|
at: "01:30"
|
|
117
136
|
require_idle: true
|
|
118
137
|
notify: false
|
|
119
|
-
enabled:
|
|
138
|
+
enabled: true
|
|
139
|
+
|
|
140
|
+
# Embedding 索引:消费 embedding_queue,生成向量嵌入,30 分钟冷却
|
|
141
|
+
- name: embedding-index
|
|
142
|
+
type: script
|
|
143
|
+
command: node ~/.metame/daemon-embedding.js
|
|
144
|
+
interval: 30m
|
|
145
|
+
timeout: 600
|
|
146
|
+
require_idle: false
|
|
147
|
+
notify: false
|
|
148
|
+
enabled: true
|
|
120
149
|
|
|
121
150
|
# Legacy flat tasks (no project isolation). New tasks should go under projects: above.
|
|
122
151
|
# Examples — uncomment or add your own:
|
|
@@ -166,6 +195,7 @@ budget:
|
|
|
166
195
|
daemon:
|
|
167
196
|
model: sonnet # sonnet, opus, haiku — model for mobile sessions
|
|
168
197
|
log_max_size: 1048576
|
|
198
|
+
wiki_output_dir: ~/Documents/ObsidianVault/MetaMe/wiki # Obsidian vault wiki subdirectory
|
|
169
199
|
heartbeat_check_interval: 60
|
|
170
200
|
# Skip all permission prompts for mobile sessions (full access)
|
|
171
201
|
# Mobile users can't click "allow" — so we pre-authorize everything.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* daemon-embedding.js — Embedding queue consumer
|
|
7
|
+
*
|
|
8
|
+
* Processes pending items in embedding_queue:
|
|
9
|
+
* 1. Reads batch from queue (attempts < 3)
|
|
10
|
+
* 2. Fetches text from content_chunks
|
|
11
|
+
* 3. Calls OpenAI embedding API
|
|
12
|
+
* 4. Writes BLOB + metadata back to content_chunks
|
|
13
|
+
* 5. Deletes completed queue rows; increments attempts on failure
|
|
14
|
+
*
|
|
15
|
+
* Designed to run as heartbeat task (interval: 30min) or post-wiki-reflect trigger.
|
|
16
|
+
* Graceful degradation: no OPENAI_API_KEY → exits immediately, no error.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
|
|
23
|
+
const HOME = os.homedir();
|
|
24
|
+
const METAME_DIR = path.join(HOME, '.metame');
|
|
25
|
+
const DB_PATH = path.join(METAME_DIR, 'memory.db');
|
|
26
|
+
const LOCK_FILE = path.join(METAME_DIR, 'daemon-embedding.lock');
|
|
27
|
+
const LOG_FILE = path.join(METAME_DIR, 'embedding_log.jsonl');
|
|
28
|
+
const LOCK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
29
|
+
const MAX_BATCH = 50;
|
|
30
|
+
|
|
31
|
+
function loadModule(name) {
|
|
32
|
+
const candidates = [
|
|
33
|
+
path.join(HOME, '.metame', name),
|
|
34
|
+
path.join(__dirname, name),
|
|
35
|
+
];
|
|
36
|
+
for (const p of candidates) {
|
|
37
|
+
try { return require(p); } catch { }
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const embedding = loadModule('core/embedding');
|
|
44
|
+
if (!embedding || !embedding.isEmbeddingAvailable()) {
|
|
45
|
+
// No API key — skip silently
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Atomic lock acquisition
|
|
50
|
+
try {
|
|
51
|
+
const fd = fs.openSync(LOCK_FILE, 'wx');
|
|
52
|
+
fs.writeFileSync(fd, String(process.pid));
|
|
53
|
+
fs.closeSync(fd);
|
|
54
|
+
} catch {
|
|
55
|
+
// Lock exists — check if stale
|
|
56
|
+
try {
|
|
57
|
+
const lockAge = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
|
|
58
|
+
if (lockAge < LOCK_TIMEOUT_MS) return; // another instance running
|
|
59
|
+
fs.unlinkSync(LOCK_FILE);
|
|
60
|
+
fs.openSync(LOCK_FILE, 'wx');
|
|
61
|
+
fs.writeFileSync(LOCK_FILE, String(process.pid));
|
|
62
|
+
} catch {
|
|
63
|
+
return; // race lost or fs error
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let db;
|
|
68
|
+
try {
|
|
69
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
70
|
+
db = new DatabaseSync(DB_PATH);
|
|
71
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
72
|
+
db.exec('PRAGMA busy_timeout = 3000');
|
|
73
|
+
|
|
74
|
+
// Ensure schema exists
|
|
75
|
+
try {
|
|
76
|
+
const { applyWikiSchema } = loadModule('memory-wiki-schema') || {};
|
|
77
|
+
if (applyWikiSchema) applyWikiSchema(db);
|
|
78
|
+
} catch { }
|
|
79
|
+
|
|
80
|
+
// Fetch pending queue items
|
|
81
|
+
const pending = db.prepare(`
|
|
82
|
+
SELECT eq.id AS queue_id, eq.item_type, eq.item_id, eq.model, eq.attempts,
|
|
83
|
+
cc.chunk_text
|
|
84
|
+
FROM embedding_queue eq
|
|
85
|
+
JOIN content_chunks cc ON eq.item_id = cc.id
|
|
86
|
+
WHERE eq.item_type = 'chunk'
|
|
87
|
+
AND eq.attempts < 3
|
|
88
|
+
ORDER BY eq.created_at ASC
|
|
89
|
+
LIMIT ?
|
|
90
|
+
`).all(MAX_BATCH);
|
|
91
|
+
|
|
92
|
+
if (pending.length === 0) return;
|
|
93
|
+
|
|
94
|
+
// Batch embed
|
|
95
|
+
const texts = pending.map(p => p.chunk_text);
|
|
96
|
+
let embeddings;
|
|
97
|
+
try {
|
|
98
|
+
embeddings = await embedding.batchEmbed(texts);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// API failure — increment attempts for all
|
|
101
|
+
const updateAttempts = db.prepare(
|
|
102
|
+
'UPDATE embedding_queue SET attempts = attempts + 1, last_error = ? WHERE id = ?',
|
|
103
|
+
);
|
|
104
|
+
for (const p of pending) {
|
|
105
|
+
updateAttempts.run(err.message.slice(0, 500), p.queue_id);
|
|
106
|
+
}
|
|
107
|
+
appendLog({ ts: new Date().toISOString(), error: err.message, batch_size: pending.length });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Write results
|
|
112
|
+
const updateChunk = db.prepare(`
|
|
113
|
+
UPDATE content_chunks
|
|
114
|
+
SET embedding = ?, embedding_model = ?, embedding_dim = ?
|
|
115
|
+
WHERE id = ?
|
|
116
|
+
`);
|
|
117
|
+
const deleteQueue = db.prepare('DELETE FROM embedding_queue WHERE id = ?');
|
|
118
|
+
const updateAttempts = db.prepare(
|
|
119
|
+
'UPDATE embedding_queue SET attempts = attempts + 1, last_error = ? WHERE id = ?',
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
let success = 0;
|
|
123
|
+
let failed = 0;
|
|
124
|
+
db.prepare('BEGIN').run();
|
|
125
|
+
try {
|
|
126
|
+
for (let i = 0; i < pending.length; i++) {
|
|
127
|
+
const emb = embeddings[i];
|
|
128
|
+
if (emb) {
|
|
129
|
+
const buf = embedding.embeddingToBuffer(emb);
|
|
130
|
+
updateChunk.run(buf, embedding.MODEL, embedding.DIMENSIONS, pending[i].item_id);
|
|
131
|
+
deleteQueue.run(pending[i].queue_id);
|
|
132
|
+
success++;
|
|
133
|
+
} else {
|
|
134
|
+
updateAttempts.run('null embedding returned', pending[i].queue_id);
|
|
135
|
+
failed++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
db.prepare('COMMIT').run();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
try { db.prepare('ROLLBACK').run(); } catch { }
|
|
141
|
+
appendLog({ ts: new Date().toISOString(), error: err.message, batch_size: pending.length });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
appendLog({ ts: new Date().toISOString(), success, failed, batch_size: pending.length });
|
|
146
|
+
|
|
147
|
+
} finally {
|
|
148
|
+
if (db) try { db.close(); } catch { }
|
|
149
|
+
try { fs.unlinkSync(LOCK_FILE); } catch { }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function appendLog(entry) {
|
|
154
|
+
try {
|
|
155
|
+
fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
|
|
156
|
+
} catch { }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main().catch(err => {
|
|
160
|
+
appendLog({ ts: new Date().toISOString(), error: err.message });
|
|
161
|
+
try { fs.unlinkSync(LOCK_FILE); } catch { }
|
|
162
|
+
});
|