mlx-bun 0.0.7 → 0.0.9

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 (138) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-v2-plan.md +515 -0
  3. package/docs/design/bucketing-stage.md +196 -0
  4. package/docs/design/compat-cli-surface-design.md +11 -11
  5. package/docs/design/diffusion-gemma-port.md +602 -0
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/orpo-dynamic-lambda.md +41 -0
  15. package/docs/design/orpo-training.md +35 -5
  16. package/docs/design/parallel-slots.md +12 -1
  17. package/docs/design/parity-tier-dag.md +13 -5
  18. package/docs/design/spec-decode-larger-targets.md +229 -0
  19. package/docs/design/tauri-desktop-app.md +2 -2
  20. package/docs/design/the-dreaming-handoff.md +141 -0
  21. package/docs/design/the-dreaming-master-plan.md +446 -0
  22. package/docs/design/write-pipeline-entity.md +468 -0
  23. package/docs/investigations/curve-bisector-routing.md +1 -1
  24. package/docs/investigations/decode-roofline-lookagain.md +262 -0
  25. package/docs/investigations/dspark-handoff.md +58 -0
  26. package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
  27. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  28. package/docs/planning/curve-sampler-research-plan.md +331 -0
  29. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  30. package/docs/planning/pi-integration-review.md +178 -0
  31. package/docs/planning/release-notes-v0.0.9.md +122 -0
  32. package/docs/planning/web-ui-pass-plan.md +213 -0
  33. package/docs/planning/website-readme-pass-plan.md +224 -0
  34. package/docs/reference/cli.md +255 -0
  35. package/docs/reference/embedding.md +7 -2
  36. package/docs/reference/library-api.md +22 -3
  37. package/docs/reference/memory.md +5 -5
  38. package/docs/reference/models.md +137 -0
  39. package/docs/reference/orpo-quickstart.md +6 -0
  40. package/docs/reference/server-api.md +187 -9
  41. package/docs/reference/server-config.md +40 -9
  42. package/docs/reference/training.md +25 -11
  43. package/package.json +1 -1
  44. package/src/anthropic.ts +22 -0
  45. package/src/assets/curve-designer.html +1 -1
  46. package/src/chat-template.ts +25 -7
  47. package/src/cli.ts +1029 -86
  48. package/src/config.ts +54 -3
  49. package/src/diffusion/diffusion-generate.ts +436 -0
  50. package/src/download.ts +99 -8
  51. package/src/embed.ts +77 -0
  52. package/src/eval/perplexity.ts +164 -0
  53. package/src/eval/runner.ts +19 -7
  54. package/src/eval/tasks/bfcl.ts +1 -1
  55. package/src/generate.ts +196 -19
  56. package/src/harness-pi.ts +43 -17
  57. package/src/index.ts +2 -0
  58. package/src/lora.ts +2 -2
  59. package/src/memory/article.ts +558 -0
  60. package/src/memory/chunk-validation.ts +150 -0
  61. package/src/memory/chunk.ts +305 -12
  62. package/src/memory/cluster.ts +344 -17
  63. package/src/memory/coverage.ts +134 -0
  64. package/src/memory/crosslink.ts +0 -0
  65. package/src/memory/db.ts +184 -45
  66. package/src/memory/entity.ts +203 -0
  67. package/src/memory/gate.ts +187 -0
  68. package/src/memory/history.ts +152 -0
  69. package/src/memory/ingest.ts +324 -11
  70. package/src/memory/model.ts +321 -0
  71. package/src/memory/normalize.ts +671 -0
  72. package/src/memory/parse.ts +22 -0
  73. package/src/memory/pipeline.ts +242 -30
  74. package/src/memory/prompts.ts +42 -0
  75. package/src/memory/query.ts +334 -0
  76. package/src/memory/reconcile.ts +734 -0
  77. package/src/memory/redirect.ts +108 -0
  78. package/src/memory/reindex.ts +325 -0
  79. package/src/memory/resolve.ts +307 -0
  80. package/src/memory/route.ts +272 -0
  81. package/src/memory/schedule.ts +6 -4
  82. package/src/memory/stages.ts +774 -0
  83. package/src/memory/synthesize.ts +1629 -20
  84. package/src/memory/tools.ts +446 -96
  85. package/src/memory/vault.ts +462 -35
  86. package/src/memory/wikify.ts +1057 -16
  87. package/src/mlx/ffi.ts +11 -0
  88. package/src/mlx/ops.ts +113 -0
  89. package/src/model/compiled-decode.ts +43 -8
  90. package/src/model/diffusion-gemma.ts +1026 -0
  91. package/src/model/factory.ts +35 -8
  92. package/src/model/flash-attention.ts +4 -2
  93. package/src/model/fused-decode-kernel.ts +14 -7
  94. package/src/model/fused-mlp-kernel.ts +169 -0
  95. package/src/model/fused-swiglu-kernel.ts +240 -0
  96. package/src/model/gemma4-base.ts +62 -6
  97. package/src/model/gemma4.ts +33 -2
  98. package/src/model/minicpm5.ts +106 -8
  99. package/src/model/qwen3.ts +276 -0
  100. package/src/model/steel-linear-kernel.ts +96 -0
  101. package/src/model/support.ts +45 -7
  102. package/src/model/universal/archs.ts +332 -0
  103. package/src/model/universal/dense.ts +576 -0
  104. package/src/model/universal/modules.ts +163 -0
  105. package/src/model/universal/rope.ts +366 -0
  106. package/src/pi-provider.ts +1 -1
  107. package/src/pi-terminal.ts +29 -8
  108. package/src/pi-web.ts +19 -2
  109. package/src/quantize/config-writer.ts +1 -1
  110. package/src/registry.ts +215 -7
  111. package/src/responses.ts +23 -0
  112. package/src/sampler.ts +220 -9
  113. package/src/serve/batch-scheduler.ts +272 -80
  114. package/src/serve/generation-gateway.ts +90 -18
  115. package/src/server.ts +643 -50
  116. package/src/spec/drafter.ts +81 -49
  117. package/src/spec/dspark/data-dflash.ts +156 -0
  118. package/src/spec/dspark/data.ts +201 -0
  119. package/src/spec/dspark/generate-dflash.ts +193 -0
  120. package/src/spec/dspark/generate.ts +290 -0
  121. package/src/spec/dspark/loss.ts +213 -0
  122. package/src/spec/dspark/module-dflash.ts +361 -0
  123. package/src/spec/dspark/module.ts +540 -0
  124. package/src/spec/dspark/sample.ts +112 -0
  125. package/src/spec/generate.ts +40 -19
  126. package/src/status-page.html +1 -1
  127. package/src/tokenizer.ts +7 -0
  128. package/src/train/diffusion-lora.ts +216 -0
  129. package/src/train/flash-cce.ts +70 -15
  130. package/src/train/fuse.ts +243 -0
  131. package/src/train/index.ts +3 -0
  132. package/src/train/job.ts +8 -0
  133. package/src/train/loss.ts +289 -30
  134. package/src/train/prefix-shared.ts +33 -5
  135. package/src/train/segmented.ts +67 -11
  136. package/src/train/trainer.ts +92 -28
  137. package/src/vision/diffusion-vision.ts +620 -0
  138. package/src/web/skills/memory/SKILL.md +40 -17
@@ -1,23 +1,1064 @@
1
- // mlx-bun memory — synthesis stage 5: WIKIFY (deterministic). ⚠️ M1 STUB.
1
+ // mlx-bun memory — the LLM WIKIFY structural pass (P8-T1 / P8-T2).
2
2
  //
3
- // Non-model editorial cleanup after synthesis: canonicalize [[wikilinks]]
4
- // (spaces↔underscores, case, resolve to existing stems), repair footnotes, and
5
- // run the deterministic editorial gate. Non-fatal — a failure here logs and
6
- // continues; it never corrupts an article. Mirrors lucien's wikify.ts +
7
- // normalize-wikilinks.ts + normalize-footnotes.ts.
3
+ // The periodic editorial sweep, distinct from the deterministic NORMALIZE pass
4
+ // (`normalize.ts`). Where NORMALIZE enforces invariants with zero judgment,
5
+ // WIKIFY asks the local model for JUDGMENT — and the deterministic gate disposes
6
+ // of weak judgment (LoRAs propose, gates dispose). It runs PERIODICALLY (the
7
+ // nightly sweep), NOT per chunk, after synthesis has folded the day's notes in.
8
8
  //
9
- // See docs/design/memory-system.md → "The nightly pipeline" (wikify/normalize).
9
+ // Two nodes, both behind the conservative gate (`gateEdit`) + NORMALIZE:
10
+ // P8-T1 · per-section IMPROVE — port of lucien `scripts/wikify.ts`
11
+ // buildEditorialPrompt, applied PER SECTION via callLocal("editor"):
12
+ // "tighten this section, keep every citation and every specific
13
+ // detail, do not generalize away particulars." A weak edit is a NO-OP
14
+ // — the section keeps its original bytes.
15
+ // P8-T2 · infobox EXTRACT/REFRESH — pull the kind's structured key-value facts
16
+ // out of the article (entity-valued fields → [[wikilinks]], declared
17
+ // aliases: feed the entity index on reindex). Creates infoboxes where
18
+ // missing and refreshes them where present, never losing a grounded
19
+ // fact; the single-info-block invariant is asserted.
20
+ //
21
+ // Every prompt here is SCHEMATIC — no copyable concrete values — because a small
22
+ // base model parrots example values verbatim (the policy-example parroting bug).
23
+ // Meta/reasoning leakage is rejected by the shared `isLeakyDraft` heuristic.
24
+
25
+ import {
26
+ articleStructure,
27
+ infoboxAliases,
28
+ isFenceLine,
29
+ fenceLabel,
30
+ parseInfobox,
31
+ validateArticleStructure,
32
+ type EntityKind,
33
+ } from "./article";
34
+ import { checkFootnoteIntegrity, gateEdit, type GateVerdict } from "./gate";
35
+ import { callLocal } from "./model";
36
+ import { articleThinness, hasStubMarker, normalizeArticle } from "./normalize";
37
+ import {
38
+ assembleArticle,
39
+ buildInfoboxFields,
40
+ countFencedInfoBlocks,
41
+ entityStem,
42
+ isLeakyDraft,
43
+ replaceSection,
44
+ sanitizeSection,
45
+ type SynthesisCall,
46
+ } from "./synthesize";
47
+ import { articlesDir, commitVault, extractSection, listArticles, slugifyHeading, vaultRoot } from "./vault";
48
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
49
+ import { join } from "node:path";
50
+
51
+ // ===========================================================================
52
+ // P8-T1 — per-section IMPROVE (the editorial tighten node)
53
+ // ===========================================================================
54
+
55
+ /** The per-section editorial prompt — a section-scoped port of lucien
56
+ * `scripts/wikify.ts` EDITORIAL_PROMPT. Purely SCHEMATIC: it names no concrete
57
+ * value the model could copy. The hard invariant (keep every [^N] and every
58
+ * particular) is what the gate then enforces deterministically. */
59
+ const SECTION_EDITORIAL_PROMPT = `You are an expert Wikipedia editor tightening ONE section of a personal-wiki article. You are NOT given new source material — your only job is to restructure and condense the section below into its leanest, clearest form.
60
+
61
+ TIGHTEN:
62
+ - Remove redundancy: wherever the same point, fact, or example is stated more than once, merge it into ONE clean sentence and delete the echoes. State each thing exactly once, in the right place.
63
+ - Improve flow: order the sentences so the section reads cleanly; fix run-ons and filler.
64
+
65
+ HARD INVARIANT — preserve every particular (non-negotiable):
66
+ - Keep EVERY citation marker [^N] exactly — never drop, renumber, or invent one.
67
+ - Keep EVERY specific detail: names, model numbers, measurements, prices, dates, and stated opinions. Do NOT generalize a particular into a vague summary; do NOT drop a claim.
68
+ - Do NOT add any claim the section did not already make.
69
+
70
+ OUTPUT:
71
+ Output ONLY the tightened section prose. Do NOT repeat the section heading, do NOT write any other section, do NOT write a "## References" section or any [^N]: definition line. No preamble, no explanation, no code fences.
72
+
73
+ SECTION TO TIGHTEN:
74
+ {{SECTION}}
75
+ `;
76
+
77
+ /** Build the per-section editorial prompt for one section's body prose. */
78
+ export function buildSectionEditorialPrompt(sectionBody: string): string {
79
+ return SECTION_EDITORIAL_PROMPT.replace("{{SECTION}}", sectionBody);
80
+ }
81
+
82
+ /** Distinct `[^N]` marker numbers in a section body (definition lines excluded —
83
+ * a section body never carries defs; those live in `## References`). */
84
+ function bodyMarkers(text: string): Set<number> {
85
+ const out = new Set<number>();
86
+ for (const line of text.split("\n")) {
87
+ if (/^\s*\[\^\d+\]:/.test(line)) continue;
88
+ for (const m of line.matchAll(/\[\^(\d+)\]/g)) out.add(parseInt(m[1]!, 10));
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /** The body-section titles of an article (in document order), excluding the
94
+ * lead, See also, Notes, and References — only the sections an editorial tighten
95
+ * should touch. */
96
+ export function improvableSectionTitles(content: string): string[] {
97
+ return articleStructure(content)
98
+ .filter((it) => it.kind === "section" && it.title)
99
+ .map((it) => it.title!);
100
+ }
101
+
102
+ export interface SectionImproveResult {
103
+ /** The article with every accepted section swapped in (raw — caller NORMALIZEs). */
104
+ content: string;
105
+ /** Sections whose tightened body passed the gate and was swapped in. */
106
+ improved: string[];
107
+ /** Sections whose tightened body was a weak edit (leak / dropped a citation /
108
+ * below the word floor / gate veto) — the original section was kept. */
109
+ rejected: string[];
110
+ }
111
+
112
+ /**
113
+ * P8-T1 driver. Tighten each body section in turn via `call("editor", …)`,
114
+ * accepting the edit ONLY when it survives the gate, otherwise keeping the
115
+ * section's original bytes (a weak edit is a NO-OP). The gate is applied at the
116
+ * whole-article level after swapping the candidate section in, so the footnote
117
+ * bijection (a dropped [^N] orphans its `## References` def) and citation
118
+ * preservation are checked the same way the write pipeline checks every edit;
119
+ * a per-section word floor + leak check pre-reject the obvious failures. Pure
120
+ * with respect to disk — the caller persists.
121
+ */
122
+ export async function improveSections(
123
+ content: string,
124
+ opts: { call: SynthesisCall; floor?: number; maxTokens?: number },
125
+ ): Promise<SectionImproveResult> {
126
+ const floor = opts.floor ?? 0.7;
127
+ const maxTokens = opts.maxTokens ?? 512;
128
+ let working = content;
129
+ const improved: string[] = [];
130
+ const rejected: string[] = [];
131
+
132
+ for (const title of improvableSectionTitles(working)) {
133
+ const anchor = slugifyHeading(title);
134
+ const block = extractSection(working, anchor);
135
+ if (block == null) continue;
136
+ const lines = block.split("\n");
137
+ const headingLine = lines[0] ?? `## ${title}`;
138
+ const beforeBody = lines.slice(1).join("\n").trim();
139
+ if (!beforeBody) continue; // empty section — nothing to tighten
140
+
141
+ const raw = await opts.call(buildSectionEditorialPrompt(beforeBody), { maxTokens });
142
+ const candidate = sanitizeSection(raw, title);
143
+
144
+ // Pre-reject the obvious failures before the swap: a meta/reasoning leak, or
145
+ // a dropped citation marker (also caught by the whole-article footnote
146
+ // bijection below, but rejected here with a crisp per-section reason). A
147
+ // per-section word floor is deliberately NOT applied — legitimately removing
148
+ // a duplicated sentence can halve a short section's words; the bulk-deletion
149
+ // guard is the whole-article gate's floor, which sees all the structural text.
150
+ if (!candidate.trim() || isLeakyDraft(candidate)) {
151
+ rejected.push(title);
152
+ continue;
153
+ }
154
+ const beforeMarkers = bodyMarkers(beforeBody);
155
+ const afterMarkers = bodyMarkers(candidate);
156
+ const lostCitation = [...beforeMarkers].some((n) => !afterMarkers.has(n));
157
+ if (lostCitation) {
158
+ rejected.push(title);
159
+ continue;
160
+ }
161
+
162
+ // Whole-article gate: swap the candidate in and verify the edit preserved
163
+ // the article (footnote bijection, citations, structure). Weak → keep original.
164
+ const trial = replaceSection(working, anchor, `${headingLine}\n\n${candidate}\n`);
165
+ if (trial == null) continue;
166
+ const verdict: GateVerdict = gateEdit(working, trial, { floor });
167
+ if (verdict.ok) {
168
+ working = trial;
169
+ improved.push(title);
170
+ } else {
171
+ rejected.push(title);
172
+ }
173
+ }
174
+
175
+ return { content: working, improved, rejected };
176
+ }
177
+
178
+ // ===========================================================================
179
+ // P8-T2 — infobox EXTRACT / REFRESH
180
+ // ===========================================================================
181
+
182
+ /** The infobox extraction prompt — SCHEMATIC (no copyable spec values). The
183
+ * model proposes `key: value` facts; `buildInfoboxFields` then applies the
184
+ * grounded-field rule (physical-spec keys only on a `kind:thing`), drops any
185
+ * model-supplied kind/aliases (we own those), and orders the block. */
186
+ function buildInfoboxRefreshPrompt(title: string, body: string, otherArticles: string): string {
187
+ return (
188
+ `Use ONLY content from the article below; never copy any name or value from these instructions.\n` +
189
+ `You are extracting the INFOBOX facts for the personal-wiki article about "${title}". ` +
190
+ `Read the article below and emit its structured key-value facts. Output ONLY \`key: value\` lines, one per line, no fence, no prose. Rules:\n` +
191
+ `- snake_case keys.\n` +
192
+ `- A value that names another entity is a [[wikilink]] to one of the stems in OTHER ARTICLES (a related entity, component, or standard).\n` +
193
+ `- Emit a world-fact (a spec-sheet/citable property) only if the article states it; emit a relationship-fact (owned / acquired / used_for / chosen_over / opinion) the article establishes.\n` +
194
+ `- Include \`type:\` (the emergent label) when the article makes it clear.\n` +
195
+ `- Do NOT emit \`kind:\` or \`aliases:\` — those are added for you.\n` +
196
+ `- Never guess a spec the article does not state.\n\n` +
197
+ `OTHER ARTICLES (link targets):\n${otherArticles}\n\n` +
198
+ `ARTICLE:\n${body}\n`
199
+ );
200
+ }
201
+
202
+ /** The article text the extractor reads — capped so the prefill stays bounded. */
203
+ function infoboxSourceText(content: string, cap = 4000): string {
204
+ return content.slice(0, cap);
205
+ }
206
+
207
+ /** A stem list block for the extractor so its [[wikilinks]] resolve. */
208
+ function otherArticlesBlock(stems: Iterable<string>, selfStem: string, cap = 200): string {
209
+ const list = [...stems].filter((s) => s !== selfStem).sort((a, b) => a.localeCompare(b)).slice(0, cap);
210
+ return list.length ? list.map((s) => `- ${s}`).join("\n") : "(none yet)";
211
+ }
212
+
213
+ /**
214
+ * Merge the model's freshly-extracted facts with the article's existing infobox
215
+ * into the final ordered field list. EXISTING facts are placed first so
216
+ * `buildInfoboxFields`' first-occurrence-wins keeps a grounded [[wikilink]] (a
217
+ * `<entity_key>: [[Linked Entity]]` already in the vault) sticky — a refresh adds
218
+ * new keys and the declared aliases, it never degrades a linked fact to a bare scalar or drops
219
+ * one. With no existing infobox this is a pure CREATE (only the model's facts).
220
+ */
221
+ export function refreshInfoboxFields(
222
+ content: string,
223
+ kind: EntityKind,
224
+ modelRaw: string,
225
+ declaredAliases: string[] = [],
226
+ ): string[] {
227
+ const box = parseInfobox(content);
228
+ const existingLines: string[] = [];
229
+ if (box) {
230
+ for (const f of box.fields) {
231
+ if (f.key === "kind" || f.key === "aliases") continue;
232
+ existingLines.push(`${f.key}: ${f.value}`);
233
+ }
234
+ }
235
+ const existingAliases = box ? infoboxAliases(box) : [];
236
+ const aliases = [...existingAliases, ...declaredAliases];
237
+ const combinedRaw = `${existingLines.join("\n")}\n${modelRaw}`;
238
+ return buildInfoboxFields(combinedRaw, kind, aliases);
239
+ }
240
+
241
+ /** The line range `[start, end]` (inclusive) of the first fenced ```info block,
242
+ * or null when there is none. */
243
+ function findInfoBlockRange(lines: string[]): { start: number; end: number } | null {
244
+ for (let i = 0; i < lines.length; i++) {
245
+ if (isFenceLine(lines[i]!) && fenceLabel(lines[i]!) === "info") {
246
+ for (let j = i + 1; j < lines.length; j++) {
247
+ if (isFenceLine(lines[j]!)) return { start: i, end: j };
248
+ }
249
+ return null; // unterminated — not a usable block
250
+ }
251
+ }
252
+ return null;
253
+ }
254
+
255
+ const HEADING_RE = /^(#{1,6})\s+/;
256
+ const SERIES_RE = /^\*Part of a series on \[\[/;
257
+
258
+ /** The index after which a freshly-created infobox should be inserted: after the
259
+ * H1, or after a series banner when one follows the H1 (the grammar order is
260
+ * H1 → series → infobox → lead). Returns -1 when there is no H1 (prepend). */
261
+ function infoboxInsertAfter(lines: string[]): number {
262
+ let inFence = false;
263
+ let h1 = -1;
264
+ let banner = -1;
265
+ for (let i = 0; i < lines.length; i++) {
266
+ if (isFenceLine(lines[i]!)) {
267
+ inFence = !inFence;
268
+ continue;
269
+ }
270
+ if (inFence) continue;
271
+ const t = lines[i]!.trim();
272
+ if (h1 < 0 && HEADING_RE.test(lines[i]!) && lines[i]!.startsWith("# ")) h1 = i;
273
+ else if (h1 >= 0 && banner < 0 && SERIES_RE.test(t)) banner = i;
274
+ }
275
+ return Math.max(h1, banner);
276
+ }
277
+
278
+ /**
279
+ * Write `fields` into the article as the ONE fenced ```info block: replace an
280
+ * existing block in place, else insert a fresh block after the H1 / series
281
+ * banner. Returns the new content. Pure.
282
+ */
283
+ export function setInfobox(content: string, fields: string[]): string {
284
+ const blockLines = ["```info", ...fields, "```"];
285
+ const lines = content.split(/\r?\n/);
286
+ const range = findInfoBlockRange(lines);
287
+ if (range) {
288
+ const next = [...lines.slice(0, range.start), ...blockLines, ...lines.slice(range.end + 1)];
289
+ return next.join("\n");
290
+ }
291
+ const after = infoboxInsertAfter(lines);
292
+ if (after < 0) return [...blockLines, "", ...lines].join("\n");
293
+ const next = [...lines.slice(0, after + 1), "", ...blockLines, ...lines.slice(after + 1)];
294
+ return next.join("\n");
295
+ }
296
+
297
+ export interface InfoboxRefreshResult {
298
+ /** The article with the refreshed single ```info block (raw — caller NORMALIZEs). */
299
+ content: string;
300
+ /** True when the on-disk content changed. */
301
+ changed: boolean;
302
+ /** The final ordered infobox field lines. */
303
+ fields: string[];
304
+ /** Set when the refresh was rejected (e.g. it would yield >1 info block). */
305
+ reason: string | null;
306
+ }
307
+
308
+ /**
309
+ * P8-T2 driver. Extract/refresh the entity's infobox: ask `call("editor", …)`
310
+ * for the structured facts, merge them with the existing box (grounded facts
311
+ * sticky), and write back the single ```info block. The single-info-block
312
+ * invariant is asserted via `countFencedInfoBlocks` — a result with more than
313
+ * one block is rejected (NO-OP) rather than committed. Pure with respect to disk.
314
+ */
315
+ export async function refreshInfobox(
316
+ content: string,
317
+ opts: {
318
+ title: string;
319
+ stem: string;
320
+ kind: EntityKind;
321
+ call: SynthesisCall;
322
+ stems?: Set<string>;
323
+ aliases?: string[];
324
+ maxTokens?: number;
325
+ },
326
+ ): Promise<InfoboxRefreshResult> {
327
+ const stems = new Set(opts.stems ?? []);
328
+ stems.add(opts.stem);
329
+ const otherArticles = otherArticlesBlock(stems, opts.stem);
330
+ const raw = await opts.call(
331
+ buildInfoboxRefreshPrompt(opts.title, infoboxSourceText(content), otherArticles),
332
+ { maxTokens: opts.maxTokens ?? 192 },
333
+ );
334
+ const fields = refreshInfoboxFields(content, opts.kind, raw, opts.aliases ?? []);
335
+ const next = setInfobox(content, fields);
336
+
337
+ if (countFencedInfoBlocks(next) !== 1) {
338
+ return { content, changed: false, fields, reason: `refresh would yield ${countFencedInfoBlocks(next)} info blocks` };
339
+ }
340
+ return { content: next, changed: next !== content, fields, reason: null };
341
+ }
342
+
343
+ // ===========================================================================
344
+ // Effectful entry — wikify one vault article (sections + infobox)
345
+ // ===========================================================================
346
+
347
+ export interface WikifyArticleOptions {
348
+ /** Article filename stem. */
349
+ stem: string;
350
+ /** Entity kind for the grounded-field rule; defaults to the infobox `kind:` or "thing". */
351
+ kind?: EntityKind;
352
+ /** Declared aliases to fold into the infobox `aliases:` line. */
353
+ aliases?: string[];
354
+ /** Vault root (honors MLX_BUN_WIKI); defaults to `vaultRoot()`. */
355
+ root?: string;
356
+ /** Model-call override (tests inject a fake). Defaults to `callLocal("editor", …)`. */
357
+ call?: SynthesisCall;
358
+ /** Run the per-section tighten node (P8-T1). Default true. */
359
+ improveSections?: boolean;
360
+ /** Run the infobox extract/refresh node (P8-T2). Default true. */
361
+ refreshInfobox?: boolean;
362
+ /** Skip the git commit (tests). */
363
+ commit?: boolean;
364
+ floor?: number;
365
+ }
366
+
367
+ export interface WikifyArticleResult {
368
+ stem: string;
369
+ status: "edited" | "unchanged" | "rejected";
370
+ /** Sections tightened and accepted by the gate. */
371
+ sectionsImproved: number;
372
+ /** Sections whose tightened body was a weak edit, original kept. */
373
+ weakEditRejected: number;
374
+ /** True when the infobox was created/refreshed. */
375
+ infoboxRefreshed: boolean;
376
+ /** Veto reason when the final article failed the gate; null otherwise. */
377
+ reason: string | null;
378
+ normalizeNotes: string[];
379
+ }
380
+
381
+ /**
382
+ * Wikify ONE vault article end to end: per-section tighten (P8-T1) → infobox
383
+ * extract/refresh (P8-T2) → NORMALIZE → final gate → write + commit. The final
384
+ * `gateEdit(original, normalized)` is the global safety net: if the cumulative
385
+ * edit somehow degraded the article (citation/structure/word-floor loss), the
386
+ * whole pass is a NO-OP and the vault stays byte-for-byte unchanged.
387
+ */
388
+ export async function wikifyArticle(opts: WikifyArticleOptions): Promise<WikifyArticleResult> {
389
+ const root = opts.root ?? vaultRoot();
390
+ const call: SynthesisCall = opts.call ?? ((p, o) => callLocal("editor", { user: p }, o));
391
+ const path = join(articlesDir(root), `${opts.stem}.md`);
392
+ const base: WikifyArticleResult = {
393
+ stem: opts.stem,
394
+ status: "unchanged",
395
+ sectionsImproved: 0,
396
+ weakEditRejected: 0,
397
+ infoboxRefreshed: false,
398
+ reason: null,
399
+ normalizeNotes: [],
400
+ };
401
+
402
+ let original: string;
403
+ try {
404
+ original = await readFile(path, "utf8");
405
+ } catch {
406
+ return { ...base, status: "rejected", reason: `article not found: ${opts.stem}` };
407
+ }
408
+
409
+ const stems = new Set(await listArticles(root));
410
+ const box = parseInfobox(original);
411
+ const kind: EntityKind = opts.kind ?? (box ? box.entityKind : "thing");
412
+ const title = opts.stem.replace(/_/g, " ");
413
+
414
+ let working = original;
415
+ let sectionsImproved = 0;
416
+ let weakEditRejected = 0;
417
+ let infoboxRefreshed = false;
418
+
419
+ if (opts.improveSections !== false) {
420
+ const r = await improveSections(working, { call, floor: opts.floor });
421
+ working = r.content;
422
+ sectionsImproved = r.improved.length;
423
+ weakEditRejected = r.rejected.length;
424
+ }
425
+
426
+ if (opts.refreshInfobox !== false) {
427
+ const r = await refreshInfobox(working, { title, stem: opts.stem, kind, call, stems, aliases: opts.aliases });
428
+ if (r.changed && r.reason == null) {
429
+ working = r.content;
430
+ infoboxRefreshed = true;
431
+ }
432
+ }
433
+
434
+ const norm = normalizeArticle(working, { stem: opts.stem, stems });
435
+ const result: WikifyArticleResult = {
436
+ ...base,
437
+ sectionsImproved,
438
+ weakEditRejected,
439
+ infoboxRefreshed,
440
+ normalizeNotes: norm.notes,
441
+ };
442
+
443
+ if (norm.content === original) return { ...result, status: "unchanged" };
444
+
445
+ // Final global safety net — the same conservative gate the write path uses.
446
+ // A {{stub}} article legitimately falls below the prose floor, so the gate is
447
+ // advisory there; otherwise a veto means NO-OP.
448
+ const verdict = gateEdit(original, norm.content, { floor: opts.floor });
449
+ if (!verdict.ok && !hasStubMarker(norm.content)) {
450
+ return { ...result, status: "rejected", reason: verdict.reason };
451
+ }
452
+
453
+ await mkdir(articlesDir(root), { recursive: true });
454
+ await writeFile(path, norm.content);
455
+ if (opts.commit !== false) {
456
+ await commitVault(root, `memory: wikify ${opts.stem} (+${sectionsImproved} sections${infoboxRefreshed ? ", infobox" : ""})`);
457
+ }
458
+ return { ...result, status: "edited" };
459
+ }
460
+
461
+ export interface WikifyVaultSummary {
462
+ /** Stems whose on-disk content changed. */
463
+ edited: string[];
464
+ /** Per-stem result for every article processed. */
465
+ results: WikifyArticleResult[];
466
+ }
467
+
468
+ /**
469
+ * Wikify every article in the vault (the periodic sweep). Best-effort and
470
+ * non-fatal per article; the caller drives scheduling. Each article commits its
471
+ * own change (or is a NO-OP).
472
+ */
473
+ export async function wikifyVault(
474
+ opts: { root?: string; call?: SynthesisCall; commit?: boolean; floor?: number } = {},
475
+ ): Promise<WikifyVaultSummary> {
476
+ const root = opts.root ?? vaultRoot();
477
+ const stems = await listArticles(root);
478
+ const edited: string[] = [];
479
+ const results: WikifyArticleResult[] = [];
480
+ for (const stem of stems) {
481
+ const r = await wikifyArticle({ stem, root, call: opts.call, commit: opts.commit, floor: opts.floor });
482
+ results.push(r);
483
+ if (r.status === "edited") edited.push(stem);
484
+ }
485
+ return { edited, results };
486
+ }
487
+
488
+ // ===========================================================================
489
+ // P8-T3 — summary-style SPLIT + series banner maintenance
490
+ //
491
+ // FORWARD maintenance, not a legacy retrofit: our vault is rebuilt entity-
492
+ // granular from chunks, so a section only outgrows its article LATER as nightly
493
+ // patches accrete. When one section comes to dominate the article (large in
494
+ // absolute prose AND bigger than every other body section combined), it is split
495
+ // into its own child entity article — the child takes the full cited prose + a
496
+ // minimal infobox, the parent keeps a 2–3 sentence summary and a
497
+ // `*Main article: [[Child]]*` pointer (the {{Main}} edge `reindex` records as
498
+ // via=main). EVERY [^N] in the moved section travels to the child; the parent's
499
+ // now-orphan defs are dropped by NORMALIZE, so no citation is lost.
500
+ //
501
+ // The split deliberately SKIPS the word-floor gate (it is a sanctioned bulk move,
502
+ // not an accidental deletion); its gate is structural — both halves must stay
503
+ // `validateArticleStructure`-clean and footnote-bijective, else the split is a
504
+ // NO-OP. Series-banner maintenance keeps a single `*Part of a series on [[X]].*`
505
+ // line directly under the H1.
506
+ // ===========================================================================
507
+
508
+ function wordCount(s: string): number {
509
+ const t = s.trim();
510
+ return t ? t.split(/\s+/).length : 0;
511
+ }
512
+
513
+ /** The H1 title of an article (the first `# Title` line), or "". */
514
+ function articleTitle(content: string): string {
515
+ for (const line of content.split(/\r?\n/)) {
516
+ const m = /^#\s+(.+)$/.exec(line);
517
+ if (m) return m[1]!.trim();
518
+ }
519
+ return "";
520
+ }
521
+
522
+ /** Map each `[^N]:` definition number to its full definition line. */
523
+ function referenceDefMap(content: string): Map<number, string> {
524
+ const out = new Map<number, string>();
525
+ for (const line of content.split("\n")) {
526
+ const d = /^\[\^(\d+)\]:.*$/.exec(line);
527
+ if (d) out.set(parseInt(d[1]!, 10), line);
528
+ }
529
+ return out;
530
+ }
531
+
532
+ /** The first `n` sentences of `text`, whitespace-collapsed (the deterministic
533
+ * summary fallback when no model is supplied / its draft is unusable). */
534
+ function firstSentences(text: string, n: number): string {
535
+ const flat = text.replace(/\s+/g, " ").trim();
536
+ if (!flat) return "";
537
+ const parts = flat.match(/[^.!?]+[.!?]+|\S[^.!?]*$/g) ?? [flat];
538
+ return parts.slice(0, Math.max(1, n)).join(" ").trim();
539
+ }
540
+
541
+ export interface BodySectionSize {
542
+ title: string;
543
+ anchor: string;
544
+ /** Word count of the section's body prose (heading excluded). */
545
+ words: number;
546
+ }
547
+
548
+ /** Per body-section prose word counts (excludes lead, See also, Notes,
549
+ * References — only the `section` skeleton items). */
550
+ export function bodySectionSizes(content: string): BodySectionSize[] {
551
+ const lines = content.split(/\r?\n/);
552
+ const out: BodySectionSize[] = [];
553
+ for (const it of articleStructure(content)) {
554
+ if (it.kind !== "section" || !it.title) continue;
555
+ const body = lines.slice(it.startLine, it.endLine).join("\n");
556
+ out.push({ title: it.title, anchor: slugifyHeading(it.title), words: wordCount(body) });
557
+ }
558
+ return out;
559
+ }
560
+
561
+ export interface SplitCandidate {
562
+ title: string;
563
+ anchor: string;
564
+ /** The candidate section's body word count. */
565
+ words: number;
566
+ /** Combined body word count of every OTHER body section. */
567
+ otherWords: number;
568
+ }
569
+
570
+ /**
571
+ * Detect a section that has outgrown the article: a body section that is large
572
+ * in absolute prose (≥ `minWords`, a screen-worth) AND dominates — at least
573
+ * `dominance`× the words of every other body section combined. Requires ≥2 body
574
+ * sections so the parent survives the split. A cohesive article whose sections
575
+ * are balanced has no dominator → null (no false split). The largest qualifying
576
+ * section wins. Pure.
577
+ */
578
+ export function detectSplitCandidate(
579
+ content: string,
580
+ opts: { minWords?: number; dominance?: number } = {},
581
+ ): SplitCandidate | null {
582
+ const minWords = opts.minWords ?? 120;
583
+ const dominance = opts.dominance ?? 1.0;
584
+ const sizes = bodySectionSizes(content);
585
+ if (sizes.length < 2) return null;
586
+ const total = sizes.reduce((s, x) => s + x.words, 0);
587
+ let best: SplitCandidate | null = null;
588
+ for (const s of sizes) {
589
+ const other = total - s.words;
590
+ if (s.words >= minWords && s.words >= dominance * other) {
591
+ if (!best || s.words > best.words) {
592
+ best = { title: s.title, anchor: s.anchor, words: s.words, otherWords: other };
593
+ }
594
+ }
595
+ }
596
+ return best;
597
+ }
598
+
599
+ /** The SCHEMATIC split-summary prompt (no copyable concrete values): the model
600
+ * writes the 2–3 sentence pointer that stays in the parent. Citations/headings
601
+ * /the Main pointer are added deterministically, so it asks for none. */
602
+ function buildSplitSummaryPrompt(parentTitle: string, childTitle: string, body: string): string {
603
+ return (
604
+ `A sub-topic has been split out of the personal-wiki article "${parentTitle}" into its own article. ` +
605
+ `Write a 2 to 3 sentence SUMMARY of "${childTitle}" to leave behind in the parent, so a reader knows what it is and follows the link for detail. ` +
606
+ `Do NOT include citation markers, headings, a "Main article" pointer, or code fences — those are added for you. Output ONLY the summary prose.\n\n` +
607
+ `SECTION BEING SPLIT OUT:\n${body}\n`
608
+ );
609
+ }
610
+
611
+ export interface SplitOptions {
612
+ /** Model override for the parent summary (tests inject a fake); falls back to
613
+ * the first sentences of the moved body when absent/leaky. */
614
+ call?: SynthesisCall;
615
+ /** Known neighbor stems (for the child's wikilink canonicalization). */
616
+ stems?: Set<string>;
617
+ /** Parent filename stem (H1 enforcement); defaults to the H1's entity stem. */
618
+ parentStem?: string;
619
+ /** Aliases to seed the child infobox `aliases:`. */
620
+ aliases?: string[];
621
+ summarySentences?: number;
622
+ maxTokens?: number;
623
+ }
624
+
625
+ export interface SplitResult {
626
+ childStem: string;
627
+ childTitle: string;
628
+ /** The new child article (post-NORMALIZE). */
629
+ child: string;
630
+ /** The parent with the section replaced by a summary + Main pointer (post-NORMALIZE). */
631
+ parent: string;
632
+ /** The `[^N]` numbers carried to the child (parent-side renumber may differ). */
633
+ movedMarkers: number[];
634
+ /** The 2–3 sentence summary left in the parent. */
635
+ summary: string;
636
+ }
637
+
638
+ /**
639
+ * Split one section out of `content` into a child entity article. The child
640
+ * takes the full cited section prose + a minimal `kind: thing` infobox; the
641
+ * parent keeps a summary + `*Main article: [[Child]]*`. Every `[^N]:` definition
642
+ * referenced by the moved section is copied to the child; the parent's resulting
643
+ * orphan defs are dropped by NORMALIZE. The word-floor gate is intentionally NOT
644
+ * applied (a split is a sanctioned move) — both halves are validated structurally
645
+ * + footnote-bijectively instead, and the split is a NO-OP (null) if either
646
+ * fails. Pure with respect to disk.
647
+ */
648
+ export async function splitOutSection(
649
+ content: string,
650
+ anchor: string,
651
+ opts: SplitOptions = {},
652
+ ): Promise<SplitResult | null> {
653
+ const block = extractSection(content, anchor);
654
+ if (block == null) return null;
655
+ const lines = block.split("\n");
656
+ const headingLine = lines[0] ?? "";
657
+ const childTitle = headingLine.replace(/^#{1,6}\s+/, "").trim();
658
+ if (!childTitle) return null;
659
+ const body = lines.slice(1).join("\n").trim();
660
+ if (!body) return null;
661
+ const childStem = entityStem(childTitle);
662
+
663
+ const movedMarkers = [...bodyMarkers(body)].sort((a, b) => a - b);
664
+ const refDefs = referenceDefMap(content);
665
+ const childDefs = movedMarkers.map((n) => refDefs.get(n)).filter((x): x is string => x != null);
666
+
667
+ // --- child: minimal infobox + the full moved prose (the lead) + moved defs ---
668
+ const stems = new Set(opts.stems ?? []);
669
+ stems.add(childStem);
670
+ const infoboxFields = buildInfoboxFields("", "thing", opts.aliases ?? []);
671
+ const childRaw = assembleArticle({
672
+ stem: childStem,
673
+ infoboxFields,
674
+ lead: body,
675
+ sections: [],
676
+ seeAlso: [],
677
+ referenceDefs: childDefs,
678
+ });
679
+ const childNorm = normalizeArticle(childRaw, { stem: childStem, stems });
680
+ if (!checkFootnoteIntegrity(childNorm.content).ok || !validateArticleStructure(childNorm.content).ok) {
681
+ return null;
682
+ }
683
+
684
+ // --- parent: summary + Main pointer in place of the section body ---
685
+ const parentTitle = articleTitle(content);
686
+ let summary = "";
687
+ if (opts.call) {
688
+ const raw = sanitizeSection(
689
+ await opts.call(buildSplitSummaryPrompt(parentTitle, childTitle, body), { maxTokens: opts.maxTokens ?? 160 }),
690
+ childTitle,
691
+ );
692
+ if (raw.trim() && !isLeakyDraft(raw)) summary = raw;
693
+ }
694
+ if (!summary.trim()) summary = firstSentences(body, opts.summarySentences ?? 2);
695
+ // The pointer summary carries NO citations — every marker travels to the child,
696
+ // so none dangles in the parent.
697
+ summary = summary.replace(/\s*\[\^\d+\]/g, "").replace(/\s+/g, " ").trim();
698
+ if (!summary) summary = `See the main article for details on ${childTitle}.`;
699
+
700
+ const newBlock = `${headingLine}\n\n${summary}\n\n*Main article: [[${childStem}]]*\n`;
701
+ const swapped = replaceSection(content, anchor, newBlock);
702
+ if (swapped == null) return null;
703
+
704
+ const parentStem = opts.parentStem ?? entityStem(parentTitle || "entity");
705
+ const parentStems = new Set(opts.stems ?? []);
706
+ parentStems.add(childStem);
707
+ parentStems.add(parentStem);
708
+ const parentNorm = normalizeArticle(swapped, { stem: parentStem, stems: parentStems });
709
+ if (!checkFootnoteIntegrity(parentNorm.content).ok || !validateArticleStructure(parentNorm.content).ok) {
710
+ return null;
711
+ }
712
+
713
+ return {
714
+ childStem,
715
+ childTitle,
716
+ child: childNorm.content,
717
+ parent: parentNorm.content,
718
+ movedMarkers,
719
+ summary,
720
+ };
721
+ }
722
+
723
+ const H1_LINE_RE = /^#\s+/;
724
+
725
+ /**
726
+ * Maintain the series banner: ensure a single `*Part of a series on [[series]].*`
727
+ * line sits directly under the H1 (the grammar slot before the infobox). Inserts
728
+ * one when absent, rewrites a divergent one, no-ops on a match. A null/empty
729
+ * `series` is a no-op — this maintains, it does not remove. Pure.
730
+ */
731
+ export function setSeriesBanner(content: string, series: string | null | undefined): { content: string; changed: boolean } {
732
+ const name = (series ?? "").trim();
733
+ if (!name) return { content, changed: false };
734
+ const banner = `*Part of a series on [[${name}]].*`;
735
+
736
+ const lines = content.split(/\r?\n/);
737
+ let inFence = false;
738
+ let h1 = -1;
739
+ let bannerIdx = -1;
740
+ for (let i = 0; i < lines.length; i++) {
741
+ if (isFenceLine(lines[i]!)) {
742
+ inFence = !inFence;
743
+ continue;
744
+ }
745
+ if (inFence) continue;
746
+ if (h1 < 0 && H1_LINE_RE.test(lines[i]!)) h1 = i;
747
+ else if (bannerIdx < 0 && SERIES_RE.test(lines[i]!.trim())) bannerIdx = i;
748
+ }
749
+
750
+ if (bannerIdx >= 0) {
751
+ if (lines[bannerIdx]!.trim() === banner) return { content, changed: false };
752
+ lines[bannerIdx] = banner;
753
+ return { content: lines.join("\n"), changed: true };
754
+ }
755
+ if (h1 < 0) return { content: `${banner}\n\n${content}`, changed: true };
756
+ lines.splice(h1 + 1, 0, "", banner);
757
+ return { content: lines.join("\n"), changed: true };
758
+ }
759
+
760
+ // ---- effectful SPLIT entry (write child + parent, commit) ------------------
761
+
762
+ export interface ApplySplitResult {
763
+ status: "split" | "no-candidate" | "rejected";
764
+ stem: string;
765
+ childStem: string | null;
766
+ anchor: string | null;
767
+ reason: string | null;
768
+ }
10
769
 
11
- import type { SynthesisEvent } from "./pipeline";
770
+ /**
771
+ * Detect and apply a summary-style split on one vault article: write the new
772
+ * child article + the trimmed parent and commit. A NO-OP when no section
773
+ * dominates ("no-candidate") or the structural split gate vetoes ("rejected").
774
+ * Never clobbers an existing child article (that would be a MERGE, not a split).
775
+ */
776
+ export async function applySplit(opts: {
777
+ stem: string;
778
+ root?: string;
779
+ call?: SynthesisCall;
780
+ commit?: boolean;
781
+ minWords?: number;
782
+ dominance?: number;
783
+ }): Promise<ApplySplitResult> {
784
+ const root = opts.root ?? vaultRoot();
785
+ const base: ApplySplitResult = { status: "no-candidate", stem: opts.stem, childStem: null, anchor: null, reason: null };
786
+ const path = join(articlesDir(root), `${opts.stem}.md`);
12
787
 
13
- export interface WikifyResult {
14
- articlesNormalized: number;
15
- linksFixed: number;
788
+ let content: string;
789
+ try {
790
+ content = await readFile(path, "utf8");
791
+ } catch {
792
+ return { ...base, status: "rejected", reason: `article not found: ${opts.stem}` };
793
+ }
794
+
795
+ const cand = detectSplitCandidate(content, { minWords: opts.minWords, dominance: opts.dominance });
796
+ if (!cand) return base;
797
+
798
+ const stems = new Set(await listArticles(root));
799
+ const result = await splitOutSection(content, cand.anchor, { call: opts.call, stems, parentStem: opts.stem });
800
+ if (!result) return { ...base, status: "rejected", anchor: cand.anchor, reason: "split failed the structural gate (NO-OP)" };
801
+
802
+ const childPath = join(articlesDir(root), `${result.childStem}.md`);
803
+ try {
804
+ await readFile(childPath, "utf8");
805
+ return { ...base, status: "rejected", anchor: cand.anchor, childStem: result.childStem, reason: "child article already exists (would be a MERGE, not a split)" };
806
+ } catch {
807
+ // child does not exist — good, proceed.
808
+ }
809
+
810
+ await mkdir(articlesDir(root), { recursive: true });
811
+ await writeFile(childPath, result.child);
812
+ await writeFile(path, result.parent);
813
+ if (opts.commit !== false) {
814
+ await commitVault(root, `memory: split ${result.childStem} out of ${opts.stem} (${result.movedMarkers.length} citations moved)`);
815
+ }
816
+ return { status: "split", stem: opts.stem, childStem: result.childStem, anchor: cand.anchor, reason: null };
817
+ }
818
+
819
+ // ===========================================================================
820
+ // P8-T3 (cont.) — MERGE signal: two near-duplicate stubs
821
+ //
822
+ // MERGE is a CROSS-article action: it is NEVER performed silently. This only
823
+ // EMITS a signal (which P8-T4 surfaces on a Talk page); a human / a later
824
+ // reconcile step decides. Two stubs describing the same thing under different
825
+ // names have near-identical token content — a high Jaccard overlap flags them.
826
+ // ===========================================================================
827
+
828
+ const MERGE_STOP = new Set([
829
+ "the", "a", "an", "and", "or", "of", "to", "for", "in", "on", "with", "is", "it", "as",
830
+ "at", "by", "be", "this", "that", "are", "was", "user", "uses", "used", "use",
831
+ ]);
832
+
833
+ /** Content tokens (title + body words), markers/links/fences stripped, stopworded
834
+ * — the bag a stub-vs-stub Jaccard compares. */
835
+ function articleTokens(content: string): Set<string> {
836
+ const flat = content
837
+ .replace(/```[\s\S]*?```/g, " ") // fenced blocks (infobox/code) out
838
+ .replace(/\[\^\d+\][:]?/g, " ") // footnote markers/defs
839
+ .replace(/\[\[([^\]]+)\]\]/g, "$1") // wikilink → its text
840
+ .replace(/[#*_`>|-]/g, " ");
841
+ const out = new Set<string>();
842
+ for (const t of flat.toLowerCase().split(/[^a-z0-9]+/)) {
843
+ if (t.length > 1 && !MERGE_STOP.has(t)) out.add(t);
844
+ }
845
+ return out;
16
846
  }
17
847
 
18
- /** Deterministic editorial gate + link/footnote normalize. STUB. */
19
- export async function wikify(_onEvent?: (e: SynthesisEvent) => void): Promise<WikifyResult> {
20
- // TODO(M1): resolveWikilinkToStem across touched articles (vault.ts already
21
- // has the resolver), footnote renumber/repair, {{stub}} marking.
22
- throw new Error("memory.wikify is not implemented yet (M1)");
848
+ function jaccard(a: Set<string>, b: Set<string>): number {
849
+ if (a.size === 0 || b.size === 0) return 0;
850
+ let inter = 0;
851
+ for (const t of a) if (b.has(t)) inter++;
852
+ return inter / (a.size + b.size - inter);
853
+ }
854
+
855
+ export interface MergeSignal {
856
+ a: string;
857
+ b: string;
858
+ jaccard: number;
859
+ reason: string;
860
+ }
861
+
862
+ /**
863
+ * Flag pairs of articles that look like the SAME thing under different names —
864
+ * a MERGE signal, never an action. By default only stubs are considered (the
865
+ * thin near-duplicates summary-style merges); their content-token Jaccard ≥
866
+ * `threshold` emits a signal. Deterministic: pairs are sorted, scanned in stem
867
+ * order. Pure.
868
+ */
869
+ export function detectMergeCandidates(
870
+ articles: { stem: string; content: string }[],
871
+ opts: { threshold?: number; stubsOnly?: boolean } = {},
872
+ ): MergeSignal[] {
873
+ const threshold = opts.threshold ?? 0.5;
874
+ const stubsOnly = opts.stubsOnly ?? true;
875
+ const pool = articles
876
+ .filter((x) => !stubsOnly || articleThinness(x.content).stub)
877
+ .map((x) => ({ stem: x.stem, toks: articleTokens(x.content) }))
878
+ .sort((p, q) => p.stem.localeCompare(q.stem));
879
+
880
+ const out: MergeSignal[] = [];
881
+ for (let i = 0; i < pool.length; i++) {
882
+ for (let j = i + 1; j < pool.length; j++) {
883
+ const score = jaccard(pool[i]!.toks, pool[j]!.toks);
884
+ if (score >= threshold) {
885
+ out.push({
886
+ a: pool[i]!.stem,
887
+ b: pool[j]!.stem,
888
+ jaccard: score,
889
+ reason: `near-duplicate stubs (token Jaccard ${score.toFixed(2)}) — likely the same thing under two names`,
890
+ });
891
+ }
892
+ }
893
+ }
894
+ return out.sort((x, y) => y.jaccard - x.jaccard || x.a.localeCompare(y.a));
895
+ }
896
+
897
+ // ===========================================================================
898
+ // P8-T4 — Talk-page restructure SIGNALS (never silent cross-article surgery)
899
+ //
900
+ // The structural pass NEVER restructures across articles on its own — split and
901
+ // merge are CROSS-article actions, so it writes SUGGESTIONS to `Talk/<stem>.md`
902
+ // (Lucien `<<<TALK>>>` style) for a human / a later reconcile step. The embedding
903
+ // SILHOUETTE is the ONE sanctioned OFFLINE triage instrument here: section-cohesion
904
+ // spots an article whose sections drifted into distinct topics (a split candidate
905
+ // the size heuristic alone might miss). It runs ONLY in this offline triage — the
906
+ // `embed` function is injected, so this module never reaches `src/embed.ts` and
907
+ // the read/route hot path stays at zero embedding calls (the P0-T6 tripwire).
908
+ // ===========================================================================
909
+
910
+ /** Inject the embedding backend (so `wikify.ts` never imports `src/embed.ts` and
911
+ * the read path can never accidentally embed). The eval wires the real
912
+ * Qwen3-Embedding through here; tests inject a deterministic fake. */
913
+ export type EmbedFn = (texts: string[]) => Float32Array[];
914
+
915
+ function cosine(a: Float32Array, b: Float32Array): number {
916
+ let dot = 0;
917
+ let na = 0;
918
+ let nb = 0;
919
+ const n = Math.min(a.length, b.length);
920
+ for (let i = 0; i < n; i++) {
921
+ dot += a[i]! * b[i]!;
922
+ na += a[i]! * a[i]!;
923
+ nb += b[i]! * b[i]!;
924
+ }
925
+ if (na === 0 || nb === 0) return 0;
926
+ return dot / Math.sqrt(na * nb);
927
+ }
928
+
929
+ export interface CohesionResult {
930
+ sections: number;
931
+ /** Mean pairwise cosine across body-section embeddings (1 when <2 sections).
932
+ * LOW = the sections cover distinct topics → a split candidate. */
933
+ meanPairwise: number;
934
+ /** The least-similar section pair's cosine (1 when <2 sections). */
935
+ minPair: number;
936
+ }
937
+
938
+ /**
939
+ * The OFFLINE cohesion silhouette: embed each body section and measure how alike
940
+ * they are. A low mean pairwise cosine means the article's sections have drifted
941
+ * into distinct topics — a structural split signal the word-size heuristic can
942
+ * miss. This is the SANCTIONED offline use of embeddings; `embed` is injected and
943
+ * MUST NOT be the read/route path. Pure aside from the injected `embed`.
944
+ */
945
+ export function sectionCohesion(content: string, embed: EmbedFn): CohesionResult {
946
+ const lines = content.split(/\r?\n/);
947
+ const bodies: string[] = [];
948
+ for (const it of articleStructure(content)) {
949
+ if (it.kind !== "section" || !it.title) continue;
950
+ const body = lines.slice(it.startLine, it.endLine).join("\n").trim();
951
+ if (body) bodies.push(body);
952
+ }
953
+ if (bodies.length < 2) return { sections: bodies.length, meanPairwise: 1, minPair: 1 };
954
+
955
+ const vecs = embed(bodies);
956
+ let sum = 0;
957
+ let count = 0;
958
+ let min = 1;
959
+ for (let i = 0; i < vecs.length; i++) {
960
+ for (let j = i + 1; j < vecs.length; j++) {
961
+ const c = cosine(vecs[i]!, vecs[j]!);
962
+ sum += c;
963
+ count++;
964
+ if (c < min) min = c;
965
+ }
966
+ }
967
+ return { sections: bodies.length, meanPairwise: count ? sum / count : 1, minPair: min };
968
+ }
969
+
970
+ export interface TalkSuggestion {
971
+ kind: "split" | "merge" | "contradiction";
972
+ text: string;
973
+ }
974
+
975
+ /**
976
+ * Compose the structural-triage suggestions for one article from the detections:
977
+ * a size-dominant split candidate, a low-cohesion silhouette split signal, and
978
+ * any merge pairs involving this article. Pure; SCHEMATIC text (no copyable
979
+ * concrete values from policy).
980
+ */
981
+ export function buildStructureTalk(input: {
982
+ stem: string;
983
+ split?: SplitCandidate | null;
984
+ cohesion?: CohesionResult | null;
985
+ merges?: MergeSignal[];
986
+ cohesionFloor?: number;
987
+ }): TalkSuggestion[] {
988
+ const out: TalkSuggestion[] = [];
989
+ const floor = input.cohesionFloor ?? 0.4;
990
+
991
+ if (input.split) {
992
+ const childStem = entityStem(input.split.title);
993
+ out.push({
994
+ kind: "split",
995
+ text:
996
+ `SPLIT: the "${input.split.title}" section (${input.split.words} words) dominates this article ` +
997
+ `(vs ${input.split.otherWords} words across all other sections). Consider splitting it to ` +
998
+ `[[${childStem}]] with a {{Main}} pointer; every [^N] in it travels to the child.`,
999
+ });
1000
+ }
1001
+ if (input.cohesion && input.cohesion.sections >= 2 && input.cohesion.meanPairwise < floor) {
1002
+ out.push({
1003
+ kind: "split",
1004
+ text:
1005
+ `SPLIT (cohesion): this article's ${input.cohesion.sections} sections read as distinct topics ` +
1006
+ `(mean section cohesion ${input.cohesion.meanPairwise.toFixed(2)} < ${floor.toFixed(2)}). ` +
1007
+ `Consider splitting the divergent section(s) into their own article(s).`,
1008
+ });
1009
+ }
1010
+ for (const m of input.merges ?? []) {
1011
+ if (m.a !== input.stem && m.b !== input.stem) continue;
1012
+ const other = m.a === input.stem ? m.b : m.a;
1013
+ out.push({
1014
+ kind: "merge",
1015
+ text: `MERGE: [[${input.stem}]] and [[${other}]] look like the same thing under two names (${m.reason}). Consider merging to the canonical title; the loser becomes an alias + redirect.`,
1016
+ });
1017
+ }
1018
+ return out;
1019
+ }
1020
+
1021
+ /** The Talk directory for a vault (sibling of articles/). */
1022
+ export function talkDir(root = vaultRoot()): string {
1023
+ return join(root, "Talk");
1024
+ }
1025
+
1026
+ /**
1027
+ * Write the structural-triage suggestions to `Talk/<stem>.md` in the Lucien
1028
+ * `<<<TALK>>>` style. Writes ONLY under `Talk/` — it NEVER edits an article, so
1029
+ * a cross-article split/merge is surfaced, never silently performed. Returns the
1030
+ * written path, or null when there is nothing to suggest. The block is rewritten
1031
+ * each run (idempotent — one managed block per article).
1032
+ */
1033
+ export async function writeTalkPage(opts: {
1034
+ stem: string;
1035
+ suggestions: TalkSuggestion[];
1036
+ title?: string;
1037
+ root?: string;
1038
+ now?: number;
1039
+ }): Promise<string | null> {
1040
+ if (opts.suggestions.length === 0) return null;
1041
+ const root = opts.root ?? vaultRoot();
1042
+ const dir = talkDir(root);
1043
+ await mkdir(dir, { recursive: true });
1044
+ const path = join(dir, `${opts.stem}.md`);
1045
+ const date = new Date(opts.now ?? Date.now()).toISOString().slice(0, 10);
1046
+ const title = opts.title ?? opts.stem.replace(/_/g, " ");
1047
+
1048
+ const body = [
1049
+ `# Talk: ${title}`,
1050
+ "",
1051
+ "> Structural-triage suggestions from the nightly wikify sweep. These are",
1052
+ "> SUGGESTIONS ONLY — mlx-bun never restructures across articles automatically.",
1053
+ "",
1054
+ "<<<TALK>>>",
1055
+ `## ${date} — structural triage`,
1056
+ "",
1057
+ ...opts.suggestions.map((s) => `- **${s.kind.toUpperCase()}** — ${s.text}`),
1058
+ "<<<END TALK>>>",
1059
+ "",
1060
+ ].join("\n");
1061
+
1062
+ await writeFile(path, body);
1063
+ return path;
23
1064
  }