mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
@@ -0,0 +1,245 @@
1
+ // Memory REST wrappers — thin loopback JSON routes over src/memory/vault.ts
2
+ // for the web chat's Memory panel (docs/design/web-chat-redesign.md §5.5,
3
+ // §9 Phase 2). These are HTTP-only conveniences for the frontend: the
4
+ // agent-tool surface (src/memory/tools.ts) stays read-only and unchanged,
5
+ // and every route here is itself read-only except /init, which delegates to
6
+ // the same `setupVault` the CLI's `mlx-bun memory init` calls (never
7
+ // duplicated logic).
8
+ //
9
+ // Every handler returns a Response and never throws — a missing vault or a
10
+ // bad param degrades to `{ ok: false, error }` (never a 500), matching the
11
+ // rest of server.ts's /api/* conventions. Route dispatch (matching
12
+ // url.pathname + method) lives in server.ts; this file is the pure handler
13
+ // bodies so they're unit-testable without booting a model context (see
14
+ // tests/memory-rest.test.ts).
15
+
16
+ import { realpath } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { dirname, resolve, sep } from "node:path";
19
+ import {
20
+ vaultRoot, vaultStatus, listArticles, listReferenceDocs, readArticle,
21
+ searchArticles, getArticleLinks, resolveArticleRelPath, articleHistory, articleDiff,
22
+ setupVault, type VaultStatus,
23
+ } from "./vault";
24
+ import { parseInfobox, parseLead, parseSeriesBanner, articleStructure } from "./article";
25
+
26
+ function jsonOk<T extends object>(body: T, init?: ResponseInit): Response {
27
+ return Response.json({ ok: true, ...body }, init);
28
+ }
29
+
30
+ function jsonErr(error: string, status = 400): Response {
31
+ return Response.json({ ok: false, error }, { status });
32
+ }
33
+
34
+ /** `{ ok:false, enabled:false }` shape shared by every route when no vault
35
+ * exists yet — never a throw, never a 404: the frontend uses this to
36
+ * render the first-run consent card instead of an error toast. */
37
+ function noVault(root: string): Response {
38
+ return Response.json({ ok: false, enabled: false, error: "no memory vault yet", root });
39
+ }
40
+
41
+ // ---- GET /api/memory/status -------------------------------------------
42
+
43
+ export async function handleMemoryStatus(): Promise<Response> {
44
+ const st = await vaultStatus();
45
+ if (!st.exists) return noVault(st.root);
46
+ return jsonOk({ enabled: true, status: st satisfies VaultStatus });
47
+ }
48
+
49
+ // ---- GET /api/memory/list ----------------------------------------------
50
+
51
+ export async function handleMemoryList(): Promise<Response> {
52
+ const root = vaultRoot();
53
+ const st = await vaultStatus(root);
54
+ if (!st.exists) return noVault(root);
55
+ const [articles, reference] = await Promise.all([listArticles(root), listReferenceDocs(root)]);
56
+ return jsonOk({ articles, reference });
57
+ }
58
+
59
+ // ---- GET /api/memory/search?q=&scope= -----------------------------------
60
+
61
+ export async function handleMemorySearch(url: URL): Promise<Response> {
62
+ const root = vaultRoot();
63
+ const st = await vaultStatus(root);
64
+ if (!st.exists) return noVault(root);
65
+ const q = (url.searchParams.get("q") ?? "").trim();
66
+ if (!q) return jsonErr("q is required");
67
+ const scopeParam = url.searchParams.get("scope");
68
+ const scope = scopeParam === "articles" || scopeParam === "reference" ? scopeParam : "all";
69
+ const limitParam = Number(url.searchParams.get("limit") ?? "50");
70
+ const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.min(limitParam, 200) : 50;
71
+ try {
72
+ const { summaries, hits } = await searchArticles(root, q, { limit, scope });
73
+ return jsonOk({ summaries, hits });
74
+ } catch (e) {
75
+ return jsonErr((e as Error).message);
76
+ }
77
+ }
78
+
79
+ // ---- GET /api/memory/article?name= --------------------------------------
80
+ //
81
+ // Rendered source + metadata: the article's raw Markdown plus the
82
+ // deterministic structure the panel needs to render it without re-parsing
83
+ // client-side (infobox, lead, series banner, section skeleton).
84
+
85
+ export async function handleMemoryArticle(url: URL): Promise<Response> {
86
+ const root = vaultRoot();
87
+ const st = await vaultStatus(root);
88
+ if (!st.exists) return noVault(root);
89
+ const name = (url.searchParams.get("name") ?? "").trim();
90
+ if (!name) return jsonErr("name is required");
91
+ try {
92
+ const { path, content } = await readArticle(root, name);
93
+ return jsonOk({
94
+ name,
95
+ path,
96
+ content,
97
+ infobox: parseInfobox(content),
98
+ lead: parseLead(content),
99
+ series: parseSeriesBanner(content),
100
+ structure: articleStructure(content),
101
+ });
102
+ } catch (e) {
103
+ return jsonErr((e as Error).message, 404);
104
+ }
105
+ }
106
+
107
+ // ---- GET /api/memory/links?name= -----------------------------------------
108
+
109
+ export async function handleMemoryLinks(url: URL): Promise<Response> {
110
+ const root = vaultRoot();
111
+ const st = await vaultStatus(root);
112
+ if (!st.exists) return noVault(root);
113
+ const name = (url.searchParams.get("name") ?? "").trim();
114
+ if (!name) return jsonErr("name is required");
115
+ try {
116
+ const links = await getArticleLinks(root, name);
117
+ return jsonOk({ name, ...links });
118
+ } catch (e) {
119
+ return jsonErr((e as Error).message, 404);
120
+ }
121
+ }
122
+
123
+ // ---- GET /api/memory/history?name= ---------------------------------------
124
+
125
+ export async function handleMemoryHistory(url: URL): Promise<Response> {
126
+ const root = vaultRoot();
127
+ const st = await vaultStatus(root);
128
+ if (!st.exists) return noVault(root);
129
+ const name = (url.searchParams.get("name") ?? "").trim();
130
+ if (!name) return jsonErr("name is required");
131
+ const limitParam = Number(url.searchParams.get("limit") ?? "50");
132
+ const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.min(limitParam, 200) : 50;
133
+ try {
134
+ const relPath = await resolveArticleRelPath(root, name); // throws if not found — validates `name`
135
+ const entries = await articleHistory(root, relPath, limit);
136
+ return jsonOk({ name, isGitRepo: st.isGitRepo, entries });
137
+ } catch (e) {
138
+ return jsonErr((e as Error).message, 404);
139
+ }
140
+ }
141
+
142
+ // ---- GET /api/memory/diff?name=&rev= -------------------------------------
143
+
144
+ /** Full 40-hex or an abbreviated (≥4 hex) commit hash — never a ref
145
+ * expression (`HEAD~1`, `main^`, `refs/...`), which could otherwise be
146
+ * abused to walk outside the intended commit or (in principle) smuggle
147
+ * option-like text into the argv. `rev` is passed to Bun.spawn as its own
148
+ * argv element (never through a shell), but this check is the defense
149
+ * against a caller trying to pass anything other than a bare commit id. */
150
+ const REV_RE = /^[0-9a-f]{4,40}$/;
151
+
152
+ export async function handleMemoryDiff(url: URL): Promise<Response> {
153
+ const root = vaultRoot();
154
+ const st = await vaultStatus(root);
155
+ if (!st.exists) return noVault(root);
156
+ const name = (url.searchParams.get("name") ?? "").trim();
157
+ const rev = (url.searchParams.get("rev") ?? "").trim();
158
+ if (!name) return jsonErr("name is required");
159
+ if (!rev) return jsonErr("rev is required");
160
+ if (!REV_RE.test(rev)) return jsonErr("rev must be a commit hash (4-40 hex chars)");
161
+ try {
162
+ const relPath = await resolveArticleRelPath(root, name); // throws if not found — validates `name`
163
+ const diff = await articleDiff(root, rev, relPath);
164
+ if (diff === null) return jsonErr("commit not found or no diff for this article at that revision", 404);
165
+ return jsonOk({ name, rev, diff });
166
+ } catch (e) {
167
+ return jsonErr((e as Error).message, 404);
168
+ }
169
+ }
170
+
171
+ // ---- POST /api/memory/init { path?: string } -----------------------------
172
+ //
173
+ // The consent-card backend: wraps the exact same `setupVault` the CLI's
174
+ // `mlx-bun memory init` calls (src/cli.ts, `case "memory":` → `sub === "init"`)
175
+ // — same idempotent create-dirs/README/Meta-pages/git-init behavior, minus
176
+ // the CLI's interactive extras (seed-from-existing-vault prompt, nightly
177
+ // schedule prompt), which stay CLI-only/TTY-gated and are out of scope here.
178
+ //
179
+ // Unlike the CLI (which only ever calls setupVault(vaultRoot()) — a
180
+ // user-supplied path there is a read-only *seed source*, never the
181
+ // setupVault target), this route takes `path` straight from an untrusted
182
+ // loopback caller. setupVault() mkdir -p's subdirectories into it and, if
183
+ // it isn't already a git repo, runs `git init && git add . && git commit`
184
+ // there — pointed at an arbitrary directory that's a destructive foot-gun
185
+ // (force-commits whatever's already there into a fresh history). There is
186
+ // no product reason for the web panel to redirect the vault root outside
187
+ // the machine's own tmp/vault trees, so `path` is confined to those before
188
+ // it ever reaches setupVault; anything else 400s instead of writing files.
189
+
190
+ /** realpath() of the deepest existing ancestor of `path`, with the
191
+ * non-existent tail (e.g. a not-yet-created vault dir) re-appended
192
+ * literally. Needed because macOS resolves `/var/folders/...` (os.tmpdir())
193
+ * through a `/private` symlink: a brand-new mkdtemp path under it won't
194
+ * literal-prefix-match the realpath'd tmpdir unless we walk up to a real
195
+ * segment the same way. */
196
+ async function realpathNearest(path: string): Promise<string> {
197
+ let cur = resolve(path);
198
+ const tail: string[] = [];
199
+ while (true) {
200
+ try {
201
+ return [await realpath(cur), ...tail.reverse()].join(sep);
202
+ } catch {
203
+ const parent = dirname(cur);
204
+ if (parent === cur) return resolve(path); // hit the fs root without finding anything real
205
+ tail.push(cur.slice(parent.length + 1));
206
+ cur = parent;
207
+ }
208
+ }
209
+ }
210
+
211
+ /** Is `candidate` equal to or a descendant of `ancestor`, after resolving
212
+ * symlinks on both sides? */
213
+ async function isUnder(candidate: string, ancestor: string): Promise<boolean> {
214
+ const realAncestor = await realpathNearest(ancestor);
215
+ const realCandidate = await realpathNearest(candidate);
216
+ return realCandidate === realAncestor || realCandidate.startsWith(realAncestor + sep);
217
+ }
218
+
219
+ /** Confine an init target to the machine's own vault/tmp trees — the only
220
+ * legitimate destinations for this route (default vault root, or a tmp dir
221
+ * for tests). Rejects anything else rather than handing setupVault() an
222
+ * arbitrary, possibly pre-existing directory to mkdir/git-init into. */
223
+ async function isAllowedVaultTarget(path: string): Promise<boolean> {
224
+ return (await isUnder(path, vaultRoot())) || (await isUnder(path, tmpdir()));
225
+ }
226
+
227
+ export async function handleMemoryInit(request: Request): Promise<Response> {
228
+ let body: { path?: string } = {};
229
+ try {
230
+ body = (await request.json()) as { path?: string };
231
+ } catch {
232
+ // empty body is fine — defaults to vaultRoot()
233
+ }
234
+ const root = body.path && body.path.trim() ? body.path.trim() : vaultRoot();
235
+ if (!(await isAllowedVaultTarget(root))) {
236
+ return jsonErr("path must be under the memory vault root or a temp directory", 400);
237
+ }
238
+ try {
239
+ const res = await setupVault(root);
240
+ const st = await vaultStatus(res.root);
241
+ return jsonOk({ result: res, status: st });
242
+ } catch (e) {
243
+ return jsonErr((e as Error).message, 500);
244
+ }
245
+ }
@@ -218,6 +218,17 @@ export async function readArticle(root: string, article: string): Promise<{ path
218
218
  return { path, content: await readFile(path, "utf8") };
219
219
  }
220
220
 
221
+ /** Vault-relative path (`articles/<stem>.md` or `Reference/<stem>.md`) for an
222
+ * existing article/reference doc — the safe argv piece for git plumbing
223
+ * (articleHistory/articleDiff), resolved via the same name normalization/
224
+ * existence check as readArticle so a caller can't smuggle a path-traversal
225
+ * or shell-metacharacter name through. Throws if the doc doesn't exist. */
226
+ export async function resolveArticleRelPath(root: string, article: string): Promise<string> {
227
+ const doc = normalizeMemoryDocId(article);
228
+ await resolveArticlePath(root, article); // existence check, reuses the same normalization
229
+ return doc.kind === "reference" ? join("Reference", `${doc.stem}.md`) : join("articles", `${doc.stem}.md`);
230
+ }
231
+
221
232
  /** Filename stems for every `*.md` under articles/, sorted alphabetically. */
222
233
  export async function listArticles(root: string): Promise<string[]> {
223
234
  let names: string[];
@@ -1013,6 +1024,69 @@ function runGit(args: string[], cwd: string): Promise<boolean> {
1013
1024
  });
1014
1025
  }
1015
1026
 
1027
+ // ---- read-only git plumbing (per-article history/diff) ----------------
1028
+ //
1029
+ // Used by the memory REST surface (GET /api/memory/history|diff) to render
1030
+ // "watch it self-heal" — the article's commit history and a specific
1031
+ // commit's diff. Read-only plumbing only (log/show), args always an
1032
+ // explicit argv array via Bun.spawn (never a shell string), and callers
1033
+ // MUST validate `rev`/`name` before calling these (see server.ts) — this
1034
+ // layer trusts its `relPath`/`rev` inputs are already safe.
1035
+
1036
+ export interface ArticleHistoryEntry {
1037
+ hash: string;
1038
+ date: string;
1039
+ subject: string;
1040
+ }
1041
+
1042
+ /** `git log` for one file, newest first, argv-only (never a shell string).
1043
+ * Returns [] when the vault isn't a git repo, the file has no history, or
1044
+ * git is unavailable. `relPath` must already be a repo-relative path (no
1045
+ * `..`, not absolute) — callers validate before calling. */
1046
+ export async function articleHistory(root: string, relPath: string, limit = 50): Promise<ArticleHistoryEntry[]> {
1047
+ if (!(await pathExists(join(root, ".git")))) return [];
1048
+ try {
1049
+ const proc = Bun.spawn(
1050
+ ["git", "log", `--max-count=${limit}`, "--date=short", "--pretty=format:%H%x1f%ad%x1f%s%x1e", "--", relPath],
1051
+ { cwd: root, stdout: "pipe", stderr: "ignore" },
1052
+ );
1053
+ const out = await new Response(proc.stdout).text();
1054
+ if ((await proc.exited) !== 0) return [];
1055
+ return out
1056
+ .split("\x1e")
1057
+ .map((rec) => rec.trim())
1058
+ .filter((rec) => rec.length > 0)
1059
+ .map((rec) => {
1060
+ const [hash = "", date = "", subject = ""] = rec.split("\x1f");
1061
+ return { hash, date, subject };
1062
+ })
1063
+ .filter((e) => e.hash.length > 0);
1064
+ } catch {
1065
+ return [];
1066
+ }
1067
+ }
1068
+
1069
+ /** `git show <rev> -- <relPath>` — the diff for one commit touching one file.
1070
+ * `rev` must already be validated as a hex commit hash by the caller (never
1071
+ * a ref expression like `HEAD~1` or `main^`, which could be abused as an
1072
+ * argument-injection vector); `relPath` must already be repo-relative.
1073
+ * Returns null when the vault isn't a git repo or the commit/diff can't be
1074
+ * produced. */
1075
+ export async function articleDiff(root: string, rev: string, relPath: string): Promise<string | null> {
1076
+ if (!(await pathExists(join(root, ".git")))) return null;
1077
+ try {
1078
+ const proc = Bun.spawn(
1079
+ ["git", "show", rev, "--pretty=format:", "--", relPath],
1080
+ { cwd: root, stdout: "pipe", stderr: "ignore" },
1081
+ );
1082
+ const out = await new Response(proc.stdout).text();
1083
+ if ((await proc.exited) !== 0) return null;
1084
+ return out;
1085
+ } catch {
1086
+ return null;
1087
+ }
1088
+ }
1089
+
1016
1090
  export interface SetupResult {
1017
1091
  root: string;
1018
1092
  created: string[];
package/src/mlx/array.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // MlxArray: ownership wrapper around an mlx_array handle.
2
2
  // Explicit .dispose() is the contract; a FinalizationRegistry backstop
3
- // frees leaked handles on GC (verified in spikes/phase0-memory.ts).
3
+ // frees leaked handles on GC (verified in lab/spikes/phase0-memory.ts).
4
4
 
5
- import { JSCallback, ptr, toArrayBuffer } from "bun:ffi";
5
+ import { dlopen, ptr, toArrayBuffer } from "bun:ffi";
6
6
  import { C, Dtype, DTYPE_NAMES, type MlxHandle, optInt, outArray, takeMlxError } from "./ffi";
7
7
  import type { SafetensorsDtype } from "../safetensors";
8
8
 
@@ -24,17 +24,39 @@ export const SAFETENSORS_TO_MLX: Record<SafetensorsDtype, Dtype> = {
24
24
 
25
25
  // --- zero-copy pinning ---------------------------------------------------
26
26
  // Buffers handed to mlx via new_data_managed_payload must outlive the mlx
27
- // array. We pin the JS view in a map keyed by a payload id; the shared dtor
28
- // callback unpins when mlx drops its last reference. threadsafe: mlx may
29
- // release buffers from its eval thread.
27
+ // array. fromView pins the JS view in this map (a GC root, nothing more);
28
+ // release is EXPLICIT and JS-thread-only via unpinHostBuffer() — never an
29
+ // mlx dtor. See the hazard note below.
30
30
 
31
31
  const pinned = new Map<number, Uint8Array>();
32
32
  let nextPinId = 1;
33
33
 
34
- const unpinCallback = new JSCallback(
35
- (payloadId: number) => { pinned.delete(payloadId); },
36
- { args: ["u64"], returns: "void", threadsafe: true },
37
- );
34
+ // HAZARD (root cause of the 2026-07-06 restart-restore hang): mlx releases
35
+ // buffer Data wherever the LAST shared_ptr drops — often the Metal
36
+ // COMPLETION thread (gpu::eval retains buffers until the command buffer
37
+ // finishes, i.e. past dispose()). A bun:ffi JSCallback dtor invoked from
38
+ // that thread deadlocks when the JS thread is inside a blocking FFI eval
39
+ // (completion waits on JS, JS waits on completion) and SIGTRAPs mid-GC.
40
+ // So NO array here ever gets a JS dtor: fromPointer AND fromView both hand
41
+ // mlx the native no-op dtor below (payload 0), and host-buffer lifetime is
42
+ // the CALLER's contract, released only from the JS thread (unpinHostBuffer)
43
+ // or never (process-lifetime mmaps: expert-offload).
44
+
45
+ // Native no-op dtor: payload is always 0, and libc free(NULL) is defined to
46
+ // do nothing — a dtor mlx can safely call from ANY thread. The pointed-to
47
+ // memory's lifetime is the CALLER's contract (weight mmaps live for the
48
+ // process; restored KV is COPIED at load since 2026-07-07 — see kv-store.ts
49
+ // loadKvCache — so no restore mapping needs pinning anymore). The address
50
+ // comes via dlsym because
51
+ // bun:ffi's symbol .ptr returns the pointer bit-cast to float64
52
+ // (lab/repro/bun-ffi-f64) — passing that back truncates to NULL.
53
+ const libcDlsym = dlopen("/usr/lib/libSystem.B.dylib", {
54
+ dlsym: { args: ["u64", "ptr"], returns: "u64" },
55
+ });
56
+ const FREE_NAME = Buffer.from("free\0");
57
+ const RTLD_DEFAULT = 0xfffffffffffffffen;
58
+ const freeFnAddr = Number(libcDlsym.symbols.dlsym(RTLD_DEFAULT, ptr(FREE_NAME)));
59
+ if (!freeFnAddr) throw new Error("dlsym(free) failed — zero-copy arrays need a native no-op dtor");
38
60
 
39
61
  export function pinnedBufferCount(): number {
40
62
  return pinned.size;
@@ -50,31 +72,61 @@ export class MlxArray {
50
72
  #handle: MlxHandle;
51
73
  #disposed = false;
52
74
  #token = {};
75
+ #pinId = 0; // fromView only: key into the pinned-view map (0 = no pin)
53
76
 
54
77
  constructor(handle: MlxHandle) {
55
78
  this.#handle = handle;
56
79
  registry.register(this, handle, this.#token);
57
80
  }
58
81
 
59
- /** Zero-copy: wrap an existing buffer (e.g. an mmap view). The view is
60
- * pinned until mlx releases it. */
82
+ /** Zero-copy: wrap an existing JS buffer. The view is pinned (GC-rooted)
83
+ * in a process-side map; mlx gets the NATIVE no-op dtor (free(NULL)) —
84
+ * never a JS callback, so the last release may happen on the Metal
85
+ * completion thread without deadlock (see hazard note above). Buffer
86
+ * lifetime is therefore the CALLER's contract:
87
+ * - mmap-backed views (toArrayBuffer aliases): the mapping owns the
88
+ * memory; prefer fromPointer, which skips the pin entirely.
89
+ * - JS-heap buffers: the pin keeps the buffer alive; call
90
+ * unpinHostBuffer() only after a JS-side sync point proves mlx is
91
+ * done with it (everything depending on the array evaluated AND the
92
+ * array disposed), or never (process-lifetime, e.g. short scripts). */
61
93
  static fromView(view: Uint8Array, shape: number[], dtype: Dtype): MlxArray {
62
94
  const sb = shapeBuf(shape);
63
95
  const id = nextPinId++;
64
96
  pinned.set(id, view);
65
97
  const handle = C.mlx_array_new_data_managed_payload(
66
- ptr(view), ptr(sb), shape.length, dtype, id, unpinCallback.ptr,
98
+ ptr(view), ptr(sb), shape.length, dtype, 0, freeFnAddr,
67
99
  );
68
- return new MlxArray(handle);
100
+ const arr = new MlxArray(handle);
101
+ arr.#pinId = id;
102
+ return arr;
103
+ }
104
+
105
+ /** Release the GC root fromView placed on this array's host buffer.
106
+ * JS-thread-only by construction (it's a plain method) — this is the
107
+ * replacement for the deadlocking JSCallback unpin dtor. Idempotent;
108
+ * no-op for non-fromView arrays. Caller contract: only call once mlx can
109
+ * no longer touch the buffer (see fromView doc), or when the memory is
110
+ * owned elsewhere (an mmap kept mapped past every use). */
111
+ unpinHostBuffer(): void {
112
+ if (this.#pinId !== 0) {
113
+ pinned.delete(this.#pinId);
114
+ this.#pinId = 0;
115
+ }
69
116
  }
70
117
 
71
118
  /** Zero-copy: wrap a raw pointer (e.g. into an mmap'd weight shard).
72
- * The caller guarantees the memory outlives the array — weight mmaps
73
- * live for the process, so the dtor is a no-op unpin of id 0. */
119
+ * The caller guarantees the memory outlives EVERY mlx
120
+ * reference to the array — including GPU command buffers that retain the
121
+ * buffer past dispose() — i.e. mmaps backing these arrays stay mapped for
122
+ * the process. The dtor is free(NULL) (payload 0): native + no-op, so
123
+ * mlx may run it from the Metal completion thread without touching the
124
+ * JS runtime (the old JSCallback dtor deadlocked serving / SIGTRAPed
125
+ * under GC when that thread held the last reference — 2026-07-06). */
74
126
  static fromPointer(dataPtr: number, shape: number[], dtype: Dtype): MlxArray {
75
127
  const sb = shapeBuf(shape);
76
128
  const handle = C.mlx_array_new_data_managed_payload(
77
- dataPtr, ptr(sb), shape.length, dtype, 0, unpinCallback.ptr,
129
+ dataPtr, ptr(sb), shape.length, dtype, 0, freeFnAddr,
78
130
  );
79
131
  return new MlxArray(handle);
80
132
  }
@@ -138,7 +190,7 @@ export class MlxArray {
138
190
  return Number(C.mlx_array_ndim(this.handle));
139
191
  }
140
192
 
141
- // NOTE on the DFG stale-read bug (repro/bun-ffi-f64/ISSUE.md): the
193
+ // NOTE on the DFG stale-read bug (lab/repro/bun-ffi-f64/ISSUE.md): the
142
194
  // toArrayBuffer readbacks below (shape/rawBytes/toFloat32) are safe.
143
195
  // The hazard is reading a *pre-existing* typed array after an FFI call
144
196
  // wrote through its pointer — the JIT forwards a stale value from before
@@ -183,6 +235,18 @@ export class MlxArray {
183
235
 
184
236
  /** Raw bytes of the evaluated array (copy). bf16/f16/f32 only. */
185
237
  rawBytes(): Uint8Array {
238
+ return this.rawBytesView().slice();
239
+ }
240
+
241
+ /** ZERO-COPY view of the evaluated array's bytes — aliases the mlx
242
+ * buffer directly, no JS-heap copy. Valid only while THIS array is
243
+ * alive and unmutated: do not retain past dispose(). Built for
244
+ * bulk-write paths (kv-store's streamed writer) where per-tensor
245
+ * rawBytes() copies pile up in the JS heap faster than GC collects
246
+ * them — the residual A7 whole-entry RSS spike (2026-07-07). The
247
+ * array must be contiguous (ops.contiguous first for views), same
248
+ * contract as rawBytes. */
249
+ rawBytesView(): Uint8Array {
186
250
  this.eval();
187
251
  const dt = this.dtype;
188
252
  const p =
@@ -190,9 +254,32 @@ export class MlxArray {
190
254
  : dt === Dtype.float16 ? C.mlx_array_data_float16(this.handle)
191
255
  : dt === Dtype.bfloat16 ? C.mlx_array_data_bfloat16(this.handle)
192
256
  : dt === Dtype.uint32 ? C.mlx_array_data_uint32(this.handle)
257
+ : dt === Dtype.uint8 ? C.mlx_array_data_uint8(this.handle)
258
+ : dt === Dtype.int8 ? C.mlx_array_data_int8(this.handle)
193
259
  : null;
194
- if (p === null) throw new Error(`rawBytes: unsupported dtype ${this.dtypeName}`);
195
- return new Uint8Array(toArrayBuffer(p!, 0, this.nbytes)).slice();
260
+ if (p === null) throw new Error(`rawBytesView: unsupported dtype ${this.dtypeName}`);
261
+ return new Uint8Array(toArrayBuffer(p!, 0, this.nbytes));
262
+ }
263
+
264
+ /** Read back an INTEGER array without enqueueing a cast kernel: eval +
265
+ * direct buffer read for uint32/int32. A cast (`astype`) is a NEW op that
266
+ * queues BEHIND everything already dispatched on the GPU stream — on the
267
+ * batched decode hot path, reading the pipeline register via toFloat32
268
+ * stalled the "overlapped" token read for a FULL step of GPU work every
269
+ * token (the core of the Phase-0 B=1 gap; unified-engine plan Phase 2).
270
+ * Non-integer dtypes fall back to the rounded float readback. */
271
+ toIntTokens(): number[] {
272
+ if (this.dtype === Dtype.uint32) {
273
+ this.eval();
274
+ const p = C.mlx_array_data_uint32(this.handle);
275
+ return [...new Uint32Array(toArrayBuffer(p!, 0, this.size * 4))];
276
+ }
277
+ if (this.dtype === Dtype.int32) {
278
+ this.eval();
279
+ const p = C.mlx_array_data_int32(this.handle);
280
+ return [...new Int32Array(toArrayBuffer(p!, 0, this.size * 4))];
281
+ }
282
+ return [...this.toFloat32()].map((x) => Math.round(x));
196
283
  }
197
284
 
198
285
  /** Read back as float32 (casts on GPU if needed, then copies out). */
@@ -205,6 +292,49 @@ export class MlxArray {
205
292
  return out;
206
293
  }
207
294
 
295
+ /** Read an f32/f16/bf16 array numerically as float32 without enqueueing a
296
+ * GPU cast. This evaluates the ORIGINAL array, then expands 16-bit values
297
+ * on the host. Use this for small readbacks after later GPU work has
298
+ * already been dispatched: toFloat32() would append astype(float32) behind
299
+ * that work and turn the readback into an accidental full-step barrier. */
300
+ toFloat32Host(): Float32Array {
301
+ this.eval();
302
+ const dt = this.dtype;
303
+ if (dt === Dtype.float32) {
304
+ const p = C.mlx_array_data_float32(this.handle);
305
+ return new Float32Array(toArrayBuffer(p!, 0, this.size * 4)).slice();
306
+ }
307
+ if (dt === Dtype.bfloat16) {
308
+ const p = C.mlx_array_data_bfloat16(this.handle);
309
+ const src = new Uint16Array(toArrayBuffer(p!, 0, this.size * 2));
310
+ const words = new Uint32Array(src.length);
311
+ for (let i = 0; i < src.length; i++) words[i] = src[i]! << 16;
312
+ return new Float32Array(words.buffer);
313
+ }
314
+ if (dt === Dtype.float16) {
315
+ const p = C.mlx_array_data_float16(this.handle);
316
+ const src = new Uint16Array(toArrayBuffer(p!, 0, this.size * 2));
317
+ const out = new Float32Array(src.length);
318
+ for (let i = 0; i < src.length; i++) {
319
+ const bits = src[i]!;
320
+ const sign = bits & 0x8000;
321
+ const exponent = (bits >>> 10) & 0x1f;
322
+ const fraction = bits & 0x03ff;
323
+ let value: number;
324
+ if (exponent === 0) {
325
+ value = fraction === 0 ? 0 : fraction * 2 ** -24;
326
+ } else if (exponent === 0x1f) {
327
+ value = fraction === 0 ? Infinity : NaN;
328
+ } else {
329
+ value = (1 + fraction / 1024) * 2 ** (exponent - 15);
330
+ }
331
+ out[i] = sign ? -value : value;
332
+ }
333
+ return out;
334
+ }
335
+ throw new Error(`toFloat32Host: unsupported dtype ${this.dtypeName}`);
336
+ }
337
+
208
338
  /** Basic slice: start/stop per dimension (stride 1). */
209
339
  slice(start: number[], stop: number[], stream: MlxHandle = gpuStream): MlxArray {
210
340
  const s = new Int32Array(start);