metame-cli 1.5.26 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/index.js +4 -1
  2. package/package.json +1 -1
  3. package/scripts/agent-layer.js +36 -0
  4. package/scripts/core/chunker.js +100 -0
  5. package/scripts/core/embedding.js +225 -0
  6. package/scripts/core/hybrid-search.js +296 -0
  7. package/scripts/core/wiki-db.js +545 -0
  8. package/scripts/core/wiki-prompt.js +88 -0
  9. package/scripts/core/wiki-slug.js +66 -0
  10. package/scripts/core/wiki-staleness.js +18 -0
  11. package/scripts/daemon-agent-commands.js +10 -4
  12. package/scripts/daemon-bridges.js +16 -0
  13. package/scripts/daemon-claude-engine.js +62 -8
  14. package/scripts/daemon-command-router.js +40 -1
  15. package/scripts/daemon-default.yaml +33 -3
  16. package/scripts/daemon-embedding.js +162 -0
  17. package/scripts/daemon-engine-runtime.js +1 -1
  18. package/scripts/daemon-health-scan.js +185 -0
  19. package/scripts/daemon-ops-commands.js +9 -18
  20. package/scripts/daemon-runtime-lifecycle.js +1 -1
  21. package/scripts/daemon-session-commands.js +4 -0
  22. package/scripts/daemon-task-scheduler.js +5 -3
  23. package/scripts/daemon-warm-pool.js +15 -0
  24. package/scripts/daemon-wiki.js +420 -0
  25. package/scripts/daemon.js +10 -5
  26. package/scripts/distill.js +1 -1
  27. package/scripts/docs/file-transfer.md +0 -1
  28. package/scripts/docs/maintenance-manual.md +2 -55
  29. package/scripts/docs/pointer-map.md +0 -34
  30. package/scripts/feishu-adapter.js +25 -0
  31. package/scripts/hooks/intent-file-transfer.js +1 -2
  32. package/scripts/memory-backfill-chunks.js +92 -0
  33. package/scripts/memory-search.js +49 -6
  34. package/scripts/memory-wiki-schema.js +255 -0
  35. package/scripts/memory.js +103 -3
  36. package/scripts/signal-capture.js +1 -1
  37. package/scripts/skill-evolution.js +2 -11
  38. package/scripts/wiki-cluster.js +121 -0
  39. package/scripts/wiki-extract.js +171 -0
  40. package/scripts/wiki-facts.js +351 -0
  41. package/scripts/wiki-import.js +256 -0
  42. package/scripts/wiki-reflect-build.js +441 -0
  43. package/scripts/wiki-reflect-export.js +448 -0
  44. package/scripts/wiki-reflect-query.js +109 -0
  45. package/scripts/wiki-reflect.js +338 -0
  46. package/scripts/wiki-synthesis.js +224 -0
@@ -0,0 +1,545 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * core/wiki-db.js — Wiki DB read/write layer
5
+ *
6
+ * All functions accept a DatabaseSync instance as first arg.
7
+ * No DB lifecycle management here — caller provides db.
8
+ *
9
+ * Exports:
10
+ * // wiki_pages CRUD
11
+ * getWikiPageBySlug(db, slug) → row | null
12
+ * listWikiPages(db, { limit=20, orderBy='updated_at' }) → row[]
13
+ * getStalePages(db, threshold=0.4) → row[]
14
+ * upsertWikiPage(db, { slug, primary_topic, title, content, raw_source_ids,
15
+ * capsule_refs, raw_source_count, topic_tags, word_count }) → void
16
+ * resetPageStaleness(db, slug, rawSourceCount) → void
17
+ *
18
+ * // wiki_topics CRUD
19
+ * upsertWikiTopic(db, tag, { label, pinned=0, force=false }) → { slug, isNew }
20
+ * checkTopicThreshold(db, tag) → boolean
21
+ * listWikiTopics(db) → row[]
22
+ *
23
+ * // search
24
+ * searchWikiAndFacts(db, query, { trackSearch=true }) → { wikiPages, facts }
25
+ * listRecentSessionSummaries(db, { limit=200 }) → row[]
26
+ *
27
+ * // staleness
28
+ * updateStalenessForTags(db, dirtyTagCounts: Map<string, number>) → void
29
+ */
30
+
31
+ const { toSlug, sanitizeFts5 } = require('./wiki-slug');
32
+ const { calcStaleness } = require('./wiki-staleness');
33
+
34
+ // ── wiki_pages CRUD ────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * @param {object} db
38
+ * @param {string} slug
39
+ * @returns {object|null}
40
+ */
41
+ function getWikiPageBySlug(db, slug) {
42
+ return db.prepare('SELECT * FROM wiki_pages WHERE slug = ?').get(slug) ?? null;
43
+ }
44
+
45
+ /**
46
+ * @param {object} db
47
+ * @param {{ limit?: number, orderBy?: string }} opts
48
+ * @returns {object[]}
49
+ */
50
+ function listWikiPages(db, { limit = 20, orderBy = 'updated_at' } = {}) {
51
+ // Whitelist orderBy to prevent SQL injection
52
+ const allowed = ['updated_at', 'created_at', 'staleness', 'title', 'last_built_at', 'word_count'];
53
+ const col = allowed.includes(orderBy) ? orderBy : 'updated_at';
54
+ return db.prepare(`SELECT * FROM wiki_pages ORDER BY ${col} DESC LIMIT ?`).all(limit);
55
+ }
56
+
57
+ /**
58
+ * @param {object} db
59
+ * @param {number} threshold
60
+ * @returns {object[]}
61
+ */
62
+ function getStalePages(db, threshold = 0.4) {
63
+ return db.prepare('SELECT * FROM wiki_pages WHERE staleness >= ? ORDER BY staleness DESC').all(threshold);
64
+ }
65
+
66
+ /**
67
+ * Upsert a wiki page (INSERT OR REPLACE).
68
+ * On insert: id = wp_<timestamp>_<random>, created_at = now.
69
+ * On update: preserves existing id/created_at, updates all provided fields.
70
+ *
71
+ * @param {object} db
72
+ * @param {{ slug: string, primary_topic: string, title: string, content: string,
73
+ * raw_source_ids?: any, capsule_refs?: any, raw_source_count?: number,
74
+ * topic_tags?: any, word_count?: number }} opts
75
+ */
76
+ function upsertWikiPage(db, {
77
+ slug,
78
+ primary_topic,
79
+ title,
80
+ content,
81
+ raw_source_ids = '[]',
82
+ capsule_refs = '[]',
83
+ raw_source_count = 0,
84
+ topic_tags = '[]',
85
+ word_count = 0,
86
+ source_type = 'memory',
87
+ membership_hash = null,
88
+ cluster_size = null,
89
+ }) {
90
+ const rawSourceIdsStr = typeof raw_source_ids === 'string'
91
+ ? raw_source_ids : JSON.stringify(raw_source_ids);
92
+ const capsuleRefsStr = typeof capsule_refs === 'string'
93
+ ? capsule_refs : JSON.stringify(capsule_refs);
94
+ const topicTagsStr = typeof topic_tags === 'string'
95
+ ? topic_tags : JSON.stringify(topic_tags);
96
+
97
+ // Check if page exists
98
+ const existing = db.prepare('SELECT id, created_at FROM wiki_pages WHERE slug = ?').get(slug);
99
+
100
+ if (existing) {
101
+ db.prepare(`
102
+ UPDATE wiki_pages
103
+ SET primary_topic = ?,
104
+ title = ?,
105
+ content = ?,
106
+ raw_source_ids = ?,
107
+ capsule_refs = ?,
108
+ raw_source_count = ?,
109
+ topic_tags = ?,
110
+ word_count = ?,
111
+ source_type = ?,
112
+ membership_hash = ?,
113
+ cluster_size = ?,
114
+ updated_at = datetime('now')
115
+ WHERE slug = ?
116
+ `).run(primary_topic, title, content, rawSourceIdsStr, capsuleRefsStr, raw_source_count, topicTagsStr, word_count, source_type, membership_hash, cluster_size, slug);
117
+ } else {
118
+ const id = `wp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
119
+ db.prepare(`
120
+ INSERT INTO wiki_pages
121
+ (id, slug, primary_topic, title, content, raw_source_ids, capsule_refs,
122
+ raw_source_count, topic_tags, word_count, staleness, new_facts_since_build,
123
+ source_type, membership_hash, cluster_size,
124
+ created_at, updated_at)
125
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0.0, 0, ?, ?, ?, datetime('now'), datetime('now'))
126
+ `).run(id, slug, primary_topic, title, content, rawSourceIdsStr, capsuleRefsStr, raw_source_count, topicTagsStr, word_count, source_type, membership_hash, cluster_size);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Reset staleness after wiki-reflect rebuilds a page.
132
+ * @param {object} db
133
+ * @param {string} slug
134
+ * @param {number} rawSourceCount
135
+ */
136
+ function resetPageStaleness(db, slug, rawSourceCount) {
137
+ db.prepare(`
138
+ UPDATE wiki_pages
139
+ SET staleness = 0.0,
140
+ new_facts_since_build = 0,
141
+ raw_source_count = ?,
142
+ last_built_at = datetime('now'),
143
+ updated_at = datetime('now')
144
+ WHERE slug = ?
145
+ `).run(rawSourceCount, slug);
146
+ }
147
+
148
+ // ── wiki_topics CRUD ──────────────────────────────────────────────────────────
149
+
150
+ /**
151
+ * Upsert a wiki topic.
152
+ * Handles slug collision by appending -2 ... -10.
153
+ * force=true skips checkTopicThreshold.
154
+ *
155
+ * @param {object} db
156
+ * @param {string} tag
157
+ * @param {{ label?: string, pinned?: number, force?: boolean }} opts
158
+ * @returns {{ slug: string, isNew: boolean }}
159
+ */
160
+ function upsertWikiTopic(db, tag, { label, pinned = 0, force = false } = {}) {
161
+ if (typeof tag !== 'string' || !tag.trim()) {
162
+ throw new Error('upsertWikiTopic: tag must be a non-empty string');
163
+ }
164
+
165
+ // Check if this exact tag already exists → idempotent update
166
+ const existing = db.prepare('SELECT slug FROM wiki_topics WHERE tag = ?').get(tag);
167
+ if (existing) {
168
+ // Update label/pinned if provided
169
+ db.prepare(`
170
+ UPDATE wiki_topics
171
+ SET label = COALESCE(?, label),
172
+ pinned = MAX(pinned, ?)
173
+ WHERE tag = ?
174
+ `).run(label ?? null, pinned, tag);
175
+ return { slug: existing.slug, isNew: false };
176
+ }
177
+
178
+ // New tag — check threshold unless force
179
+ if (!force) {
180
+ const passes = checkTopicThreshold(db, tag);
181
+ if (!passes) {
182
+ throw new Error(`upsertWikiTopic: tag "${tag}" does not meet threshold (need ≥5 active raw facts AND ≥1 in last 30 days)`);
183
+ }
184
+ }
185
+
186
+ // Generate slug with collision handling
187
+ let baseSlug;
188
+ try {
189
+ baseSlug = toSlug(tag);
190
+ } catch (err) {
191
+ throw new Error(`upsertWikiTopic: ${err.message}`);
192
+ }
193
+
194
+ let finalSlug = baseSlug;
195
+ // Check collision: same slug, different tag
196
+ const collision = db.prepare('SELECT tag FROM wiki_topics WHERE slug = ? AND tag != ?').get(finalSlug, tag);
197
+ if (collision) {
198
+ let found = false;
199
+ for (let i = 2; i <= 10; i++) {
200
+ const candidate = `${baseSlug}-${i}`;
201
+ const exists = db.prepare('SELECT tag FROM wiki_topics WHERE slug = ?').get(candidate);
202
+ if (!exists) {
203
+ finalSlug = candidate;
204
+ found = true;
205
+ break;
206
+ }
207
+ }
208
+ if (!found) {
209
+ throw new Error(`upsertWikiTopic: slug collision for tag "${tag}" — exhausted -2 to -10 suffixes`);
210
+ }
211
+ }
212
+
213
+ const effectiveLabel = label ?? tag;
214
+ db.prepare(`
215
+ INSERT INTO wiki_topics (tag, slug, label, pinned)
216
+ VALUES (?, ?, ?, ?)
217
+ `).run(tag, finalSlug, effectiveLabel, pinned);
218
+
219
+ return { slug: finalSlug, isNew: true };
220
+ }
221
+
222
+ /**
223
+ * Check whether a tag meets the threshold for wiki topic registration.
224
+ * Condition 1: active raw facts COUNT >= 5 (lifetime)
225
+ * Condition 2: active raw facts COUNT >= 1 WHERE created_at >= datetime('now', '-30 days') (UTC)
226
+ * Both must be true.
227
+ *
228
+ * "raw facts" = state IN ('active', 'candidate') AND (relation NOT IN (...) OR relation IS NULL)
229
+ * Counts both states so that topic promotion can fire during saveFacts (facts enter
230
+ * as 'candidate' and are promoted to 'active' by nightly-reflect).
231
+ *
232
+ * @param {object} db
233
+ * @param {string} tag
234
+ * @returns {boolean}
235
+ */
236
+ function checkTopicThreshold(db, tag) {
237
+ const DERIVED = ['synthesized_insight', 'knowledge_capsule'];
238
+ const placeholders = DERIVED.map(() => '?').join(', ');
239
+
240
+ // Condition 1: lifetime count >= 5 (active or candidate)
241
+ const row1 = db.prepare(`
242
+ SELECT COUNT(*) as cnt
243
+ FROM memory_items mi
244
+ JOIN json_each(mi.tags) jt ON lower(trim(jt.value)) = lower(trim(?))
245
+ WHERE mi.state IN ('active', 'candidate')
246
+ AND (mi.relation NOT IN (${placeholders}) OR mi.relation IS NULL)
247
+ `).get(tag, ...DERIVED);
248
+
249
+ if (!row1 || row1.cnt < 5) return false;
250
+
251
+ // Condition 2: at least 1 in last 30 days (active or candidate)
252
+ const row2 = db.prepare(`
253
+ SELECT COUNT(*) as cnt
254
+ FROM memory_items mi
255
+ JOIN json_each(mi.tags) jt ON lower(trim(jt.value)) = lower(trim(?))
256
+ WHERE mi.state IN ('active', 'candidate')
257
+ AND (mi.relation NOT IN (${placeholders}) OR mi.relation IS NULL)
258
+ AND mi.created_at >= datetime('now', '-30 days')
259
+ `).get(tag, ...DERIVED);
260
+
261
+ if (!row2 || row2.cnt < 1) return false;
262
+
263
+ return true;
264
+ }
265
+
266
+ /**
267
+ * @param {object} db
268
+ * @returns {object[]}
269
+ */
270
+ function listWikiTopics(db) {
271
+ return db.prepare('SELECT * FROM wiki_topics ORDER BY created_at DESC').all();
272
+ }
273
+
274
+ function listRecentSessionSummaries(db, { limit = 200 } = {}) {
275
+ return db.prepare(`
276
+ SELECT
277
+ id,
278
+ session_id,
279
+ project,
280
+ scope,
281
+ title,
282
+ content,
283
+ tags,
284
+ created_at,
285
+ updated_at
286
+ FROM memory_items
287
+ WHERE kind = 'episode'
288
+ AND state = 'active'
289
+ ORDER BY created_at DESC
290
+ LIMIT ?
291
+ `).all(limit);
292
+ }
293
+
294
+ // ── Timeline ──────────────────────────────────────────────────────────────────
295
+
296
+ /**
297
+ * Append a timestamped entry to a wiki page's timeline (evidence trail).
298
+ * Does NOT touch content (compiled truth) — timeline is append-only.
299
+ *
300
+ * @param {object} db
301
+ * @param {string} slug
302
+ * @param {string} entry — free-text description of what happened
303
+ */
304
+ function appendWikiTimeline(db, slug, entry) {
305
+ const ts = new Date().toISOString().slice(0, 10);
306
+ const line = `[${ts}] ${entry}`;
307
+ db.prepare(
308
+ `UPDATE wiki_pages SET timeline = COALESCE(timeline, '') || ? || char(10), updated_at = datetime('now') WHERE slug = ?`,
309
+ ).run(line, slug);
310
+ }
311
+
312
+ // ── Search ────────────────────────────────────────────────────────────────────
313
+
314
+ /**
315
+ * Search wiki pages and memory facts via FTS5.
316
+ * trackSearch=true → UPDATE search_count on matched facts.
317
+ *
318
+ * @param {object} db
319
+ * @param {string} query
320
+ * @param {{ trackSearch?: boolean }} opts
321
+ * @returns {{ wikiPages: object[], facts: object[] }}
322
+ */
323
+ function searchWikiAndFacts(db, query, { trackSearch = true } = {}) {
324
+ const safeQuery = sanitizeFts5(query);
325
+ if (!safeQuery) return { wikiPages: [], facts: [] };
326
+
327
+ // 1. FTS5 search wiki_pages_fts (weight 1.5x)
328
+ const wikiPages = db.prepare(`
329
+ SELECT wp.slug, wp.title, wp.staleness, wp.last_built_at,
330
+ snippet(wiki_pages_fts, 2, '<b>', '</b>', '...', 20) as excerpt,
331
+ rank * 1.5 as score
332
+ FROM wiki_pages_fts
333
+ JOIN wiki_pages wp ON wiki_pages_fts.rowid = wp.rowid
334
+ WHERE wiki_pages_fts MATCH ?
335
+ ORDER BY rank
336
+ LIMIT 5
337
+ `).all(safeQuery);
338
+
339
+ // Add stale flag to wiki results (staleness >= 0.3 means compiled truth may be outdated)
340
+ for (const wp of wikiPages) {
341
+ wp.stale = typeof wp.staleness === 'number' && wp.staleness >= 0.3;
342
+ }
343
+
344
+ // 2. FTS5 search memory_items_fts — graceful fallback if table doesn't exist
345
+ let facts = [];
346
+ try {
347
+ facts = db.prepare(`
348
+ SELECT mi.id, mi.title, mi.content, mi.kind, mi.confidence,
349
+ snippet(memory_items_fts, 1, '<b>', '</b>', '...', 20) as excerpt,
350
+ rank as score
351
+ FROM memory_items_fts
352
+ JOIN memory_items mi ON memory_items_fts.rowid = mi.rowid
353
+ WHERE memory_items_fts MATCH ?
354
+ AND mi.state = 'active'
355
+ ORDER BY rank
356
+ LIMIT 10
357
+ `).all(safeQuery);
358
+ } catch {
359
+ facts = [];
360
+ }
361
+
362
+ // 3. trackSearch: update search_count on matched facts
363
+ if (trackSearch && facts.length > 0) {
364
+ _trackSearch(db, facts.map(r => r.id));
365
+ }
366
+
367
+ return { wikiPages, facts };
368
+ }
369
+
370
+ /**
371
+ * Increment search_count and update last_searched_at for given memory item IDs.
372
+ * @param {object} db
373
+ * @param {string[]} ids
374
+ */
375
+ function _trackSearch(db, ids) {
376
+ if (!ids || ids.length === 0) return;
377
+ const placeholders = ids.map(() => '?').join(', ');
378
+ db.prepare(`
379
+ UPDATE memory_items
380
+ SET search_count = search_count + 1,
381
+ last_searched_at = datetime('now')
382
+ WHERE id IN (${placeholders})
383
+ `).run(...ids);
384
+ }
385
+
386
+ // ── Staleness ─────────────────────────────────────────────────────────────────
387
+
388
+ /**
389
+ * Update staleness for wiki pages matching dirty tags.
390
+ * Routes through wiki_topics (the canonical tag registry) so that casing
391
+ * differences between fact tags and wiki_pages.primary_topic are bridged.
392
+ * RHS expressions in a single UPDATE see pre-update column values (SQLite semantics),
393
+ * so new_facts_since_build in the staleness formula is the original value.
394
+ *
395
+ * @param {object} db
396
+ * @param {Map<string, number>} dirtyTagCounts
397
+ */
398
+ function updateStalenessForTags(db, dirtyTagCounts) {
399
+ for (const [tag, newCount] of dirtyTagCounts) {
400
+ if (newCount <= 0) continue;
401
+
402
+ // Match via wiki_topics.tag (canonical registry) → wiki_pages.slug
403
+ db.prepare(`
404
+ UPDATE wiki_pages
405
+ SET new_facts_since_build = new_facts_since_build + ?,
406
+ staleness = MIN(1.0,
407
+ CAST(new_facts_since_build + ? AS REAL)
408
+ / NULLIF(raw_source_count + new_facts_since_build + ?, 0)
409
+ ),
410
+ updated_at = datetime('now')
411
+ WHERE slug IN (
412
+ SELECT slug FROM wiki_topics WHERE lower(trim(tag)) = lower(trim(?))
413
+ )
414
+ `).run(newCount, newCount, newCount, tag);
415
+ }
416
+ }
417
+
418
+ // ── doc_sources CRUD ──────────────────────────────────────────────────────────
419
+
420
+ function upsertDocSource(db, { filePath, fileHash, mtimeMs, sizeBytes, fileType,
421
+ extractor, extractStatus, extractedTextHash, title, slug }) {
422
+ const now = new Date().toISOString();
423
+ const existing = db.prepare('SELECT file_hash, extracted_text_hash FROM doc_sources WHERE file_path=?').get(filePath);
424
+ const hashChanged = !existing
425
+ || existing.file_hash !== fileHash
426
+ || existing.extracted_text_hash !== (extractedTextHash || null);
427
+
428
+ db.prepare(`
429
+ INSERT INTO doc_sources
430
+ (file_path, file_hash, mtime_ms, size_bytes, extracted_text_hash, file_type, extractor,
431
+ extract_status, title, slug, status, indexed_at, last_seen_at, content_stale)
432
+ VALUES (?,?,?,?,?,?,?,?,?,?,'active',?,?,?)
433
+ ON CONFLICT(file_path) DO UPDATE SET
434
+ file_hash=excluded.file_hash,
435
+ mtime_ms=excluded.mtime_ms,
436
+ size_bytes=excluded.size_bytes,
437
+ extracted_text_hash=excluded.extracted_text_hash,
438
+ extractor=excluded.extractor,
439
+ extract_status=excluded.extract_status,
440
+ title=excluded.title,
441
+ status='active',
442
+ last_seen_at=excluded.last_seen_at,
443
+ content_stale=CASE
444
+ WHEN excluded.file_hash != doc_sources.file_hash
445
+ OR COALESCE(excluded.extracted_text_hash,'') != COALESCE(doc_sources.extracted_text_hash,'')
446
+ THEN 1
447
+ ELSE doc_sources.content_stale
448
+ END
449
+ `).run(
450
+ filePath, fileHash, mtimeMs || null, sizeBytes || null,
451
+ extractedTextHash || null, fileType, extractor || null,
452
+ extractStatus || 'pending', title || null, slug,
453
+ now, now, hashChanged ? 1 : 0
454
+ );
455
+ }
456
+
457
+ function getDocSourceByPath(db, filePath) {
458
+ return db.prepare('SELECT * FROM doc_sources WHERE file_path=?').get(filePath) || null;
459
+ }
460
+
461
+ function getDocSourceBySlug(db, slug) {
462
+ return db.prepare('SELECT * FROM doc_sources WHERE slug=?').get(slug) || null;
463
+ }
464
+
465
+ function listStaleDocSources(db) {
466
+ return db.prepare("SELECT * FROM doc_sources WHERE content_stale=1 AND status='active'").all();
467
+ }
468
+
469
+ function markDocSourcesMissing(db, seenPaths) {
470
+ if (seenPaths.length === 0) return;
471
+ const set = new Set(seenPaths);
472
+ // Infer the scan directory from the seen paths so we only mark files
473
+ // within that directory scope as missing (not docs imported from other paths).
474
+ const path = require('node:path');
475
+ const dirs = new Set(seenPaths.map(p => path.dirname(p)));
476
+ const all = db.prepare("SELECT id, file_path FROM doc_sources WHERE status='active'").all();
477
+ const missingIds = all
478
+ .filter(r => dirs.has(path.dirname(r.file_path)) && !set.has(r.file_path))
479
+ .map(r => r.id);
480
+ if (missingIds.length === 0) return;
481
+ const ph = missingIds.map(() => '?').join(',');
482
+ db.prepare(`UPDATE doc_sources SET status='missing' WHERE id IN (${ph})`).run(...missingIds);
483
+ }
484
+
485
+ function upsertDocPageLink(db, pageSlug, docSourceId, role) {
486
+ db.prepare(`
487
+ INSERT OR IGNORE INTO wiki_page_doc_sources (page_slug, doc_source_id, role)
488
+ VALUES (?, ?, ?)
489
+ `).run(pageSlug, docSourceId, role);
490
+ }
491
+
492
+ function getClusterMemberIds(db, pageSlug) {
493
+ return db.prepare(
494
+ "SELECT doc_source_id FROM wiki_page_doc_sources WHERE page_slug=? AND role='cluster_member'"
495
+ ).all(pageSlug).map(r => r.doc_source_id);
496
+ }
497
+
498
+ function replaceClusterMembers(db, pageSlug, docSourceIds) {
499
+ db.exec('BEGIN');
500
+ try {
501
+ db.prepare("DELETE FROM wiki_page_doc_sources WHERE page_slug=? AND role='cluster_member'").run(pageSlug);
502
+ const ins = db.prepare("INSERT INTO wiki_page_doc_sources (page_slug, doc_source_id, role) VALUES (?,?,'cluster_member')");
503
+ for (const id of docSourceIds) ins.run(pageSlug, id);
504
+ db.exec('COMMIT');
505
+ } catch (err) {
506
+ db.exec('ROLLBACK');
507
+ throw err;
508
+ }
509
+ }
510
+
511
+ function listClusterPages(db) {
512
+ return db.prepare(
513
+ "SELECT slug, membership_hash, cluster_size FROM wiki_pages WHERE source_type='topic_cluster'"
514
+ ).all();
515
+ }
516
+
517
+ module.exports = {
518
+ // wiki_pages
519
+ getWikiPageBySlug,
520
+ listWikiPages,
521
+ getStalePages,
522
+ upsertWikiPage,
523
+ resetPageStaleness,
524
+ appendWikiTimeline,
525
+ // wiki_topics
526
+ upsertWikiTopic,
527
+ checkTopicThreshold,
528
+ listWikiTopics,
529
+ listRecentSessionSummaries,
530
+ // search
531
+ searchWikiAndFacts,
532
+ // staleness
533
+ updateStalenessForTags,
534
+ // doc_sources CRUD
535
+ upsertDocSource,
536
+ getDocSourceByPath,
537
+ getDocSourceBySlug,
538
+ listStaleDocSources,
539
+ markDocSourcesMissing,
540
+ // wiki_page_doc_sources CRUD
541
+ upsertDocPageLink,
542
+ getClusterMemberIds,
543
+ replaceClusterMembers,
544
+ listClusterPages,
545
+ };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * core/wiki-prompt.js — Pure functions for wiki article generation prompts
5
+ *
6
+ * Builds prompts for callHaiku and validates [[wikilinks]] against a whitelist.
7
+ * Zero I/O, zero DB, zero side effects.
8
+ */
9
+
10
+ /**
11
+ * Build a prompt string for generating a wiki article section.
12
+ *
13
+ * @param {{ tag: string, slug: string, label: string }} topic
14
+ * @param {Array<{ title: string, content: string, confidence: number, search_count: number }>} facts
15
+ * @param {string} capsuleExcerpts - Optional background context (may be empty)
16
+ * @param {string[]} allowedSlugs - Whitelist of [[wikilink]] slugs
17
+ * @returns {string} Prompt string ready to pass to callHaiku
18
+ */
19
+ function buildWikiPrompt(topic, facts, capsuleExcerpts, allowedSlugs) {
20
+ const parts = [];
21
+
22
+ parts.push(`你是一个中文知识百科写作助手。请为以下主题撰写一段简洁、准确的百科条目正文。`);
23
+ parts.push('');
24
+ parts.push(`## 主题`);
25
+ parts.push(`标签:${topic.tag}`);
26
+ parts.push(`Slug:${topic.slug}`);
27
+ parts.push(`名称:${topic.label || topic.tag}`);
28
+ parts.push('');
29
+
30
+ if (facts && facts.length > 0) {
31
+ parts.push('## 参考事实');
32
+ facts.forEach((fact, i) => {
33
+ parts.push(`${i + 1}. **${fact.title}**(可信度:${fact.confidence},搜索次数:${fact.search_count})`);
34
+ parts.push(` ${fact.content}`);
35
+ });
36
+ parts.push('');
37
+ }
38
+
39
+ if (capsuleExcerpts && capsuleExcerpts.trim()) {
40
+ parts.push('## 背景补充');
41
+ parts.push(capsuleExcerpts.trim());
42
+ parts.push('');
43
+ }
44
+
45
+ if (allowedSlugs && allowedSlugs.length > 0) {
46
+ parts.push('## 允许的内链([[wikilinks]])');
47
+ parts.push('正文中如需引用以下条目,请使用 [[slug]] 格式;其余 slug 请勿使用 [[]] 包裹:');
48
+ parts.push(allowedSlugs.map(s => `- [[${s}]]`).join('\n'));
49
+ parts.push('');
50
+ } else {
51
+ parts.push('## 内链说明');
52
+ parts.push('正文中不得使用任何 [[wikilinks]] 格式。');
53
+ parts.push('');
54
+ }
55
+
56
+ parts.push('## 要求');
57
+ parts.push('- 用中文撰写');
58
+ parts.push('- 语言简洁准确,适合百科风格');
59
+ parts.push('- 只使用上方允许列表中的 [[slug]] 内链,其他 slug 直接用纯文本');
60
+ parts.push('- 不要重复本条目自身的 slug 作为内链');
61
+ parts.push('- 直接输出正文,不需要标题');
62
+
63
+ return parts.join('\n');
64
+ }
65
+
66
+ /**
67
+ * Validate and strip [[wikilinks]] not in the allowedSlugs whitelist.
68
+ *
69
+ * @param {string} content - Article body text possibly containing [[slug]] links
70
+ * @param {string[]} allowedSlugs - Whitelist of permitted slugs
71
+ * @returns {{ content: string, stripped: string[] }}
72
+ */
73
+ function validateWikilinks(content, allowedSlugs) {
74
+ const allowed = new Set(allowedSlugs || []);
75
+ const stripped = [];
76
+
77
+ const cleaned = content.replace(/\[\[([^\]]+)\]\]/g, (match, slug) => {
78
+ if (allowed.has(slug)) {
79
+ return match; // keep [[slug]]
80
+ }
81
+ stripped.push(slug);
82
+ return slug; // strip [[ ]]
83
+ });
84
+
85
+ return { content: cleaned, stripped };
86
+ }
87
+
88
+ module.exports = { buildWikiPrompt, validateWikilinks };
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * toSlug(tag) → string
5
+ * Rules:
6
+ * - lowercase
7
+ * - keep only \w chars, Chinese [\u4e00-\u9fa5], spaces, hyphens
8
+ * - spaces → hyphens
9
+ * - collapse multiple hyphens → single hyphen
10
+ * - trim leading/trailing hyphens
11
+ * - truncate to 80 chars
12
+ * - if result is empty → throw Error
13
+ */
14
+ function toSlug(tag) {
15
+ if (typeof tag !== 'string') {
16
+ throw new Error('toSlug: input must be a string');
17
+ }
18
+
19
+ // lowercase
20
+ let s = tag.toLowerCase();
21
+
22
+ // keep only: word chars (\w = [a-z0-9_]), Chinese, spaces, hyphens
23
+ s = s.replace(/[^\w\u4e00-\u9fa5 -]/g, '');
24
+
25
+ // spaces → hyphens
26
+ s = s.replace(/ /g, '-');
27
+
28
+ // collapse multiple hyphens
29
+ s = s.replace(/-{2,}/g, '-');
30
+
31
+ // trim leading/trailing hyphens
32
+ s = s.replace(/^-+|-+$/g, '');
33
+
34
+ // truncate to 80 chars
35
+ s = s.slice(0, 80);
36
+
37
+ if (s.length === 0) {
38
+ throw new Error('toSlug: result is empty after normalization');
39
+ }
40
+
41
+ return s;
42
+ }
43
+
44
+ /**
45
+ * sanitizeFts5(input) → string | null
46
+ * Strips FTS5 special characters: " * ^ ( ) { } :
47
+ * Returns null if result is empty after trim.
48
+ */
49
+ function sanitizeFts5(input) {
50
+ if (typeof input !== 'string') {
51
+ return null;
52
+ }
53
+
54
+ // Remove FTS5 special chars: " * ^ ( ) { } :
55
+ let s = input.replace(/["*^(){}:]/g, '');
56
+
57
+ s = s.trim();
58
+
59
+ if (s.length === 0) {
60
+ return null;
61
+ }
62
+
63
+ return s;
64
+ }
65
+
66
+ module.exports = { toSlug, sanitizeFts5 };