mlx-bun 0.0.5 → 0.0.7
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.
- package/README.md +11 -2
- package/docs/dag/training-inference-map.html +575 -0
- package/docs/design/memory-system.md +193 -295
- package/docs/design/orpo-dynamic-lambda.md +179 -0
- package/docs/design/orpo-future-enhancements.md +42 -0
- package/docs/design/orpo-training.md +13 -0
- package/docs/design/parity-tier-dag.md +143 -0
- package/docs/design/w4a16-compute-precision-spike.md +232 -0
- package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
- package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
- package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
- package/docs/investigations/steel-flash-cce-handoff.md +58 -0
- package/docs/investigations/trainer-validation-experiment.md +173 -0
- package/docs/reference/memory.md +77 -0
- package/docs/reference/orpo-quickstart.md +44 -5
- package/docs/reference/training.md +9 -5
- package/package.json +3 -3
- package/src/cli.ts +651 -18
- package/src/eval/runner.ts +76 -6
- package/src/eval/tasks/bfcl.ts +49 -15
- package/src/eval/tasks/gsm8k.ts +21 -9
- package/src/eval/tasks/hashhop.ts +33 -4
- package/src/eval/tasks/humaneval.ts +30 -8
- package/src/eval/tasks/ifeval.ts +52 -18
- package/src/eval/tasks/mmlu.ts +85 -74
- package/src/jobs/runner.ts +6 -0
- package/src/memory/chunk.ts +25 -0
- package/src/memory/cluster.ts +24 -0
- package/src/memory/db.ts +114 -0
- package/src/memory/ingest.ts +22 -0
- package/src/memory/pipeline.ts +63 -0
- package/src/memory/prompts.ts +23 -0
- package/src/memory/schedule.ts +163 -0
- package/src/memory/synthesize.ts +29 -0
- package/src/memory/tools.ts +399 -0
- package/src/memory/vault.ts +694 -0
- package/src/memory/wikify.ts +23 -0
- package/src/mlx/metal-kernel.ts +13 -0
- package/src/model/flash-attention.ts +5 -3
- package/src/pi-provider.ts +29 -13
- package/src/pi-session.ts +81 -0
- package/src/pi-terminal.ts +13 -24
- package/src/pi-web.ts +246 -166
- package/src/quantize/job.ts +17 -2
- package/src/quantize/quantizer.ts +26 -2
- package/src/server.ts +213 -44
- package/src/tokenizer.ts +6 -2
- package/src/train/dataset.ts +11 -3
- package/src/train/flash-cce.ts +18 -5
- package/src/train/job.ts +41 -4
- package/src/train/loss.ts +10 -4
- package/src/train/trainer.ts +62 -3
- package/src/train/watch.ts +542 -0
- package/src/web/app.html +264 -14
- package/src/web/skills/memory/SKILL.md +97 -0
- package/src/web/skills/web-research/SKILL.md +3 -1
- 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
|
+
}
|