@ijfw/memory-server 1.5.0 → 1.5.3

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 (71) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/package.json +8 -4
  10. package/src/brain/budget-guard.js +86 -0
  11. package/src/brain/citation-resolver.js +41 -0
  12. package/src/brain/context-injection.js +69 -0
  13. package/src/brain/discovery.js +83 -0
  14. package/src/brain/dream-pipeline.js +324 -0
  15. package/src/brain/dump-ingest.js +88 -0
  16. package/src/brain/entity-collapse.js +28 -0
  17. package/src/brain/export.js +112 -0
  18. package/src/brain/extractors/index.js +24 -0
  19. package/src/brain/extractors/markdown.js +27 -0
  20. package/src/brain/extractors/pdf.js +31 -0
  21. package/src/brain/extractors/transcript.js +38 -0
  22. package/src/brain/first-run-scan.js +61 -0
  23. package/src/brain/index.js +1 -0
  24. package/src/brain/layout-sentinel.js +29 -0
  25. package/src/brain/migrate-facts-internal-once.js +87 -0
  26. package/src/brain/path-guard.js +103 -0
  27. package/src/brain/paths.js +26 -0
  28. package/src/brain/promotion-suggester.js +41 -0
  29. package/src/brain/stub-detector.js +33 -0
  30. package/src/brain/tiered-llm.js +83 -0
  31. package/src/brain/wiki-compiler.js +144 -0
  32. package/src/brain/wiki-sentinels.js +45 -0
  33. package/src/brain/wiki-templates.js +94 -0
  34. package/src/cross-orchestrator-cli.js +336 -150
  35. package/src/cross-orchestrator.js +52 -3
  36. package/src/dashboard-server.js +1 -1
  37. package/src/dispatch/extension.js +1 -1
  38. package/src/dream/runner.mjs +21 -0
  39. package/src/extension-registry.js +2 -2
  40. package/src/handlers/brain-handler.js +319 -0
  41. package/src/hardware-signer.js +4 -2
  42. package/src/lib/ui-review-runner.js +48 -7
  43. package/src/memory/auto-linker.js +121 -2
  44. package/src/memory/benchmark.js +4 -3
  45. package/src/memory/layout-migrations/001-visible-layer.js +131 -0
  46. package/src/memory/layout-migrations/index.js +50 -0
  47. package/src/memory/migration-runner.js +37 -3
  48. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  49. package/src/memory/obsidian-parser.js +65 -2
  50. package/src/memory/reader.js +2 -1
  51. package/src/memory/search.js +190 -41
  52. package/src/memory/temporal.js +40 -1
  53. package/src/orchestrator/agents-md-blackboard.js +114 -1
  54. package/src/orchestrator/debug-trident-trigger.js +374 -0
  55. package/src/orchestrator/discipline-selector.js +276 -0
  56. package/src/orchestrator/merge-block-aware.js +15 -5
  57. package/src/orchestrator/post-done-runner.js +36 -8
  58. package/src/orchestrator/state-sdk.js +216 -10
  59. package/src/orchestrator/subagent-telemetry.js +19 -0
  60. package/src/orchestrator/wave-state.js +38 -0
  61. package/src/override-resolver.js +5 -3
  62. package/src/recovery/code-fixer.js +311 -6
  63. package/src/runtime-mediator.js +0 -1
  64. package/src/server.js +486 -132
  65. package/src/swarm-config.js +30 -22
  66. package/src/team/domain-templates/business.json +4 -1
  67. package/src/team/domain-templates/research.json +4 -1
  68. package/src/team/generator.js +162 -0
  69. package/src/update-apply.js +1 -1
  70. package/src/dashboard-charts.js +0 -239
  71. package/src/orchestrator/runtime-loop.js +0 -430
@@ -0,0 +1,41 @@
1
+ // IJFW v1.5.2 -- brain promotion suggester.
2
+ //
3
+ // Walks per-project db paths and surfaces (subject, predicate, object) triples
4
+ // that appear across >= minProjects (default 3) projects. Promotion is ALWAYS
5
+ // operator-confirmed -- this module only surfaces candidates.
6
+
7
+ import Database from 'better-sqlite3';
8
+
9
+ export function suggestPromotions({ projectDbs, minProjects = 3 } = {}) {
10
+ if (!Array.isArray(projectDbs) || projectDbs.length === 0) return [];
11
+ const tally = new Map();
12
+ for (const entry of projectDbs) {
13
+ const { name, dbPath } = entry;
14
+ let db;
15
+ try { db = new Database(dbPath, { readonly: true, fileMustExist: true }); }
16
+ catch { continue; }
17
+ try {
18
+ const rows = db.prepare(`
19
+ SELECT DISTINCT subject, predicate, object
20
+ FROM facts
21
+ WHERE valid_to IS NULL
22
+ `).all();
23
+ for (const r of rows) {
24
+ const key = `${r.subject || ''}|${r.predicate || ''}|${r.object || ''}`;
25
+ if (!tally.has(key)) tally.set(key, new Set());
26
+ tally.get(key).add(name);
27
+ }
28
+ } finally { try { db.close(); } catch {} }
29
+ }
30
+ const out = [];
31
+ for (const [key, projects] of tally) {
32
+ if (projects.size < minProjects) continue;
33
+ const [subject, predicate, object] = key.split('|');
34
+ out.push({
35
+ subject, predicate, object,
36
+ projects: [...projects].sort(),
37
+ confidence: projects.size >= 5 ? 'high' : 'medium',
38
+ });
39
+ }
40
+ return out.sort((a, b) => b.projects.length - a.projects.length);
41
+ }
@@ -0,0 +1,33 @@
1
+ // IJFW v1.5.2 -- brain stub detector.
2
+ //
3
+ // A "stub" is a wikilink target with N+ incoming references but no actual
4
+ // wiki page at ijfw/wiki/<type>s/<slug>.md. Surfaces gaps the operator may
5
+ // want to fill. Checks both visible ijfw/ and legacy .ijfw/ paths during
6
+ // the v1->v2 transition.
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ const WIKI_TYPES = ['concepts', 'entities', 'decisions', 'milestones'];
12
+
13
+ function pageExistsAnywhere(repoRoot, target) {
14
+ for (const t of WIKI_TYPES) {
15
+ if (existsSync(join(repoRoot, 'ijfw', 'wiki', t, `${target}.md`))) return true;
16
+ if (existsSync(join(repoRoot, '.ijfw', 'wiki', t, `${target}.md`))) return true;
17
+ }
18
+ return false;
19
+ }
20
+
21
+ export function detectStubs(db, repoRoot, { minIncomingLinks = 3 } = {}) {
22
+ const rows = db.prepare(`
23
+ SELECT to_target AS target, COUNT(*) AS incomingLinks
24
+ FROM memory_links
25
+ WHERE to_target IS NOT NULL AND to_target != ''
26
+ GROUP BY to_target
27
+ HAVING COUNT(*) >= ?
28
+ `).all(minIncomingLinks);
29
+ return rows
30
+ .filter((r) => !pageExistsAnywhere(repoRoot, r.target))
31
+ .map((r) => ({ target: r.target, incomingLinks: r.incomingLinks }))
32
+ .sort((a, b) => b.incomingLinks - a.incomingLinks);
33
+ }
@@ -0,0 +1,83 @@
1
+ // IJFW v1.5.2 -- brain tiered LLM router.
2
+ //
3
+ // Two tiers used by the dream-cycle pipeline:
4
+ // - extract: cheap, high-volume fact extraction (default: claude-haiku-4-5-20251001)
5
+ // - synth: mid-tier reconciliation / page synthesis (default: claude-sonnet-4-6)
6
+ //
7
+ // When IJFW_BRAIN_LOCAL_URL is set, we try the local endpoint first (Ollama-
8
+ // compatible /api/generate). On any local-call error, we fall back to the
9
+ // Anthropic API. This makes "local-first" cost discipline opt-in via env.
10
+ //
11
+ // Tests inject custom callers via opts._callers to avoid hitting real APIs.
12
+
13
+ const DEFAULTS = {
14
+ extract: 'claude-haiku-4-5-20251001',
15
+ synth: 'claude-sonnet-4-6',
16
+ };
17
+
18
+ const DEFAULT_MAX_TOKENS = {
19
+ extract: 512,
20
+ synth: 1500,
21
+ };
22
+
23
+ export function resolveTierModel(tier, env = process.env) {
24
+ if (tier === 'extract') return env.IJFW_BRAIN_EXTRACT_MODEL || DEFAULTS.extract;
25
+ if (tier === 'synth') return env.IJFW_BRAIN_SYNTH_MODEL || DEFAULTS.synth;
26
+ throw new Error(`tiered-llm: unknown tier '${tier}'`);
27
+ }
28
+
29
+ function defaultCallers() {
30
+ return {
31
+ async local({ url, model, prompt, maxTokens }) {
32
+ // Ollama-compatible /api/generate -- streamless single-response mode.
33
+ const res = await fetch(url.replace(/\/$/, '') + '/api/generate', {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ model, prompt, stream: false, options: { num_predict: maxTokens } }),
37
+ });
38
+ if (!res.ok) throw new Error(`local LLM HTTP ${res.status}`);
39
+ const data = await res.json();
40
+ return { text: data.response || '', usage: { input: data.prompt_eval_count, output: data.eval_count }, model, via: 'local' };
41
+ },
42
+ async anthropic({ model, prompt, maxTokens, apiKey }) {
43
+ if (!apiKey) throw new Error('tiered-llm: ANTHROPIC_API_KEY (or IJFW_BRAIN_API_KEY) required for Anthropic fallback');
44
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ 'x-api-key': apiKey,
49
+ 'anthropic-version': '2023-06-01',
50
+ },
51
+ body: JSON.stringify({
52
+ model,
53
+ max_tokens: maxTokens,
54
+ messages: [{ role: 'user', content: prompt }],
55
+ }),
56
+ });
57
+ if (!res.ok) throw new Error(`Anthropic HTTP ${res.status}`);
58
+ const data = await res.json();
59
+ const text = (data.content || []).map((b) => b.text || '').join('');
60
+ return { text, usage: data.usage, model, via: 'anthropic' };
61
+ },
62
+ };
63
+ }
64
+
65
+ export async function callTiered(tier, prompt, opts = {}) {
66
+ const env = opts.env || process.env;
67
+ const model = resolveTierModel(tier, env);
68
+ const maxTokens = opts.maxTokens || DEFAULT_MAX_TOKENS[tier] || 512;
69
+ const callers = opts._callers || defaultCallers();
70
+ if (env.IJFW_BRAIN_LOCAL_URL) {
71
+ try {
72
+ return await callers.local({ url: env.IJFW_BRAIN_LOCAL_URL, model, prompt, maxTokens });
73
+ } catch {
74
+ // fall through to Anthropic
75
+ }
76
+ }
77
+ return callers.anthropic({
78
+ model,
79
+ prompt,
80
+ maxTokens,
81
+ apiKey: env.IJFW_BRAIN_API_KEY || env.ANTHROPIC_API_KEY,
82
+ });
83
+ }
@@ -0,0 +1,144 @@
1
+ // IJFW v1.5.2 -- brain wiki compiler (Trident F-B1 + F-F2 enforcement).
2
+ //
3
+ // compileWikiPage(db, { repoRoot, type, subject }) renders the page from
4
+ // structured facts + history + backlinks + sources, runs the result through
5
+ // resolveCitations(), and ATOMICALLY writes (.tmp + rename) only if every
6
+ // citation resolves. Returns {ok:false, unresolved[]} when any cite is
7
+ // dangling -- this is the hallucination defense.
8
+ //
9
+ // Wiki path: <ijfw|.ijfw>/wiki/<type>s/<slug>.md per the layout sentinel.
10
+ // Slug is the subject lowercased + non-alphanum -> '-'.
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, statSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ const STALE_LOCK_MS = 60_000; // 60s — locks older than this are assumed dead-process orphans
16
+ import { resolveBrainPaths } from './paths.js';
17
+ import { applyTemplate } from './wiki-templates.js';
18
+ import { resolveCitations } from './citation-resolver.js';
19
+ import { getHistoryWindow } from '../memory/temporal.js';
20
+ import { validateSafeRepoPath } from './path-guard.js';
21
+
22
+ export function slugify(s) {
23
+ return String(s || '').toLowerCase().trim()
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-+|-+$/g, '') || 'untitled';
26
+ }
27
+
28
+ function pluralType(type) {
29
+ // Templates store under <type>s/ -- e.g. type='entity' -> 'entities'.
30
+ if (type === 'entity') return 'entities';
31
+ if (type === 'concept') return 'concepts';
32
+ if (type === 'decision') return 'decisions';
33
+ if (type === 'milestone') return 'milestones';
34
+ return `${type}s`;
35
+ }
36
+
37
+ function queryFacts(db, subject) {
38
+ try {
39
+ return db.prepare(
40
+ 'SELECT id, predicate, object, valid_from, valid_to, memory_id, source, confidence FROM facts WHERE subject = ? AND valid_to IS NULL ORDER BY id DESC'
41
+ ).all(subject);
42
+ } catch { return []; }
43
+ }
44
+
45
+ function queryBacklinks(db, target) {
46
+ try {
47
+ return db.prepare(
48
+ `SELECT to_target AS target, COUNT(*) AS count
49
+ FROM memory_links
50
+ WHERE to_target IS NOT NULL AND to_target = ?
51
+ GROUP BY to_target`
52
+ ).all(target);
53
+ } catch { return []; }
54
+ }
55
+
56
+ function querySources(db, subject) {
57
+ // Top-5 memory entries whose body mentions the subject.
58
+ try {
59
+ const rows = db.prepare(
60
+ `SELECT path, kind, COUNT(*) AS mentions
61
+ FROM memory_entries
62
+ WHERE body LIKE ?
63
+ GROUP BY path, kind
64
+ ORDER BY mentions DESC
65
+ LIMIT 5`
66
+ ).all(`%${subject}%`);
67
+ return rows.map((r) => ({ path: r.path, kind: r.kind, mentions: r.mentions }));
68
+ } catch { return []; }
69
+ }
70
+
71
+ export function compileWikiPage(db, { repoRoot, type, subject } = {}) {
72
+ if (!subject) return { ok: false, error: 'missing-subject' };
73
+ const paths = resolveBrainPaths(repoRoot);
74
+ const slug = slugify(subject);
75
+ const pageDir = join(paths.wikiDir, pluralType(type));
76
+ const pagePath = join(pageDir, `${slug}.md`);
77
+
78
+ // F-LENS2-05: enforce containment + reserved-name on the compile target.
79
+ // A symlinked wiki dir or a maliciously-crafted subject (slugify removes
80
+ // most danger, but defense-in-depth) could otherwise direct the atomic
81
+ // rename outside the repo. mkdirSync(pageDir,…) below runs only if guard
82
+ // passes so we never even create a parent directory outside repoRoot.
83
+ const guard = validateSafeRepoPath(repoRoot, pagePath);
84
+ if (!guard.ok) return guard;
85
+
86
+ // F8: per-page advisory lock prevents two concurrent compiles for the
87
+ // same subject from interleaving (both read existing → both render →
88
+ // both rename → operator NOTES from the intermediate state could be
89
+ // lost). EEXIST-based exclusive open is portable + atomic.
90
+ mkdirSync(pageDir, { recursive: true });
91
+ const lockPath = pagePath + '.lock';
92
+ let lockFd;
93
+ try {
94
+ lockFd = openSync(lockPath, 'wx');
95
+ } catch (e) {
96
+ if (e.code === 'EEXIST') {
97
+ // FLAG-8: stale-lock recovery. If a prior compile process was SIGKILL'd
98
+ // between acquire and finally, the lockfile orphans and every subsequent
99
+ // compile fails. Check the lockfile's age; if older than STALE_LOCK_MS,
100
+ // assume it's a dead-process orphan and reclaim.
101
+ let stale = false;
102
+ try {
103
+ const age = Date.now() - statSync(lockPath).mtimeMs;
104
+ if (age > STALE_LOCK_MS) stale = true;
105
+ } catch { /* lockfile vanished while we checked — race won by us */ stale = true; }
106
+ if (stale) {
107
+ try { unlinkSync(lockPath); } catch {}
108
+ try { lockFd = openSync(lockPath, 'wx'); }
109
+ catch (e2) { return { ok: false, error: 'page-locked-by-concurrent-compile', pagePath, staleReclaimFailed: e2.code }; }
110
+ } else {
111
+ return { ok: false, error: 'page-locked-by-concurrent-compile', pagePath };
112
+ }
113
+ } else {
114
+ throw e;
115
+ }
116
+ }
117
+
118
+ try {
119
+ // Read existing AFTER acquiring the lock so we see the freshest committed
120
+ // state (no race with another compile that just landed its rename).
121
+ const existing = existsSync(pagePath) ? readFileSync(pagePath, 'utf8') : '';
122
+ const facts = queryFacts(db, subject);
123
+ const history = getHistoryWindow(db, subject, null, { limit: 50 });
124
+ const backlinks = queryBacklinks(db, slug);
125
+ const sources = querySources(db, subject);
126
+
127
+ const candidate = applyTemplate(type, existing, { subject, facts, history, backlinks, sources });
128
+
129
+ const verdict = resolveCitations(db, candidate);
130
+ if (!verdict.ok) {
131
+ return { ok: false, error: 'unresolved-citations', unresolved: verdict.unresolved, pagePath };
132
+ }
133
+
134
+ // Atomic write
135
+ const tmp = pagePath + '.tmp';
136
+ writeFileSync(tmp, candidate);
137
+ renameSync(tmp, pagePath);
138
+
139
+ return { ok: true, pagePath, factsCount: facts.length, historyRows: history.rows.length };
140
+ } finally {
141
+ try { closeSync(lockFd); } catch {}
142
+ try { unlinkSync(lockPath); } catch {}
143
+ }
144
+ }
@@ -0,0 +1,45 @@
1
+ // IJFW v1.5.2 -- brain wiki section sentinels (Trident F-F2).
2
+ //
3
+ // Wiki pages carry LLM-managed regions delimited by HTML comments:
4
+ // <!-- ijfw:auto:begin section="current-state" -->
5
+ // ...AUTO content (replaced by the compiler)...
6
+ // <!-- ijfw:auto:end section="current-state" -->
7
+ //
8
+ // Everything OUTSIDE these blocks is operator-owned ("NOTES") and the
9
+ // compiler MUST preserve it verbatim. extractSections() returns the AUTO
10
+ // regions; replaceSection() swaps a named region's body while preserving
11
+ // the NOTES content and all other AUTO regions unchanged.
12
+
13
+ const SECTION_RE = /<!--\s*ijfw:auto:begin\s+section="([^"]+)"\s*-->([\s\S]*?)<!--\s*ijfw:auto:end\s+section="\1"\s*-->/g;
14
+
15
+ export function extractSections(md) {
16
+ if (typeof md !== 'string') return [];
17
+ const out = [];
18
+ for (const m of md.matchAll(SECTION_RE)) {
19
+ out.push({ name: m[1], body: m[2] });
20
+ }
21
+ return out;
22
+ }
23
+
24
+ function escapeRe(s) {
25
+ return s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
26
+ }
27
+
28
+ function makeBlock(name, body) {
29
+ return `<!-- ijfw:auto:begin section="${name}" -->\n${body}\n<!-- ijfw:auto:end section="${name}" -->`;
30
+ }
31
+
32
+ export function replaceSection(md, name, newBody) {
33
+ if (typeof md !== 'string') md = '';
34
+ const escaped = escapeRe(name);
35
+ const targetRe = new RegExp(
36
+ `<!--\\s*ijfw:auto:begin\\s+section="${escaped}"\\s*-->[\\s\\S]*?<!--\\s*ijfw:auto:end\\s+section="${escaped}"\\s*-->`
37
+ );
38
+ const replacement = makeBlock(name, newBody);
39
+ if (targetRe.test(md)) {
40
+ return md.replace(targetRe, replacement);
41
+ }
42
+ // Section absent -- append it. If md ends without trailing newline, add one.
43
+ const sep = md.length === 0 || md.endsWith('\n') ? '' : '\n';
44
+ return md + sep + '\n' + replacement + '\n';
45
+ }
@@ -0,0 +1,94 @@
1
+ // IJFW v1.5.2 -- brain wiki page templates.
2
+ //
3
+ // applyTemplate(type, existingMd, { subject, facts, history, backlinks, sources })
4
+ // returns markdown with 4 AUTO sections (current-state, history, backlinks,
5
+ // sources) wrapped in ijfw:auto sentinels. Operator-owned NOTES regions OUTSIDE
6
+ // the sentinels are preserved verbatim by the underlying replaceSection().
7
+ //
8
+ // Per Trident F-B2, the history section is windowed (consumer passes
9
+ // {rows, older} from getHistoryWindow); when older is present we append a
10
+ // rollup line ("Older: 55 events between A and B") instead of pasting more rows.
11
+
12
+ import { replaceSection } from './wiki-sentinels.js';
13
+
14
+ function isoDate(s) {
15
+ if (!s) return '';
16
+ try {
17
+ const d = new Date(s);
18
+ if (Number.isNaN(d.getTime())) return s;
19
+ return d.toISOString().slice(0, 10);
20
+ } catch { return s; }
21
+ }
22
+
23
+ function renderCurrentState({ subject, facts }) {
24
+ if (!facts || facts.length === 0) {
25
+ return `_No facts about **${subject}** yet._`;
26
+ }
27
+ const lines = [];
28
+ for (const f of facts) {
29
+ const cite = f.id != null ? ` [fact:${f.id}]` : '';
30
+ const memCite = f.memory_id != null ? ` [mem:${f.memory_id}]` : '';
31
+ lines.push(`- ${f.predicate || '(no predicate)'}: **${f.object ?? ''}**${cite}${memCite}`);
32
+ }
33
+ return lines.join('\n');
34
+ }
35
+
36
+ function renderHistory({ history }) {
37
+ if (!history) return '_No history available._';
38
+ const { rows = [], older = null } = history;
39
+ if (rows.length === 0) return '_No history available._';
40
+ const lines = [];
41
+ for (const r of rows) {
42
+ const date = isoDate(r.valid_from);
43
+ const closed = r.valid_to ? ` (closed ${isoDate(r.valid_to)})` : '';
44
+ const memCite = r.memory_id != null ? ` [mem:${r.memory_id}]` : '';
45
+ const factCite = r.id != null ? ` [fact:${r.id}]` : '';
46
+ lines.push(`- ${date}: ${r.predicate || '(no predicate)'} = **${r.object ?? ''}**${closed}${factCite}${memCite}`);
47
+ }
48
+ if (older) {
49
+ lines.push('');
50
+ lines.push(`_Older: ${older.count} event${older.count === 1 ? '' : 's'} between ${isoDate(older.fromIso)} and ${isoDate(older.toIso)}._`);
51
+ }
52
+ return lines.join('\n');
53
+ }
54
+
55
+ function renderBacklinks({ backlinks }) {
56
+ if (!backlinks || backlinks.length === 0) return '_No backlinks._';
57
+ const lines = [];
58
+ for (const b of backlinks) {
59
+ const target = b.target || b.to_target || '';
60
+ const n = b.count ?? b.incomingLinks ?? 1;
61
+ if (!target) continue;
62
+ lines.push(`- [[${target}]] (${n} link${n === 1 ? '' : 's'})`);
63
+ }
64
+ return lines.length ? lines.join('\n') : '_No backlinks._';
65
+ }
66
+
67
+ function renderSources({ sources }) {
68
+ if (!sources || sources.length === 0) return '_No sources._';
69
+ const top = sources.slice(0, 5);
70
+ const lines = [];
71
+ for (const s of top) {
72
+ const path = s.path || '(no path)';
73
+ const kind = s.kind ? ` (${s.kind})` : '';
74
+ const mentions = s.mentions != null ? ` — ${s.mentions} mention${s.mentions === 1 ? '' : 's'}` : '';
75
+ lines.push(`- \`${path}\`${kind}${mentions}`);
76
+ }
77
+ return lines.join('\n');
78
+ }
79
+
80
+ export function applyTemplate(type, existingMd, data) {
81
+ let md = existingMd || '';
82
+ md = replaceSection(md, 'current-state', renderCurrentState(data));
83
+ md = replaceSection(md, 'history', renderHistory(data));
84
+ md = replaceSection(md, 'backlinks', renderBacklinks(data));
85
+ md = replaceSection(md, 'sources', renderSources(data));
86
+ return md;
87
+ }
88
+
89
+ export {
90
+ renderCurrentState as _renderCurrentState,
91
+ renderHistory as _renderHistory,
92
+ renderBacklinks as _renderBacklinks,
93
+ renderSources as _renderSources,
94
+ };