metame-cli 1.6.0 → 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.
@@ -17,7 +17,7 @@
17
17
 
18
18
  const os = require('os');
19
19
  const path = require('path');
20
- const { execSync } = require('child_process');
20
+ const { execFileSync } = require('child_process');
21
21
 
22
22
  const {
23
23
  listWikiPages,
@@ -80,6 +80,11 @@ function createWikiCommandHandler(deps) {
80
80
  await _handleHelp(bot, chatId);
81
81
  return true;
82
82
  }
83
+ if (trimmed === '/wiki import' || trimmed.startsWith('/wiki import ')) {
84
+ const args = trimmed.slice(12).trim();
85
+ await _handleImport(bot, chatId, args);
86
+ return true;
87
+ }
83
88
  // Unknown /wiki subcommand — show help
84
89
  if (trimmed.startsWith('/wiki ')) {
85
90
  await _handleHelp(bot, chatId);
@@ -123,7 +128,14 @@ function createWikiCommandHandler(deps) {
123
128
  }
124
129
 
125
130
  const db = getDb();
126
- const { wikiPages, facts } = searchWikiAndFacts(db, query, { trackSearch: true });
131
+ let wikiPages, facts;
132
+ try {
133
+ const { hybridSearchWiki } = require('./core/hybrid-search');
134
+ ({ wikiPages, facts } = await hybridSearchWiki(db, query, { trackSearch: true }));
135
+ } catch (err) {
136
+ log('WARN', `[wiki-research] hybrid search fallback to FTS: ${err.message}`);
137
+ ({ wikiPages, facts } = searchWikiAndFacts(db, query, { trackSearch: true }));
138
+ }
127
139
 
128
140
  if (wikiPages.length === 0 && facts.length === 0) {
129
141
  await bot.sendMessage(chatId,
@@ -206,6 +218,13 @@ function createWikiCommandHandler(deps) {
206
218
  const lines = ['✅ Wiki 重建完成'];
207
219
  if (result.built.length > 0) {
208
220
  lines.push(`• 重建: ${result.built.join(', ')}`);
221
+ try {
222
+ const { execFile } = require('child_process');
223
+ const embScript = path.join(os.homedir(), '.metame', 'daemon-embedding.js');
224
+ execFile('node', [embScript], { timeout: 120000, stdio: 'ignore' }, (err) => {
225
+ if (err) log('WARN', `[wiki-sync] embedding trigger failed: ${err.message}`);
226
+ });
227
+ } catch { }
209
228
  }
210
229
  if (result.failed.length > 0) {
211
230
  lines.push(`• 失败: ${result.failed.map(f => f.slug).join(', ')}`);
@@ -214,6 +233,68 @@ function createWikiCommandHandler(deps) {
214
233
  lines.push(`• 文件导出失败 (DB 已更新): ${result.exportFailed.join(', ')}`);
215
234
  }
216
235
  await bot.sendMessage(chatId, lines.join('\n'));
236
+
237
+ // Phased doc + cluster rebuild (wiki-import feature extension)
238
+ const { listStaleDocSources } = require('./core/wiki-db');
239
+ const { buildDocWikiPage } = require('./wiki-reflect-build');
240
+ const { extractText } = require('./wiki-extract');
241
+ const allDocSlugsForSync = db.prepare("SELECT slug FROM doc_sources WHERE status='active'").all().map(r => r.slug);
242
+ const staleDocSources = listStaleDocSources(db);
243
+ const builtDocSlugs = [];
244
+ for (const docSrc of staleDocSources) {
245
+ try {
246
+ const { text } = await extractText(docSrc.file_path);
247
+ const docResult = await buildDocWikiPage(db, docSrc, text, { allowedSlugs: allDocSlugsForSync, providers });
248
+ if (docResult) {
249
+ db.prepare("UPDATE doc_sources SET content_stale=0, built_at=? WHERE id=?")
250
+ .run(new Date().toISOString(), docSrc.id);
251
+ builtDocSlugs.push(docSrc.slug);
252
+ }
253
+ } catch (docErr) {
254
+ db.prepare("UPDATE doc_sources SET error_message=? WHERE id=?").run(docErr.message, docSrc.id);
255
+ log('WARN', `[wiki-sync] doc rebuild failed ${docSrc.slug}: ${docErr.message}`);
256
+ }
257
+ }
258
+ // Cascade stale cluster pages
259
+ if (builtDocSlugs.length > 0) {
260
+ const ph = builtDocSlugs.map(() => '?').join(',');
261
+ const affected = db.prepare(`SELECT DISTINCT page_slug FROM wiki_page_doc_sources
262
+ WHERE role='cluster_member' AND doc_source_id IN
263
+ (SELECT id FROM doc_sources WHERE slug IN (${ph}))`).all(...builtDocSlugs).map(r => r.page_slug);
264
+ if (affected.length) {
265
+ db.prepare(`UPDATE wiki_pages SET staleness=1 WHERE slug IN (${affected.map(() => '?').join(',')})`).run(...affected);
266
+ }
267
+ }
268
+ // Rebuild stale cluster pages after embedding drain
269
+ const { waitForEmbeddingDrain } = require('./wiki-import');
270
+ const phase1ChunkIds = builtDocSlugs.flatMap(slug =>
271
+ db.prepare("SELECT id FROM content_chunks WHERE page_slug=?").all(slug).map(c => c.id)
272
+ );
273
+ const drainedOk = await waitForEmbeddingDrain(db, phase1ChunkIds, (msg) => log('INFO', msg));
274
+ if (drainedOk) {
275
+ const { buildTopicClusterPage } = require('./wiki-reflect-build');
276
+ const { getClusterMemberIds } = require('./core/wiki-db');
277
+ const staleClusterPages = db.prepare("SELECT * FROM wiki_pages WHERE source_type='topic_cluster' AND staleness=1").all();
278
+ for (const cp of staleClusterPages) {
279
+ const memberIds = getClusterMemberIds(db, cp.slug);
280
+ const getDocSrcById = db.prepare("SELECT * FROM doc_sources WHERE id=?");
281
+ const docRows = memberIds.map(id => getDocSrcById.get(id)).filter(Boolean);
282
+ try {
283
+ await buildTopicClusterPage(db, docRows, { allowedSlugs: allDocSlugsForSync, providers, existingClusters: [] });
284
+ } catch (clErr) {
285
+ log('WARN', `[wiki-sync] cluster rebuild failed ${cp.slug}: ${clErr.message}`);
286
+ }
287
+ }
288
+ }
289
+ if (staleDocSources.length > 0) {
290
+ const docMsg = [];
291
+ if (builtDocSlugs.length > 0) docMsg.push(`• 文档页面重建: ${builtDocSlugs.join(', ')}`);
292
+ const docFailed = staleDocSources.length - builtDocSlugs.length;
293
+ if (docFailed > 0) docMsg.push(`• 文档重建失败: ${docFailed} 个(见日志)`);
294
+ if (docMsg.length > 0) {
295
+ await bot.sendMessage(chatId, `📄 文档页面同步\n\n${docMsg.join('\n')}`);
296
+ }
297
+ }
217
298
  } catch (err) {
218
299
  log('ERROR', `[wiki-sync] ${err.message}`);
219
300
  if (err.message.includes('another instance')) {
@@ -263,10 +344,10 @@ function createWikiCommandHandler(deps) {
263
344
  // Try Obsidian URI first (opens vault by path if already configured)
264
345
  const vaultName = path.basename(outputDir);
265
346
  try {
266
- execSync(`open "obsidian://open?vault=${encodeURIComponent(vaultName)}"`, { timeout: 5000 });
347
+ execFileSync('open', [`obsidian://open?vault=${encodeURIComponent(vaultName)}`], { timeout: 5000 });
267
348
  } catch {
268
349
  // Fallback: open folder in Finder — user can then drag into Obsidian
269
- execSync(`open "${outputDir}"`, { timeout: 5000 });
350
+ execFileSync('open', [outputDir], { timeout: 5000 });
270
351
  }
271
352
  await bot.sendMessage(chatId,
272
353
  `📂 已打开 Obsidian vault: \`${outputDir}\`\n\n` +
@@ -279,6 +360,46 @@ function createWikiCommandHandler(deps) {
279
360
  }
280
361
  }
281
362
 
363
+ async function _handleImport(bot, chatId, args) {
364
+ const noCluster = args.includes('--no-cluster');
365
+ const inputPath = args.replace('--no-cluster', '').trim();
366
+
367
+ if (!inputPath) {
368
+ await bot.sendMessage(chatId, '用法: `/wiki import <路径或文件>` [--no-cluster]\n\n示例:\n`/wiki import ~/Documents/notes`\n`/wiki import ~/report.pdf`');
369
+ return;
370
+ }
371
+
372
+ const resolvedPath = inputPath.replace(/^~/, require('node:os').homedir());
373
+
374
+ let stat;
375
+ try { stat = require('node:fs').statSync(resolvedPath); }
376
+ catch { await bot.sendMessage(chatId, `❌ 路径不存在: ${resolvedPath}`); return; }
377
+
378
+ const isDir = stat.isDirectory();
379
+ await bot.sendMessage(chatId, `⏳ 开始导入 ${isDir ? '目录' : '文件'}: \`${resolvedPath}\`\n${noCluster ? '(跳过聚类)' : '(含自动聚类)'}`);
380
+
381
+ const { runWikiImport } = require('./wiki-import');
382
+ const db = getDb();
383
+ const logFn = (msg) => { log('INFO', msg); };
384
+
385
+ try {
386
+ const stats = await runWikiImport(db, resolvedPath, {
387
+ providers, noCluster, log: logFn,
388
+ });
389
+ await bot.sendMessage(chatId,
390
+ `✅ 导入完成\n\n` +
391
+ `- 新建/更新页面: ${stats.imported}\n` +
392
+ `- 跳过 (未变更): ${stats.skipped}\n` +
393
+ `- 失败: ${stats.failed}\n` +
394
+ `- 聚类页面: ${stats.clusters}\n\n` +
395
+ `使用 \`/wiki\` 查看全部页面`
396
+ );
397
+ } catch (err) {
398
+ log('ERROR', `[wiki-import] ${err.message}`);
399
+ await bot.sendMessage(chatId, `❌ 导入失败: ${err.message}`);
400
+ }
401
+ }
402
+
282
403
  async function _handleHelp(bot, chatId) {
283
404
  await bot.sendMessage(chatId, [
284
405
  '📚 **Wiki 命令**',
@@ -287,6 +408,7 @@ function createWikiCommandHandler(deps) {
287
408
  '`/wiki research <关键词>` — 搜索知识',
288
409
  '`/wiki page <slug>` — 查看页面全文',
289
410
  '`/wiki sync` — 重建陈旧页面',
411
+ '`/wiki import <路径>` — 导入本地文档 (md/txt/PDF)',
290
412
  '`/wiki pin <标签> [标题]` — 手工注册主题',
291
413
  '`/wiki open` — 在 Obsidian 中打开 vault',
292
414
  ].join('\n'));
package/scripts/daemon.js CHANGED
@@ -2387,7 +2387,7 @@ async function main() {
2387
2387
  }
2388
2388
 
2389
2389
  // Config validation: warn on unknown/suspect fields
2390
- const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'weixin', 'heartbeat', 'budget', 'projects', 'imessage', 'siri_bridge'];
2390
+ const KNOWN_SECTIONS = ['daemon', 'telegram', 'feishu', 'weixin', 'heartbeat', 'budget', 'projects', 'imessage', 'siri_bridge', 'hooks'];
2391
2391
  const KNOWN_DAEMON = [
2392
2392
  'model', // legacy (still valid as fallback)
2393
2393
  'models', // per-engine model map: { claude, codex }
@@ -2402,6 +2402,8 @@ async function main() {
2402
2402
  'mac_control_mode',
2403
2403
  'enable_nl_mac_control',
2404
2404
  'enable_nl_mac_fallback',
2405
+ 'wiki_output_dir', // wiki export path (used by daemon-command-router)
2406
+ 'skill_evolution_notify', // whether to notify on skill evolution (used by daemon-task-scheduler)
2405
2407
  ];
2406
2408
  for (const key of Object.keys(config)) {
2407
2409
  if (!KNOWN_SECTIONS.includes(key)) log('WARN', `Config: unknown section "${key}" (typo?)`);
@@ -2510,7 +2512,7 @@ async function main() {
2510
2512
  };
2511
2513
 
2512
2514
  // Start heartbeat scheduler
2513
- let heartbeatTimer = startHeartbeat(config, notifyFn, notifyPersonalFn);
2515
+ let heartbeatTimer = startHeartbeat(config, notifyFn, notifyPersonalFn, adminNotifyFn);
2514
2516
 
2515
2517
  let shuttingDown = false;
2516
2518
  function spawnReplacementDaemon(reason) {
@@ -393,11 +393,14 @@ function createBot(config) {
393
393
  let stopped = false;
394
394
  let currentWs = null;
395
395
  let healthTimer = null;
396
+ let sleepWakeTimer = null;
396
397
  let reconnectTimer = null;
397
398
  let reconnectDelay = 5000; // start 5s, doubles up to 60s
398
399
  const MAX_RECONNECT_DELAY = 60000;
399
400
  const HEALTH_CHECK_INTERVAL = 90000; // check every 90s
400
401
  const SILENT_THRESHOLD = 300000; // 5 min no SDK activity → suspect dead
402
+ const SLEEP_DETECT_INTERVAL = 5000; // tick every 5s to detect clock jump
403
+ const SLEEP_JUMP_THRESHOLD = 30000; // clock jump >30s = system was sleeping
401
404
 
402
405
  // Track last SDK activity (any event received = alive)
403
406
  let _lastActivityAt = Date.now();
@@ -538,15 +541,37 @@ function createBot(config) {
538
541
  }, HEALTH_CHECK_INTERVAL);
539
542
  }
540
543
 
544
+ // Sleep/wake detector: if the JS clock jumps >30s, system was sleeping → force reconnect
545
+ function startSleepWakeDetector() {
546
+ let _lastTickAt = Date.now();
547
+ sleepWakeTimer = setInterval(() => {
548
+ if (stopped) return;
549
+ const now = Date.now();
550
+ const elapsed = now - _lastTickAt;
551
+ _lastTickAt = now;
552
+ if (elapsed > SLEEP_JUMP_THRESHOLD) {
553
+ _log('INFO', `System wake detected (${Math.round(elapsed / 1000)}s gap) — forcing reconnect`);
554
+ reconnectDelay = 5000;
555
+ clearTimeout(reconnectTimer);
556
+ try { currentWs?.stop?.(); } catch { /* ignore */ }
557
+ currentWs = null;
558
+ touchActivity(); // reset silence counter so health check doesn't double-fire
559
+ connect();
560
+ }
561
+ }, SLEEP_DETECT_INTERVAL);
562
+ }
563
+
541
564
  // Initial connect
542
565
  connect();
543
566
  startHealthCheck();
567
+ startSleepWakeDetector();
544
568
 
545
569
  return Promise.resolve({
546
570
  stop() {
547
571
  stopped = true;
548
572
  clearTimeout(reconnectTimer);
549
573
  clearInterval(healthTimer);
574
+ clearInterval(sleepWakeTimer);
550
575
  currentWs = null;
551
576
  },
552
577
  reconnect() {
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * memory-backfill-chunks.js — One-time backfill for existing wiki pages
7
+ *
8
+ * For each wiki page that has content but no content_chunks rows:
9
+ * 1. Splits content into chunks via recursive chunker
10
+ * 2. Inserts chunk rows
11
+ * 3. Enqueues each chunk for embedding generation
12
+ *
13
+ * Idempotent: pages with existing chunks are skipped.
14
+ *
15
+ * Usage: node scripts/memory-backfill-chunks.js
16
+ */
17
+
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ const DB_PATH = path.join(os.homedir(), '.metame', 'memory.db');
22
+
23
+ function main() {
24
+ const { DatabaseSync } = require('node:sqlite');
25
+ const db = new DatabaseSync(DB_PATH);
26
+ db.exec('PRAGMA journal_mode = WAL');
27
+ db.exec('PRAGMA busy_timeout = 3000');
28
+
29
+ // Ensure schema is up to date
30
+ try {
31
+ const { applyWikiSchema } = require('./memory-wiki-schema');
32
+ applyWikiSchema(db);
33
+ } catch (err) {
34
+ process.stderr.write(`Schema init failed: ${err.message}\n`);
35
+ db.close();
36
+ process.exit(1);
37
+ }
38
+
39
+ const { chunkText } = require('./core/chunker');
40
+
41
+ // Find pages without chunks
42
+ const pages = db.prepare(`
43
+ SELECT wp.slug, wp.content
44
+ FROM wiki_pages wp
45
+ WHERE wp.content IS NOT NULL
46
+ AND wp.content != ''
47
+ AND NOT EXISTS (
48
+ SELECT 1 FROM content_chunks cc WHERE cc.page_slug = wp.slug
49
+ )
50
+ `).all();
51
+
52
+ if (pages.length === 0) {
53
+ console.log('All wiki pages already have chunks. Nothing to backfill.');
54
+ db.close();
55
+ return;
56
+ }
57
+
58
+ console.log(`Backfilling ${pages.length} wiki pages...`);
59
+
60
+ const insertChunk = db.prepare(
61
+ 'INSERT INTO content_chunks (id, page_slug, chunk_text, chunk_idx) VALUES (?, ?, ?, ?)',
62
+ );
63
+ const enqueue = db.prepare(
64
+ "INSERT INTO embedding_queue (item_type, item_id) VALUES ('chunk', ?)",
65
+ );
66
+
67
+ let totalChunks = 0;
68
+
69
+ db.prepare('BEGIN').run();
70
+ try {
71
+ for (const page of pages) {
72
+ const chunks = chunkText(page.content, { targetWords: 300 });
73
+ for (let i = 0; i < chunks.length; i++) {
74
+ const chunkId = `ck_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
75
+ insertChunk.run(chunkId, page.slug, chunks[i], i);
76
+ enqueue.run(chunkId);
77
+ totalChunks++;
78
+ }
79
+ }
80
+ db.prepare('COMMIT').run();
81
+ } catch (err) {
82
+ try { db.prepare('ROLLBACK').run(); } catch { }
83
+ process.stderr.write(`Backfill failed: ${err.message}\n`);
84
+ db.close();
85
+ process.exit(1);
86
+ }
87
+
88
+ console.log(`Done. Created ${totalChunks} chunks for ${pages.length} pages. Run daemon-embedding.js to generate embeddings.`);
89
+ db.close();
90
+ }
91
+
92
+ main();
@@ -3,14 +3,15 @@
3
3
  * memory-search.js — Cross-session memory recall CLI
4
4
  *
5
5
  * Usage:
6
- * node memory-search.js "<query>" # hybrid search (QMD + FTS5)
6
+ * node memory-search.js "<query>" # hybrid search (FTS5 + vector + RRF)
7
7
  * node memory-search.js "<q1>" "<q2>" "<q3>" # multi-keyword parallel search
8
8
  * node memory-search.js --facts "<query>" # search facts only
9
9
  * node memory-search.js --sessions "<query>" # search sessions only
10
+ * node memory-search.js --fts-only "<query>" # force pure FTS5 (no vector)
10
11
  * node memory-search.js --recent # show recent sessions
11
12
  *
12
13
  * Multi-keyword: results are deduplicated by fact ID, best rank wins.
13
- * Async: uses QMD hybrid search (BM25 + vector) when available, falls back to FTS5.
14
+ * Hybrid: uses FTS5 + vector embeddings + RRF fusion when available, falls back to FTS5.
14
15
  */
15
16
 
16
17
  'use strict';
@@ -31,8 +32,20 @@ if (!memoryPath) {
31
32
  const memory = require(memoryPath);
32
33
 
33
34
  const args = process.argv.slice(2);
34
- const mode = args[0] && args[0].startsWith('--') ? args[0] : null;
35
- const queries = mode ? args.slice(1) : args;
35
+ // Parse flags: allow multiple -- flags before queries
36
+ const flags = new Set();
37
+ let firstQueryIdx = 0;
38
+ for (let i = 0; i < args.length; i++) {
39
+ if (args[i].startsWith('--')) { flags.add(args[i]); firstQueryIdx = i + 1; }
40
+ else break;
41
+ }
42
+ const mode = flags.has('--facts') ? '--facts'
43
+ : flags.has('--sessions') ? '--sessions'
44
+ : flags.has('--recent') ? '--recent'
45
+ : flags.has('--fts-only') ? '--fts-only'
46
+ : null;
47
+ const ftsOnly = flags.has('--fts-only');
48
+ const queries = args.slice(firstQueryIdx);
36
49
 
37
50
  async function main() {
38
51
  try {
@@ -79,20 +92,35 @@ async function main() {
79
92
  limit: 3,
80
93
  });
81
94
 
82
- // Wiki pages (if available)
95
+ // Wiki pages — hybrid search (FTS5 + vector + RRF) when available
83
96
  let wikiResults = [];
84
- if (typeof memory.searchWikiAndFacts === 'function') {
85
- try {
86
- const allWiki = [];
87
- for (const q of queries) {
88
- const { wikiPages } = memory.searchWikiAndFacts(q, { trackSearch: true });
89
- for (const p of (wikiPages || [])) {
90
- allWiki.push({ type: 'wiki', slug: p.slug, title: p.title, excerpt: p.excerpt, last_built_at: p.last_built_at });
97
+ const useHybrid = typeof memory.hybridSearchWiki === 'function';
98
+ try {
99
+ const allWiki = [];
100
+ const seen = new Set();
101
+ for (const q of queries) {
102
+ const { wikiPages } = useHybrid
103
+ ? await memory.hybridSearchWiki(q, { ftsOnly, trackSearch: true })
104
+ : (typeof memory.searchWikiAndFacts === 'function'
105
+ ? memory.searchWikiAndFacts(q, { trackSearch: true })
106
+ : { wikiPages: [] });
107
+ for (const p of (wikiPages || [])) {
108
+ if (!seen.has(p.slug)) {
109
+ seen.add(p.slug);
110
+ allWiki.push({
111
+ type: 'wiki',
112
+ slug: p.slug,
113
+ title: p.title,
114
+ excerpt: p.excerpt,
115
+ score: p.score,
116
+ stale: p.stale,
117
+ source: p.source,
118
+ });
91
119
  }
92
120
  }
93
- wikiResults = allWiki.slice(0, 5);
94
- } catch { /* wiki not available */ }
95
- }
121
+ }
122
+ wikiResults = allWiki.slice(0, 5);
123
+ } catch { /* wiki not available */ }
96
124
 
97
125
  console.log(JSON.stringify([...wikiResults, ...factResults, ...sessionResults], null, 2));
98
126
 
@@ -11,6 +11,8 @@
11
11
  * wiki_pages — topic knowledge pages
12
12
  * wiki_topics — controlled topic registry
13
13
  * wiki_pages_fts — FTS5 virtual table (content table, trigram tokenizer)
14
+ * content_chunks — chunked page content with optional vector embeddings
15
+ * embedding_queue — durable async queue for embedding generation
14
16
  *
15
17
  * Triggers:
16
18
  * wiki_pages_fts_insert / wiki_pages_fts_update / wiki_pages_fts_delete
@@ -42,6 +44,9 @@ function applyWikiSchema(db) {
42
44
  )
43
45
  `);
44
46
 
47
+ // Migration: add timeline column for Compiled Truth + Timeline model (existing DBs)
48
+ try { db.exec("ALTER TABLE wiki_pages ADD COLUMN timeline TEXT DEFAULT ''"); } catch { /* column already exists */ }
49
+
45
50
  // ── wiki_topics ─────────────────────────────────────────────────────────────
46
51
  db.exec(`
47
52
  CREATE TABLE IF NOT EXISTS wiki_topics (
@@ -74,9 +79,14 @@ function applyWikiSchema(db) {
74
79
  END
75
80
  `);
76
81
 
82
+ // DROP+CREATE to upgrade existing unguarded trigger on deployed DBs
83
+ db.exec('DROP TRIGGER IF EXISTS wiki_pages_fts_update');
77
84
  db.exec(`
78
- CREATE TRIGGER IF NOT EXISTS wiki_pages_fts_update
79
- AFTER UPDATE ON wiki_pages BEGIN
85
+ CREATE TRIGGER wiki_pages_fts_update
86
+ AFTER UPDATE ON wiki_pages
87
+ WHEN old.slug IS NOT new.slug OR old.title IS NOT new.title
88
+ OR old.content IS NOT new.content OR old.topic_tags IS NOT new.topic_tags
89
+ BEGIN
80
90
  INSERT INTO wiki_pages_fts(wiki_pages_fts, rowid, slug, title, content, topic_tags)
81
91
  VALUES ('delete', old.rowid, old.slug, old.title, old.content, old.topic_tags);
82
92
  INSERT INTO wiki_pages_fts(rowid, slug, title, content, topic_tags)
@@ -91,6 +101,155 @@ function applyWikiSchema(db) {
91
101
  VALUES ('delete', old.rowid, old.slug, old.title, old.content, old.topic_tags);
92
102
  END
93
103
  `);
104
+
105
+ // ── content_chunks (vector embedding storage for wiki pages) ────────────────
106
+ db.exec(`
107
+ CREATE TABLE IF NOT EXISTS content_chunks (
108
+ id TEXT PRIMARY KEY,
109
+ page_slug TEXT NOT NULL,
110
+ chunk_text TEXT NOT NULL,
111
+ chunk_idx INTEGER NOT NULL,
112
+ embedding BLOB,
113
+ embedding_model TEXT,
114
+ embedding_dim INTEGER,
115
+ created_at TEXT DEFAULT (datetime('now'))
116
+ )
117
+ `);
118
+ try { db.exec('CREATE INDEX IF NOT EXISTS idx_chunks_slug ON content_chunks(page_slug)'); } catch { }
119
+
120
+ // ── embedding_queue (durable async queue for embedding generation) ──────────
121
+ db.exec(`
122
+ CREATE TABLE IF NOT EXISTS embedding_queue (
123
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
124
+ item_type TEXT NOT NULL,
125
+ item_id TEXT NOT NULL,
126
+ model TEXT DEFAULT 'text-embedding-3-small',
127
+ attempts INTEGER DEFAULT 0,
128
+ last_error TEXT,
129
+ created_at TEXT DEFAULT (datetime('now'))
130
+ )
131
+ `);
132
+
133
+ // ── doc_sources ───────────────────────────────────────────────────────────
134
+ db.exec(`
135
+ CREATE TABLE IF NOT EXISTS doc_sources (
136
+ id INTEGER PRIMARY KEY,
137
+ file_path TEXT UNIQUE NOT NULL,
138
+ file_hash TEXT NOT NULL,
139
+ mtime_ms INTEGER,
140
+ size_bytes INTEGER,
141
+ extracted_text_hash TEXT,
142
+ file_type TEXT NOT NULL CHECK (file_type IN ('md','txt','pdf')),
143
+ extractor TEXT,
144
+ extract_status TEXT DEFAULT 'pending'
145
+ CHECK (extract_status IN ('ok','empty_or_scanned','error','pending')),
146
+ title TEXT,
147
+ slug TEXT UNIQUE NOT NULL,
148
+ status TEXT DEFAULT 'active'
149
+ CHECK (status IN ('active','orphaned','missing')),
150
+ error_message TEXT,
151
+ indexed_at TEXT NOT NULL,
152
+ last_seen_at TEXT,
153
+ built_at TEXT,
154
+ content_stale INTEGER DEFAULT 1
155
+ )
156
+ `);
157
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_doc_sources_status ON doc_sources(status)`);
158
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_doc_sources_file_hash ON doc_sources(file_hash)`);
159
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_doc_sources_slug ON doc_sources(slug)`);
160
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_doc_sources_content_stale ON doc_sources(content_stale)`);
161
+
162
+ // ── wiki_page_doc_sources ─────────────────────────────────────────────────
163
+ db.exec(`
164
+ CREATE TABLE IF NOT EXISTS wiki_page_doc_sources (
165
+ page_slug TEXT NOT NULL,
166
+ doc_source_id INTEGER NOT NULL,
167
+ role TEXT NOT NULL CHECK (role IN ('primary','cluster_member')),
168
+ PRIMARY KEY (page_slug, doc_source_id, role),
169
+ FOREIGN KEY (page_slug) REFERENCES wiki_pages(slug) ON DELETE CASCADE,
170
+ FOREIGN KEY (doc_source_id) REFERENCES doc_sources(id) ON DELETE CASCADE
171
+ )
172
+ `);
173
+
174
+ // ── wiki_pages additions (idempotent ALTER) ───────────────────────────────
175
+ for (const [col, def] of [
176
+ ['source_type', "TEXT DEFAULT 'memory'"],
177
+ ['membership_hash','TEXT'],
178
+ ['cluster_size', 'INTEGER'],
179
+ ]) {
180
+ try { db.exec(`ALTER TABLE wiki_pages ADD COLUMN ${col} ${def}`); } catch { /* already exists */ }
181
+ }
182
+ db.exec("UPDATE wiki_pages SET source_type = 'memory' WHERE source_type IS NULL");
183
+
184
+ // ── doc_sources additions (idempotent ALTER) ──────────────────────────────
185
+ for (const [col, def] of [
186
+ ['doi', 'TEXT'],
187
+ ['year', 'INTEGER'],
188
+ ['venue', 'TEXT'],
189
+ ['zotero_key', 'TEXT'],
190
+ ['citation_count', 'INTEGER'],
191
+ ]) {
192
+ try { db.exec(`ALTER TABLE doc_sources ADD COLUMN ${col} ${def}`); } catch { /* already exists */ }
193
+ }
194
+
195
+ // ── paper_facts ───────────────────────────────────────────────────────────
196
+ db.exec(`
197
+ CREATE TABLE IF NOT EXISTS paper_facts (
198
+ id TEXT PRIMARY KEY,
199
+ doc_source_id INTEGER NOT NULL,
200
+ fact_type TEXT NOT NULL CHECK (fact_type IN (
201
+ 'problem','method','claim','assumption',
202
+ 'dataset','metric','result','baseline',
203
+ 'limitation','future_work','contradiction_note'
204
+ )),
205
+ subject TEXT,
206
+ predicate TEXT,
207
+ object TEXT,
208
+ value TEXT,
209
+ unit TEXT,
210
+ context TEXT,
211
+ evidence_text TEXT NOT NULL,
212
+ section TEXT,
213
+ extraction_source TEXT DEFAULT 'pdf_llm_section'
214
+ CHECK (extraction_source IN (
215
+ 'pdf_llm_section',
216
+ 'zotero_deep_read',
217
+ 'manual'
218
+ )),
219
+ confidence REAL DEFAULT 0.7,
220
+ created_at TEXT DEFAULT (datetime('now')),
221
+ FOREIGN KEY (doc_source_id) REFERENCES doc_sources(id) ON DELETE CASCADE
222
+ )
223
+ `);
224
+ db.exec('CREATE INDEX IF NOT EXISTS idx_paper_facts_doc ON paper_facts(doc_source_id)');
225
+ db.exec('CREATE INDEX IF NOT EXISTS idx_paper_facts_type ON paper_facts(fact_type)');
226
+ db.exec('CREATE INDEX IF NOT EXISTS idx_paper_facts_subject ON paper_facts(subject)');
227
+
228
+ // ── research_entities ─────────────────────────────────────────────────────
229
+ db.exec(`
230
+ CREATE TABLE IF NOT EXISTS research_entities (
231
+ id TEXT PRIMARY KEY,
232
+ entity_type TEXT NOT NULL CHECK (entity_type IN (
233
+ 'problem','concept','method_family','dataset','metric','application'
234
+ )),
235
+ name TEXT NOT NULL UNIQUE,
236
+ aliases TEXT DEFAULT '[]',
237
+ description TEXT,
238
+ created_at TEXT DEFAULT (datetime('now'))
239
+ )
240
+ `);
241
+
242
+ // ── fact_entity_links ─────────────────────────────────────────────────────
243
+ db.exec(`
244
+ CREATE TABLE IF NOT EXISTS fact_entity_links (
245
+ fact_id TEXT NOT NULL,
246
+ entity_id TEXT NOT NULL,
247
+ role TEXT,
248
+ PRIMARY KEY (fact_id, entity_id),
249
+ FOREIGN KEY (fact_id) REFERENCES paper_facts(id) ON DELETE CASCADE,
250
+ FOREIGN KEY (entity_id) REFERENCES research_entities(id) ON DELETE CASCADE
251
+ )
252
+ `);
94
253
  }
95
254
 
96
255
  module.exports = { applyWikiSchema };
package/scripts/memory.js CHANGED
@@ -47,6 +47,7 @@ function getDb() {
47
47
 
48
48
  _db.exec('PRAGMA journal_mode = WAL');
49
49
  _db.exec('PRAGMA busy_timeout = 3000');
50
+ _db.exec('PRAGMA foreign_keys = ON');
50
51
 
51
52
  _db.exec(`
52
53
  CREATE TABLE IF NOT EXISTS memory_items (
@@ -547,6 +548,19 @@ function searchWikiAndFacts(query, { trackSearch = true } = {}) {
547
548
  }
548
549
  }
549
550
 
551
+ /**
552
+ * Hybrid wiki search (FTS5 + vector + RRF fusion).
553
+ * Falls back to pure FTS5 if hybrid-search module is unavailable.
554
+ */
555
+ async function hybridSearchWiki(query, { ftsOnly = false, expand = false, trackSearch = true } = {}) {
556
+ try {
557
+ const { hybridSearchWiki: fn } = require('./core/hybrid-search');
558
+ return await fn(getDb(), query, { ftsOnly, trackSearch });
559
+ } catch {
560
+ return searchWikiAndFacts(query, { trackSearch });
561
+ }
562
+ }
563
+
550
564
  module.exports = {
551
565
  // core
552
566
  saveMemoryItem,
@@ -558,6 +572,7 @@ module.exports = {
558
572
  assembleContext,
559
573
  // wiki
560
574
  searchWikiAndFacts,
575
+ hybridSearchWiki,
561
576
  // compatibility
562
577
  saveSession,
563
578
  saveFacts,