metame-cli 1.5.26 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/index.js +4 -1
  2. package/package.json +1 -1
  3. package/scripts/agent-layer.js +36 -0
  4. package/scripts/core/chunker.js +100 -0
  5. package/scripts/core/embedding.js +225 -0
  6. package/scripts/core/hybrid-search.js +296 -0
  7. package/scripts/core/wiki-db.js +545 -0
  8. package/scripts/core/wiki-prompt.js +88 -0
  9. package/scripts/core/wiki-slug.js +66 -0
  10. package/scripts/core/wiki-staleness.js +18 -0
  11. package/scripts/daemon-agent-commands.js +10 -4
  12. package/scripts/daemon-bridges.js +16 -0
  13. package/scripts/daemon-claude-engine.js +62 -8
  14. package/scripts/daemon-command-router.js +40 -1
  15. package/scripts/daemon-default.yaml +33 -3
  16. package/scripts/daemon-embedding.js +162 -0
  17. package/scripts/daemon-engine-runtime.js +1 -1
  18. package/scripts/daemon-health-scan.js +185 -0
  19. package/scripts/daemon-ops-commands.js +9 -18
  20. package/scripts/daemon-runtime-lifecycle.js +1 -1
  21. package/scripts/daemon-session-commands.js +4 -0
  22. package/scripts/daemon-task-scheduler.js +5 -3
  23. package/scripts/daemon-warm-pool.js +15 -0
  24. package/scripts/daemon-wiki.js +420 -0
  25. package/scripts/daemon.js +10 -5
  26. package/scripts/distill.js +1 -1
  27. package/scripts/docs/file-transfer.md +0 -1
  28. package/scripts/docs/maintenance-manual.md +2 -55
  29. package/scripts/docs/pointer-map.md +0 -34
  30. package/scripts/feishu-adapter.js +25 -0
  31. package/scripts/hooks/intent-file-transfer.js +1 -2
  32. package/scripts/memory-backfill-chunks.js +92 -0
  33. package/scripts/memory-search.js +49 -6
  34. package/scripts/memory-wiki-schema.js +255 -0
  35. package/scripts/memory.js +103 -3
  36. package/scripts/signal-capture.js +1 -1
  37. package/scripts/skill-evolution.js +2 -11
  38. package/scripts/wiki-cluster.js +121 -0
  39. package/scripts/wiki-extract.js +171 -0
  40. package/scripts/wiki-facts.js +351 -0
  41. package/scripts/wiki-import.js +256 -0
  42. package/scripts/wiki-reflect-build.js +441 -0
  43. package/scripts/wiki-reflect-export.js +448 -0
  44. package/scripts/wiki-reflect-query.js +109 -0
  45. package/scripts/wiki-reflect.js +338 -0
  46. package/scripts/wiki-synthesis.js +224 -0
@@ -0,0 +1,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💬 上次你说: _${snippet}${recentCtx.lastUser.length > 80 ? '…' : ''}_`;
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 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();
@@ -598,7 +613,31 @@ function createCommandRouter(deps) {
598
613
  await bot.sendMessage(chatId, 'Daily token budget exceeded.');
599
614
  return;
600
615
  }
601
- const claudeResult = await askClaude(bot, chatId, text, config, readOnly, senderId);
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: false
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
+ });
@@ -22,7 +22,7 @@ const ENGINE_TIMEOUT_DEFAULTS = Object.freeze({
22
22
  ceilingMs: null,
23
23
  }),
24
24
  claude: Object.freeze({
25
- idleMs: 5 * 60 * 1000,
25
+ idleMs: 20 * 60 * 1000,
26
26
  toolMs: 25 * 60 * 1000,
27
27
  ceilingMs: null,
28
28
  }),