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.
- 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 +255 -0
- 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 +98 -0
- 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 +18 -2
- package/docs/design/ssd-kv-cold-tier.md +288 -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/decode-roofline-lookagain.md +92 -6
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
- 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.10.md +57 -0
- 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 +251 -119
- package/docs/reference/training.md +20 -16
- 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 +402 -107
- 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 -16
- package/src/grammar.ts +585 -0
- package/src/hub-rest.ts +242 -0
- package/src/kv-store.ts +618 -122
- 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 +127 -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 +179 -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 +391 -34
- 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 +123 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +99 -25
- 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 +338 -32
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +61 -3
- package/src/serve/batch-scheduler.ts +1001 -76
- package/src/serve/generation-gateway.ts +292 -30
- 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 +1460 -105
- 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 +285 -0
- 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 +29 -26
- package/src/train/job.ts +0 -2
- package/src/train/loss.ts +53 -11
- package/src/train/prefix-shared.ts +30 -7
- package/src/train/segmented.ts +150 -46
- package/src/train/trainer.ts +22 -15
- 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
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Full-text search across web-chat session message BODIES (plan §9 Phase 3,
|
|
2
|
+
// beat-matrix Axis 10/11 "the full-text BEAT": Claude's own session search is
|
|
3
|
+
// title-only — a widely-cited annoyance — and local storage removes any
|
|
4
|
+
// server-cost excuse to skip real body search).
|
|
5
|
+
//
|
|
6
|
+
// Reads the SAME session directory pi-web.ts's PiWebSession uses
|
|
7
|
+
// (`~/.mlx-bun/sessions`, pi's own JSONL-per-session format —
|
|
8
|
+
// `join(homedir(), ".mlx-bun", "sessions")`, see pi-web.ts's `sessionDir`
|
|
9
|
+
// field and SessionManager.list's own file resolution in
|
|
10
|
+
// @earendil-works/pi-coding-agent). Deliberately NOT a SessionManager import:
|
|
11
|
+
// SessionManager.list() builds the full session tree (labels, branches,
|
|
12
|
+
// compaction) for the chat runtime; this scanner only needs a flat
|
|
13
|
+
// "role + text" reading of each line for substring matching, so a small,
|
|
14
|
+
// independent, read-only line parser is both simpler and can never
|
|
15
|
+
// accidentally mutate/lock a live session file.
|
|
16
|
+
//
|
|
17
|
+
// v1 is a linear, case-insensitive substring scan over every session file on
|
|
18
|
+
// every request — no index. That's the right trade at personal-chat-corpus
|
|
19
|
+
// scale (a few hundred to a few thousand sessions, read once per keystroke's
|
|
20
|
+
// debounced request); if the corpus ever grows enough to matter, the upgrade
|
|
21
|
+
// path is a background-built inverted index (token -> {sessionPath, lineNo})
|
|
22
|
+
// refreshed on file mtime change, not a rewrite of this module's contract.
|
|
23
|
+
//
|
|
24
|
+
// JSONL-tolerant: a corrupt or partially-written line (the active session's
|
|
25
|
+
// file can be mid-append) is skipped, never thrown — matching
|
|
26
|
+
// SessionManager's own `parseSessionEntryLine` tolerance.
|
|
27
|
+
|
|
28
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { join, resolve } from "node:path";
|
|
31
|
+
|
|
32
|
+
/** Same default session directory pi-web.ts's PiWebSession uses. Exported so
|
|
33
|
+
* server.ts/tests can override it (tests always pass an explicit dir —
|
|
34
|
+
* never touch the real `~/.mlx-bun/sessions`). */
|
|
35
|
+
export function defaultSessionDir(): string {
|
|
36
|
+
return join(homedir(), ".mlx-bun", "sessions");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SearchMatch {
|
|
40
|
+
/** ±60-char plain-text window around the match, NOT html-escaped and NOT
|
|
41
|
+
* html-highlighted — matches are reported as plain-text [start,end)
|
|
42
|
+
* ranges into `snippet` so the caller (server-api consumer) chooses how
|
|
43
|
+
* to render highlighting (the frontend escapes then re-inserts a <mark>,
|
|
44
|
+
* matching the rest of this codebase's escape-then-restore convention —
|
|
45
|
+
* see markdown.ts's code-span handling for the same pattern applied to a
|
|
46
|
+
* different problem). */
|
|
47
|
+
snippet: string;
|
|
48
|
+
/** [start,end) offsets of the matched substring within `snippet`, one pair
|
|
49
|
+
* per occurrence actually contained in this snippet window (usually one —
|
|
50
|
+
* more only if two hits land within 60 chars of each other). */
|
|
51
|
+
ranges: Array<[number, number]>;
|
|
52
|
+
role: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SessionSearchResult {
|
|
56
|
+
sessionPath: string;
|
|
57
|
+
sessionTitle: string;
|
|
58
|
+
matches: SearchMatch[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ParsedLine {
|
|
62
|
+
type?: string;
|
|
63
|
+
message?: { role?: string; content?: unknown; timestamp?: number };
|
|
64
|
+
name?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Best-effort JSON.parse of one JSONL line; never throws. Mirrors
|
|
68
|
+
* pi-coding-agent's own parseSessionEntryLine tolerance (skip malformed
|
|
69
|
+
* lines rather than fail the whole file). */
|
|
70
|
+
function parseLine(line: string): ParsedLine | null {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (!trimmed) return null;
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(trimmed) as ParsedLine;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Flatten a message's content (string | content-block array) to plain text,
|
|
81
|
+
* same shape pi-coding-agent's own extractTextContent handles. */
|
|
82
|
+
function contentText(content: unknown): string {
|
|
83
|
+
if (typeof content === "string") return content;
|
|
84
|
+
if (Array.isArray(content)) {
|
|
85
|
+
return content
|
|
86
|
+
.filter((p): p is { type: string; text: string } =>
|
|
87
|
+
!!p && (p as { type?: string }).type === "text" && typeof (p as { text?: unknown }).text === "string")
|
|
88
|
+
.map((p) => p.text)
|
|
89
|
+
.join(" ");
|
|
90
|
+
}
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const MAX_MATCHES_PER_SESSION = 10;
|
|
95
|
+
const MAX_TOTAL_MATCHES = 50;
|
|
96
|
+
const SNIPPET_RADIUS = 60;
|
|
97
|
+
|
|
98
|
+
/** Build one snippet window (±SNIPPET_RADIUS chars) around a single match
|
|
99
|
+
* index in `text`, plus the match's own [start,end) offsets translated into
|
|
100
|
+
* the snippet's local coordinate space. Pure — independently testable. */
|
|
101
|
+
export function buildSnippet(text: string, matchStart: number, matchLen: number): { snippet: string; range: [number, number] } {
|
|
102
|
+
const start = Math.max(0, matchStart - SNIPPET_RADIUS);
|
|
103
|
+
const end = Math.min(text.length, matchStart + matchLen + SNIPPET_RADIUS);
|
|
104
|
+
const prefix = start > 0 ? "…" : "";
|
|
105
|
+
const suffix = end < text.length ? "…" : "";
|
|
106
|
+
const snippet = prefix + text.slice(start, end) + suffix;
|
|
107
|
+
const localStart = matchStart - start + prefix.length;
|
|
108
|
+
return { snippet, range: [localStart, localStart + matchLen] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Build a case-folded copy of `text` PLUS a parallel index that maps every
|
|
112
|
+
* position in the folded string back to the position it came from in the
|
|
113
|
+
* original — needed because `String.prototype.toLowerCase()` is not
|
|
114
|
+
* length-preserving for every character (e.g. Turkish dotted capital I,
|
|
115
|
+
* U+0130, folds to a 2-code-unit "i̇": `"İ".length === 1` but
|
|
116
|
+
* `"İ".toLowerCase().length === 2`). Folding the WHOLE string at once (the
|
|
117
|
+
* original approach) desyncs any match offset that falls after such a
|
|
118
|
+
* character: `text.toLowerCase().indexOf(...)` returns an index into the
|
|
119
|
+
* now-longer folded string, but callers then slice the shorter original
|
|
120
|
+
* `text` at that same numeric offset. Folding one character at a time and
|
|
121
|
+
* recording, for each folded code unit, which original index it expanded
|
|
122
|
+
* from keeps the scan itself simple (still a plain indexOf loop) while
|
|
123
|
+
* making offset translation exact for every fold, not just the common
|
|
124
|
+
* 1:1 case. */
|
|
125
|
+
function foldWithIndex(text: string): { folded: string; index: number[] } {
|
|
126
|
+
let folded = "";
|
|
127
|
+
const index: number[] = [];
|
|
128
|
+
for (let i = 0; i < text.length; i++) {
|
|
129
|
+
const piece = text[i]!.toLowerCase();
|
|
130
|
+
for (let j = 0; j < piece.length; j++) index.push(i);
|
|
131
|
+
folded += piece;
|
|
132
|
+
}
|
|
133
|
+
return { folded, index };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Find every occurrence of `qLower` (already lowercased) in `textLower`,
|
|
137
|
+
* returning [start, len] pairs translated back into the ORIGINAL
|
|
138
|
+
* (pre-fold) string's coordinate space via `index` (see foldWithIndex) —
|
|
139
|
+
* callers slice the original `text`, not `textLower`, so both the start
|
|
140
|
+
* offset AND the matched span's length must live in that space. `len` is
|
|
141
|
+
* computed from the first and last folded code unit's original index
|
|
142
|
+
* (not `qLower.length`) so a match whose folded span itself contains a
|
|
143
|
+
* length-expanding character (e.g. the query matches text spanning a
|
|
144
|
+
* Turkish dotted İ) still yields the exact original substring, not a
|
|
145
|
+
* same-length-as-query slice that's now off by the expansion. Pure
|
|
146
|
+
* substring scan — no regex (query is user-controlled; escaping for
|
|
147
|
+
* RegExp is an unforced footgun v1 doesn't need since substring search is
|
|
148
|
+
* the stated v1 bar). */
|
|
149
|
+
function findOccurrences(textLower: string, qLower: string, index: readonly number[]): Array<[number, number]> {
|
|
150
|
+
if (!qLower) return [];
|
|
151
|
+
const offsets: Array<[number, number]> = [];
|
|
152
|
+
let i = 0;
|
|
153
|
+
for (;;) {
|
|
154
|
+
const idx = textLower.indexOf(qLower, i);
|
|
155
|
+
if (idx === -1) break;
|
|
156
|
+
const start = index[idx] ?? idx;
|
|
157
|
+
const lastFoldedIdx = idx + qLower.length - 1;
|
|
158
|
+
const end = index[lastFoldedIdx] ?? lastFoldedIdx;
|
|
159
|
+
offsets.push([start, end - start + 1]);
|
|
160
|
+
i = idx + qLower.length;
|
|
161
|
+
}
|
|
162
|
+
return offsets;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Scan one session file's raw JSONL content for `query` (case-insensitive
|
|
166
|
+
* substring), returning up to MAX_MATCHES_PER_SESSION per session. Pure
|
|
167
|
+
* given the file content string — split out from the disk read so it's
|
|
168
|
+
* unit-testable without touching the filesystem. */
|
|
169
|
+
export function searchSessionContent(content: string, query: string): SessionSearchResult | null {
|
|
170
|
+
const qLower = query.toLowerCase();
|
|
171
|
+
if (!qLower) return null;
|
|
172
|
+
|
|
173
|
+
const lines = content.split("\n");
|
|
174
|
+
let sessionPath = "";
|
|
175
|
+
let title = "";
|
|
176
|
+
let firstUserMessage = "";
|
|
177
|
+
const matches: SearchMatch[] = [];
|
|
178
|
+
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
const entry = parseLine(line);
|
|
181
|
+
if (!entry) continue;
|
|
182
|
+
if (entry.type === "session_info" && typeof entry.name === "string" && entry.name.trim()) {
|
|
183
|
+
title = entry.name.trim();
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (entry.type !== "message" || !entry.message) continue;
|
|
187
|
+
const role = entry.message.role;
|
|
188
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
189
|
+
const text = contentText(entry.message.content);
|
|
190
|
+
if (!text) continue;
|
|
191
|
+
if (!firstUserMessage && role === "user") firstUserMessage = text;
|
|
192
|
+
|
|
193
|
+
if (matches.length >= MAX_MATCHES_PER_SESSION) continue;
|
|
194
|
+
// Fold with an index (not a plain text.toLowerCase()) so match offsets
|
|
195
|
+
// stay valid against the ORIGINAL `text` that buildSnippet slices below
|
|
196
|
+
// — a length-expanding fold (e.g. Turkish dotted İ -> 2-code-unit "i̇")
|
|
197
|
+
// would otherwise desync a whole-string lowercase's offsets from the
|
|
198
|
+
// original string's coordinates for any match after it (see
|
|
199
|
+
// foldWithIndex's comment).
|
|
200
|
+
const { folded: textLower, index } = foldWithIndex(text);
|
|
201
|
+
const offsets = findOccurrences(textLower, qLower, index);
|
|
202
|
+
for (const [off, len] of offsets) {
|
|
203
|
+
if (matches.length >= MAX_MATCHES_PER_SESSION) break;
|
|
204
|
+
const { snippet, range } = buildSnippet(text, off, len);
|
|
205
|
+
matches.push({ snippet, ranges: [range], role });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (matches.length === 0) return null;
|
|
210
|
+
return {
|
|
211
|
+
sessionPath,
|
|
212
|
+
sessionTitle: (title || firstUserMessage || "New chat").slice(0, 80),
|
|
213
|
+
matches,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Scan every `.jsonl` file under `sessionDir` for `query`. Read-only,
|
|
218
|
+
* JSONL-tolerant (a corrupt/mid-write file contributes whatever valid lines
|
|
219
|
+
* it has rather than aborting the whole scan), capped at
|
|
220
|
+
* MAX_TOTAL_MATCHES results across all sessions. Sessions are visited in
|
|
221
|
+
* directory-listing order (pi names files `<timestamp>_<id>.jsonl`, so this
|
|
222
|
+
* is naturally oldest-first — good enough for v1; result ordering isn't a
|
|
223
|
+
* relevance rank, just "found in these sessions"). */
|
|
224
|
+
export async function searchSessions(sessionDir: string, query: string): Promise<SessionSearchResult[]> {
|
|
225
|
+
const trimmed = query.trim();
|
|
226
|
+
if (!trimmed) return [];
|
|
227
|
+
|
|
228
|
+
let files: string[];
|
|
229
|
+
try {
|
|
230
|
+
files = (await readdir(sessionDir)).filter((f) => f.endsWith(".jsonl"));
|
|
231
|
+
} catch {
|
|
232
|
+
return []; // no session dir yet — never throw, just no results
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const results: SessionSearchResult[] = [];
|
|
236
|
+
let total = 0;
|
|
237
|
+
for (const file of files) {
|
|
238
|
+
if (total >= MAX_TOTAL_MATCHES) break;
|
|
239
|
+
const path = join(sessionDir, file);
|
|
240
|
+
let content: string;
|
|
241
|
+
try {
|
|
242
|
+
content = await readFile(path, "utf8");
|
|
243
|
+
} catch {
|
|
244
|
+
continue; // deleted mid-scan, permissions, etc. — skip, don't throw
|
|
245
|
+
}
|
|
246
|
+
const result = searchSessionContent(content, trimmed);
|
|
247
|
+
if (!result) continue;
|
|
248
|
+
result.sessionPath = path;
|
|
249
|
+
const remaining = MAX_TOTAL_MATCHES - total;
|
|
250
|
+
if (result.matches.length > remaining) result.matches = result.matches.slice(0, remaining);
|
|
251
|
+
total += result.matches.length;
|
|
252
|
+
results.push(result);
|
|
253
|
+
}
|
|
254
|
+
return results;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
258
|
+
REST handlers — pure (URL) => Response, no `ctx` dependency, same
|
|
259
|
+
convention as src/memory/rest.ts / src/hub-rest.ts (dispatched from
|
|
260
|
+
server.ts by path+method; unit-tested directly in
|
|
261
|
+
tests/session-search.test.ts without booting a model context).
|
|
262
|
+
──────────────────────────────────────────────────────────────────── */
|
|
263
|
+
|
|
264
|
+
function jsonOk<T extends object>(body: T, init?: ResponseInit): Response {
|
|
265
|
+
return Response.json({ ok: true, ...body }, init);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function jsonErr(error: string, status = 400): Response {
|
|
269
|
+
return Response.json({ ok: false, error }, { status });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** True if `path` resolves under `sessionDir` — same guard shape as
|
|
273
|
+
* pi-web.ts's private `isUnderSessionDir` (root itself or a path prefixed
|
|
274
|
+
* by `root + "/"`), reimplemented here (not imported) since pi-web.ts's
|
|
275
|
+
* copy is a private method on PiWebSession, not a standalone export, and
|
|
276
|
+
* duplicating this one three-line check is cheaper than changing that
|
|
277
|
+
* class's visibility for two read-only REST routes. */
|
|
278
|
+
export function isUnderSessionDir(path: string, sessionDir: string): boolean {
|
|
279
|
+
const root = resolve(sessionDir);
|
|
280
|
+
const p = resolve(path);
|
|
281
|
+
return p === root || p.startsWith(root + "/");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---- GET /api/sessions/search?q= ----------------------------------------
|
|
285
|
+
|
|
286
|
+
/** `sessionDirOverride` lets tests point at a tmp fixture dir instead of the
|
|
287
|
+
* real `~/.mlx-bun/sessions` (never touched by tests). */
|
|
288
|
+
export async function handleSessionsSearch(url: URL, sessionDirOverride?: string): Promise<Response> {
|
|
289
|
+
const q = (url.searchParams.get("q") ?? "").trim();
|
|
290
|
+
if (!q) return jsonErr("q is required");
|
|
291
|
+
const dir = sessionDirOverride ?? defaultSessionDir();
|
|
292
|
+
const results = await searchSessions(dir, q);
|
|
293
|
+
return jsonOk({ results });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---- GET /api/sessions/export?path= --------------------------------------
|
|
297
|
+
|
|
298
|
+
/** Returns the raw session JSONL, parsed into an array of entries (one per
|
|
299
|
+
* line) — the frontend's non-open-session export path reads this to
|
|
300
|
+
* render Markdown/JSON client-side without a second, divergent parser.
|
|
301
|
+
* `path` is validated under sessionDir exactly like pi-web's
|
|
302
|
+
* isUnderSessionDir — a path outside the session directory 400s before
|
|
303
|
+
* touching the filesystem. */
|
|
304
|
+
export async function handleSessionsExport(url: URL, sessionDirOverride?: string): Promise<Response> {
|
|
305
|
+
const path = (url.searchParams.get("path") ?? "").trim();
|
|
306
|
+
if (!path) return jsonErr("path is required");
|
|
307
|
+
const dir = sessionDirOverride ?? defaultSessionDir();
|
|
308
|
+
if (!isUnderSessionDir(path, dir)) return jsonErr("path must be under the session directory", 403);
|
|
309
|
+
let content: string;
|
|
310
|
+
try {
|
|
311
|
+
content = await readFile(path, "utf8");
|
|
312
|
+
} catch {
|
|
313
|
+
return jsonErr("session not found", 404);
|
|
314
|
+
}
|
|
315
|
+
const entries: unknown[] = [];
|
|
316
|
+
for (const line of content.split("\n")) {
|
|
317
|
+
const trimmed = line.trim();
|
|
318
|
+
if (!trimmed) continue;
|
|
319
|
+
try {
|
|
320
|
+
entries.push(JSON.parse(trimmed));
|
|
321
|
+
} catch {
|
|
322
|
+
// skip malformed lines — same JSONL tolerance as the search scanner
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return jsonOk({ path, entries });
|
|
326
|
+
}
|