mlx-bun 0.0.10 → 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.
- package/README.md +66 -21
- package/THIRD_PARTY_LICENSES.md +1 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +58 -35
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +14 -6
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +3 -2
- package/docs/design/ssd-kv-cold-tier.md +130 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/reference/cli.md +35 -16
- package/docs/reference/distribution.md +13 -6
- package/docs/reference/embedding.md +35 -14
- package/docs/reference/features-matrix.md +97 -0
- package/docs/reference/library-api.md +30 -9
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +11 -1
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +634 -22
- package/docs/reference/server-config.md +250 -121
- package/docs/reference/training.md +14 -15
- package/package.json +5 -3
- package/src/anthropic.ts +13 -0
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/cli.ts +356 -105
- package/src/config.ts +49 -2
- package/src/download.ts +40 -0
- package/src/eval/README.md +7 -6
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +3 -3
- package/src/expert-offload.ts +15 -2
- package/src/flags.ts +13 -0
- package/src/generate.ts +330 -24
- package/src/grammar.ts +585 -0
- package/src/hub-rest.ts +242 -0
- package/src/kv-store.ts +442 -88
- package/src/lora.ts +8 -0
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +284 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +106 -19
- package/src/mlx/autograd.ts +2 -2
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +153 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +10 -4
- package/src/model/gemma4-base.ts +363 -39
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +76 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +88 -23
- package/src/model/support.ts +12 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +293 -33
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +57 -2
- package/src/serve/batch-scheduler.ts +965 -72
- package/src/serve/generation-gateway.ts +214 -11
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1380 -135
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +536 -0
- package/src/spec/source.ts +93 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +107 -35
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +408 -20
- package/src/train/flash-cce.ts +2 -2
- package/src/train/job.ts +0 -2
- package/src/train/trainer.ts +1 -4
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1184 -1789
- package/src/web/app.js +5667 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +252 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- 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]));
|
package/src/md-modules.d.ts
CHANGED
|
@@ -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
|
+
}
|