metame-cli 1.6.0 → 1.6.2

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.
@@ -10,31 +10,35 @@
10
10
  * Exports:
11
11
  * buildWikiPage(db, topic, queryResult, { allowedSlugs, providers })
12
12
  * → { slug, content, strippedLinks, rawSourceIds } | null
13
+ * generateWikiContent(prompt, providers, allowedSlugs)
14
+ * → { content, strippedLinks } | null
15
+ * writeWikiPageWithChunks(db, pageSpec, content, { docSourceIds, role })
16
+ * → void
13
17
  */
14
18
 
19
+ const crypto = require('node:crypto');
15
20
  const { buildWikiPrompt, validateWikilinks } = require('./core/wiki-prompt');
16
- const { upsertWikiPage, resetPageStaleness } = require('./core/wiki-db');
21
+ const { extractText, extractSections } = require('./wiki-extract');
22
+ const { extractPaperFacts, buildTier1Prompt } = require('./wiki-facts');
23
+ const { buildComparisonMatrix, buildTimeline, detectContradictions, buildCoverageReport } = require('./wiki-synthesis');
24
+ const { upsertWikiPage, resetPageStaleness, appendWikiTimeline } = require('./core/wiki-db');
25
+ const { chunkText } = require('./core/chunker');
26
+ const { membershipHash, findMatchingCluster } = require('./wiki-cluster');
17
27
 
18
28
  const LLM_TIMEOUT_MS = 60000; // Sonnet needs more time than Haiku
19
29
 
20
30
  /**
21
- * Build a wiki page: call LLM, validate links, write to DB.
31
+ * Call the LLM with a prompt and validate [[wikilinks]] in the response.
22
32
  *
23
- * @param {object} db - DatabaseSync instance
24
- * @param {{ tag: string, slug: string, label: string }} topic
25
- * @param {{ totalCount: number, facts: object[], capsuleExcerpts: string }} queryResult
26
- * @param {{ allowedSlugs: string[], providers: { callHaiku: Function, buildDistillEnv: Function } }} opts
27
- * @returns {{ slug: string, content: string, strippedLinks: string[], rawSourceIds: string[] } | null}
28
- * Returns null on LLM failure (caller enqueues for retry). DB write failure throws.
33
+ * @param {string} prompt
34
+ * @param {{ callHaiku: Function, buildDistillEnv: Function }} providers
35
+ * @param {string[]} allowedSlugs
36
+ * @returns {{ content: string, strippedLinks: string[] } | null}
37
+ * Returns null on LLM failure or empty response (caller enqueues for retry).
29
38
  */
30
- async function buildWikiPage(db, topic, queryResult, { allowedSlugs = [], providers }) {
39
+ async function generateWikiContent(prompt, providers, allowedSlugs) {
31
40
  const { callHaiku, buildDistillEnv } = providers;
32
- const { totalCount, facts, capsuleExcerpts } = queryResult;
33
41
 
34
- // Build prompt
35
- const prompt = buildWikiPrompt(topic, facts, capsuleExcerpts, allowedSlugs);
36
-
37
- // Call LLM — return null on failure so caller can schedule exponential-backoff retry
38
42
  let rawContent;
39
43
  try {
40
44
  const env = buildDistillEnv();
@@ -49,34 +53,141 @@ async function buildWikiPage(db, topic, queryResult, { allowedSlugs = [], provid
49
53
 
50
54
  // Validate and strip illegal [[wikilinks]]
51
55
  const { content, stripped: strippedLinks } = validateWikilinks(rawContent.trim(), allowedSlugs);
56
+ return { content, strippedLinks };
57
+ }
52
58
 
53
- // Collect source IDs from facts
54
- const rawSourceIds = facts.map(f => f.id).filter(Boolean);
59
+ /**
60
+ * Atomic DB write: upsert wiki_page, reset staleness, replace chunks, enqueue
61
+ * embeddings, and optionally link doc_sources. All inside a single transaction.
62
+ *
63
+ * @param {object} db - DatabaseSync instance
64
+ * @param {{ slug: string, title: string, primary_topic: string, source_type?: string,
65
+ * raw_source_ids?: string, capsule_refs?: string, raw_source_count?: number,
66
+ * topic_tags?: string, word_count?: number, membership_hash?: string,
67
+ * cluster_size?: number }} pageSpec
68
+ * @param {string} content
69
+ * @param {{ docSourceIds?: number[], role?: string }} opts
70
+ */
71
+ function writeWikiPageWithChunks(db, pageSpec, content, { docSourceIds = [], role } = {}) {
72
+ const {
73
+ slug,
74
+ title,
75
+ primary_topic,
76
+ source_type = 'memory',
77
+ raw_source_ids,
78
+ capsule_refs,
79
+ raw_source_count = 0,
80
+ topic_tags,
81
+ membership_hash,
82
+ cluster_size,
83
+ } = pageSpec;
84
+
85
+ const wordCount = content.split(/\s+/).filter(Boolean).length;
55
86
 
56
- // Write to DB in a transaction
57
- const topicTagsArr = [topic.tag];
58
87
  db.prepare('BEGIN').run();
59
88
  try {
60
89
  upsertWikiPage(db, {
61
- slug: topic.slug,
62
- primary_topic: topic.tag,
63
- title: topic.label || topic.tag,
90
+ slug,
91
+ primary_topic,
92
+ title,
64
93
  content,
65
- raw_source_ids: JSON.stringify(rawSourceIds),
66
- capsule_refs: '[]',
67
- raw_source_count: totalCount,
68
- topic_tags: JSON.stringify(topicTagsArr),
69
- word_count: content.split(/\s+/).filter(Boolean).length,
94
+ raw_source_ids: raw_source_ids !== undefined ? raw_source_ids : '[]',
95
+ capsule_refs: capsule_refs !== undefined ? capsule_refs : '[]',
96
+ raw_source_count,
97
+ topic_tags: topic_tags !== undefined ? topic_tags : '[]',
98
+ word_count: wordCount,
99
+ source_type,
100
+ membership_hash: membership_hash !== undefined ? membership_hash : null,
101
+ cluster_size: cluster_size !== undefined ? cluster_size : null,
70
102
  });
71
103
 
72
104
  // Reset staleness counters via canonical helper (staleness=0, last_built_at=now)
73
- resetPageStaleness(db, topic.slug, totalCount);
105
+ resetPageStaleness(db, slug, raw_source_count);
106
+
107
+ // ── Chunk content + enqueue embeddings ──────────────────────────────────
108
+ // Clean stale embedding_queue entries for this page's old chunks
109
+ const oldChunkIds = db.prepare(
110
+ 'SELECT id FROM content_chunks WHERE page_slug = ?',
111
+ ).all(slug).map(r => r.id);
112
+ if (oldChunkIds.length > 0) {
113
+ const ph = oldChunkIds.map(() => '?').join(', ');
114
+ db.prepare(`DELETE FROM embedding_queue WHERE item_type = 'chunk' AND item_id IN (${ph})`).run(...oldChunkIds);
115
+ }
116
+ // Delete old chunks
117
+ db.prepare('DELETE FROM content_chunks WHERE page_slug = ?').run(slug);
118
+
119
+ // Create new chunks + enqueue
120
+ const chunks = chunkText(content, { targetWords: 300 });
121
+ const insertChunk = db.prepare(
122
+ 'INSERT INTO content_chunks (id, page_slug, chunk_text, chunk_idx) VALUES (?, ?, ?, ?)',
123
+ );
124
+ const enqueue = db.prepare(
125
+ "INSERT INTO embedding_queue (item_type, item_id) VALUES ('chunk', ?)",
126
+ );
127
+ for (let i = 0; i < chunks.length; i++) {
128
+ const chunkId = `ck_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
129
+ insertChunk.run(chunkId, slug, chunks[i], i);
130
+ enqueue.run(chunkId);
131
+ }
132
+
133
+ // Link doc_sources if provided
134
+ if (docSourceIds.length > 0) {
135
+ const insertLink = db.prepare(
136
+ 'INSERT OR IGNORE INTO wiki_page_doc_sources (page_slug, doc_source_id, role) VALUES (?, ?, ?)',
137
+ );
138
+ const effectiveRole = role || 'primary';
139
+ for (const docId of docSourceIds) {
140
+ insertLink.run(slug, docId, effectiveRole);
141
+ }
142
+ }
74
143
 
75
144
  db.prepare('COMMIT').run();
76
145
  } catch (err) {
77
146
  try { db.prepare('ROLLBACK').run(); } catch { /* ignore */ }
78
147
  throw err; // propagate DB errors to caller
79
148
  }
149
+ }
150
+
151
+ /**
152
+ * Build a wiki page: call LLM, validate links, write to DB.
153
+ *
154
+ * @param {object} db - DatabaseSync instance
155
+ * @param {{ tag: string, slug: string, label: string }} topic
156
+ * @param {{ totalCount: number, facts: object[], capsuleExcerpts: string }} queryResult
157
+ * @param {{ allowedSlugs: string[], providers: { callHaiku: Function, buildDistillEnv: Function } }} opts
158
+ * @returns {{ slug: string, content: string, strippedLinks: string[], rawSourceIds: string[] } | null}
159
+ * Returns null on LLM failure (caller enqueues for retry). DB write failure throws.
160
+ */
161
+ async function buildWikiPage(db, topic, queryResult, { allowedSlugs = [], providers }) {
162
+ const { totalCount, facts, capsuleExcerpts } = queryResult;
163
+
164
+ // Build prompt
165
+ const prompt = buildWikiPrompt(topic, facts, capsuleExcerpts, allowedSlugs);
166
+
167
+ // Call LLM — return null on failure so caller can schedule exponential-backoff retry
168
+ const llmResult = await generateWikiContent(prompt, providers, allowedSlugs);
169
+ if (!llmResult) return null;
170
+
171
+ const { content, strippedLinks } = llmResult;
172
+
173
+ // Collect source IDs from facts
174
+ const rawSourceIds = facts.map(f => f.id).filter(Boolean);
175
+
176
+ // Write to DB in a transaction
177
+ const topicTagsArr = [topic.tag];
178
+ writeWikiPageWithChunks(db, {
179
+ slug: topic.slug,
180
+ primary_topic: topic.tag,
181
+ title: topic.label || topic.tag,
182
+ raw_source_ids: JSON.stringify(rawSourceIds),
183
+ capsule_refs: '[]',
184
+ raw_source_count: totalCount,
185
+ topic_tags: JSON.stringify(topicTagsArr),
186
+ }, content, { docSourceIds: [] });
187
+
188
+ // Append evidence to timeline (compiled truth was just rewritten above)
189
+ const chunks = chunkText(content, { targetWords: 300 });
190
+ appendWikiTimeline(db, topic.slug, `基于 ${totalCount} 条 facts 重建 (${rawSourceIds.length} 条直接引用, ${chunks.length} chunks)`);
80
191
 
81
192
  return { slug: topic.slug, content, strippedLinks, rawSourceIds };
82
193
  }
@@ -114,4 +225,217 @@ function buildFallbackWikiContent(topic, queryResult) {
114
225
  return lines.join('\n').trim();
115
226
  }
116
227
 
117
- module.exports = { buildWikiPage, buildFallbackWikiContent };
228
+ /**
229
+ * Build a Tier 1 wiki page from pre-extracted facts.
230
+ * LLM call is outside any DB transaction.
231
+ *
232
+ * @param {object} db
233
+ * @param {object} docSource — row from doc_sources
234
+ * @param {object[]} facts — rows from paper_facts (already written to DB)
235
+ * @param {{ allowedSlugs: string[], providers: object }} opts
236
+ * @returns {Promise<{slug, content, strippedLinks}|null>}
237
+ */
238
+ async function buildTier1Page(db, docSource, facts, { allowedSlugs, providers }) {
239
+ const { slug, title, id: docSourceId } = docSource;
240
+ const displayTitle = title || slug;
241
+
242
+ // Build prompt from facts — no text truncation, evidence-grounded
243
+ const prompt = buildTier1Prompt(displayTitle, facts);
244
+ const result = await generateWikiContent(prompt, providers, allowedSlugs);
245
+ if (!result) return null;
246
+
247
+ const { content, strippedLinks } = result;
248
+
249
+ writeWikiPageWithChunks(db, {
250
+ slug,
251
+ title: displayTitle,
252
+ primary_topic: slug,
253
+ source_type: 'doc',
254
+ raw_source_ids: '[]',
255
+ topic_tags: '[]',
256
+ raw_source_count: facts.length,
257
+ }, content, { docSourceIds: [docSourceId], role: 'primary' });
258
+
259
+ return { slug, content, strippedLinks };
260
+ }
261
+
262
+ /**
263
+ * Build a Tier 1 wiki page from a document source.
264
+ *
265
+ * New flow (evidence-first):
266
+ * 1. extractSections(text) — wiki-extract.js
267
+ * 2. extractPaperFacts(sections) — wiki-facts.js (writes paper_facts, all LLM calls here)
268
+ * 3. buildTier1Page(facts) — generates wiki page from evidence
269
+ *
270
+ * Falls back to null if text is unavailable (scanned PDF).
271
+ *
272
+ * @param {object} db
273
+ * @param {object} docSource — row from doc_sources
274
+ * @param {string} extractedText — full text (may be empty for stale re-runs)
275
+ * @param {{ allowedSlugs: string[], providers: object }} opts
276
+ * @returns {Promise<{slug, content, strippedLinks}|null>}
277
+ */
278
+ async function buildDocWikiPage(db, docSource, extractedText, { allowedSlugs, providers }) {
279
+ // FLAG-5 fix: re-extract if caller passed empty string (stale re-run path)
280
+ let text = extractedText;
281
+ if (!text || !text.trim()) {
282
+ const reExtracted = await extractText(docSource.file_path).catch(() => ({ text: '' }));
283
+ text = reExtracted.text || '';
284
+ }
285
+
286
+ if (!text || !text.trim()) return null; // scanned PDF or missing file — skip
287
+
288
+ // Step 1: structured section split
289
+ const sections = extractSections(text);
290
+
291
+ // Step 2: per-section fact extraction (all LLM calls, writes paper_facts)
292
+ const facts = await extractPaperFacts(db, docSource, sections, providers);
293
+
294
+ // Step 3: generate Tier 1 wiki page from facts
295
+ return buildTier1Page(db, docSource, facts, { allowedSlugs, providers });
296
+ }
297
+
298
+ async function buildTopicClusterPage(db, docSourceRows, { allowedSlugs, providers, existingClusters = [] }) {
299
+ if (!docSourceRows || docSourceRows.length === 0) return null;
300
+
301
+ const memberIds = docSourceRows.map(r => r.id);
302
+ const memberSlugs = docSourceRows.map(r => r.slug);
303
+ const mHash = membershipHash(memberSlugs);
304
+
305
+ // Find or create stable slug
306
+ const match = findMatchingCluster(existingClusters, memberIds);
307
+ const clusterSlug = match ? match.slug : 'cluster-' + crypto.randomBytes(4).toString('hex');
308
+
309
+ // ── Gather evidence (all sync DB reads, no LLM yet) ──────────────────────
310
+ const matrix = buildComparisonMatrix(db, memberIds);
311
+ const timeline = buildTimeline(db, memberIds);
312
+ const contradictions = detectContradictions(db, memberIds);
313
+ const coverage = buildCoverageReport(db, memberIds);
314
+
315
+ // Total facts referenced in this cluster
316
+ const factsRow = db.prepare(
317
+ `SELECT COUNT(*) as n FROM paper_facts WHERE doc_source_id IN (${memberIds.map(() => '?').join(',')})`
318
+ ).get(...memberIds);
319
+ const totalFacts = factsRow ? factsRow.n : 0;
320
+
321
+ // ── LLM synthesis (outside any DB transaction) ───────────────────────────
322
+ const prompt = buildEvidenceClusterPrompt(docSourceRows, {
323
+ matrix, timeline, contradictions, coverage, allowedSlugs,
324
+ });
325
+ const result = await generateWikiContent(prompt, providers, allowedSlugs);
326
+ if (!result) return null;
327
+ const { content, strippedLinks: clusterStrippedLinks } = result;
328
+
329
+ const clusterLabel = inferClusterLabel(docSourceRows.map(r => r.title || r.slug));
330
+
331
+ writeWikiPageWithChunks(db, {
332
+ slug: clusterSlug,
333
+ title: clusterLabel,
334
+ primary_topic: clusterSlug,
335
+ source_type: 'topic_cluster',
336
+ staleness: 0.0,
337
+ raw_source_ids: '[]',
338
+ raw_source_count: totalFacts,
339
+ topic_tags: '[]',
340
+ membership_hash: mHash,
341
+ cluster_size: memberIds.length,
342
+ }, content, { docSourceIds: memberIds, role: 'cluster_member' });
343
+
344
+ return { slug: clusterSlug, strippedLinks: clusterStrippedLinks || [] };
345
+ }
346
+
347
+ function inferClusterLabel(titles) {
348
+ const words = titles.flatMap(t => t.toLowerCase().split(/\W+/).filter(w => w.length > 3));
349
+ const freq = {};
350
+ for (const w of words) freq[w] = (freq[w] || 0) + 1;
351
+ const top = Object.entries(freq).filter(([, c]) => c > 1).sort((a, b) => b[1] - a[1]).slice(0, 2);
352
+ return top.length ? top.map(([w]) => w).join(' & ') + ' cluster' : 'Document Cluster';
353
+ }
354
+
355
+ /**
356
+ * Evidence-based cluster prompt — uses synthesis intermediates from wiki-synthesis.js.
357
+ * Produces a structured Tier 4 survey page (600–1500 words).
358
+ */
359
+ function buildEvidenceClusterPrompt(docSourceRows, { matrix, timeline, contradictions, coverage, allowedSlugs }) {
360
+ const memberLinks = docSourceRows.map(r => {
361
+ const safeTitle = (r.title || r.slug || '').slice(0, 100).replace(/[\r\n]/g, ' ');
362
+ return `- [[${r.slug}]] — ${safeTitle}`;
363
+ }).join('\n');
364
+
365
+ // Render contradiction section (up to 5 pairs to stay within token budget)
366
+ const contradictionText = contradictions.length === 0
367
+ ? 'No contradictions detected in current evidence.'
368
+ : contradictions.slice(0, 5).map((c, i) => {
369
+ return `${i + 1}. **"${c.factA.subject} ${c.factA.predicate}"** differs:\n` +
370
+ ` - [[${c.slugA}]]: ${c.factA.object}\n` +
371
+ ` - [[${c.slugB}]]: ${c.factB.object}`;
372
+ }).join('\n');
373
+
374
+ // Keep prompt under ~3000 tokens: truncate all variable-length sections
375
+ const matrixTrunc = matrix.length > 2000 ? matrix.slice(0, 2000) + '\n...[truncated]' : matrix;
376
+ const timelineTrunc = timeline.length > 1000 ? timeline.slice(0, 1000) + '\n...[truncated]' : timeline;
377
+ const coverageTrunc = coverage.length > 500 ? coverage.slice(0, 500) + '\n...[truncated]' : coverage;
378
+ const linksTrunc = memberLinks.length > 800 ? memberLinks.slice(0, 800) + '\n...[truncated]' : memberLinks;
379
+ const contradictionsTrunc = contradictionText.length > 600 ? contradictionText.slice(0, 600) + '\n...[truncated]' : contradictionText;
380
+
381
+ return `You are writing a Tier 4 survey wiki page that synthesizes evidence from ${docSourceRows.length} related academic papers.
382
+
383
+ Member papers:
384
+ ${linksTrunc}
385
+
386
+ ## Comparison Matrix (auto-generated)
387
+ ${matrixTrunc || '(no result/metric facts available)'}
388
+
389
+ ## Timeline (auto-generated)
390
+ ${timelineTrunc || '(no year data available)'}
391
+
392
+ ## Contradictions (auto-detected)
393
+ ${contradictionsTrunc}
394
+
395
+ ## Coverage Report (auto-generated)
396
+ ${coverageTrunc || '(no coverage data)'}
397
+
398
+ Write a survey page with EXACTLY these eight sections in order:
399
+ ## Scope
400
+ ## Method Families
401
+ ## Comparison Matrix
402
+ ## Timeline
403
+ ## Agreements
404
+ ## Contradictions
405
+ ## Open Questions / Gaps
406
+ ## Source Papers
407
+
408
+ Rules:
409
+ - For "## Comparison Matrix": reproduce or improve on the auto-generated table above using exact evidence
410
+ - For "## Timeline": reproduce or improve the auto-generated timeline
411
+ - For "## Contradictions": explain the contradictions above in plain language; write "None detected." if empty
412
+ - For "## Agreements": summarize what all papers agree on
413
+ - For "## Open Questions / Gaps": derive from the coverage report above — what questions remain unanswered?
414
+ - For "## Source Papers": list all members as [[wikilinks]]
415
+ - Ground every claim in the evidence above — do not hallucinate
416
+ - Use [[wikilink]] syntax when referencing member papers by slug
417
+ - 600–1500 words total
418
+ - Respond with only the wiki page content`;
419
+ }
420
+
421
+ // @deprecated — use buildEvidenceClusterPrompt for evidence-grounded Tier 4 pages
422
+ function buildClusterPrompt(titles, slugs) {
423
+ const links = slugs.map((s, i) => {
424
+ const safeTitle = (titles[i] || '').slice(0, 120).replace(/[\r\n]/g, ' ');
425
+ return `- [[${s}]] — ${safeTitle}`;
426
+ }).join('\n');
427
+ return `You are writing a wiki overview page that synthesizes multiple related documents.
428
+
429
+ Member documents:
430
+ ${links}
431
+
432
+ Write a concise wiki overview page (150–300 words) that:
433
+ - Opens with a paragraph explaining what these documents share in common
434
+ - Briefly notes what each document covers (1 sentence each)
435
+ - Uses [[wikilink]] syntax when referencing the member documents by slug
436
+ - Ends with a "## See Also" section listing all members as [[wikilinks]]
437
+
438
+ Respond with only the wiki page content.`;
439
+ }
440
+
441
+ module.exports = { buildWikiPage, buildFallbackWikiContent, generateWikiContent, writeWikiPageWithChunks, buildDocWikiPage, buildTier1Page, buildTopicClusterPage };
@@ -226,6 +226,118 @@ function rebuildCapsulesIndex(capsuleFiles, outputDir = DEFAULT_WIKI_DIR) {
226
226
  fs.renameSync(tmpPath, filePath);
227
227
  }
228
228
 
229
+ /**
230
+ * Mirror all .md files from srcDir into outputDir/subdir (atomic write).
231
+ * Pattern mirrors exportCapsuleFile.
232
+ *
233
+ * @param {string} srcDir — e.g. ~/.metame/memory/decisions
234
+ * @param {string} subdir — vault subdirectory name, e.g. 'decisions'
235
+ * @param {string} [outputDir]
236
+ * @returns {string[]} — list of destination file paths written
237
+ */
238
+ function exportReflectDir(srcDir, subdir, outputDir = DEFAULT_WIKI_DIR) {
239
+ if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return [];
240
+ const destDir = path.join(outputDir, subdir);
241
+ _ensureDir(destDir);
242
+
243
+ const written = [];
244
+ for (const name of fs.readdirSync(srcDir)) {
245
+ if (!name.endsWith('.md')) continue;
246
+ const src = path.join(srcDir, name);
247
+ const dest = path.join(destDir, name);
248
+ const tmp = `${dest}.tmp`;
249
+ try {
250
+ const content = fs.readFileSync(src, 'utf8');
251
+ try { fs.unlinkSync(tmp); } catch { /* not present */ }
252
+ fs.writeFileSync(tmp, content.endsWith('\n') ? content : `${content}\n`, 'utf8');
253
+ fs.renameSync(tmp, dest);
254
+ written.push(dest);
255
+ } catch { /* skip unreadable file */ }
256
+ }
257
+ return written;
258
+ }
259
+
260
+ /**
261
+ * Write _index.md for a reflect subdirectory (decisions or lessons).
262
+ *
263
+ * @param {string[]} fileNames — bare filenames (not full paths)
264
+ * @param {string} subdir — 'decisions' | 'lessons'
265
+ * @param {string} [outputDir]
266
+ */
267
+ function rebuildReflectDirIndex(fileNames, subdir, outputDir = DEFAULT_WIKI_DIR) {
268
+ const destDir = path.join(outputDir, subdir);
269
+ _ensureDir(destDir);
270
+
271
+ const label = subdir === 'decisions' ? 'Architecture Decisions' : 'Operational Lessons';
272
+ const lines = [
273
+ '---',
274
+ `title: ${label}`,
275
+ `updated: ${new Date().toISOString().slice(0, 10)}`,
276
+ 'type: reflect-index',
277
+ '---',
278
+ '',
279
+ `# ${label}`,
280
+ '',
281
+ `> ${fileNames.length} entries · 自动生成,勿手动编辑`,
282
+ '',
283
+ ];
284
+
285
+ for (const name of [...fileNames].sort().reverse()) {
286
+ const base = path.basename(name, '.md');
287
+ lines.push(`- [[${subdir}/${base}|${base}]]`);
288
+ }
289
+
290
+ const filePath = path.join(destDir, '_index.md');
291
+ const tmpPath = `${filePath}.tmp`;
292
+ try { fs.unlinkSync(tmpPath); } catch { /* not present */ }
293
+ fs.writeFileSync(tmpPath, lines.join('\n') + '\n', 'utf8');
294
+ fs.renameSync(tmpPath, filePath);
295
+ }
296
+
297
+ /**
298
+ * Export all doc/cluster wiki pages from DB to Obsidian vault.
299
+ * Called by runWikiReflect after the memory-topic loop.
300
+ * Pages with empty content are skipped.
301
+ *
302
+ * @param {object} db — DatabaseSync instance
303
+ * @param {string} [outputDir]
304
+ * @returns {{ exported: string[], skipped: string[] }}
305
+ */
306
+ function exportDocPages(db, outputDir = DEFAULT_WIKI_DIR) {
307
+ _ensureDir(outputDir);
308
+ const rows = db.prepare(
309
+ `SELECT slug, title, primary_topic, source_type, content,
310
+ topic_tags, created_at, last_built_at, raw_source_count, staleness
311
+ FROM wiki_pages
312
+ WHERE source_type IN ('doc', 'topic_cluster')
313
+ AND content IS NOT NULL AND content != ''`
314
+ ).all();
315
+
316
+ const exported = [];
317
+ const skipped = [];
318
+
319
+ for (const row of rows) {
320
+ try {
321
+ const tags = _safeJsonArray(row.topic_tags);
322
+ const frontmatter = {
323
+ title: row.title || row.slug,
324
+ slug: row.slug,
325
+ tags,
326
+ created: (row.created_at || '').slice(0, 10),
327
+ last_built: (row.last_built_at || '').slice(0, 10),
328
+ raw_sources: row.raw_source_count || 0,
329
+ staleness: row.staleness || 0,
330
+ };
331
+ exportWikiPage(row.slug, frontmatter, row.content, outputDir);
332
+ exported.push(row.slug);
333
+ } catch {
334
+ skipped.push(row.slug);
335
+ }
336
+ }
337
+
338
+ return { exported, skipped };
339
+ }
340
+
229
341
  // ── helpers ───────────────────────────────────────────────────────────────────
230
342
 
231
343
  function _ensureDir(dir) {
@@ -330,4 +442,7 @@ module.exports = {
330
442
  rebuildSessionsIndex,
331
443
  exportCapsuleFile,
332
444
  rebuildCapsulesIndex,
445
+ exportReflectDir,
446
+ rebuildReflectDirIndex,
447
+ exportDocPages, // new
333
448
  };
@@ -29,11 +29,16 @@ const {
29
29
  rebuildSessionsIndex,
30
30
  exportCapsuleFile,
31
31
  rebuildCapsulesIndex,
32
+ exportReflectDir,
33
+ rebuildReflectDirIndex,
34
+ exportDocPages,
32
35
  } = require('./wiki-reflect-export');
33
36
 
34
37
  const DEFAULT_WIKI_DIR = path.join(os.homedir(), '.metame', 'wiki');
35
38
  const DEFAULT_CAPSULES_DIR = path.join(os.homedir(), '.metame', 'memory', 'capsules');
36
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');
37
42
  const LOCK_FILE = path.join(os.homedir(), '.metame', 'wiki-reflect.lock');
38
43
  const LOCK_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes
39
44
  const STALENESS_THRESHOLD = 0.4;
@@ -55,6 +60,8 @@ const MAX_RETRIES = 3;
55
60
  async function runWikiReflect(db, {
56
61
  outputDir = DEFAULT_WIKI_DIR,
57
62
  capsulesDir = DEFAULT_CAPSULES_DIR,
63
+ decisionsDir = DEFAULT_DECISIONS_DIR,
64
+ lessonsDir = DEFAULT_LESSONS_DIR,
58
65
  logPath = DEFAULT_LOG_PATH,
59
66
  providers,
60
67
  threshold = STALENESS_THRESHOLD,
@@ -70,6 +77,8 @@ async function runWikiReflect(db, {
70
77
  const failed = [];
71
78
  const exportFailed = [];
72
79
  const strippedLinksMap = {};
80
+ let docsExported = 0;
81
+ let reflectExported = 0;
73
82
 
74
83
  try {
75
84
  // 2. Load previous failed_slugs for retry logic
@@ -179,6 +188,28 @@ async function runWikiReflect(db, {
179
188
  }
180
189
  try { rebuildCapsulesIndex(capsuleFiles, outputDir); } catch { /* non-fatal */ }
181
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
+
182
213
  } finally {
183
214
  // 6. Release lock
184
215
  _releaseLock(LOCK_FILE);
@@ -190,6 +221,8 @@ async function runWikiReflect(db, {
190
221
  export_failed_slugs: exportFailed,
191
222
  failed_slugs: failed,
192
223
  stripped_links: strippedLinksMap,
224
+ docs_exported: docsExported,
225
+ reflect_exported: reflectExported,
193
226
  duration_ms: Date.now() - startMs,
194
227
  };
195
228
  try {
@@ -197,7 +230,7 @@ async function runWikiReflect(db, {
197
230
  } catch { /* non-fatal */ }
198
231
  }
199
232
 
200
- return { built, failed, exportFailed };
233
+ return { built, failed, exportFailed, docsExported, reflectExported };
201
234
  }
202
235
 
203
236
  // ── Lock helpers ──────────────────────────────────────────────────────────────