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,338 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* wiki-reflect.js — Wiki page rebuild orchestrator
|
|
5
|
+
*
|
|
6
|
+
* Process flow:
|
|
7
|
+
* 1. Acquire process lock (O_EXCL file flag, 10-min staleness detection)
|
|
8
|
+
* 2. Read all wiki_topics from DB
|
|
9
|
+
* 3. Per topic: query → build → export (failure per page does not stop others)
|
|
10
|
+
* 4. Rebuild _index.md
|
|
11
|
+
* 5. Release lock
|
|
12
|
+
* 6. Append audit log entry to wiki_reflect_log.jsonl
|
|
13
|
+
*
|
|
14
|
+
* Exports:
|
|
15
|
+
* runWikiReflect(db, { outputDir, capsulesDir, logPath, providers, staleness }) → { built, failed, exportFailed }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
|
|
22
|
+
const { listWikiTopics, getWikiPageBySlug, listWikiPages, listRecentSessionSummaries } = require('./core/wiki-db');
|
|
23
|
+
const { queryRawFacts } = require('./wiki-reflect-query');
|
|
24
|
+
const { buildWikiPage } = require('./wiki-reflect-build');
|
|
25
|
+
const {
|
|
26
|
+
exportWikiPage,
|
|
27
|
+
rebuildIndex,
|
|
28
|
+
exportSessionSummary,
|
|
29
|
+
rebuildSessionsIndex,
|
|
30
|
+
exportCapsuleFile,
|
|
31
|
+
rebuildCapsulesIndex,
|
|
32
|
+
exportReflectDir,
|
|
33
|
+
rebuildReflectDirIndex,
|
|
34
|
+
exportDocPages,
|
|
35
|
+
} = require('./wiki-reflect-export');
|
|
36
|
+
|
|
37
|
+
const DEFAULT_WIKI_DIR = path.join(os.homedir(), '.metame', 'wiki');
|
|
38
|
+
const DEFAULT_CAPSULES_DIR = path.join(os.homedir(), '.metame', 'memory', 'capsules');
|
|
39
|
+
const DEFAULT_LOG_PATH = path.join(os.homedir(), '.metame', 'wiki_reflect_log.jsonl');
|
|
40
|
+
const DEFAULT_DECISIONS_DIR = path.join(os.homedir(), '.metame', 'memory', 'decisions');
|
|
41
|
+
const DEFAULT_LESSONS_DIR = path.join(os.homedir(), '.metame', 'memory', 'lessons');
|
|
42
|
+
const LOCK_FILE = path.join(os.homedir(), '.metame', 'wiki-reflect.lock');
|
|
43
|
+
const LOCK_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes
|
|
44
|
+
const STALENESS_THRESHOLD = 0.4;
|
|
45
|
+
const MAX_RETRIES = 3;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run wiki reflect pipeline.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} db - DatabaseSync instance
|
|
51
|
+
* @param {{
|
|
52
|
+
* outputDir?: string,
|
|
53
|
+
* capsulesDir?: string,
|
|
54
|
+
* logPath?: string,
|
|
55
|
+
* providers: { callHaiku: Function, buildDistillEnv: Function },
|
|
56
|
+
* threshold?: number,
|
|
57
|
+
* }} opts
|
|
58
|
+
* @returns {{ built: string[], failed: object[], exportFailed: string[] }}
|
|
59
|
+
*/
|
|
60
|
+
async function runWikiReflect(db, {
|
|
61
|
+
outputDir = DEFAULT_WIKI_DIR,
|
|
62
|
+
capsulesDir = DEFAULT_CAPSULES_DIR,
|
|
63
|
+
decisionsDir = DEFAULT_DECISIONS_DIR,
|
|
64
|
+
lessonsDir = DEFAULT_LESSONS_DIR,
|
|
65
|
+
logPath = DEFAULT_LOG_PATH,
|
|
66
|
+
providers,
|
|
67
|
+
threshold = STALENESS_THRESHOLD,
|
|
68
|
+
} = {}) {
|
|
69
|
+
const startMs = Date.now();
|
|
70
|
+
|
|
71
|
+
// 1. Acquire lock
|
|
72
|
+
if (!_acquireLock(LOCK_FILE)) {
|
|
73
|
+
throw new Error('wiki-reflect: another instance is running (lock file exists and is recent)');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const built = [];
|
|
77
|
+
const failed = [];
|
|
78
|
+
const exportFailed = [];
|
|
79
|
+
const strippedLinksMap = {};
|
|
80
|
+
let docsExported = 0;
|
|
81
|
+
let reflectExported = 0;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
// 2. Load previous failed_slugs for retry logic
|
|
85
|
+
const failedSlugsMap = _loadFailedSlugs(logPath);
|
|
86
|
+
|
|
87
|
+
// 3. Get all registered topics and their allowed slugs (for wikilink whitelist)
|
|
88
|
+
const topics = listWikiTopics(db);
|
|
89
|
+
const allowedSlugs = topics.map(t => t.slug);
|
|
90
|
+
|
|
91
|
+
// 4. Process each topic
|
|
92
|
+
for (const topic of topics) {
|
|
93
|
+
const slug = topic.slug;
|
|
94
|
+
|
|
95
|
+
// Determine if this page should be rebuilt
|
|
96
|
+
const existingPage = getWikiPageBySlug(db, slug);
|
|
97
|
+
const staleness = existingPage ? existingPage.staleness : 1.0;
|
|
98
|
+
const failedEntry = failedSlugsMap.get(slug);
|
|
99
|
+
|
|
100
|
+
const needsBuild = _shouldBuild(staleness, failedEntry, threshold);
|
|
101
|
+
if (!needsBuild) continue;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
// Query raw facts
|
|
105
|
+
const queryResult = queryRawFacts(db, topic.tag, { capsulesDir });
|
|
106
|
+
|
|
107
|
+
if (queryResult.totalCount === 0) {
|
|
108
|
+
// No facts for this topic yet — skip without marking as failed
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Build (LLM + DB write)
|
|
113
|
+
const buildResult = await buildWikiPage(db, topic, queryResult, {
|
|
114
|
+
allowedSlugs,
|
|
115
|
+
providers,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (buildResult === null) {
|
|
119
|
+
// LLM failure
|
|
120
|
+
const retries = failedEntry ? failedEntry.retries + 1 : 1;
|
|
121
|
+
failed.push({
|
|
122
|
+
slug,
|
|
123
|
+
retries,
|
|
124
|
+
next_retry: retries >= MAX_RETRIES ? null : _nextRetryISO(retries),
|
|
125
|
+
permanent_error: retries >= MAX_RETRIES,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Track stripped links for audit log
|
|
131
|
+
if (buildResult.strippedLinks.length > 0) {
|
|
132
|
+
strippedLinksMap[slug] = buildResult.strippedLinks;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Export (file write)
|
|
136
|
+
const updatedPage = getWikiPageBySlug(db, slug);
|
|
137
|
+
const frontmatter = {
|
|
138
|
+
title: updatedPage.title,
|
|
139
|
+
slug,
|
|
140
|
+
tags: _parseTags(updatedPage.topic_tags),
|
|
141
|
+
created: (updatedPage.created_at || '').slice(0, 10),
|
|
142
|
+
last_built: (updatedPage.last_built_at || '').slice(0, 10),
|
|
143
|
+
raw_sources: updatedPage.raw_source_count,
|
|
144
|
+
staleness: updatedPage.staleness,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
exportWikiPage(slug, frontmatter, buildResult.content, outputDir);
|
|
149
|
+
built.push(slug);
|
|
150
|
+
} catch (exportErr) {
|
|
151
|
+
// DB write succeeded, file write failed — log separately.
|
|
152
|
+
// Do NOT push to built: callers must not assume the file exists.
|
|
153
|
+
exportFailed.push(slug);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// Unexpected error (DB failure from buildWikiPage throws)
|
|
158
|
+
const retries = failedEntry ? failedEntry.retries + 1 : 1;
|
|
159
|
+
failed.push({
|
|
160
|
+
slug,
|
|
161
|
+
retries,
|
|
162
|
+
next_retry: retries >= MAX_RETRIES ? null : _nextRetryISO(retries),
|
|
163
|
+
permanent_error: retries >= MAX_RETRIES,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 5. Rebuild index — per-operation try/catch so one failure doesn't suppress the rest
|
|
169
|
+
let allPages = [];
|
|
170
|
+
let sessions = [];
|
|
171
|
+
try { allPages = listWikiPages(db, { limit: 1000, orderBy: 'title' }); } catch { /* non-fatal */ }
|
|
172
|
+
try { sessions = listRecentSessionSummaries(db, { limit: 200 }); } catch { /* non-fatal */ }
|
|
173
|
+
const capsuleFiles = _listCapsuleFiles(capsulesDir);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
rebuildIndex(allPages, outputDir, { sessionCount: sessions.length, capsuleCount: capsuleFiles.length });
|
|
177
|
+
} catch { /* non-fatal — _index.md not updated */ }
|
|
178
|
+
|
|
179
|
+
for (const entry of sessions) {
|
|
180
|
+
try { exportSessionSummary(entry, outputDir, { wikiPages: allPages, capsuleFiles }); }
|
|
181
|
+
catch { /* non-fatal — skip this session */ }
|
|
182
|
+
}
|
|
183
|
+
try { rebuildSessionsIndex(sessions, outputDir); } catch { /* non-fatal */ }
|
|
184
|
+
|
|
185
|
+
for (const capsuleFile of capsuleFiles) {
|
|
186
|
+
try { exportCapsuleFile(capsuleFile, outputDir); }
|
|
187
|
+
catch { /* non-fatal — skip this capsule */ }
|
|
188
|
+
}
|
|
189
|
+
try { rebuildCapsulesIndex(capsuleFiles, outputDir); } catch { /* non-fatal */ }
|
|
190
|
+
|
|
191
|
+
// Step 6: Export doc/cluster pages from DB
|
|
192
|
+
try {
|
|
193
|
+
const { exported } = exportDocPages(db, outputDir);
|
|
194
|
+
docsExported = exported.length;
|
|
195
|
+
} catch { /* non-fatal */ }
|
|
196
|
+
|
|
197
|
+
// Step 7: Mirror decisions and lessons to vault
|
|
198
|
+
try {
|
|
199
|
+
const decWritten = exportReflectDir(decisionsDir, 'decisions', outputDir);
|
|
200
|
+
const lesWritten = exportReflectDir(lessonsDir, 'lessons', outputDir);
|
|
201
|
+
reflectExported = decWritten.length + lesWritten.length;
|
|
202
|
+
|
|
203
|
+
const decFiles = fs.existsSync(decisionsDir) && fs.statSync(decisionsDir).isDirectory()
|
|
204
|
+
? fs.readdirSync(decisionsDir).filter(f => f.endsWith('.md'))
|
|
205
|
+
: [];
|
|
206
|
+
const lesFiles = fs.existsSync(lessonsDir) && fs.statSync(lessonsDir).isDirectory()
|
|
207
|
+
? fs.readdirSync(lessonsDir).filter(f => f.endsWith('.md'))
|
|
208
|
+
: [];
|
|
209
|
+
if (decFiles.length > 0) rebuildReflectDirIndex(decFiles, 'decisions', outputDir);
|
|
210
|
+
if (lesFiles.length > 0) rebuildReflectDirIndex(lesFiles, 'lessons', outputDir);
|
|
211
|
+
} catch { /* non-fatal */ }
|
|
212
|
+
|
|
213
|
+
} finally {
|
|
214
|
+
// 6. Release lock
|
|
215
|
+
_releaseLock(LOCK_FILE);
|
|
216
|
+
|
|
217
|
+
// 7. Write audit log
|
|
218
|
+
const entry = {
|
|
219
|
+
ts: new Date().toISOString(),
|
|
220
|
+
slugs_built: built,
|
|
221
|
+
export_failed_slugs: exportFailed,
|
|
222
|
+
failed_slugs: failed,
|
|
223
|
+
stripped_links: strippedLinksMap,
|
|
224
|
+
docs_exported: docsExported,
|
|
225
|
+
reflect_exported: reflectExported,
|
|
226
|
+
duration_ms: Date.now() - startMs,
|
|
227
|
+
};
|
|
228
|
+
try {
|
|
229
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf8');
|
|
230
|
+
} catch { /* non-fatal */ }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { built, failed, exportFailed, docsExported, reflectExported };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Lock helpers ──────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
function _acquireLock(lockFile) {
|
|
239
|
+
// Check if lock file exists and is recent
|
|
240
|
+
try {
|
|
241
|
+
const stat = fs.statSync(lockFile);
|
|
242
|
+
const age = Date.now() - stat.mtimeMs;
|
|
243
|
+
if (age < LOCK_MAX_AGE_MS) return false; // Lock is held
|
|
244
|
+
// Stale lock — remove it
|
|
245
|
+
fs.unlinkSync(lockFile);
|
|
246
|
+
} catch {
|
|
247
|
+
// Lock file doesn't exist — proceed
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
// O_EXCL ensures atomic creation (no race condition)
|
|
252
|
+
fs.writeFileSync(lockFile, String(process.pid), { flag: 'wx' });
|
|
253
|
+
return true;
|
|
254
|
+
} catch {
|
|
255
|
+
return false; // Another process created the lock between our check and write
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _releaseLock(lockFile) {
|
|
260
|
+
try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _listCapsuleFiles(capsulesDir) {
|
|
264
|
+
try {
|
|
265
|
+
if (!fs.existsSync(capsulesDir)) return [];
|
|
266
|
+
return fs.readdirSync(capsulesDir)
|
|
267
|
+
.filter(name => name.endsWith('.md'))
|
|
268
|
+
.map(name => path.join(capsulesDir, name));
|
|
269
|
+
} catch {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── failed_slugs helpers ──────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Load the most recent failed_slugs from the audit log.
|
|
278
|
+
* @param {string} logPath
|
|
279
|
+
* @returns {Map<string, { retries: number, next_retry: string|null, permanent_error?: boolean }>}
|
|
280
|
+
*/
|
|
281
|
+
function _loadFailedSlugs(logPath) {
|
|
282
|
+
const map = new Map();
|
|
283
|
+
if (!fs.existsSync(logPath)) return map;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
287
|
+
if (lines.length === 0) return map;
|
|
288
|
+
|
|
289
|
+
// Use the most recent log entry
|
|
290
|
+
const last = JSON.parse(lines[lines.length - 1]);
|
|
291
|
+
for (const entry of (last.failed_slugs || [])) {
|
|
292
|
+
map.set(entry.slug, {
|
|
293
|
+
retries: entry.retries || 0,
|
|
294
|
+
next_retry: entry.next_retry || null,
|
|
295
|
+
permanent_error: entry.permanent_error || false,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
} catch { /* corrupted log — start fresh */ }
|
|
299
|
+
|
|
300
|
+
return map;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Determine if a page should be rebuilt this round.
|
|
305
|
+
*/
|
|
306
|
+
function _shouldBuild(staleness, failedEntry, threshold) {
|
|
307
|
+
// Permanent error → skip
|
|
308
|
+
if (failedEntry && failedEntry.permanent_error) return false;
|
|
309
|
+
|
|
310
|
+
// Retry queue: retries < MAX_RETRIES AND next_retry has passed
|
|
311
|
+
if (failedEntry && failedEntry.retries < MAX_RETRIES && failedEntry.next_retry) {
|
|
312
|
+
if (Date.now() >= Date.parse(failedEntry.next_retry)) return true;
|
|
313
|
+
return false; // Not yet time to retry
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Normal staleness gate
|
|
317
|
+
return staleness >= threshold;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Calculate next retry time using exponential backoff (2^retries days).
|
|
322
|
+
*/
|
|
323
|
+
function _nextRetryISO(retries) {
|
|
324
|
+
const daysMs = Math.pow(2, retries) * 24 * 60 * 60 * 1000;
|
|
325
|
+
return new Date(Date.now() + daysMs).toISOString();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Tag helpers ────────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
function _parseTags(raw) {
|
|
331
|
+
if (Array.isArray(raw)) return raw;
|
|
332
|
+
if (typeof raw === 'string') {
|
|
333
|
+
try { const p = JSON.parse(raw); return Array.isArray(p) ? p : []; } catch { return []; }
|
|
334
|
+
}
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = { runWikiReflect };
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* wiki-synthesis.js — Evidence synthesis engines for Tier 2 cluster pages
|
|
5
|
+
*
|
|
6
|
+
* Single responsibility: DB queries + pure computation to produce structured
|
|
7
|
+
* intermediate artifacts. Zero LLM calls. All functions are synchronous and
|
|
8
|
+
* take a DatabaseSync instance + array of doc_source ids.
|
|
9
|
+
*
|
|
10
|
+
* Exports:
|
|
11
|
+
* buildComparisonMatrix(db, docSourceIds) → string (markdown table)
|
|
12
|
+
* buildTimeline(db, docSourceIds) → string (markdown list)
|
|
13
|
+
* detectContradictions(db, docSourceIds) → object[]
|
|
14
|
+
* buildCoverageReport(db, docSourceIds) → string (markdown list)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const EXPECTED_TYPES = ['problem', 'method', 'result', 'dataset', 'limitation'];
|
|
18
|
+
const MAX_COLS = 8; // max papers in comparison table before truncation
|
|
19
|
+
const MAX_ROWS = 20; // max predicate groups in comparison table
|
|
20
|
+
const TRUNCATE_TITLE = 28; // char limit for column headers
|
|
21
|
+
|
|
22
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function ph(ids) {
|
|
25
|
+
return ids.map(() => '?').join(',');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function shortTitle(t, len = TRUNCATE_TITLE) {
|
|
29
|
+
if (!t) return '?';
|
|
30
|
+
return t.length > len ? t.slice(0, len - 1) + '…' : t;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── buildComparisonMatrix ─────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a markdown comparison table of results/metrics across papers.
|
|
37
|
+
* Groups by predicate; columns are papers (up to MAX_COLS).
|
|
38
|
+
*
|
|
39
|
+
* @param {object} db - DatabaseSync
|
|
40
|
+
* @param {number[]} docSourceIds
|
|
41
|
+
* @returns {string} markdown table, or empty string if no result facts
|
|
42
|
+
*/
|
|
43
|
+
function buildComparisonMatrix(db, docSourceIds) {
|
|
44
|
+
if (docSourceIds.length === 0) return '';
|
|
45
|
+
|
|
46
|
+
const rows = db.prepare(`
|
|
47
|
+
SELECT pf.predicate, pf.subject, pf.object, pf.value, pf.unit, pf.context,
|
|
48
|
+
ds.id as doc_id, ds.title
|
|
49
|
+
FROM paper_facts pf
|
|
50
|
+
JOIN doc_sources ds ON ds.id = pf.doc_source_id
|
|
51
|
+
WHERE pf.doc_source_id IN (${ph(docSourceIds)})
|
|
52
|
+
AND pf.fact_type IN ('result','metric','baseline')
|
|
53
|
+
AND pf.predicate IS NOT NULL
|
|
54
|
+
ORDER BY pf.predicate, ds.id
|
|
55
|
+
`).all(...docSourceIds);
|
|
56
|
+
|
|
57
|
+
if (rows.length === 0) return '';
|
|
58
|
+
|
|
59
|
+
// Collect ordered unique papers (cap at MAX_COLS)
|
|
60
|
+
const paperOrder = [];
|
|
61
|
+
const paperTitles = {};
|
|
62
|
+
for (const r of rows) {
|
|
63
|
+
if (!paperTitles[r.doc_id]) {
|
|
64
|
+
paperOrder.push(r.doc_id);
|
|
65
|
+
paperTitles[r.doc_id] = r.title;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const papers = paperOrder.slice(0, MAX_COLS);
|
|
69
|
+
|
|
70
|
+
// Group by predicate → { docId → cell text }
|
|
71
|
+
const groups = {};
|
|
72
|
+
for (const r of rows) {
|
|
73
|
+
if (!papers.includes(r.doc_id)) continue;
|
|
74
|
+
const key = r.predicate;
|
|
75
|
+
if (!groups[key]) groups[key] = {};
|
|
76
|
+
const parts = [r.subject, r.object].filter(Boolean);
|
|
77
|
+
if (r.value) parts.push(r.value + (r.unit ? ' ' + r.unit : ''));
|
|
78
|
+
if (r.context) parts.push(`*(${r.context})*`);
|
|
79
|
+
// Keep first occurrence per (predicate, docId)
|
|
80
|
+
if (!groups[key][r.doc_id]) groups[key][r.doc_id] = parts.join(' — ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const predicates = Object.keys(groups).slice(0, MAX_ROWS);
|
|
84
|
+
if (predicates.length === 0) return '';
|
|
85
|
+
|
|
86
|
+
// Build table
|
|
87
|
+
const header = ['Metric / Result', ...papers.map(id => shortTitle(paperTitles[id]))];
|
|
88
|
+
const separator = header.map(() => '---');
|
|
89
|
+
const tableRows = predicates.map(pred => {
|
|
90
|
+
const cells = papers.map(id => groups[pred][id] || '—');
|
|
91
|
+
return [pred, ...cells];
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const fmt = (row) => '| ' + row.join(' | ') + ' |';
|
|
95
|
+
return [fmt(header), fmt(separator), ...tableRows.map(fmt)].join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── buildTimeline ─────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build a chronological timeline of core method contributions per paper.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} db
|
|
104
|
+
* @param {number[]} docSourceIds
|
|
105
|
+
* @returns {string} markdown list
|
|
106
|
+
*/
|
|
107
|
+
function buildTimeline(db, docSourceIds) {
|
|
108
|
+
if (docSourceIds.length === 0) return '';
|
|
109
|
+
|
|
110
|
+
// Get top method/claim fact per paper (by confidence desc)
|
|
111
|
+
const rows = db.prepare(`
|
|
112
|
+
SELECT ds.year, ds.title, ds.slug, ds.id as doc_id,
|
|
113
|
+
pf.subject, pf.predicate, pf.object, pf.evidence_text, pf.confidence
|
|
114
|
+
FROM doc_sources ds
|
|
115
|
+
LEFT JOIN paper_facts pf ON pf.doc_source_id = ds.id
|
|
116
|
+
AND pf.fact_type IN ('method','claim')
|
|
117
|
+
WHERE ds.id IN (${ph(docSourceIds)})
|
|
118
|
+
ORDER BY ds.year ASC NULLS LAST, ds.id ASC, pf.confidence DESC
|
|
119
|
+
`).all(...docSourceIds);
|
|
120
|
+
|
|
121
|
+
if (rows.length === 0) return '';
|
|
122
|
+
|
|
123
|
+
// Deduplicate: one entry per doc (keep first = highest confidence)
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
const entries = [];
|
|
126
|
+
for (const r of rows) {
|
|
127
|
+
if (seen.has(r.doc_id)) continue;
|
|
128
|
+
seen.add(r.doc_id);
|
|
129
|
+
entries.push(r);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return entries.map(r => {
|
|
133
|
+
const year = r.year ? `**${r.year}**` : '**year unknown**';
|
|
134
|
+
const slug = r.slug ? `[[${r.slug}]]` : shortTitle(r.title);
|
|
135
|
+
let claim = '';
|
|
136
|
+
if (r.subject && r.predicate && r.object) {
|
|
137
|
+
claim = ` — ${r.subject} ${r.predicate} ${r.object}`;
|
|
138
|
+
} else if (r.evidence_text) {
|
|
139
|
+
claim = ` — "${r.evidence_text.slice(0, 120)}"`;
|
|
140
|
+
}
|
|
141
|
+
return `- ${year} ${slug}${claim}`;
|
|
142
|
+
}).join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── detectContradictions ──────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Detect fact pairs where same (subject, predicate) yields different objects
|
|
149
|
+
* across different papers.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} db
|
|
152
|
+
* @param {number[]} docSourceIds
|
|
153
|
+
* @returns {{ slugA, titleA, factA, slugB, titleB, factB }[]}
|
|
154
|
+
*/
|
|
155
|
+
function detectContradictions(db, docSourceIds) {
|
|
156
|
+
if (docSourceIds.length < 2) return [];
|
|
157
|
+
|
|
158
|
+
const rows = db.prepare(`
|
|
159
|
+
SELECT
|
|
160
|
+
a.id as id_a, a.subject, a.predicate, a.object as object_a,
|
|
161
|
+
a.evidence_text as ev_a, a.confidence as conf_a,
|
|
162
|
+
b.id as id_b, b.object as object_b,
|
|
163
|
+
b.evidence_text as ev_b, b.confidence as conf_b,
|
|
164
|
+
ds_a.slug as slug_a, ds_a.title as title_a,
|
|
165
|
+
ds_b.slug as slug_b, ds_b.title as title_b
|
|
166
|
+
FROM paper_facts a
|
|
167
|
+
JOIN paper_facts b ON (
|
|
168
|
+
a.subject IS NOT NULL AND a.subject = b.subject AND
|
|
169
|
+
a.predicate IS NOT NULL AND a.predicate = b.predicate AND
|
|
170
|
+
a.object IS NOT NULL AND b.object IS NOT NULL AND
|
|
171
|
+
a.object != b.object AND
|
|
172
|
+
a.doc_source_id < b.doc_source_id
|
|
173
|
+
)
|
|
174
|
+
JOIN doc_sources ds_a ON ds_a.id = a.doc_source_id
|
|
175
|
+
JOIN doc_sources ds_b ON ds_b.id = b.doc_source_id
|
|
176
|
+
WHERE a.doc_source_id IN (${ph(docSourceIds)})
|
|
177
|
+
AND b.doc_source_id IN (${ph(docSourceIds)})
|
|
178
|
+
AND a.fact_type IN ('result','claim','metric')
|
|
179
|
+
AND b.fact_type IN ('result','claim','metric')
|
|
180
|
+
LIMIT 20
|
|
181
|
+
`).all(...docSourceIds, ...docSourceIds);
|
|
182
|
+
|
|
183
|
+
return rows.map(r => ({
|
|
184
|
+
slugA: r.slug_a, titleA: r.title_a,
|
|
185
|
+
factA: { subject: r.subject, predicate: r.predicate, object: r.object_a, evidence: r.ev_a },
|
|
186
|
+
slugB: r.slug_b, titleB: r.title_b,
|
|
187
|
+
factB: { subject: r.subject, predicate: r.predicate, object: r.object_b, evidence: r.ev_b },
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── buildCoverageReport ───────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Report which fact types are covered per paper, highlighting gaps.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} db
|
|
197
|
+
* @param {number[]} docSourceIds
|
|
198
|
+
* @returns {string} markdown list
|
|
199
|
+
*/
|
|
200
|
+
function buildCoverageReport(db, docSourceIds) {
|
|
201
|
+
if (docSourceIds.length === 0) return '';
|
|
202
|
+
|
|
203
|
+
const rows = db.prepare(`
|
|
204
|
+
SELECT ds.id, ds.title, ds.slug,
|
|
205
|
+
GROUP_CONCAT(DISTINCT pf.fact_type) as covered_types
|
|
206
|
+
FROM doc_sources ds
|
|
207
|
+
LEFT JOIN paper_facts pf ON pf.doc_source_id = ds.id
|
|
208
|
+
WHERE ds.id IN (${ph(docSourceIds)})
|
|
209
|
+
GROUP BY ds.id
|
|
210
|
+
ORDER BY ds.id
|
|
211
|
+
`).all(...docSourceIds);
|
|
212
|
+
|
|
213
|
+
const lines = rows.map(r => {
|
|
214
|
+
const covered = new Set((r.covered_types || '').split(',').filter(Boolean));
|
|
215
|
+
const missing = EXPECTED_TYPES.filter(t => !covered.has(t));
|
|
216
|
+
const covStr = EXPECTED_TYPES.map(t => covered.has(t) ? `✓${t}` : `✗${t}`).join(' ');
|
|
217
|
+
const gapNote = missing.length ? ` — **gaps: ${missing.join(', ')}**` : ' — complete';
|
|
218
|
+
return `- [[${r.slug || '?'}]] ${covStr}${gapNote}`;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return lines.join('\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { buildComparisonMatrix, buildTimeline, detectContradictions, buildCoverageReport };
|