mlx-bun 0.0.5 → 0.0.6

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 (57) hide show
  1. package/README.md +11 -2
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/memory-system.md +193 -295
  4. package/docs/design/orpo-dynamic-lambda.md +179 -0
  5. package/docs/design/orpo-future-enhancements.md +42 -0
  6. package/docs/design/orpo-training.md +13 -0
  7. package/docs/design/parity-tier-dag.md +143 -0
  8. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  9. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  10. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  11. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  12. package/docs/investigations/steel-flash-cce-handoff.md +58 -0
  13. package/docs/investigations/trainer-validation-experiment.md +173 -0
  14. package/docs/reference/memory.md +77 -0
  15. package/docs/reference/orpo-quickstart.md +44 -5
  16. package/docs/reference/training.md +9 -5
  17. package/package.json +1 -1
  18. package/src/cli.ts +651 -18
  19. package/src/eval/runner.ts +76 -6
  20. package/src/eval/tasks/bfcl.ts +49 -15
  21. package/src/eval/tasks/gsm8k.ts +21 -9
  22. package/src/eval/tasks/hashhop.ts +33 -4
  23. package/src/eval/tasks/humaneval.ts +30 -8
  24. package/src/eval/tasks/ifeval.ts +52 -18
  25. package/src/eval/tasks/mmlu.ts +85 -74
  26. package/src/jobs/runner.ts +6 -0
  27. package/src/memory/chunk.ts +25 -0
  28. package/src/memory/cluster.ts +24 -0
  29. package/src/memory/db.ts +114 -0
  30. package/src/memory/ingest.ts +22 -0
  31. package/src/memory/pipeline.ts +63 -0
  32. package/src/memory/prompts.ts +23 -0
  33. package/src/memory/schedule.ts +163 -0
  34. package/src/memory/synthesize.ts +29 -0
  35. package/src/memory/tools.ts +399 -0
  36. package/src/memory/vault.ts +694 -0
  37. package/src/memory/wikify.ts +23 -0
  38. package/src/mlx/metal-kernel.ts +13 -0
  39. package/src/model/flash-attention.ts +5 -3
  40. package/src/pi-provider.ts +29 -13
  41. package/src/pi-session.ts +81 -0
  42. package/src/pi-terminal.ts +13 -24
  43. package/src/pi-web.ts +246 -166
  44. package/src/quantize/job.ts +17 -2
  45. package/src/quantize/quantizer.ts +26 -2
  46. package/src/server.ts +213 -44
  47. package/src/tokenizer.ts +6 -2
  48. package/src/train/dataset.ts +11 -3
  49. package/src/train/flash-cce.ts +18 -5
  50. package/src/train/job.ts +41 -4
  51. package/src/train/loss.ts +10 -4
  52. package/src/train/trainer.ts +62 -3
  53. package/src/train/watch.ts +542 -0
  54. package/src/web/app.html +264 -14
  55. package/src/web/skills/memory/SKILL.md +97 -0
  56. package/src/web/skills/web-research/SKILL.md +3 -1
  57. package/src/web/skills.ts +46 -8
@@ -0,0 +1,694 @@
1
+ // mlx-bun memory — the wiki vault on disk.
2
+ //
3
+ // "Memory" is the user-facing capability; the store is a plain Markdown wiki at
4
+ // ~/.mlx-bun/wiki/ — articles the local assistant reads to remember you across
5
+ // sessions. One self-contained, portable folder: copy it and your memory moves
6
+ // with you. It's a valid Obsidian vault for free.
7
+ //
8
+ // The article read/search/link helpers are ported from lucien (~/Code/lucien),
9
+ // an already-working system at real scale — they're pure filesystem functions
10
+ // with no MCP coupling, so they drop straight in. We keep lucien's good
11
+ // descriptive concepts (articles, [[wikilinks]], Meta governance pages,
12
+ // conv: citations) and drop the mythology. See docs/design/memory-system.md.
13
+ //
14
+ // M0 is the read path: setup the vault, then list / read / search / index it.
15
+ // Synthesis (writing articles from conversations) is M1 and lives elsewhere.
16
+
17
+ import { access, lstat, mkdir, readdir, readFile, readlink, stat, symlink, unlink, writeFile } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { isAbsolute, join } from "node:path";
20
+ import { spawn } from "node:child_process";
21
+
22
+ // ---- paths -----------------------------------------------------------
23
+
24
+ /** Root of the memory vault: ~/.mlx-bun/wiki (override with MLX_BUN_WIKI). */
25
+ export function vaultRoot(): string {
26
+ const override = process.env.MLX_BUN_WIKI?.trim();
27
+ if (override) return override.replace(/^~(?=$|[/\\])/, homedir());
28
+ return join(homedir(), ".mlx-bun", "wiki");
29
+ }
30
+
31
+ export function articlesDir(root = vaultRoot()): string {
32
+ return join(root, "articles");
33
+ }
34
+
35
+ export function referenceDir(root = vaultRoot()): string {
36
+ return join(root, "Reference");
37
+ }
38
+
39
+ // ---- types -----------------------------------------------------------
40
+
41
+ export interface TocEntry {
42
+ depth: number;
43
+ title: string;
44
+ anchor: string;
45
+ }
46
+
47
+ export interface SearchHit {
48
+ article: string;
49
+ anchor: string | null;
50
+ line: number;
51
+ excerpt: string;
52
+ }
53
+
54
+ export interface ArticleSearchSummary {
55
+ article: string;
56
+ occurrences: number;
57
+ /** Distinct query terms that matched this article (multi-term queries). */
58
+ matched_terms?: string[];
59
+ /** Number of query terms that appear in the article's title/stem. */
60
+ title_matches?: number;
61
+ }
62
+
63
+ // ---- markdown helpers (ported from lucien) ---------------------------
64
+
65
+ export function slugifyHeading(title: string): string {
66
+ return title
67
+ .trim()
68
+ .toLowerCase()
69
+ .replace(/[^a-z0-9]+/g, "-")
70
+ .replace(/^-+|-+$/g, "");
71
+ }
72
+
73
+ /** Non-overlapping substring matches (whole file). */
74
+ export function countSubstringOccurrences(text: string, needle: string, caseSensitive: boolean): number {
75
+ const n = needle.trim();
76
+ if (!n) return 0;
77
+ const haystack = caseSensitive ? text : text.toLowerCase();
78
+ const nd = caseSensitive ? n : n.toLowerCase();
79
+ let count = 0;
80
+ let pos = 0;
81
+ for (;;) {
82
+ const idx = haystack.indexOf(nd, pos);
83
+ if (idx === -1) break;
84
+ count++;
85
+ pos = idx + nd.length;
86
+ }
87
+ return count;
88
+ }
89
+
90
+ function isFenceLine(line: string): boolean {
91
+ return /^```/.test(line);
92
+ }
93
+
94
+ /** Heading outside fences; depth 1 = `#`, … 6 = `######`. */
95
+ function parseHeadingLine(line: string): { depth: number; title: string } | null {
96
+ const m = /^(#{1,6})\s+(.+)$/.exec(line);
97
+ if (!m) return null;
98
+ return { depth: m[1]!.length, title: m[2]!.trim() };
99
+ }
100
+
101
+ export function parseToc(markdown: string): TocEntry[] {
102
+ const lines = markdown.split(/\r?\n/);
103
+ let inFence = false;
104
+ const toc: TocEntry[] = [];
105
+ for (const line of lines) {
106
+ if (isFenceLine(line)) {
107
+ inFence = !inFence;
108
+ continue;
109
+ }
110
+ if (inFence) continue;
111
+ const h = parseHeadingLine(line);
112
+ if (h) toc.push({ depth: h.depth, title: h.title, anchor: slugifyHeading(h.title) });
113
+ }
114
+ return toc;
115
+ }
116
+
117
+ function fenceAfterLine(lines: string[], lineIdx: number): boolean {
118
+ let inFence = false;
119
+ for (let i = 0; i <= lineIdx; i++) {
120
+ if (isFenceLine(lines[i]!)) inFence = !inFence;
121
+ }
122
+ return inFence;
123
+ }
124
+
125
+ /**
126
+ * First heading with matching anchor wins. Includes the heading line. Stops
127
+ * before the next heading (outside fences) of equal or shallower depth.
128
+ */
129
+ export function extractSection(markdown: string, anchor: string): string | null {
130
+ const lines = markdown.split(/\r?\n/);
131
+ let inFence = false;
132
+ let startIdx = -1;
133
+ let startDepth = 0;
134
+ for (let i = 0; i < lines.length; i++) {
135
+ const line = lines[i]!;
136
+ if (isFenceLine(line)) {
137
+ inFence = !inFence;
138
+ continue;
139
+ }
140
+ if (inFence) continue;
141
+ const h = parseHeadingLine(line);
142
+ if (!h) continue;
143
+ if (slugifyHeading(h.title) === anchor) {
144
+ startIdx = i;
145
+ startDepth = h.depth;
146
+ break;
147
+ }
148
+ }
149
+ if (startIdx < 0) return null;
150
+
151
+ let fence = fenceAfterLine(lines, startIdx);
152
+ let endIdxExclusive = lines.length;
153
+ for (let j = startIdx + 1; j < lines.length; j++) {
154
+ const line = lines[j]!;
155
+ if (isFenceLine(line)) {
156
+ fence = !fence;
157
+ continue;
158
+ }
159
+ if (fence) continue;
160
+ const h = parseHeadingLine(line);
161
+ if (h && h.depth <= startDepth) {
162
+ endIdxExclusive = j;
163
+ break;
164
+ }
165
+ }
166
+ return lines.slice(startIdx, endIdxExclusive).join("\n");
167
+ }
168
+
169
+ // ---- article/reference addressing ------------------------------------
170
+
171
+ function normalizeBareStem(article: string): string {
172
+ const t = article.trim();
173
+ if (!t) throw new Error("article name is required");
174
+ const stem = t.endsWith(".md") ? t.slice(0, -3).trim() : t;
175
+ if (!stem) throw new Error("article name is invalid");
176
+ if (stem.includes("..")) throw new Error("article must not contain ..");
177
+ if (isAbsolute(stem)) throw new Error("article must be a bare filename, not a path");
178
+ if (stem.includes("/") || stem.includes("\\")) throw new Error("article must not contain path separators");
179
+ if (stem.startsWith(".")) throw new Error("article must not be hidden or relative");
180
+ return stem;
181
+ }
182
+
183
+ function normalizeMemoryDocId(article: string): { kind: "article" | "reference"; stem: string; id: string } {
184
+ const raw = article.trim();
185
+ if (/^Reference[/\\]/.test(raw)) {
186
+ const rest = raw.replace(/^Reference[/\\]/, "");
187
+ const stem = normalizeBareStem(rest);
188
+ return { kind: "reference", stem, id: `Reference/${stem}` };
189
+ }
190
+ const stem = normalizeBareStem(raw);
191
+ return { kind: "article", stem, id: stem };
192
+ }
193
+
194
+ function normalizeArticleStem(article: string): string {
195
+ const doc = normalizeMemoryDocId(article);
196
+ if (doc.kind !== "article") throw new Error("expected a user article, not a Reference document");
197
+ return doc.stem;
198
+ }
199
+
200
+ async function pathExists(path: string): Promise<boolean> {
201
+ try {
202
+ await access(path);
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ export async function resolveArticlePath(root: string, article: string): Promise<string> {
210
+ const doc = normalizeMemoryDocId(article);
211
+ const base = doc.kind === "reference" ? referenceDir(root) : articlesDir(root);
212
+ const filePath = join(base, `${doc.stem}.md`);
213
+ if (!(await pathExists(filePath))) {
214
+ throw new Error(`${doc.kind === "reference" ? "Reference" : "Article"} not found: ${doc.id}.md under ${base}`);
215
+ }
216
+ return filePath;
217
+ }
218
+
219
+ export async function readArticle(root: string, article: string): Promise<{ path: string; content: string }> {
220
+ const path = await resolveArticlePath(root, article);
221
+ return { path, content: await readFile(path, "utf8") };
222
+ }
223
+
224
+ /** Filename stems for every `*.md` under articles/, sorted alphabetically. */
225
+ export async function listArticles(root: string): Promise<string[]> {
226
+ let names: string[];
227
+ try {
228
+ names = await readdir(articlesDir(root));
229
+ } catch {
230
+ return [];
231
+ }
232
+ return names
233
+ .filter((n) => n.endsWith(".md") && !n.startsWith("."))
234
+ .map((n) => n.slice(0, -3))
235
+ .sort((a, b) => a.localeCompare(b));
236
+ }
237
+
238
+ /** Reference document ids, prefixed so tools can pass them back to memory_read. */
239
+ export async function listReferenceDocs(root: string): Promise<string[]> {
240
+ let names: string[];
241
+ try {
242
+ names = await readdir(referenceDir(root));
243
+ } catch {
244
+ return [];
245
+ }
246
+ return names
247
+ .filter((n) => n.endsWith(".md") && !n.startsWith("."))
248
+ .map((n) => `Reference/${n.slice(0, -3)}`)
249
+ .sort((a, b) => a.localeCompare(b));
250
+ }
251
+
252
+ export async function listMemoryDocuments(root: string): Promise<string[]> {
253
+ return [...(await listArticles(root)), ...(await listReferenceDocs(root))];
254
+ }
255
+
256
+ // ---- wikilinks --------------------------------------------------------
257
+
258
+ const wikilinkInnerRe = /\[\[([^\]]+)\]\]/g;
259
+
260
+ /** Raw wikilink targets from prose only (not fenced code); includes conv: etc. */
261
+ export function extractWikilinkTargets(markdown: string): string[] {
262
+ const lines = markdown.split(/\r?\n/);
263
+ let inFence = false;
264
+ const out: string[] = [];
265
+ for (const line of lines) {
266
+ if (isFenceLine(line)) {
267
+ inFence = !inFence;
268
+ continue;
269
+ }
270
+ if (inFence) continue;
271
+ wikilinkInnerRe.lastIndex = 0;
272
+ let m: RegExpExecArray | null;
273
+ while ((m = wikilinkInnerRe.exec(line)) !== null) {
274
+ const inner = m[1]!.trim();
275
+ if (!inner || /^conv:/i.test(inner)) continue;
276
+ const hash = inner.split("|")[0]!.split("#")[0]!.trim();
277
+ if (hash) out.push(hash);
278
+ }
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /** Map wikilink display text to an existing article stem (underscore filenames). */
284
+ export function resolveWikilinkToStem(raw: string, stems: Set<string>): string | null {
285
+ const base = raw.trim();
286
+ if (!base) return null;
287
+ if (stems.has(base)) return base;
288
+ const underscored = base.replace(/\s+/g, "_");
289
+ if (stems.has(underscored)) return underscored;
290
+ const lower = underscored.toLowerCase();
291
+ const spacedLower = base.replace(/\s+/g, " ").trim().toLowerCase();
292
+ for (const stem of stems) {
293
+ if (stem.toLowerCase() === lower) return stem;
294
+ if (stem.replace(/_/g, " ").trim().toLowerCase() === spacedLower) return stem;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ export async function getArticleLinks(root: string, article: string): Promise<{ outbound: string[]; inbound: string[] }> {
300
+ const stem = normalizeArticleStem(article);
301
+ const stems = await listArticles(root);
302
+ const stemSet = new Set(stems);
303
+ await resolveArticlePath(root, stem);
304
+
305
+ const dir = articlesDir(root);
306
+ const outboundByStem = new Map<string, Set<string>>();
307
+ for (const name of stems) {
308
+ const content = await readFile(join(dir, `${name}.md`), "utf8");
309
+ const resolved = new Set<string>();
310
+ for (const t of extractWikilinkTargets(content)) {
311
+ const r = resolveWikilinkToStem(t, stemSet);
312
+ if (r !== null && r !== name) resolved.add(r);
313
+ }
314
+ outboundByStem.set(name, resolved);
315
+ }
316
+
317
+ const outbound = [...(outboundByStem.get(stem) ?? new Set<string>())].sort((a, b) => a.localeCompare(b));
318
+ const inbound: string[] = [];
319
+ for (const [from, targets] of outboundByStem) {
320
+ if (from !== stem && targets.has(stem)) inbound.push(from);
321
+ }
322
+ inbound.sort((a, b) => a.localeCompare(b));
323
+ return { outbound, inbound };
324
+ }
325
+
326
+ // ---- search -----------------------------------------------------------
327
+
328
+ const STOPWORDS = new Set([
329
+ "the", "a", "an", "of", "for", "to", "in", "on", "at", "and", "or", "is", "it", "as", "with",
330
+ ]);
331
+
332
+ export async function searchArticles(
333
+ root: string,
334
+ query: string,
335
+ options?: { limit?: number; case_sensitive?: boolean; scope?: "all" | "articles" | "reference" },
336
+ ): Promise<{ summaries: ArticleSearchSummary[]; hits: SearchHit[] }> {
337
+ const limit = options?.limit ?? 50;
338
+ const caseSensitive = options?.case_sensitive ?? false;
339
+ const q = query.trim();
340
+ if (!q) throw new Error("search query is required");
341
+
342
+ const scope = options?.scope ?? "all";
343
+ const docs: { id: string; stem: string; path: string }[] = [];
344
+ if (scope === "all" || scope === "articles") {
345
+ for (const stem of await listArticles(root)) {
346
+ docs.push({ id: stem, stem, path: join(articlesDir(root), `${stem}.md`) });
347
+ }
348
+ }
349
+ if (scope === "all" || scope === "reference") {
350
+ for (const id of await listReferenceDocs(root)) {
351
+ const stem = id.slice("Reference/".length);
352
+ docs.push({ id, stem, path: join(referenceDir(root), `${stem}.md`) });
353
+ }
354
+ }
355
+ if (docs.length === 0) return { summaries: [], hits: [] };
356
+
357
+ // Split into terms and scan each independently; aggregate per-article so a
358
+ // multi-word query that never appears contiguously still surfaces the
359
+ // articles hitting the most distinct terms. Drop trivial stopwords; if the
360
+ // query is ONLY stopwords, fall back to the whole query as one substring.
361
+ const rawTerms = q.split(/\s+/).filter((t) => t.length > 0);
362
+ const filtered = rawTerms.filter((t) => !STOPWORDS.has(t.toLowerCase()));
363
+ const terms = filtered.length > 0 ? filtered : [q];
364
+ const isMulti = terms.length > 1;
365
+
366
+ const summaries: ArticleSearchSummary[] = [];
367
+ type HitWithCount = SearchHit & { _termCount: number };
368
+ const allHits: HitWithCount[] = [];
369
+
370
+ for (const doc of docs.sort((a, b) => a.id.localeCompare(b.id))) {
371
+ const stem = doc.id;
372
+ let content: string;
373
+ try {
374
+ content = await readFile(doc.path, "utf8");
375
+ } catch {
376
+ continue;
377
+ }
378
+
379
+ const matchedTerms: string[] = [];
380
+ let totalOccurrences = 0;
381
+ for (const t of terms) {
382
+ const n = countSubstringOccurrences(content, t, caseSensitive);
383
+ if (n > 0) {
384
+ matchedTerms.push(t);
385
+ totalOccurrences += n;
386
+ }
387
+ }
388
+ if (totalOccurrences === 0) continue;
389
+
390
+ // Title/stem match bonus: a term in the stem signals the article is *about*
391
+ // that term, not just mentioning it (secondary sort key).
392
+ const stemForMatch = caseSensitive ? stem : stem.toLowerCase();
393
+ let titleMatches = 0;
394
+ for (const t of terms) {
395
+ if (stemForMatch.includes(caseSensitive ? t : t.toLowerCase())) titleMatches++;
396
+ }
397
+
398
+ const summary: ArticleSearchSummary = { article: stem, occurrences: totalOccurrences };
399
+ if (isMulti) summary.matched_terms = matchedTerms;
400
+ if (titleMatches > 0) summary.title_matches = titleMatches;
401
+ summaries.push(summary);
402
+
403
+ const lines = content.split(/\r?\n/);
404
+ let inFence = false;
405
+ let lastAnchor: string | null = null;
406
+ const lcTerms = caseSensitive ? matchedTerms : matchedTerms.map((t) => t.toLowerCase());
407
+ for (let i = 0; i < lines.length; i++) {
408
+ const line = lines[i]!;
409
+ if (isFenceLine(line)) {
410
+ inFence = !inFence;
411
+ } else if (!inFence) {
412
+ const h = parseHeadingLine(line);
413
+ if (h) lastAnchor = slugifyHeading(h.title);
414
+ }
415
+ const compareLine = caseSensitive ? line : line.toLowerCase();
416
+ let termCount = 0;
417
+ for (const t of lcTerms) if (compareLine.includes(t)) termCount++;
418
+ if (termCount > 0) {
419
+ allHits.push({ article: stem, anchor: lastAnchor, line: i + 1, excerpt: line.trim(), _termCount: termCount });
420
+ }
421
+ }
422
+ }
423
+
424
+ // Rank summaries: distinct terms (desc) → title matches (desc) →
425
+ // occurrences (desc) → name (asc, stable).
426
+ summaries.sort((a, b) => {
427
+ const am = a.matched_terms?.length ?? 1;
428
+ const bm = b.matched_terms?.length ?? 1;
429
+ if (bm !== am) return bm - am;
430
+ const at = a.title_matches ?? 0;
431
+ const bt = b.title_matches ?? 0;
432
+ if (bt !== at) return bt - at;
433
+ if (b.occurrences !== a.occurrences) return b.occurrences - a.occurrences;
434
+ return a.article.localeCompare(b.article);
435
+ });
436
+
437
+ allHits.sort((a, b) => b._termCount - a._termCount || a.article.localeCompare(b.article) || a.line - b.line);
438
+ const hits: SearchHit[] = allHits.slice(0, limit).map(({ _termCount, ...h }) => h);
439
+ return { summaries, hits };
440
+ }
441
+
442
+ // ---- setup / status ---------------------------------------------------
443
+
444
+ const README = `# Your memory
445
+
446
+ This is your personal wiki — a structured, durable record of your thinking,
447
+ synthesized from your conversations with your local AI. It belongs to you: plain
448
+ Markdown, version-controlled with git, readable and editable with any tool
449
+ (Obsidian opens this folder as a vault with no setup).
450
+
451
+ mlx-bun curates it; you own it. Edit any file directly — the next synthesis run
452
+ respects your changes.
453
+
454
+ ## Structure
455
+
456
+ - \`articles/\` — the wiki articles themselves (\`Topic_Name.md\`)
457
+ - \`Reference/\` — read-only symlinks to mlx-bun's own docs; synthesis never
458
+ writes these
459
+ - \`Meta/\` — operational pages: editorial guidelines, conventions, the
460
+ bucket taxonomy, topics to ignore
461
+ - \`Talk/\` — per-article discussion / conflict notes
462
+
463
+ Nothing here ever leaves your machine.
464
+ `;
465
+
466
+ const EDITORIAL_GUIDELINES = `# Editorial Guidelines
467
+
468
+ This wiki follows Wikipedia's editorial conventions except as noted. When in
469
+ doubt, do what Wikipedia would do.
470
+
471
+ ## Scope adaptations for a personal wiki
472
+
473
+ - **Subject**: you. Articles describe the people, projects, ideas, tools, and
474
+ topics relevant to your thinking.
475
+ - **Sources**: your conversation transcripts with the local assistant. Each
476
+ article cites the conversations that contributed to it (\`conv:HASH\`).
477
+ - **Neutral point of view**: adapted. The wiki represents your actual views and
478
+ reasoning. "Neutral" here means faithful to the evidence in your
479
+ conversations, not pretending to be from no perspective.
480
+ - **Notability**: anything that recurs in your thinking is notable enough. Stub
481
+ articles are encouraged for emerging topics.
482
+
483
+ ## Maintenance disposition
484
+
485
+ Synthesis operates like a Wikipedia editor making small, conservative
486
+ contributions. Edits integrate rather than replace. Trajectories ("used to
487
+ think X, now thinks Y") are preserved. Talk pages surface concerns rather than
488
+ silently resolving them.
489
+ `;
490
+
491
+ const ARTICLE_CONVENTIONS = `# Article Conventions
492
+
493
+ Each article follows roughly this shape:
494
+
495
+ 1. **Lead paragraph** — a 2-4 sentence summary of what the article is about
496
+ 2. **Sections** — organized by sub-topic, in a logical reading order
497
+ 3. **See also** — links to related articles
498
+ 4. **References** — \`conv:HASH\` citations to source conversations
499
+
500
+ ## Citations
501
+
502
+ Inline claims are followed by a footnote marker; the \`## References\` section at
503
+ the bottom collects the entries. The canonical conversation identifier is the
504
+ first 8 hex chars of its session UUID, written \`conv:HASH\`, followed by an
505
+ em-dash and a short description of what the conversation covered.
506
+
507
+ ## Links
508
+
509
+ Use wikilinks for internal references: \`[[Article Name]]\`. These resolve to the
510
+ correct files (spaces vs underscores and case are normalized).
511
+
512
+ ## Stubs
513
+
514
+ New or thin articles may be marked \`{{stub}}\` at the top. Stubs are legitimate —
515
+ the wiki earns its value through coverage; depth comes through accumulation.
516
+ `;
517
+
518
+ const BUCKETS = `# Buckets
519
+
520
+ The bucket taxonomy used to organize segments of conversation into articles.
521
+ Buckets are emergent — derived from the actual content of conversations, not
522
+ imposed in advance. Populated by synthesis runs.
523
+ `;
524
+
525
+ const TOPICS_TO_IGNORE = `# Topics to Ignore
526
+
527
+ Topics, individuals, or content categories that should NOT be synthesized into
528
+ your wiki. Synthesis consults this page during its filter step. Add entries as
529
+ plain-text descriptions; semantic matching catches related content even when
530
+ the phrasing differs.
531
+
532
+ ## Examples
533
+
534
+ - (none yet — add your own)
535
+ `;
536
+
537
+ const META_PAGES: Record<string, string> = {
538
+ "Editorial_Guidelines.md": EDITORIAL_GUIDELINES,
539
+ "Article_Conventions.md": ARTICLE_CONVENTIONS,
540
+ "Buckets.md": BUCKETS,
541
+ "Topics_to_Ignore.md": TOPICS_TO_IGNORE,
542
+ };
543
+
544
+ const REPO_ROOT = join(import.meta.dir, "..", "..");
545
+
546
+ const REFERENCE_LINKS: { name: string; source: string }[] = [
547
+ { name: "mlx-bun_README", source: join(REPO_ROOT, "README.md") },
548
+ { name: "mlx-bun_Server_API", source: join(REPO_ROOT, "docs", "reference", "server-api.md") },
549
+ { name: "mlx-bun_Server_Config", source: join(REPO_ROOT, "docs", "reference", "server-config.md") },
550
+ { name: "mlx-bun_Library_API", source: join(REPO_ROOT, "docs", "reference", "library-api.md") },
551
+ { name: "mlx-bun_Embedding", source: join(REPO_ROOT, "docs", "reference", "embedding.md") },
552
+ { name: "mlx-bun_Distribution", source: join(REPO_ROOT, "docs", "reference", "distribution.md") },
553
+ { name: "mlx-bun_Training", source: join(REPO_ROOT, "docs", "reference", "training.md") },
554
+ { name: "mlx-bun_ORPO_Quickstart", source: join(REPO_ROOT, "docs", "reference", "orpo-quickstart.md") },
555
+ { name: "mlx-bun_Product_Roadmap", source: join(REPO_ROOT, "docs", "planning", "PRODUCT_ROADMAP.md") },
556
+ ];
557
+
558
+ async function seedReferenceLinks(root: string): Promise<string[]> {
559
+ await mkdir(referenceDir(root), { recursive: true });
560
+ const changed: string[] = [];
561
+ for (const ref of REFERENCE_LINKS) {
562
+ if (!(await pathExists(ref.source))) continue;
563
+ const dest = join(referenceDir(root), `${ref.name}.md`);
564
+ try {
565
+ const existing = await lstat(dest);
566
+ if (existing.isSymbolicLink()) {
567
+ if ((await readlink(dest)) === ref.source) continue;
568
+ await unlink(dest);
569
+ } else {
570
+ continue; // never clobber a user's real file
571
+ }
572
+ } catch {
573
+ // Missing: create it below.
574
+ }
575
+ await symlink(ref.source, dest);
576
+ changed.push(dest);
577
+ }
578
+ return changed;
579
+ }
580
+
581
+ function runGit(args: string[], cwd: string): Promise<boolean> {
582
+ return new Promise((resolve) => {
583
+ const proc = spawn("git", args, { cwd, stdio: "ignore" });
584
+ proc.on("exit", (code) => resolve(code === 0));
585
+ proc.on("error", () => resolve(false));
586
+ });
587
+ }
588
+
589
+ export interface SetupResult {
590
+ root: string;
591
+ created: string[];
592
+ gitInitialized: boolean;
593
+ alreadySetUp: boolean;
594
+ }
595
+
596
+ /** Idempotent: create dirs, write README + Meta pages (only if missing — never
597
+ * clobber user edits), git init + initial commit. Safe to re-run. */
598
+ export async function setupVault(root = vaultRoot()): Promise<SetupResult> {
599
+ const created: string[] = [];
600
+ const ensureDir = async (p: string) => {
601
+ if (!(await pathExists(p))) {
602
+ await mkdir(p, { recursive: true });
603
+ created.push(p);
604
+ }
605
+ };
606
+ const writeIfMissing = async (p: string, content: string) => {
607
+ if (!(await pathExists(p))) {
608
+ await writeFile(p, content);
609
+ created.push(p);
610
+ }
611
+ };
612
+
613
+ await ensureDir(root);
614
+ for (const sub of ["articles", "Reference", "Meta", "Talk"]) await ensureDir(join(root, sub));
615
+ await writeIfMissing(join(root, "README.md"), README);
616
+ // The vault is pure markdown — the synthesis DB lives in the rebuildable
617
+ // cache (~/.cache/mlx-bun/memory.sqlite), not here. Just keep OS/editor
618
+ // cruft (Finder, Obsidian workspace state) out of git history.
619
+ await writeIfMissing(join(root, ".gitignore"), ".DS_Store\n.obsidian/workspace*\n");
620
+ for (const [filename, content] of Object.entries(META_PAGES)) {
621
+ await writeIfMissing(join(root, "Meta", filename), content);
622
+ }
623
+ created.push(...(await seedReferenceLinks(root)));
624
+
625
+ let gitInitialized = false;
626
+ if (!(await pathExists(join(root, ".git")))) {
627
+ if (await runGit(["init"], root)) {
628
+ await runGit(["add", "."], root);
629
+ await runGit(["commit", "-m", "Initialize mlx-bun memory wiki"], root);
630
+ gitInitialized = true;
631
+ }
632
+ }
633
+
634
+ return { root, created, gitInitialized, alreadySetUp: created.length === 0 && !gitInitialized };
635
+ }
636
+
637
+ /** Stage everything and commit, if the vault is a git repo and has changes.
638
+ * Best-effort: silently no-ops when git is unavailable or nothing changed. */
639
+ export async function commitVault(root: string, message: string): Promise<void> {
640
+ if (!(await pathExists(join(root, ".git")))) return;
641
+ await runGit(["add", "."], root);
642
+ await runGit(["commit", "-m", message], root);
643
+ }
644
+
645
+ export interface VaultStatus {
646
+ root: string;
647
+ exists: boolean;
648
+ articleCount: number;
649
+ referenceCount: number;
650
+ isGitRepo: boolean;
651
+ recentArticles: { article: string; mtimeMs: number }[];
652
+ }
653
+
654
+ export async function vaultStatus(root = vaultRoot()): Promise<VaultStatus> {
655
+ const exists = await pathExists(root);
656
+ const stems = exists ? await listArticles(root) : [];
657
+ const refs = exists ? await listReferenceDocs(root) : [];
658
+ const isGitRepo = exists && (await pathExists(join(root, ".git")));
659
+ const recentArticles: { article: string; mtimeMs: number }[] = [];
660
+ if (exists) {
661
+ for (const article of stems) {
662
+ try {
663
+ const s = await stat(join(articlesDir(root), `${article}.md`));
664
+ recentArticles.push({ article, mtimeMs: s.mtimeMs });
665
+ } catch {
666
+ // Ignore races/deleted files; status is best-effort.
667
+ }
668
+ }
669
+ recentArticles.sort((a, b) => b.mtimeMs - a.mtimeMs);
670
+ }
671
+ return { root, exists, articleCount: stems.length, referenceCount: refs.length, isGitRepo, recentArticles: recentArticles.slice(0, 8) };
672
+ }
673
+
674
+ /** Copy `*.md` articles from another wiki/vault's articles/ dir into this one
675
+ * (skips files that already exist). Used by `memory init` to seed from an
676
+ * existing vault. Returns the imported stems. */
677
+ export async function importArticlesFrom(sourceArticlesDir: string, root = vaultRoot()): Promise<string[]> {
678
+ let names: string[];
679
+ try {
680
+ names = await readdir(sourceArticlesDir);
681
+ } catch {
682
+ return [];
683
+ }
684
+ await mkdir(articlesDir(root), { recursive: true });
685
+ const imported: string[] = [];
686
+ for (const name of names) {
687
+ if (!name.endsWith(".md") || name.startsWith(".")) continue;
688
+ const dest = join(articlesDir(root), name);
689
+ if (await pathExists(dest)) continue;
690
+ await writeFile(dest, await readFile(join(sourceArticlesDir, name), "utf8"));
691
+ imported.push(name.slice(0, -3));
692
+ }
693
+ return imported;
694
+ }