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.
- package/index.js +4 -1
- package/package.json +1 -1
- 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 +144 -3
- package/scripts/daemon-command-router.js +25 -1
- package/scripts/daemon-default.yaml +31 -0
- 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-runtime-lifecycle.js +1 -1
- package/scripts/daemon-task-scheduler.js +5 -3
- package/scripts/daemon-wiki.js +126 -4
- package/scripts/daemon.js +4 -2
- package/scripts/feishu-adapter.js +25 -0
- package/scripts/memory-backfill-chunks.js +92 -0
- package/scripts/memory-search.js +43 -15
- package/scripts/memory-wiki-schema.js +161 -2
- package/scripts/memory.js +15 -0
- 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 +352 -28
- package/scripts/wiki-reflect-export.js +115 -0
- package/scripts/wiki-reflect.js +34 -1
- package/scripts/wiki-synthesis.js +224 -0
package/scripts/daemon-wiki.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
const os = require('os');
|
|
19
19
|
const path = require('path');
|
|
20
|
-
const {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/scripts/memory-search.js
CHANGED
|
@@ -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 (
|
|
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
|
-
*
|
|
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
|
-
|
|
35
|
-
const
|
|
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 (
|
|
95
|
+
// Wiki pages — hybrid search (FTS5 + vector + RRF) when available
|
|
83
96
|
let wikiResults = [];
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
79
|
-
AFTER UPDATE ON wiki_pages
|
|
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,
|