mlx-bun 0.0.9 → 0.0.11

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 (179) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -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 +255 -0
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +98 -0
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +18 -2
  27. package/docs/design/ssd-kv-cold-tier.md +288 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/decode-roofline-lookagain.md +92 -6
  35. package/docs/investigations/dspark-handoff.md +182 -16
  36. package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
  37. package/docs/investigations/lab-build-journal.md +2 -2
  38. package/docs/investigations/openwiki-evaluation.md +249 -0
  39. package/docs/planning/IDEAS.md +14 -0
  40. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  41. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  42. package/docs/planning/chat-ui-vision.md +50 -0
  43. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  44. package/docs/planning/release-notes-v0.0.10.md +57 -0
  45. package/docs/planning/release-notes-v0.0.11.md +239 -0
  46. package/docs/reference/cli.md +35 -16
  47. package/docs/reference/distribution.md +13 -6
  48. package/docs/reference/embedding.md +35 -14
  49. package/docs/reference/features-matrix.md +97 -0
  50. package/docs/reference/library-api.md +30 -9
  51. package/docs/reference/memory.md +26 -6
  52. package/docs/reference/models.md +11 -1
  53. package/docs/reference/orpo-quickstart.md +3 -4
  54. package/docs/reference/server-api.md +634 -22
  55. package/docs/reference/server-config.md +251 -119
  56. package/docs/reference/training.md +20 -16
  57. package/package.json +5 -3
  58. package/src/anthropic.ts +13 -0
  59. package/src/audio/conformer.ts +647 -0
  60. package/src/audio/decode.ts +133 -0
  61. package/src/audio/features.ts +202 -0
  62. package/src/audio/transcode.ts +67 -0
  63. package/src/cli.ts +402 -107
  64. package/src/config.ts +49 -2
  65. package/src/download.ts +40 -0
  66. package/src/eval/README.md +7 -6
  67. package/src/eval/kl.ts +59 -3
  68. package/src/eval/quality-db.ts +2 -2
  69. package/src/eval/runner.ts +3 -3
  70. package/src/expert-offload.ts +15 -2
  71. package/src/flags.ts +13 -0
  72. package/src/generate.ts +330 -16
  73. package/src/grammar.ts +585 -0
  74. package/src/hub-rest.ts +242 -0
  75. package/src/kv-store.ts +618 -122
  76. package/src/lora.ts +8 -0
  77. package/src/md-modules.d.ts +42 -0
  78. package/src/media-fetch.ts +284 -0
  79. package/src/memory/rest.ts +245 -0
  80. package/src/memory/vault.ts +74 -0
  81. package/src/mlx/array.ts +127 -19
  82. package/src/mlx/autograd.ts +2 -2
  83. package/src/mlx/ffi.ts +27 -3
  84. package/src/mlx/metal-kernel.ts +7 -4
  85. package/src/mlx/ops.ts +179 -23
  86. package/src/mlx/turboquant-ops.ts +831 -0
  87. package/src/mlx/turboquant-tables.ts +237 -0
  88. package/src/model/batched-mask.ts +62 -1
  89. package/src/model/batched-quant.ts +206 -0
  90. package/src/model/batched-rotating-quant.ts +346 -0
  91. package/src/model/batched-rotating.ts +46 -1
  92. package/src/model/compiled-decode.ts +4 -15
  93. package/src/model/factory.ts +10 -4
  94. package/src/model/gemma4-base.ts +391 -34
  95. package/src/model/gemma4.ts +122 -33
  96. package/src/model/generated/gemma4-12b.ts +9 -13
  97. package/src/model/generated/gemma4-26b.ts +10 -5
  98. package/src/model/generated/gemma4-e4b.ts +14 -9
  99. package/src/model/minicpm5.ts +58 -102
  100. package/src/model/paged-kv.ts +253 -0
  101. package/src/model/qwen3-delta.ts +123 -15
  102. package/src/model/qwen3-moe.ts +404 -0
  103. package/src/model/qwen3.ts +9 -8
  104. package/src/model/qwen3_5.ts +99 -25
  105. package/src/model/support.ts +12 -1
  106. package/src/model/universal/dense.ts +40 -5
  107. package/src/model/universal/rope.ts +36 -21
  108. package/src/pi-web.ts +1299 -82
  109. package/src/preflight.ts +27 -8
  110. package/src/prompt-cache.ts +338 -32
  111. package/src/quantize/quantizer.ts +16 -1
  112. package/src/registry.ts +61 -3
  113. package/src/serve/batch-scheduler.ts +1001 -76
  114. package/src/serve/generation-gateway.ts +292 -30
  115. package/src/serve/isolate.ts +417 -0
  116. package/src/serve/lane-registry.ts +44 -0
  117. package/src/serve/session-search.ts +326 -0
  118. package/src/server.ts +1460 -105
  119. package/src/spec/assistant-source.ts +150 -0
  120. package/src/spec/deepspec-source.ts +143 -0
  121. package/src/spec/dflash-source.ts +124 -0
  122. package/src/spec/dspark/ab-stats.ts +136 -0
  123. package/src/spec/dspark/calibration.ts +112 -0
  124. package/src/spec/dspark/deepspec-module.ts +801 -0
  125. package/src/spec/dspark/generate-dflash.ts +72 -20
  126. package/src/spec/dspark/loader.ts +23 -0
  127. package/src/spec/dspark/module-dflash.ts +278 -36
  128. package/src/spec/dspark/quantize-drafter.ts +91 -0
  129. package/src/spec/generate.ts +59 -25
  130. package/src/spec/ngram-source.ts +132 -0
  131. package/src/spec/serve-loop.ts +536 -0
  132. package/src/spec/source.ts +93 -0
  133. package/src/spec/two-model.ts +171 -0
  134. package/src/ssd-cache.ts +285 -0
  135. package/src/tokenizer.ts +288 -2
  136. package/src/tool-approvals.ts +131 -0
  137. package/src/tool-call.ts +408 -20
  138. package/src/train/flash-cce.ts +29 -26
  139. package/src/train/job.ts +0 -2
  140. package/src/train/loss.ts +53 -11
  141. package/src/train/prefix-shared.ts +30 -7
  142. package/src/train/segmented.ts +150 -46
  143. package/src/train/trainer.ts +22 -15
  144. package/src/vision/prompt.ts +217 -48
  145. package/src/web/app.html +1184 -1789
  146. package/src/web/app.js +5667 -0
  147. package/src/web/icon.svg +13 -0
  148. package/src/web/manifest.webmanifest +24 -0
  149. package/src/web/src/adapters-panel.ts +288 -0
  150. package/src/web/src/api.ts +57 -0
  151. package/src/web/src/assistant.ts +485 -0
  152. package/src/web/src/chat.ts +1050 -0
  153. package/src/web/src/composer.ts +959 -0
  154. package/src/web/src/dataset.ts +132 -0
  155. package/src/web/src/finetune.ts +267 -0
  156. package/src/web/src/hub.ts +428 -0
  157. package/src/web/src/main.ts +98 -0
  158. package/src/web/src/markdown.ts +0 -0
  159. package/src/web/src/memory-panel.ts +572 -0
  160. package/src/web/src/model-picker.ts +159 -0
  161. package/src/web/src/palette.ts +346 -0
  162. package/src/web/src/protocol.ts +62 -0
  163. package/src/web/src/quantize.ts +167 -0
  164. package/src/web/src/rag.ts +274 -0
  165. package/src/web/src/sessions.ts +469 -0
  166. package/src/web/src/shell.ts +804 -0
  167. package/src/web/src/status.ts +252 -0
  168. package/src/web/src/ui-catalog.ts +92 -0
  169. package/src/web/sw.js +71 -0
  170. package/src/web/vendor/README.md +61 -0
  171. package/src/web/vendor/hljs-LICENSE +29 -0
  172. package/src/web/vendor/hljs-theme.css +59 -0
  173. package/src/web/vendor/hljs.js +26 -0
  174. package/src/model/fused-decode-kernel.ts +0 -312
  175. package/src/model/fused-geglu-kernel.ts +0 -198
  176. package/src/model/fused-mlp-kernel.ts +0 -169
  177. package/src/model/fused-swiglu-kernel.ts +0 -240
  178. package/src/model/steel-linear-kernel.ts +0 -96
  179. package/src/status-page.html +0 -497
package/src/lora.ts CHANGED
@@ -31,6 +31,12 @@ export interface AdapterInfo {
31
31
  /** Adapter tensors for modules outside the 7 target suffixes
32
32
  * (per-layer-input projections etc.) — skipped, like the reference. */
33
33
  skippedTensors: number;
34
+ /** Actual resident bytes of this adapter's mounted lora_a/lora_b arrays
35
+ * (sum of MlxArray.nbytes — the real RAM cost while mounted, not the
36
+ * on-disk safetensors size). Web chat's adapter routing table (plan
37
+ * §5.6/§9 Phase 2) shows this per loaded adapter instead of guessing
38
+ * from file size. */
39
+ ramBytes: number;
34
40
  }
35
41
 
36
42
  /** Read every tensor from an adapter safetensors file (small, f32) into
@@ -266,6 +272,7 @@ export class AdapterManager {
266
272
  linear.loraState = this.#model.loraState;
267
273
  }
268
274
 
275
+ const ramBytes = validated.reduce((sum, { lw }) => sum + lw.a.nbytes + lw.b.nbytes, 0);
269
276
  const info: AdapterInfo = {
270
277
  id,
271
278
  path,
@@ -274,6 +281,7 @@ export class AdapterManager {
274
281
  sizeBytes: statSync(weightsFile).size,
275
282
  mountedLayers: validated.length,
276
283
  skippedTensors: skipped,
284
+ ramBytes,
277
285
  };
278
286
  this.#mounted.set(id, info);
279
287
  this.#arrays.set(id, validated.flatMap(({ lw }) => [lw.a, lw.b]));
@@ -5,3 +5,45 @@ declare module "*.md" {
5
5
  const content: string;
6
6
  export default content;
7
7
  }
8
+
9
+ // Same deal for vendored text-imported assets under src/web/vendor/ (see
10
+ // that dir's README) — served as raw strings via `with { type: "text" }`,
11
+ // but bun-types has no ambient declaration for .js/.css read as text.
12
+ // Scoped to the vendor path (not a blanket "*.js"/"*.css") so ordinary JS/CSS
13
+ // module resolution elsewhere in the repo is untouched.
14
+ declare module "./web/vendor/*.js" {
15
+ const content: string;
16
+ export default content;
17
+ }
18
+ declare module "./web/vendor/*.css" {
19
+ const content: string;
20
+ export default content;
21
+ }
22
+
23
+ // The frontend bundle (plan §7/§9 Phase 2 module split): GENERATED from
24
+ // src/web/src/*.ts by scripts/build-web.ts into src/web/app.js, imported as
25
+ // raw text in src/server.ts the same way as the vendored assets above.
26
+ // Must stay a glob ("./web/*.js"), not the exact "./web/app.js" path — TS's
27
+ // ambient-module resolution only matches the real on-disk src/web/app.js
28
+ // against a wildcard pattern here, not an exact-path declaration (verified
29
+ // 2026-07: an exact-path declare module silently failed to match while the
30
+ // file existed, and "cannot find module" once it didn't — a TS quirk, not
31
+ // intentional design; keep the glob if this ever needs revisiting).
32
+ declare module "./web/*.js" {
33
+ const content: string;
34
+ export default content;
35
+ }
36
+
37
+ // PWA sources (plan §9 Phase 3): manifest.webmanifest + icon.svg,
38
+ // text-imported into src/server.ts the same way. Globs, not exact paths —
39
+ // the app.js comment above already found (2026-07) that TS's ambient
40
+ // module resolution only matches a real on-disk file against a wildcard
41
+ // pattern, not an exact-path declaration; same quirk applies here.
42
+ declare module "./web/*.webmanifest" {
43
+ const content: string;
44
+ export default content;
45
+ }
46
+ declare module "./web/*.svg" {
47
+ const content: string;
48
+ export default content;
49
+ }
@@ -0,0 +1,284 @@
1
+ // Remote media (image_url / audio_url) fetching with an SSRF/resource-
2
+ // exhaustion policy. A chat request's URL is attacker-controlled input to
3
+ // a server that may hold credentials for its LAN (the classic target:
4
+ // cloud metadata at 169.254.169.254, router admin pages, other local
5
+ // services), so by default the destination of every hop — the original
6
+ // URL and each redirect — must be a public http(s) host, the whole fetch
7
+ // is wall-clock bounded, and the response is size-capped.
8
+ //
9
+ // Escape hatch: `mlx-bun serve --allow-private-media` (or
10
+ // MLX_BUN_ALLOW_PRIVATE_MEDIA=1) re-permits private/loopback/link-local
11
+ // destinations — this is a local single-user server and pointing it at a
12
+ // NAS or another LAN box is legitimate. Scheme/timeout/size limits still
13
+ // apply.
14
+ //
15
+ // DNS names are resolved and every resolved address is checked before the
16
+ // fetch. The fetch itself re-resolves, so a DNS-rebinding attacker who
17
+ // controls TTLs can theoretically race the check — accepted for a local
18
+ // single-user server (fixing it needs pinned-address dialing, which
19
+ // bun's fetch doesn't expose).
20
+ //
21
+ // This module must stay free of mlx imports so tests/media-fetch.test.ts
22
+ // runs model-free.
23
+
24
+ import { lookup } from "node:dns/promises";
25
+
26
+ export interface MediaFetchPolicy {
27
+ /** Permit private/loopback/link-local destinations (LAN hosts). */
28
+ allowPrivate: boolean;
29
+ /** Wall-clock budget for the whole fetch — every redirect hop plus the
30
+ * body read share one AbortSignal.timeout. */
31
+ timeoutMs: number;
32
+ /** Response body cap in bytes (checked against Content-Length up front
33
+ * AND while streaming, since the header is optional/spoofable). */
34
+ maxBytes: number;
35
+ maxRedirects: number;
36
+ }
37
+
38
+ export function defaultMediaFetchPolicy(): MediaFetchPolicy {
39
+ return {
40
+ allowPrivate: process.env.MLX_BUN_ALLOW_PRIVATE_MEDIA === "1",
41
+ // 10 s / 64 MB: well above what a 30 s audio clip (~5 MB of 16 kHz
42
+ // WAV, less compressed) or any supported image needs.
43
+ timeoutMs: 10_000,
44
+ maxBytes: 64 * 1024 * 1024,
45
+ maxRedirects: 5,
46
+ };
47
+ }
48
+
49
+ const PRIVATE_HINT =
50
+ "private/loopback hosts are blocked by default; start the server with " +
51
+ "--allow-private-media (or MLX_BUN_ALLOW_PRIVATE_MEDIA=1) to fetch from LAN hosts";
52
+
53
+ function parseIpv4(s: string): number[] | null {
54
+ const parts = s.split(".");
55
+ if (parts.length !== 4) return null;
56
+ const out: number[] = [];
57
+ for (const p of parts) {
58
+ if (!/^\d{1,3}$/.test(p)) return null;
59
+ const n = Number(p);
60
+ if (n > 255) return null;
61
+ out.push(n);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function blockedIpv4(o: number[]): boolean {
67
+ const [a, b] = o as [number, number];
68
+ if (a === 0 || a === 10 || a === 127) return true; // this-net, 10/8, loopback
69
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10
70
+ if (a === 169 && b === 254) return true; // link-local (cloud metadata lives here)
71
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
72
+ if (a === 192 && b === 168) return true; // 192.168/16
73
+ if (a >= 224) return true; // multicast + reserved + broadcast
74
+ return false;
75
+ }
76
+
77
+ /** Expand an IPv6 literal (with `::` compression, an optional zone index,
78
+ * and an optional dotted-quad tail like ::ffff:127.0.0.1) into its 8
79
+ * 16-bit groups. null = not valid IPv6. */
80
+ function parseIpv6(addr: string): number[] | null {
81
+ let s = addr;
82
+ const pct = s.indexOf("%");
83
+ if (pct !== -1) s = s.slice(0, pct);
84
+ if (s.includes(".")) {
85
+ // rewrite the dotted-quad tail as two hex groups
86
+ const i = s.lastIndexOf(":");
87
+ const v4 = parseIpv4(s.slice(i + 1));
88
+ if (!v4) return null;
89
+ s =
90
+ s.slice(0, i + 1) +
91
+ (((v4[0]! << 8) | v4[1]!).toString(16)) + ":" +
92
+ (((v4[2]! << 8) | v4[3]!).toString(16));
93
+ }
94
+ const halves = s.split("::");
95
+ if (halves.length > 2) return null;
96
+ const toGroups = (part: string): number[] | null => {
97
+ if (part === "") return [];
98
+ const out: number[] = [];
99
+ for (const g of part.split(":")) {
100
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
101
+ out.push(parseInt(g, 16));
102
+ }
103
+ return out;
104
+ };
105
+ const head = toGroups(halves[0]!);
106
+ if (head === null) return null;
107
+ if (halves.length === 1) return head.length === 8 ? head : null;
108
+ const tail = toGroups(halves[1]!);
109
+ if (tail === null) return null;
110
+ if (head.length + tail.length > 7) return null;
111
+ return [...head, ...new Array(8 - head.length - tail.length).fill(0), ...tail];
112
+ }
113
+
114
+ function blockedIpv6(g: number[]): boolean {
115
+ if (g.every((x) => x === 0)) return true; // :: unspecified
116
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return true; // ::1
117
+ if ((g[0]! & 0xffc0) === 0xfe80) return true; // link-local fe80::/10
118
+ if ((g[0]! & 0xfe00) === 0xfc00) return true; // ULA fc00::/7
119
+ if ((g[0]! & 0xff00) === 0xff00) return true; // multicast ff00::/8
120
+ // v4-mapped (::ffff:0:0/96) and NAT64 (64:ff9b::/96) embed an IPv4
121
+ // address in the low 32 bits — judge the embedded address.
122
+ const v4Embedded =
123
+ (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) ||
124
+ (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0));
125
+ if (v4Embedded)
126
+ return blockedIpv4([g[6]! >> 8, g[6]! & 0xff, g[7]! >> 8, g[7]! & 0xff]);
127
+ return false;
128
+ }
129
+
130
+ /** True when `addr` is a private/loopback/link-local/reserved IP literal
131
+ * — or unparseable as an IP at all (fail closed; callers only pass
132
+ * things believed to be addresses). Accepts bracketed IPv6. */
133
+ export function isBlockedAddress(addr: string): boolean {
134
+ let s = addr;
135
+ if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
136
+ const v4 = parseIpv4(s);
137
+ if (v4) return blockedIpv4(v4);
138
+ const v6 = parseIpv6(s);
139
+ if (v6) return blockedIpv6(v6);
140
+ return true;
141
+ }
142
+
143
+ /** Sync per-hop destination check: scheme + hostname-literal policy.
144
+ * Returns a rejection reason or null. DNS names pass here and get their
145
+ * resolved addresses checked in fetchMediaBytes (async). Exported pure
146
+ * so the policy is testable without any network. */
147
+ export function checkMediaUrl(u: URL, policy: MediaFetchPolicy): string | null {
148
+ if (u.protocol !== "http:" && u.protocol !== "https:")
149
+ return `unsupported url scheme "${u.protocol.replace(/:$/, "")}" (only http/https)`;
150
+ const host =
151
+ u.hostname.startsWith("[") && u.hostname.endsWith("]")
152
+ ? u.hostname.slice(1, -1)
153
+ : u.hostname;
154
+ if (!host) return "missing host";
155
+ if (policy.allowPrivate) return null;
156
+ const lower = host.toLowerCase();
157
+ if (lower === "localhost" || lower.endsWith(".localhost"))
158
+ return `destination "${host}" — ${PRIVATE_HINT}`;
159
+ // WHATWG URL parsing already canonicalized numeric IPv4 forms
160
+ // (http://2130706433/, http://0x7f000001/ → 127.0.0.1).
161
+ if (parseIpv4(lower) || parseIpv6(lower)) {
162
+ if (isBlockedAddress(lower)) return `destination "${host}" — ${PRIVATE_HINT}`;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ async function assertResolvesPublic(
168
+ hostname: string,
169
+ kind: string,
170
+ policy: MediaFetchPolicy,
171
+ ): Promise<void> {
172
+ if (policy.allowPrivate) return;
173
+ const host =
174
+ hostname.startsWith("[") && hostname.endsWith("]")
175
+ ? hostname.slice(1, -1)
176
+ : hostname;
177
+ if (parseIpv4(host) || parseIpv6(host)) return; // literal — checked sync
178
+ let addrs: { address: string }[];
179
+ try {
180
+ addrs = await lookup(host, { all: true, verbatim: true });
181
+ } catch {
182
+ throw new Error(`${kind} fetch failed: could not resolve host "${host}"`);
183
+ }
184
+ for (const a of addrs) {
185
+ if (isBlockedAddress(a.address))
186
+ throw new Error(
187
+ `${kind} url rejected: host "${host}" resolves to ${a.address} — ${PRIVATE_HINT}`,
188
+ );
189
+ }
190
+ }
191
+
192
+ /** Fetch image/audio bytes for an OpenAI-style image_url/audio_url part.
193
+ * data: URLs decode locally (no policy — the bytes came in the request);
194
+ * http(s) URLs go through the destination policy above, with every
195
+ * redirect hop re-validated, one wall-clock timeout, and a streaming
196
+ * size cap. Throws Error with a client-presentable message — callers
197
+ * surface it as a 400 (the prompt-build catch in server.ts). */
198
+ export async function fetchMediaBytes(
199
+ url: string,
200
+ kind: "image" | "audio",
201
+ policy: MediaFetchPolicy = defaultMediaFetchPolicy(),
202
+ ): Promise<Uint8Array> {
203
+ if (url.startsWith("data:")) {
204
+ const comma = url.indexOf(",");
205
+ if (comma === -1) throw new Error("malformed data: URL");
206
+ const meta = url.slice(0, comma);
207
+ const body = url.slice(comma + 1);
208
+ if (!meta.includes("base64")) throw new Error("data: URL must be base64");
209
+ return Uint8Array.from(Buffer.from(body, "base64"));
210
+ }
211
+ let current: URL;
212
+ try {
213
+ current = new URL(url);
214
+ } catch {
215
+ throw new Error(`unsupported ${kind} url scheme: ${url.slice(0, 16)}`);
216
+ }
217
+ const capMb = Math.round(policy.maxBytes / (1024 * 1024));
218
+ const signal = AbortSignal.timeout(policy.timeoutMs);
219
+ let overCap = false;
220
+ try {
221
+ for (let hop = 0; ; hop++) {
222
+ const reason = checkMediaUrl(current, policy);
223
+ if (reason) throw new Error(`${kind} url rejected: ${reason}`);
224
+ await assertResolvesPublic(current.hostname, kind, policy);
225
+ const res = await fetch(current, { redirect: "manual", signal });
226
+ if (res.status >= 300 && res.status < 400) {
227
+ const loc = res.headers.get("location");
228
+ if (!loc)
229
+ throw new Error(`${kind} fetch failed: ${res.status} redirect without location`);
230
+ if (hop >= policy.maxRedirects)
231
+ throw new Error(`${kind} fetch failed: more than ${policy.maxRedirects} redirects`);
232
+ try {
233
+ await res.body?.cancel();
234
+ } catch {}
235
+ try {
236
+ current = new URL(loc, current);
237
+ } catch {
238
+ throw new Error(`${kind} fetch failed: malformed redirect location`);
239
+ }
240
+ continue;
241
+ }
242
+ if (!res.ok) throw new Error(`${kind} fetch failed: ${res.status} ${current}`);
243
+ const declared = Number(res.headers.get("content-length") ?? "0");
244
+ if (declared > policy.maxBytes) {
245
+ overCap = true;
246
+ try {
247
+ await res.body?.cancel();
248
+ } catch {}
249
+ throw new Error(`${kind} response exceeds the ${capMb} MB limit`);
250
+ }
251
+ if (!res.body) return new Uint8Array(await res.arrayBuffer());
252
+ // stream + count: Content-Length is optional (chunked) and spoofable
253
+ const reader = res.body.getReader();
254
+ const chunks: Uint8Array[] = [];
255
+ let total = 0;
256
+ for (;;) {
257
+ const { done, value } = await reader.read();
258
+ if (done) break;
259
+ total += value.byteLength;
260
+ if (total > policy.maxBytes) {
261
+ overCap = true;
262
+ try {
263
+ await reader.cancel();
264
+ } catch {}
265
+ throw new Error(`${kind} response exceeds the ${capMb} MB limit`);
266
+ }
267
+ chunks.push(value);
268
+ }
269
+ const out = new Uint8Array(total);
270
+ let off = 0;
271
+ for (const c of chunks) {
272
+ out.set(c, off);
273
+ off += c.byteLength;
274
+ }
275
+ return out;
276
+ }
277
+ } catch (e) {
278
+ if (signal.aborted && !overCap)
279
+ throw new Error(
280
+ `${kind} fetch timed out after ${policy.timeoutMs} ms: ${url.slice(0, 200)}`,
281
+ );
282
+ throw e;
283
+ }
284
+ }
@@ -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
+ }