@rarusoft/dendrite-wiki 0.1.0-alpha.0

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 (74) hide show
  1. package/README.md +79 -0
  2. package/dist/api-extractor/extract.js +269 -0
  3. package/dist/api-extractor/language-extractor.js +15 -0
  4. package/dist/api-extractor/python-extractor.js +358 -0
  5. package/dist/api-extractor/render.js +195 -0
  6. package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
  7. package/dist/api-extractor/types.js +11 -0
  8. package/dist/api-extractor/typescript-extractor.js +50 -0
  9. package/dist/api-extractor/walk.js +178 -0
  10. package/dist/api-reference.js +438 -0
  11. package/dist/benchmark-events.js +129 -0
  12. package/dist/benchmark.js +270 -0
  13. package/dist/binder-export.js +381 -0
  14. package/dist/canonical-target.js +168 -0
  15. package/dist/chart-insert.js +377 -0
  16. package/dist/chart-prompts.js +414 -0
  17. package/dist/context-cache.js +98 -0
  18. package/dist/contradicts-shipped-memory.js +232 -0
  19. package/dist/diff-context.js +142 -0
  20. package/dist/doctor.js +220 -0
  21. package/dist/generated-docs.js +219 -0
  22. package/dist/i18n.js +71 -0
  23. package/dist/index.js +49 -0
  24. package/dist/librarian.js +255 -0
  25. package/dist/maintenance-actions.js +244 -0
  26. package/dist/maintenance-inbox.js +842 -0
  27. package/dist/maintenance-runner.js +62 -0
  28. package/dist/page-drift.js +225 -0
  29. package/dist/page-inbox.js +168 -0
  30. package/dist/report-export.js +339 -0
  31. package/dist/review-bridge.js +1386 -0
  32. package/dist/search-index.js +199 -0
  33. package/dist/store.js +1617 -0
  34. package/dist/telemetry-defaults.js +44 -0
  35. package/dist/telemetry-report.js +263 -0
  36. package/dist/telemetry.js +544 -0
  37. package/dist/wiki-synthesis.js +901 -0
  38. package/package.json +35 -0
  39. package/src/api-extractor/extract.ts +333 -0
  40. package/src/api-extractor/language-extractor.ts +37 -0
  41. package/src/api-extractor/python-extractor.ts +380 -0
  42. package/src/api-extractor/render.ts +267 -0
  43. package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
  44. package/src/api-extractor/types.ts +41 -0
  45. package/src/api-extractor/typescript-extractor.ts +56 -0
  46. package/src/api-extractor/walk.ts +209 -0
  47. package/src/api-reference.ts +552 -0
  48. package/src/benchmark-events.ts +216 -0
  49. package/src/benchmark.ts +376 -0
  50. package/src/binder-export.ts +437 -0
  51. package/src/canonical-target.ts +192 -0
  52. package/src/chart-insert.ts +478 -0
  53. package/src/chart-prompts.ts +417 -0
  54. package/src/context-cache.ts +129 -0
  55. package/src/contradicts-shipped-memory.ts +311 -0
  56. package/src/diff-context.ts +187 -0
  57. package/src/doctor.ts +260 -0
  58. package/src/generated-docs.ts +316 -0
  59. package/src/i18n.ts +106 -0
  60. package/src/index.ts +59 -0
  61. package/src/librarian.ts +331 -0
  62. package/src/maintenance-actions.ts +314 -0
  63. package/src/maintenance-inbox.ts +1132 -0
  64. package/src/maintenance-runner.ts +85 -0
  65. package/src/page-drift.ts +292 -0
  66. package/src/page-inbox.ts +254 -0
  67. package/src/report-export.ts +392 -0
  68. package/src/review-bridge.ts +1729 -0
  69. package/src/search-index.ts +266 -0
  70. package/src/store.ts +2171 -0
  71. package/src/telemetry-defaults.ts +50 -0
  72. package/src/telemetry-report.ts +365 -0
  73. package/src/telemetry.ts +757 -0
  74. package/src/wiki-synthesis.ts +1307 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Compile-to-Binder export — R6 of the retro-editor experiment.
3
+ *
4
+ * Produces a single self-contained HTML file from selected wiki pages,
5
+ * styled to print well on paper: cover page with project name and
6
+ * timestamp, table of contents, page-break rules between sections, claim
7
+ * and source citations distinguished even in B/W. Open the output in a
8
+ * browser and File → Print → Save as PDF for the binder workflow.
9
+ *
10
+ * Driven by `dendrite-wiki binder:export [--all | --pages a,b,c]
11
+ * [--theme selectric|amber|wordperfect|modern] [--output path]
12
+ * [--title text]`. Default output: `docs/public/binder.html` (gitignored
13
+ * via `docs/public/*.html` patterns the operator may already have).
14
+ *
15
+ * Intentionally does NOT shell out to headless Chrome — Puppeteer adds
16
+ * ~150 MB to the install footprint, and the browser-as-print-engine path
17
+ * works on every machine without any new install. If a future R6.1 wants
18
+ * one-step PDF, it can layer Puppeteer on top of this HTML output.
19
+ */
20
+ import { promises as fs } from 'node:fs';
21
+ import path from 'node:path';
22
+ import MarkdownIt from 'markdown-it';
23
+ import { listWikiPages, readWikiPage } from './store.js';
24
+
25
+ export type BinderTheme = 'selectric' | 'amber' | 'wordperfect' | 'modern';
26
+
27
+ export interface BinderExportOptions {
28
+ root?: string;
29
+ slugs?: string[];
30
+ all?: boolean;
31
+ theme?: BinderTheme;
32
+ outputPath?: string;
33
+ title?: string;
34
+ }
35
+
36
+ export interface BinderExportResult {
37
+ outputPath: string;
38
+ pageCount: number;
39
+ bytesWritten: number;
40
+ pages: Array<{ slug: string; title: string }>;
41
+ theme: BinderTheme;
42
+ }
43
+
44
+ const DEFAULT_TITLE = 'Dendrite Wiki MCP — Binder';
45
+
46
+ export async function exportBinderHtml(options: BinderExportOptions = {}): Promise<BinderExportResult> {
47
+ const root = path.resolve(options.root ?? process.cwd());
48
+ const outputPath = path.resolve(options.outputPath ?? path.join(root, 'docs', 'public', 'binder.html'));
49
+ const theme: BinderTheme = options.theme ?? 'selectric';
50
+ const title = options.title ?? DEFAULT_TITLE;
51
+
52
+ const allPages = await listWikiPages();
53
+ const allBySlug = new Map(allPages.map((p) => [p.slug, p] as const));
54
+
55
+ let selectedSlugs: string[];
56
+ if (options.all || !options.slugs || options.slugs.length === 0) {
57
+ // Default: every page except generated reference (api/*) — those
58
+ // are noisy in a binder and the operator can opt them in via --pages.
59
+ selectedSlugs = allPages
60
+ .filter((p) => !p.slug.startsWith('api/'))
61
+ .map((p) => p.slug)
62
+ .sort();
63
+ } else {
64
+ selectedSlugs = options.slugs;
65
+ }
66
+
67
+ const pages: Array<{ slug: string; title: string; html: string }> = [];
68
+ for (const slug of selectedSlugs) {
69
+ const summary = allBySlug.get(slug);
70
+ if (!summary) {
71
+ throw new Error(`Unknown wiki page slug: ${slug}`);
72
+ }
73
+ const raw = await readWikiPage(slug);
74
+ const stripped = stripFrontmatter(raw);
75
+ const html = renderMarkdown(stripped);
76
+ pages.push({ slug, title: summary.title, html });
77
+ }
78
+
79
+ const html = renderBinderHtml({ title, theme, pages });
80
+
81
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
82
+ await fs.writeFile(outputPath, html, 'utf8');
83
+ const stat = await fs.stat(outputPath);
84
+
85
+ return {
86
+ outputPath,
87
+ pageCount: pages.length,
88
+ bytesWritten: stat.size,
89
+ pages: pages.map(({ slug, title: t }) => ({ slug, title: t })),
90
+ theme
91
+ };
92
+ }
93
+
94
+ function stripFrontmatter(content: string): string {
95
+ const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
96
+ return match ? content.slice(match[0].length) : content;
97
+ }
98
+
99
+ function renderMarkdown(markdown: string): string {
100
+ const md = new MarkdownIt({
101
+ html: false,
102
+ linkify: true,
103
+ typographer: true,
104
+ breaks: false
105
+ });
106
+ return md.render(markdown);
107
+ }
108
+
109
+ interface RenderInput {
110
+ title: string;
111
+ theme: BinderTheme;
112
+ pages: Array<{ slug: string; title: string; html: string }>;
113
+ }
114
+
115
+ function renderBinderHtml({ title, theme, pages }: RenderInput): string {
116
+ const generatedAt = new Date();
117
+ const generatedHuman = generatedAt.toLocaleString('en-US', {
118
+ weekday: 'long',
119
+ year: 'numeric',
120
+ month: 'long',
121
+ day: 'numeric',
122
+ hour: 'numeric',
123
+ minute: '2-digit'
124
+ });
125
+ const generatedIso = generatedAt.toISOString();
126
+
127
+ const palette = themePalette(theme);
128
+ const tocItems = pages
129
+ .map((p, idx) => ` <li><a href="#page-${idx + 1}"><span class="toc-num">${String(idx + 1).padStart(2, '0')}</span> <span class="toc-title">${escapeHtml(p.title)}</span> <span class="toc-slug">${escapeHtml(p.slug)}</span></a></li>`)
130
+ .join('\n');
131
+
132
+ const sections = pages
133
+ .map(
134
+ (p, idx) => ` <section class="binder-page" id="page-${idx + 1}">
135
+ <header class="binder-page-header">
136
+ <span class="binder-page-num">PAGE ${String(idx + 1).padStart(2, '0')} OF ${String(pages.length).padStart(2, '0')}</span>
137
+ <span class="binder-page-slug">${escapeHtml(p.slug)}.md</span>
138
+ </header>
139
+ <div class="binder-page-body">
140
+ ${p.html}
141
+ </div>
142
+ </section>`
143
+ )
144
+ .join('\n');
145
+
146
+ return `<!DOCTYPE html>
147
+ <html lang="en">
148
+ <head>
149
+ <meta charset="utf-8">
150
+ <meta name="viewport" content="width=device-width,initial-scale=1">
151
+ <title>${escapeHtml(title)}</title>
152
+ <style>
153
+ ${binderStyles(palette)}
154
+ </style>
155
+ </head>
156
+ <body data-theme="${theme}">
157
+ <main class="binder">
158
+ <section class="binder-cover">
159
+ <div class="binder-cover-frame">
160
+ <p class="binder-cover-eyebrow">Dendrite Wiki MCP</p>
161
+ <h1 class="binder-cover-title">${escapeHtml(title)}</h1>
162
+ <p class="binder-cover-meta">
163
+ Compiled <strong>${escapeHtml(generatedHuman)}</strong>
164
+ · ${pages.length} page${pages.length === 1 ? '' : 's'}
165
+ · theme: <em>${escapeHtml(theme)}</em>
166
+ </p>
167
+ <p class="binder-cover-iso"><code>${escapeHtml(generatedIso)}</code></p>
168
+ </div>
169
+ </section>
170
+
171
+ <section class="binder-toc">
172
+ <h2 class="binder-toc-title">Contents</h2>
173
+ <ol class="binder-toc-list">
174
+ ${tocItems}
175
+ </ol>
176
+ </section>
177
+
178
+ ${sections}
179
+
180
+ <footer class="binder-foot">
181
+ <p>Generated by <strong>Dendrite Wiki MCP</strong> · binder:export · ${escapeHtml(theme)} theme · ${escapeHtml(generatedIso)}</p>
182
+ </footer>
183
+ </main>
184
+ </body>
185
+ </html>
186
+ `;
187
+ }
188
+
189
+ interface ThemePalette {
190
+ bg: string;
191
+ fg: string;
192
+ accent: string;
193
+ muted: string;
194
+ divider: string;
195
+ bgAlt: string;
196
+ fontBody: string;
197
+ fontMono: string;
198
+ bodyJustify: boolean;
199
+ uppercaseHeadings: boolean;
200
+ }
201
+
202
+ function themePalette(theme: BinderTheme): ThemePalette {
203
+ switch (theme) {
204
+ case 'amber':
205
+ return {
206
+ bg: '#150a00',
207
+ fg: '#ffb000',
208
+ accent: '#ffd166',
209
+ muted: '#a06900',
210
+ divider: '#4a2a00',
211
+ bgAlt: '#1f0e00',
212
+ fontBody: "'VT323', 'Courier New', monospace",
213
+ fontMono: "'VT323', 'Courier New', monospace",
214
+ bodyJustify: false,
215
+ uppercaseHeadings: true
216
+ };
217
+ case 'wordperfect':
218
+ return {
219
+ bg: '#0000aa',
220
+ fg: '#f0f0f0',
221
+ accent: '#ffff55',
222
+ muted: '#a0a0c0',
223
+ divider: '#5555cc',
224
+ bgAlt: '#00008b',
225
+ fontBody: "'IBM Plex Mono', 'Courier New', monospace",
226
+ fontMono: "'IBM Plex Mono', 'Courier New', monospace",
227
+ bodyJustify: false,
228
+ uppercaseHeadings: false
229
+ };
230
+ case 'modern':
231
+ return {
232
+ bg: '#ffffff',
233
+ fg: '#1a1a1a',
234
+ accent: '#1f6feb',
235
+ muted: '#666666',
236
+ divider: '#e6e6e6',
237
+ bgAlt: '#fafafa',
238
+ fontBody: "'Inter', system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
239
+ fontMono: "'JetBrains Mono', 'Consolas', monospace",
240
+ bodyJustify: false,
241
+ uppercaseHeadings: false
242
+ };
243
+ case 'selectric':
244
+ default:
245
+ return {
246
+ bg: '#f5f0e1',
247
+ fg: '#1a1410',
248
+ accent: '#8b1a1a',
249
+ muted: '#5e4f40',
250
+ divider: '#c4b89e',
251
+ bgAlt: '#ede6d2',
252
+ fontBody: "'Special Elite', 'Cutive Mono', 'Courier New', monospace",
253
+ fontMono: "'Cutive Mono', 'Courier New', monospace",
254
+ bodyJustify: true,
255
+ uppercaseHeadings: true
256
+ };
257
+ }
258
+ }
259
+
260
+ function binderStyles(p: ThemePalette): string {
261
+ return `
262
+ @import url('https://fonts.googleapis.com/css2?family=VT323&family=IBM+Plex+Mono:wght@400;500;600;700&family=Special+Elite&family=Cutive+Mono&family=Inter:wght@400;500;600;700&display=swap');
263
+
264
+ :root {
265
+ --bg: ${p.bg};
266
+ --fg: ${p.fg};
267
+ --accent: ${p.accent};
268
+ --muted: ${p.muted};
269
+ --divider: ${p.divider};
270
+ --bg-alt: ${p.bgAlt};
271
+ --font-body: ${p.fontBody};
272
+ --font-mono: ${p.fontMono};
273
+ }
274
+
275
+ * { box-sizing: border-box; }
276
+
277
+ html, body {
278
+ margin: 0;
279
+ padding: 0;
280
+ background: var(--bg);
281
+ color: var(--fg);
282
+ font-family: var(--font-body);
283
+ line-height: 1.6;
284
+ }
285
+
286
+ .binder {
287
+ max-width: 7.5in;
288
+ margin: 0 auto;
289
+ padding: 0.6in 0.75in;
290
+ }
291
+
292
+ a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
293
+ code { font-family: var(--font-mono); background: var(--bg-alt); padding: 0.05rem 0.3rem; border-radius: 2px; font-size: 0.92em; }
294
+ pre { background: var(--bg-alt); border: 1px solid var(--divider); padding: 0.6rem 0.8rem; overflow-x: auto; border-radius: 3px; font-size: 0.85em; line-height: 1.5; }
295
+ pre code { background: transparent; padding: 0; }
296
+ blockquote { border-left: 3px solid var(--accent); margin: 1em 0; padding: 0.2em 1em; color: var(--muted); font-style: italic; }
297
+ hr { border: none; border-top: 1px solid var(--divider); margin: 1.5em 0; }
298
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 0.92em; }
299
+ th, td { border: 1px solid var(--divider); padding: 0.4em 0.6em; text-align: left; }
300
+ th { background: var(--bg-alt); }
301
+
302
+ h1, h2, h3, h4, h5, h6 {
303
+ font-family: var(--font-body);
304
+ color: var(--fg);
305
+ ${p.uppercaseHeadings ? 'text-transform: uppercase; letter-spacing: 0.06em; font-weight: 400;' : 'font-weight: 700;'}
306
+ }
307
+ h1 { font-size: 1.7em; border-bottom: 2px solid var(--fg); padding-bottom: 0.3em; margin-top: 0; }
308
+ h2 { font-size: 1.3em; border-bottom: 1px solid var(--divider); padding-bottom: 0.2em; margin-top: 1.6em; }
309
+ h3 { font-size: 1.1em; margin-top: 1.4em; }
310
+
311
+ ${p.bodyJustify ? '.binder-page-body p, .binder-page-body li { text-align: justify; hyphens: auto; }' : ''}
312
+
313
+ /* Cover */
314
+ .binder-cover {
315
+ min-height: 9in;
316
+ display: flex;
317
+ align-items: center;
318
+ justify-content: center;
319
+ page-break-after: always;
320
+ border-bottom: 1px solid var(--divider);
321
+ }
322
+ .binder-cover-frame {
323
+ border: 4px double var(--fg);
324
+ padding: 1.4in 1.1in;
325
+ text-align: center;
326
+ max-width: 6in;
327
+ }
328
+ .binder-cover-eyebrow {
329
+ margin: 0 0 0.6em 0;
330
+ font-size: 0.85em;
331
+ letter-spacing: 0.4em;
332
+ text-transform: uppercase;
333
+ color: var(--muted);
334
+ }
335
+ .binder-cover-title {
336
+ font-size: 2.2em;
337
+ margin: 0 0 1em 0;
338
+ border: none;
339
+ padding: 0;
340
+ ${p.uppercaseHeadings ? '' : 'font-weight: 700;'}
341
+ }
342
+ .binder-cover-meta {
343
+ margin: 0.4em 0;
344
+ font-size: 0.95em;
345
+ color: var(--muted);
346
+ }
347
+ .binder-cover-meta strong { color: var(--fg); }
348
+ .binder-cover-meta em { color: var(--accent); font-style: normal; }
349
+ .binder-cover-iso {
350
+ margin: 1em 0 0 0;
351
+ font-size: 0.78em;
352
+ color: var(--muted);
353
+ }
354
+ .binder-cover-iso code { background: transparent; }
355
+
356
+ /* TOC */
357
+ .binder-toc {
358
+ page-break-after: always;
359
+ padding: 0 0 0.5in 0;
360
+ }
361
+ .binder-toc-title {
362
+ margin-top: 0;
363
+ }
364
+ .binder-toc-list {
365
+ list-style: none;
366
+ padding: 0;
367
+ margin: 1em 0;
368
+ font-family: var(--font-mono);
369
+ font-size: 0.95em;
370
+ }
371
+ .binder-toc-list li {
372
+ margin: 0.3em 0;
373
+ border-bottom: 1px dotted var(--divider);
374
+ padding: 0.2em 0;
375
+ }
376
+ .binder-toc-list a {
377
+ display: grid;
378
+ grid-template-columns: 2.5em 1fr auto;
379
+ gap: 0.5em;
380
+ text-decoration: none;
381
+ color: var(--fg);
382
+ }
383
+ .binder-toc-list a:hover { color: var(--accent); }
384
+ .toc-num { color: var(--muted); }
385
+ .toc-slug { color: var(--muted); font-size: 0.85em; }
386
+
387
+ /* Pages */
388
+ .binder-page {
389
+ page-break-before: always;
390
+ padding-top: 0.4in;
391
+ }
392
+ .binder-page-header {
393
+ display: flex;
394
+ justify-content: space-between;
395
+ font-family: var(--font-mono);
396
+ font-size: 0.78em;
397
+ letter-spacing: 0.08em;
398
+ color: var(--muted);
399
+ border-bottom: 1px solid var(--divider);
400
+ padding-bottom: 0.4em;
401
+ margin-bottom: 1em;
402
+ }
403
+ .binder-page-body h1 { margin-top: 0.2em; }
404
+ .binder-page-body img { max-width: 100%; }
405
+
406
+ .binder-foot {
407
+ margin-top: 2em;
408
+ padding-top: 1em;
409
+ border-top: 1px solid var(--divider);
410
+ color: var(--muted);
411
+ font-size: 0.78em;
412
+ text-align: center;
413
+ }
414
+
415
+ /* Print rules */
416
+ @media print {
417
+ html, body { background: #ffffff !important; color: #000 !important; }
418
+ body[data-theme="amber"], body[data-theme="amber"] * { background: #ffffff !important; color: #000000 !important; }
419
+ body[data-theme="wordperfect"], body[data-theme="wordperfect"] * { background: #ffffff !important; color: #000000 !important; }
420
+ a { color: #000 !important; text-decoration: underline; }
421
+ pre, table { page-break-inside: avoid; }
422
+ h1, h2, h3 { page-break-after: avoid; }
423
+ .binder { padding: 0; max-width: none; }
424
+ .binder-cover { min-height: 95vh; }
425
+ .binder-page { padding-top: 0; }
426
+ }
427
+ `;
428
+ }
429
+
430
+ function escapeHtml(value: string): string {
431
+ return value
432
+ .replace(/&/g, '&amp;')
433
+ .replace(/</g, '&lt;')
434
+ .replace(/>/g, '&gt;')
435
+ .replace(/"/g, '&quot;')
436
+ .replace(/'/g, '&#39;');
437
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * WikiCanonicalTarget — the markdown-wiki implementation of `CanonicalTarget`.
3
+ *
4
+ * Phase 4 slice B wave 3 of the Library Extraction Roadmap split this file. The
5
+ * `CanonicalTarget` interface itself lives in `@rarusoft/dendrite-memory` so the brain's
6
+ * promotion path is backend-agnostic; this file holds only the wiki-flavored
7
+ * implementation plus the wiki-specific defaults. The constant
8
+ * `DEFAULT_WIKI_PROMOTION_TARGET_SLUG` stays here (wiki-specific) and is also
9
+ * imported by `auto-promote.ts` and `consolidate.ts` for trust gating.
10
+ *
11
+ * The module registers `WikiCanonicalTarget` as the brain's default target at
12
+ * the bottom of this file via a top-level side effect, so any code path that
13
+ * loads the wiki tier (everything that goes through `src/server.ts` → `./store.js`
14
+ * → here) auto-wires the default. Tests that bypass the wiki tier and exercise
15
+ * brain promotion directly must either `setDefaultCanonicalTarget(...)` with a
16
+ * mock or `import './canonical-target.js'` for the side effect.
17
+ */
18
+ import path from 'node:path';
19
+ import { setDefaultCanonicalTarget, type CanonicalTarget, type ProjectMemoryRecord } from '@rarusoft/dendrite-memory';
20
+ import { appendProjectLog, listWikiPages, pagePathFromSlug, readWikiPage, writeWikiPage } from './store.js';
21
+
22
+ /**
23
+ * Default target id when the records don't suggest one and no caller-supplied id
24
+ * is provided. Wiki-specific.
25
+ */
26
+ export const DEFAULT_WIKI_PROMOTION_TARGET_SLUG = 'architecture';
27
+
28
+ /**
29
+ * The markdown-wiki implementation of `CanonicalTarget`. Wraps the existing
30
+ * `readWikiPage` / `writeWikiPage` / `appendProjectLog` plus the markdown
31
+ * formatting rules that used to live inline in `memory-promotion.ts`.
32
+ */
33
+ export class WikiCanonicalTarget implements CanonicalTarget {
34
+ async readContent(targetId: string): Promise<string> {
35
+ return readWikiPage(targetId).catch(() => '');
36
+ }
37
+
38
+ async writeContent(targetId: string, content: string): Promise<void> {
39
+ await writeWikiPage(targetId, content);
40
+ }
41
+
42
+ async appendChangeLog(entry: string): Promise<void> {
43
+ await appendProjectLog(entry);
44
+ }
45
+
46
+ async listAvailableTargetIds(): Promise<string[]> {
47
+ const pages = await listWikiPages();
48
+ return pages.map((page) => page.slug);
49
+ }
50
+
51
+ formatTargetPath(targetId: string): string {
52
+ return `docs/wiki/${targetId}.md`;
53
+ }
54
+
55
+ resolveTitle(targetId: string, currentContent: string): string {
56
+ const fromContent = currentContent.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? '';
57
+ if (fromContent) return fromContent;
58
+ // Slug → Title Case fallback. Mirrors the legacy `titleFromSlug` exactly so the
59
+ // preview UI sees the same string before and after the Phase 2 refactor.
60
+ const slugTitle = targetId
61
+ .split('/')
62
+ .pop()
63
+ ?.split('-')
64
+ .map((segment) => (segment ? segment[0].toUpperCase() + segment.slice(1) : segment))
65
+ .join(' ');
66
+ return slugTitle ?? path.basename(pagePathFromSlug(targetId), '.md');
67
+ }
68
+
69
+ resolveTargetId(
70
+ records: Pick<ProjectMemoryRecord, 'relatedPages' | 'sources'>[],
71
+ requestedTargetId?: string
72
+ ): string {
73
+ const requested = requestedTargetId?.trim();
74
+ if (requested) {
75
+ return requested;
76
+ }
77
+
78
+ // Rank candidate target slugs by how many records mention them in relatedPages.
79
+ // Ties broken alphabetically for deterministic output. Mirrors the legacy
80
+ // `resolvePromotionTargetSlug` exactly.
81
+ const relatedPageCounts = new Map<string, number>();
82
+ for (const record of records) {
83
+ for (const page of record.relatedPages) {
84
+ relatedPageCounts.set(page, (relatedPageCounts.get(page) ?? 0) + 1);
85
+ }
86
+ }
87
+ const rankedRelatedPage = [...relatedPageCounts.entries()].sort(
88
+ (left, right) => right[1] - left[1] || left[0].localeCompare(right[0])
89
+ )[0]?.[0];
90
+ if (rankedRelatedPage) {
91
+ return rankedRelatedPage;
92
+ }
93
+
94
+ // Second-choice fallback: the first wiki-kinded source slug across all records.
95
+ const wikiSource = records
96
+ .flatMap((record) => record.sources)
97
+ .find((source) => source.kind === 'wiki')?.slug;
98
+ if (wikiSource) {
99
+ return wikiSource;
100
+ }
101
+
102
+ // Default to 'architecture' rather than 'project-log' — the project log is for
103
+ // chronological change history, not durable lessons. Architecture is the
104
+ // seeded canonical page in every dendrite-wiki project and is the right
105
+ // fallback for general project facts. The operator can always override by
106
+ // passing requestedTargetId explicitly.
107
+ return DEFAULT_WIKI_PROMOTION_TARGET_SLUG;
108
+ }
109
+
110
+ resolveSectionHeading(records: ProjectMemoryRecord[]): string {
111
+ const kinds = new Set(records.map((record) => record.kind));
112
+ if (kinds.size === 1 && kinds.has('warning')) {
113
+ return '## Promoted Warnings';
114
+ }
115
+ if (kinds.size === 1 && kinds.has('handoff')) {
116
+ return '## Promoted Handoff Notes';
117
+ }
118
+ return '## Promoted Lessons';
119
+ }
120
+
121
+ formatPromotionBlock(sectionHeading: string, records: ProjectMemoryRecord[]): string {
122
+ const lines = [sectionHeading, ''];
123
+ for (const record of records) {
124
+ const provenance = this.buildProvenanceLine(record);
125
+ lines.push(`- ${escapeMarkdownForVue(record.text)}`);
126
+ if (provenance) {
127
+ lines.push(` - ${provenance}`);
128
+ }
129
+ }
130
+ return `${lines.join('\n')}\n`;
131
+ }
132
+
133
+ composeNewContent(existingContent: string, proposedText: string, fallbackTitle: string): string {
134
+ if (existingContent === '') {
135
+ return `# ${fallbackTitle}\n\n${proposedText.trim()}\n`;
136
+ }
137
+ const trimmed = existingContent.replace(/\s+$/g, '');
138
+ return `${trimmed}\n\n${proposedText.trim()}\n`;
139
+ }
140
+
141
+ isPromotionAlreadyApplied(existingContent: string, proposedText: string): boolean {
142
+ return existingContent.includes(proposedText.trim());
143
+ }
144
+
145
+ anchorForHeading(heading: string): string {
146
+ return heading
147
+ .replace(/^#+\s*/, '')
148
+ .toLowerCase()
149
+ .replace(/[^a-z0-9\s-]/g, '')
150
+ .trim()
151
+ .replace(/\s+/g, '-');
152
+ }
153
+
154
+ // ─── Wiki-specific internals ──────────────────────────────────────────────
155
+
156
+ private buildProvenanceLine(record: ProjectMemoryRecord): string {
157
+ const segments: string[] = [`kind: ${record.kind}`];
158
+ if (record.recallCount > 0) {
159
+ segments.push(`recalled ${record.recallCount}x`);
160
+ }
161
+ if (record.sources.length > 0) {
162
+ segments.push(`Sources: ${record.sources.map((source) => `${source.kind}:${source.slug}`).join(', ')}`);
163
+ } else {
164
+ segments.push('Sources: none');
165
+ }
166
+ return `_Provenance: ${segments.join(' · ')}_`;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Factory: build a WikiCanonicalTarget. Mirrors the `createFilesystemMemoryStorage`
172
+ * pattern from Phase 1 so call sites in `memory-promotion.ts`, `auto-promote.ts`,
173
+ * and `consolidate.ts` look uniform.
174
+ */
175
+ export function createWikiCanonicalTarget(): CanonicalTarget {
176
+ return new WikiCanonicalTarget();
177
+ }
178
+
179
+ // VitePress parses every markdown page as a Vue SFC, so any literal `<word>` substring
180
+ // (e.g. `.github/agents/<name>.agent.md` from a memory body) trips the Vue tag parser
181
+ // with "Element is missing end tag" and breaks docs:build. Centralized here as a
182
+ // module-level helper rather than a method because the same rule applies to anything
183
+ // the wiki adapter emits into a VitePress-rendered page.
184
+ function escapeMarkdownForVue(value: string): string {
185
+ return value.replace(/</g, '&lt;').replace(/>/g, '&gt;');
186
+ }
187
+
188
+ // Slice B wave 3: register WikiCanonicalTarget as the brain's default at module
189
+ // load. Any code path that imports this file (or any wiki-side module that
190
+ // transitively imports it) auto-wires the DI surface so brain promotion functions
191
+ // resolve to the wiki adapter.
192
+ setDefaultCanonicalTarget(createWikiCanonicalTarget());