mlx-bun 0.0.4
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/LICENSE +21 -0
- package/README.md +362 -0
- package/THIRD_PARTY_LICENSES.md +41 -0
- package/bin/mlx-bun.mjs +32 -0
- package/docs/curve-designer.html +303 -0
- package/docs/design/adapters-end-to-end.md +201 -0
- package/docs/design/compat-cli-surface-design.md +227 -0
- package/docs/design/docs-reorg-plan.md +152 -0
- package/docs/design/hlg-sampling.md +340 -0
- package/docs/design/memory-system.md +339 -0
- package/docs/design/optimization_plan.md +344 -0
- package/docs/design/parallel-slots.md +515 -0
- package/docs/design/runtime-isolation.md +247 -0
- package/docs/design/segmented-backward-training.md +508 -0
- package/docs/investigations/chunk-finetune-experiment.md +84 -0
- package/docs/investigations/curve-bisector-routing.md +123 -0
- package/docs/investigations/curve-runs/bisector-route.json +98 -0
- package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
- package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
- package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
- package/docs/investigations/curve-terrain.html +105 -0
- package/docs/investigations/expert-offload-single-user-moe.md +292 -0
- package/docs/investigations/hlg-ab.html +34 -0
- package/docs/investigations/hlg-explorer.html +238 -0
- package/docs/investigations/hlg-figs/cliff.svg +1 -0
- package/docs/investigations/hlg-figs/cross.svg +1 -0
- package/docs/investigations/hlg-figs/edges.svg +1 -0
- package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
- package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
- package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
- package/docs/investigations/hlg-figs/terrain.svg +1 -0
- package/docs/investigations/hlg-report.html +262 -0
- package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
- package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
- package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
- package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
- package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
- package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
- package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
- package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
- package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
- package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
- package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
- package/docs/investigations/hlg-runs/hlg-map.json +383 -0
- package/docs/investigations/hlg-runs/run0-original.log +159 -0
- package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
- package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
- package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
- package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
- package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
- package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
- package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
- package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
- package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
- package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
- package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
- package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
- package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
- package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
- package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
- package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
- package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
- package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
- package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
- package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
- package/docs/investigations/hlg-runs/runN-verify.log +116 -0
- package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
- package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
- package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
- package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
- package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
- package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
- package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
- package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
- package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
- package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
- package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
- package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
- package/docs/investigations/hlg-sampling-investigation.md +970 -0
- package/docs/investigations/lab-build-journal.md +157 -0
- package/docs/investigations/pi-builtin-investigation.md +308 -0
- package/docs/investigations/starter-model-port-handoff.md +323 -0
- package/docs/planning/IDEAS.md +26 -0
- package/docs/planning/PRODUCT_ROADMAP.md +426 -0
- package/docs/planning/ResearchTopics.md +119 -0
- package/docs/planning/chunk-task-roadmap.md +77 -0
- package/docs/planning/journal.md +155 -0
- package/docs/reference/distribution.md +121 -0
- package/docs/reference/embedding.md +168 -0
- package/docs/reference/library-api.md +153 -0
- package/docs/reference/server-api.md +224 -0
- package/docs/reference/server-config.md +243 -0
- package/docs/segmented-backward-handoff.md +148 -0
- package/package.json +68 -0
- package/src/anthropic.ts +491 -0
- package/src/chat-template.ts +186 -0
- package/src/cli.ts +1075 -0
- package/src/config.ts +229 -0
- package/src/curve-sampler.ts +122 -0
- package/src/dataset/generators.ts +1087 -0
- package/src/dataset/index.ts +12 -0
- package/src/dataset/job.ts +81 -0
- package/src/dataset/llm.ts +71 -0
- package/src/dataset/registry.ts +528 -0
- package/src/download.ts +283 -0
- package/src/eval/README.md +86 -0
- package/src/eval/kl-prompts.ts +29 -0
- package/src/eval/kl.ts +294 -0
- package/src/eval/quality-db.ts +92 -0
- package/src/eval/runner.ts +102 -0
- package/src/eval/score.ts +49 -0
- package/src/eval/tasks/bfcl.ts +500 -0
- package/src/eval/tasks/gsm8k.ts +85 -0
- package/src/eval/tasks/hashhop.ts +109 -0
- package/src/eval/tasks/humaneval.ts +252 -0
- package/src/eval/tasks/ifeval.ts +354 -0
- package/src/eval/tasks/mmlu.ts +145 -0
- package/src/evaldb.ts +199 -0
- package/src/expert-offload-build.ts +77 -0
- package/src/expert-offload.ts +61 -0
- package/src/expert-trace.ts +67 -0
- package/src/fit.ts +254 -0
- package/src/generate.ts +451 -0
- package/src/harness-pi.ts +177 -0
- package/src/hf-push.ts +628 -0
- package/src/index.ts +22 -0
- package/src/jobs/db.ts +139 -0
- package/src/jobs/index.ts +29 -0
- package/src/jobs/job-entry.ts +106 -0
- package/src/jobs/runner.ts +304 -0
- package/src/jobs/sse.ts +114 -0
- package/src/jobs/types.ts +58 -0
- package/src/kv-store.ts +185 -0
- package/src/lora.ts +314 -0
- package/src/md-modules.d.ts +7 -0
- package/src/mlx/array.ts +237 -0
- package/src/mlx/autograd.ts +267 -0
- package/src/mlx/checkpoint.ts +116 -0
- package/src/mlx/compile.ts +158 -0
- package/src/mlx/custom-vjp.ts +140 -0
- package/src/mlx/ffi.ts +433 -0
- package/src/mlx/metal-kernel.ts +130 -0
- package/src/mlx/ops.ts +707 -0
- package/src/mmap.ts +90 -0
- package/src/model/batched-mask.ts +204 -0
- package/src/model/batched-rotating.ts +300 -0
- package/src/model/compiled-decode.ts +681 -0
- package/src/model/factory.ts +27 -0
- package/src/model/fingerprint.ts +33 -0
- package/src/model/flash-attention.ts +674 -0
- package/src/model/fused-decode-kernel.ts +305 -0
- package/src/model/fused-geglu-kernel.ts +87 -0
- package/src/model/gemma4-base.ts +1616 -0
- package/src/model/gemma4.ts +1012 -0
- package/src/model/generated/gemma4-12b.ts +526 -0
- package/src/model/generated/gemma4-26b.ts +500 -0
- package/src/model/generated/gemma4-e4b.ts +757 -0
- package/src/model/generated/index.ts +18 -0
- package/src/model/minicpm5.ts +278 -0
- package/src/model/qwen3-delta.ts +224 -0
- package/src/model/qwen3_5.ts +429 -0
- package/src/model/support.ts +46 -0
- package/src/native-pack.ts +152 -0
- package/src/pi-provider.ts +95 -0
- package/src/pi-terminal.ts +264 -0
- package/src/pi-web.ts +924 -0
- package/src/preflight.ts +123 -0
- package/src/prompt-cache.ts +113 -0
- package/src/quantize/allocator.ts +627 -0
- package/src/quantize/calibration.ts +180 -0
- package/src/quantize/config-writer.ts +132 -0
- package/src/quantize/index.ts +57 -0
- package/src/quantize/job.ts +136 -0
- package/src/quantize/quantizer.ts +462 -0
- package/src/quantize/safetensors-writer.ts +171 -0
- package/src/quantize/sensitivity.ts +234 -0
- package/src/registry.ts +336 -0
- package/src/responses.ts +774 -0
- package/src/safetensors.ts +158 -0
- package/src/sampler.ts +517 -0
- package/src/serve/batch-scheduler.ts +362 -0
- package/src/serve/generation-gateway.ts +160 -0
- package/src/server.ts +1891 -0
- package/src/spec/drafter.ts +261 -0
- package/src/spec/generate.ts +221 -0
- package/src/status-page.html +497 -0
- package/src/tokenizer.ts +38 -0
- package/src/tool-call.ts +242 -0
- package/src/train/dataset.ts +461 -0
- package/src/train/export.ts +32 -0
- package/src/train/forward.ts +229 -0
- package/src/train/index.ts +43 -0
- package/src/train/job.ts +148 -0
- package/src/train/lora-params.ts +194 -0
- package/src/train/loss.ts +342 -0
- package/src/train/merge.ts +195 -0
- package/src/train/optimizer.ts +176 -0
- package/src/train/rank.ts +101 -0
- package/src/train/segmented.ts +520 -0
- package/src/train/trainer.ts +600 -0
- package/src/tui.ts +161 -0
- package/src/vision/embedder.ts +168 -0
- package/src/vision/preprocess.ts +247 -0
- package/src/vision/prompt.ts +158 -0
- package/src/vision/siglip.ts +599 -0
- package/src/web/app.html +2471 -0
- package/src/web/skills/web-research/SKILL.md +44 -0
- package/src/web/skills.ts +59 -0
- package/src/web-tools.ts +426 -0
- package/src/weights.ts +91 -0
package/src/pi-web.ts
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
// mlx-bun pi-web — server-side embed of the pi agent SDK that bridges a
|
|
2
|
+
// browser WebSocket to an in-process AgentSession driving mlx-bun's own
|
|
3
|
+
// local model (Phase 16, web half).
|
|
4
|
+
//
|
|
5
|
+
// Each browser WS gets its own PiWebSession: an in-memory pi AgentSession
|
|
6
|
+
// configured with the full coding toolset, pointed at our loopback /v1 so
|
|
7
|
+
// model calls serialize through the server's generation queue. Session
|
|
8
|
+
// events are translated to the WS protocol below; tool calls that mutate
|
|
9
|
+
// (bash/edit/write) are gated through the browser via a pre-execution
|
|
10
|
+
// approval handshake.
|
|
11
|
+
//
|
|
12
|
+
// PROVIDER WIRING mirrors src/harness-pi.ts exactly (the subprocess path):
|
|
13
|
+
// provider id "mlx-bun", apiKey "sk-mlx-bun-local", model id "local",
|
|
14
|
+
// api "openai-completions", baseUrl "http://127.0.0.1:<port>/v1". The
|
|
15
|
+
// context window comes from the server (ctx.model.config.text
|
|
16
|
+
// .maxPositionEmbeddings, surfaced via /v1/models -> context_window).
|
|
17
|
+
//
|
|
18
|
+
// APPROVAL GATE: implemented for real (NOT the documented fallback). The
|
|
19
|
+
// pre-execution gate is the inline extension `pi.on("tool_call", ...)`
|
|
20
|
+
// handler, wired into createAgentSession via DefaultResourceLoader's
|
|
21
|
+
// `extensionFactories`. The handler is async, so it awaits the browser's
|
|
22
|
+
// allow/deny decision before the tool runs and returns { block: true,
|
|
23
|
+
// reason } to deny. Read-only tools (read/grep/find/ls) auto-allow.
|
|
24
|
+
// tool_execution_* events still drive the tool cards (start/update/end).
|
|
25
|
+
|
|
26
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { join, resolve } from "node:path";
|
|
29
|
+
import type { ServerWebSocket, WebSocketHandler } from "bun";
|
|
30
|
+
import {
|
|
31
|
+
createAgentSession,
|
|
32
|
+
DefaultResourceLoader,
|
|
33
|
+
SessionManager,
|
|
34
|
+
type AgentSession,
|
|
35
|
+
type AgentSessionEvent,
|
|
36
|
+
type ExtensionAPI,
|
|
37
|
+
type SessionEntry,
|
|
38
|
+
type SessionInfo,
|
|
39
|
+
type ToolCallEvent,
|
|
40
|
+
} from "@earendil-works/pi-coding-agent";
|
|
41
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
42
|
+
import { createWebTools, WEB_TOOL_NAMES } from "./web-tools";
|
|
43
|
+
import { materializeBundledSkills } from "./web/skills";
|
|
44
|
+
import { buildPiProvider, DEFAULT_CONTEXT_WINDOW, PI_LOCAL_MODEL_ID } from "./pi-provider";
|
|
45
|
+
import { downloadsSnapshot } from "./download";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Full toolset the web agent is offered. The built-in coding tools plus our
|
|
49
|
+
* outward-facing web tools (web_search/web_fetch/weather). NOTE: pi treats
|
|
50
|
+
* this list as an allowlist (createAgentSession `tools`), so a custom tool's
|
|
51
|
+
* name MUST appear here or it gets filtered out before the model ever sees it.
|
|
52
|
+
*/
|
|
53
|
+
const ALL_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", ...WEB_TOOL_NAMES];
|
|
54
|
+
/**
|
|
55
|
+
* Tools that never mutate the user's machine; auto-allowed without a browser
|
|
56
|
+
* round-trip. The web tools make outbound network requests but change nothing
|
|
57
|
+
* locally, so they're auto-allowed too (and remain usable in read-only mode).
|
|
58
|
+
*/
|
|
59
|
+
const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOL_NAMES]);
|
|
60
|
+
/** Tools that require explicit per-call browser approval. */
|
|
61
|
+
const GATED_TOOLS = new Set(["bash", "edit", "write"]);
|
|
62
|
+
|
|
63
|
+
/** Auto-deny a pending approval after this long with no browser decision. */
|
|
64
|
+
const APPROVAL_TIMEOUT_MS = 120_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* System prompt for the web chat assistant.
|
|
68
|
+
*
|
|
69
|
+
* This fully REPLACES pi's default coding-agent prompt (it flows to
|
|
70
|
+
* buildSystemPrompt's `customPrompt` via DefaultResourceLoader.systemPrompt).
|
|
71
|
+
* Replacing rather than appending drops two things we don't want in an
|
|
72
|
+
* end-user chat: the "operating inside pi, a coding agent harness" framing
|
|
73
|
+
* and pi's block of internal documentation paths. pi still auto-appends the
|
|
74
|
+
* current date and working directory, so we don't repeat them here.
|
|
75
|
+
*
|
|
76
|
+
* Goal: a helpful, eager assistant that actually uses its tools instead of
|
|
77
|
+
* talking about them. The capability list is kept honest per `readOnly` so
|
|
78
|
+
* the model never promises an action the approval gate will refuse.
|
|
79
|
+
*/
|
|
80
|
+
export function buildWebChatSystemPrompt(
|
|
81
|
+
readOnly: boolean,
|
|
82
|
+
about?: { modelId?: string; downloadingModel?: string | null },
|
|
83
|
+
): string {
|
|
84
|
+
const capabilities = readOnly
|
|
85
|
+
? `You have these tools (all used freely, no confirmation needed):
|
|
86
|
+
- web_search — search the web for current, real-world information
|
|
87
|
+
- web_fetch — fetch a URL and read the page or data behind it
|
|
88
|
+
- weather — get current conditions and a short forecast for any place
|
|
89
|
+
- read — open and read any file
|
|
90
|
+
- ls, find, grep — list directories and search the filesystem by name or content
|
|
91
|
+
|
|
92
|
+
This is a read-only session: you can look things up and inspect files, but cannot run commands or change anything on disk.`
|
|
93
|
+
: `You have a real toolset — use it.
|
|
94
|
+
|
|
95
|
+
Used freely, no confirmation needed:
|
|
96
|
+
- web_search — search the web for current, real-world information (news, prices, docs, anything that changes or that you don't already know)
|
|
97
|
+
- web_fetch — fetch a URL and read the page or data behind it (great as a follow-up to web_search, or when the user gives you a link)
|
|
98
|
+
- weather — get current conditions and a short forecast for any place
|
|
99
|
+
- read, ls, find, grep — open files and search the filesystem
|
|
100
|
+
|
|
101
|
+
Asks the user for approval first (they stay in control):
|
|
102
|
+
- bash — run shell commands
|
|
103
|
+
- edit, write — modify existing files or create new ones
|
|
104
|
+
|
|
105
|
+
Prefer doing the work over describing it — run the search, fetch the page, propose the concrete command or edit and let the user approve it.`;
|
|
106
|
+
|
|
107
|
+
const servedModel =
|
|
108
|
+
about?.modelId && about.modelId !== PI_LOCAL_MODEL_ID ? about.modelId : null;
|
|
109
|
+
const downloading = about?.downloadingModel ?? null;
|
|
110
|
+
const modelLine = servedModel
|
|
111
|
+
? `\n- The model answering you right now is \`${servedModel}\`.${
|
|
112
|
+
downloading
|
|
113
|
+
? ` It's a small, fast starter so you can chat immediately; a more capable model (\`${downloading}\`) is downloading in the background and becomes the default next time the server starts — the Status tab shows progress.`
|
|
114
|
+
: ""
|
|
115
|
+
}`
|
|
116
|
+
: "";
|
|
117
|
+
|
|
118
|
+
const aboutMlxBun = `You're part of mlx-bun and can answer questions about it:
|
|
119
|
+
- mlx-bun runs open LLMs locally on the user's Apple-silicon Mac using Apple's MLX framework on the Bun runtime — no Python, no cloud. This chat stays on the machine; only the explicit web tools below reach the internet.
|
|
120
|
+
- This page is mlx-bun's built-in web app. Tabs across the top: Chat (here), Quantize, Finetune, Dataset, and Status (live memory, prompt-cache, and download info).
|
|
121
|
+
- It serves one model at a time over an OpenAI- and Anthropic-compatible API at \`/v1\`, so other apps and agents can point at this same local server.
|
|
122
|
+
- To change models, the user runs \`mlx-bun serve <name>\` in their terminal, or downloads more with \`mlx-bun get <repo-id>\`.${modelLine}
|
|
123
|
+
- mlx-bun is new and moving fast, so it isn't reliably documented online yet: answer questions about it from what's written here, not from web search. If something isn't covered, say so and point the user to the project's README or the Status tab — don't invent specifics.`;
|
|
124
|
+
|
|
125
|
+
return `You are a helpful, capable AI assistant running locally inside mlx-bun.
|
|
126
|
+
|
|
127
|
+
Be an eager, proactive partner. When a request can be answered or a task moved forward by using your tools, use them right away instead of guessing or asking the user to do it for you. Take initiative: investigate, gather what you need, and follow through to a real result.
|
|
128
|
+
|
|
129
|
+
${aboutMlxBun}
|
|
130
|
+
|
|
131
|
+
${capabilities}
|
|
132
|
+
|
|
133
|
+
When you don't know something — especially anything current, factual, or time-sensitive — look it up with web_search and web_fetch rather than speculating or relying on stale memory (questions about mlx-bun itself are the exception — use the facts above). If a request is genuinely ambiguous, ask one brief clarifying question; otherwise make a sensible choice and proceed. Be honest about what you did and what you found — including mistakes, dead ends, and things you couldn't do.
|
|
134
|
+
|
|
135
|
+
Keep your responses clear and to the point. Format with Markdown, cite links when you used the web, and show file paths and commands plainly so they're easy to read.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Per-connection data the server attaches at upgrade time. */
|
|
139
|
+
export interface PiWsData {
|
|
140
|
+
sessionId: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type PiWs = ServerWebSocket<PiWsData>;
|
|
144
|
+
|
|
145
|
+
// ---- WS protocol message shapes --------------------------------------
|
|
146
|
+
|
|
147
|
+
/** Decision a browser returns for a gated tool call. */
|
|
148
|
+
type ApprovalDecision = "allow" | "deny";
|
|
149
|
+
|
|
150
|
+
/** An image attachment from the browser: base64 (no data: prefix) + mime. */
|
|
151
|
+
interface ImageAttachment {
|
|
152
|
+
data: string;
|
|
153
|
+
mimeType: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Client -> server frames. */
|
|
157
|
+
type ClientMessage =
|
|
158
|
+
| { type: "prompt"; text: string; images?: ImageAttachment[] }
|
|
159
|
+
| { type: "steer"; text: string; images?: ImageAttachment[] }
|
|
160
|
+
| { type: "abort" }
|
|
161
|
+
| { type: "approval"; callId: string; decision: ApprovalDecision }
|
|
162
|
+
// Toggle the model's reasoning channel on/off (only meaningful when the
|
|
163
|
+
// model supports thinking — ready.thinking). Maps to Pi's session thinking
|
|
164
|
+
// level: "medium" (on) ↔ "off". Pi sends it as enable_thinking to the server.
|
|
165
|
+
| { type: "set_thinking"; enabled: boolean }
|
|
166
|
+
// Select the active LoRA adapter for subsequent turns (null = none/base).
|
|
167
|
+
// app.html mounts it (POST /v1/adapters) before sending this; the
|
|
168
|
+
// before_provider_request hook injects it into the provider payload.
|
|
169
|
+
| { type: "set_adapter"; id: string | null }
|
|
170
|
+
// Session management (recent-chats sidebar + new chat).
|
|
171
|
+
| { type: "new_session" }
|
|
172
|
+
| { type: "list_sessions" }
|
|
173
|
+
| { type: "open_session"; path: string }
|
|
174
|
+
| { type: "fork_session"; path: string }
|
|
175
|
+
| { type: "delete_session"; path: string };
|
|
176
|
+
|
|
177
|
+
/** Server -> client frames. */
|
|
178
|
+
type ServerMessage =
|
|
179
|
+
// `vision`: whether the loaded model can accept images (drives the UI's
|
|
180
|
+
// image-attach affordance — false on e4b until the SigLIP sidecar lands).
|
|
181
|
+
// `thinking`: whether the model has a switchable reasoning channel (drives
|
|
182
|
+
// the UI's thinking on/off toggle; false hides it).
|
|
183
|
+
| { type: "ready"; model: string; vision: boolean; thinking: boolean }
|
|
184
|
+
| { type: "turn_start" }
|
|
185
|
+
| { type: "text_delta"; delta: string }
|
|
186
|
+
| { type: "thinking_delta"; delta: string }
|
|
187
|
+
| { type: "tool_start"; callId: string; tool: string; args: unknown }
|
|
188
|
+
| { type: "tool_approval_request"; callId: string; tool: string; args: unknown }
|
|
189
|
+
| { type: "tool_update"; callId: string; chunk: unknown }
|
|
190
|
+
| { type: "tool_end"; callId: string; ok: boolean; result: unknown }
|
|
191
|
+
| { type: "turn_end" }
|
|
192
|
+
| { type: "queue_update"; steering: readonly string[]; followUp: readonly string[] }
|
|
193
|
+
// Replay a session's transcript (rebuilds the thread); and the sidebar list.
|
|
194
|
+
| { type: "history"; items: HistoryItem[] }
|
|
195
|
+
| { type: "sessions"; items: SessionListItem[]; activePath?: string }
|
|
196
|
+
// Context-window usage indicator. tokens/percent are null right after a
|
|
197
|
+
// compaction until the next assistant reply (pi can't estimate yet).
|
|
198
|
+
| { type: "context"; tokens: number | null; contextWindow: number; percent: number | null }
|
|
199
|
+
| { type: "error"; message: string };
|
|
200
|
+
|
|
201
|
+
// ---- Session serialization (pure, unit-tested) -----------------------
|
|
202
|
+
|
|
203
|
+
/** A finished tool call as rendered in replayed history. */
|
|
204
|
+
export interface HistoryToolItem {
|
|
205
|
+
callId: string;
|
|
206
|
+
name: string;
|
|
207
|
+
args: unknown;
|
|
208
|
+
/** Tool result text, filled from the matching toolResult message. */
|
|
209
|
+
result: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One replayed transcript turn: a user or assistant message (assistant
|
|
213
|
+
* messages carry any tool calls they made, with results merged in). */
|
|
214
|
+
export interface HistoryItem {
|
|
215
|
+
role: "user" | "assistant";
|
|
216
|
+
text: string;
|
|
217
|
+
/** Model reasoning/thinking, kept separate from the final answer. */
|
|
218
|
+
thinking?: string;
|
|
219
|
+
tools: HistoryToolItem[];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A row in the recent-chats sidebar, derived from pi's SessionInfo. */
|
|
223
|
+
export interface SessionListItem {
|
|
224
|
+
path: string;
|
|
225
|
+
id: string;
|
|
226
|
+
title: string;
|
|
227
|
+
/** Last-modified epoch ms (newest first). */
|
|
228
|
+
modified: number;
|
|
229
|
+
messageCount: number;
|
|
230
|
+
forked: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Flatten a content value (string | content-parts) to its text. */
|
|
234
|
+
function contentText(content: unknown): string {
|
|
235
|
+
if (typeof content === "string") return content;
|
|
236
|
+
if (Array.isArray(content)) {
|
|
237
|
+
return content
|
|
238
|
+
.filter((p): p is { type: string; text: string } =>
|
|
239
|
+
!!p && (p as { type?: string }).type === "text" && typeof (p as { text?: unknown }).text === "string")
|
|
240
|
+
.map((p) => p.text)
|
|
241
|
+
.join("");
|
|
242
|
+
}
|
|
243
|
+
return "";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function contentThinking(content: unknown): string {
|
|
247
|
+
if (!Array.isArray(content)) return "";
|
|
248
|
+
return content
|
|
249
|
+
.filter((p): p is { type: string; thinking: string } =>
|
|
250
|
+
!!p && (p as { type?: string }).type === "thinking" && typeof (p as { thinking?: unknown }).thinking === "string")
|
|
251
|
+
.map((p) => p.thinking)
|
|
252
|
+
.join("");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Turn a session's entries into a flat, browser-renderable transcript.
|
|
257
|
+
*
|
|
258
|
+
* Walks message entries in order: user/assistant text become items, the
|
|
259
|
+
* assistant's toolCall parts become tool items, and each later toolResult
|
|
260
|
+
* is merged back onto its tool by callId. Non-message entries (model
|
|
261
|
+
* changes, compaction, thinking) are skipped. Pure so it's unit-tested
|
|
262
|
+
* without a live session.
|
|
263
|
+
*/
|
|
264
|
+
export function serializeHistory(entries: readonly SessionEntry[]): HistoryItem[] {
|
|
265
|
+
const items: HistoryItem[] = [];
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
if (entry.type !== "message") continue;
|
|
268
|
+
const m = (entry as { message?: unknown }).message as
|
|
269
|
+
| { role?: string; content?: unknown; toolCallId?: string }
|
|
270
|
+
| undefined;
|
|
271
|
+
if (!m) continue;
|
|
272
|
+
if (m.role === "user") {
|
|
273
|
+
const text = contentText(m.content);
|
|
274
|
+
if (text.trim()) items.push({ role: "user", text, tools: [] });
|
|
275
|
+
} else if (m.role === "assistant") {
|
|
276
|
+
const parts = Array.isArray(m.content) ? (m.content as unknown[]) : [];
|
|
277
|
+
const text = contentText(parts);
|
|
278
|
+
const thinking = contentThinking(parts);
|
|
279
|
+
const tools: HistoryToolItem[] = parts
|
|
280
|
+
.filter((p): p is { type: string; id?: unknown; name?: unknown; arguments?: unknown } =>
|
|
281
|
+
!!p && (p as { type?: string }).type === "toolCall")
|
|
282
|
+
.map((p) => ({ callId: String(p.id ?? ""), name: String(p.name ?? "tool"), args: p.arguments, result: "" }));
|
|
283
|
+
if (text.trim() || thinking.trim() || tools.length > 0)
|
|
284
|
+
items.push({ role: "assistant", text, ...(thinking.trim() ? { thinking } : {}), tools });
|
|
285
|
+
} else if (m.role === "toolResult") {
|
|
286
|
+
const callId = String(m.toolCallId ?? "");
|
|
287
|
+
const result = contentText(m.content);
|
|
288
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
289
|
+
const it = items[i];
|
|
290
|
+
if (!it) continue;
|
|
291
|
+
const tool = it.tools.find((t) => t.callId === callId);
|
|
292
|
+
if (tool) {
|
|
293
|
+
tool.result = result;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return items;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Map browser image attachments to pi's ImageContent shape (or undefined). */
|
|
303
|
+
function toPiImages(images?: ImageAttachment[]): ImageContent[] | undefined {
|
|
304
|
+
if (!images || images.length === 0) return undefined;
|
|
305
|
+
return images.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType }));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Map pi's SessionInfo[] to sidebar rows, newest first. */
|
|
309
|
+
export function toSessionListItems(infos: readonly SessionInfo[]): SessionListItem[] {
|
|
310
|
+
return infos
|
|
311
|
+
.map((s) => ({
|
|
312
|
+
path: s.path,
|
|
313
|
+
id: s.id,
|
|
314
|
+
title: ((s.name && s.name.trim()) || (s.firstMessage && s.firstMessage.trim()) || "New chat").slice(0, 80),
|
|
315
|
+
modified: s.modified instanceof Date ? s.modified.getTime() : new Date(s.modified as unknown as string).getTime(),
|
|
316
|
+
messageCount: s.messageCount,
|
|
317
|
+
forked: !!s.parentSessionPath,
|
|
318
|
+
}))
|
|
319
|
+
.sort((a, b) => b.modified - a.modified);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- Pure helper: event mapping (unit-tested) ------------------------
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Translate one pi AgentSessionEvent into zero or more WS frames.
|
|
326
|
+
*
|
|
327
|
+
* Pure and side-effect-free so it can be unit-tested without a live
|
|
328
|
+
* session. The approval gate is handled separately (in the tool_call
|
|
329
|
+
* extension handler), so the tool_execution_start event here simply
|
|
330
|
+
* renders the tool card; it does not itself request approval.
|
|
331
|
+
*
|
|
332
|
+
* Returns [] for events that have no browser-facing representation.
|
|
333
|
+
*/
|
|
334
|
+
export function mapEventToFrames(event: AgentSessionEvent): ServerMessage[] {
|
|
335
|
+
switch (event.type) {
|
|
336
|
+
case "turn_start":
|
|
337
|
+
return [{ type: "turn_start" }];
|
|
338
|
+
case "turn_end": {
|
|
339
|
+
// A turn can complete with stopReason "error" (e.g. the model request
|
|
340
|
+
// 400'd) WITHOUT any error being thrown up to the WS message handler, so
|
|
341
|
+
// it would otherwise vanish — the browser just sees an empty turn ("no
|
|
342
|
+
// messages"). Surface it as an error frame so the UI can show it.
|
|
343
|
+
const msg = (event as { message?: { stopReason?: string; errorMessage?: string } }).message;
|
|
344
|
+
if (msg?.stopReason === "error") {
|
|
345
|
+
return [
|
|
346
|
+
{ type: "error", message: msg.errorMessage || "the model request failed" },
|
|
347
|
+
{ type: "turn_end" },
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
return [{ type: "turn_end" }];
|
|
351
|
+
}
|
|
352
|
+
case "message_update": {
|
|
353
|
+
const ame = event.assistantMessageEvent;
|
|
354
|
+
if (ame.type === "text_delta") return [{ type: "text_delta", delta: ame.delta }];
|
|
355
|
+
if (ame.type === "thinking_delta") return [{ type: "thinking_delta", delta: ame.delta }];
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
case "tool_execution_start":
|
|
359
|
+
return [
|
|
360
|
+
{
|
|
361
|
+
type: "tool_start",
|
|
362
|
+
callId: event.toolCallId,
|
|
363
|
+
tool: event.toolName,
|
|
364
|
+
args: event.args,
|
|
365
|
+
},
|
|
366
|
+
];
|
|
367
|
+
case "tool_execution_update":
|
|
368
|
+
return [
|
|
369
|
+
{
|
|
370
|
+
type: "tool_update",
|
|
371
|
+
callId: event.toolCallId,
|
|
372
|
+
chunk: event.partialResult,
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
case "tool_execution_end":
|
|
376
|
+
return [
|
|
377
|
+
{
|
|
378
|
+
type: "tool_end",
|
|
379
|
+
callId: event.toolCallId,
|
|
380
|
+
ok: !event.isError,
|
|
381
|
+
result: event.result,
|
|
382
|
+
},
|
|
383
|
+
];
|
|
384
|
+
case "queue_update":
|
|
385
|
+
return [
|
|
386
|
+
{
|
|
387
|
+
type: "queue_update",
|
|
388
|
+
steering: event.steering,
|
|
389
|
+
followUp: event.followUp,
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
default:
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---- Session pool ----------------------------------------------------
|
|
398
|
+
|
|
399
|
+
/** Live sessions keyed by their owning WebSocket. */
|
|
400
|
+
const sessions = new Map<PiWs, PiWebSession>();
|
|
401
|
+
|
|
402
|
+
/** Body of the `before_provider_request` hook: inject the selected LoRA adapter
|
|
403
|
+
* into the outgoing provider payload. null/empty selection → return undefined so
|
|
404
|
+
* Pi keeps the payload unchanged (base model); Pi replaces the payload only when a
|
|
405
|
+
* handler returns a value. Pure + exported for unit testing. */
|
|
406
|
+
export function injectAdapter(
|
|
407
|
+
payload: Record<string, unknown>,
|
|
408
|
+
selected: string | null,
|
|
409
|
+
): Record<string, unknown> | undefined {
|
|
410
|
+
if (!selected) return undefined;
|
|
411
|
+
return { ...payload, adapter: selected };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* One browser connection's pi agent. Owns the AgentSession, the event
|
|
416
|
+
* subscription, and the pending tool-approval handshakes.
|
|
417
|
+
*/
|
|
418
|
+
class PiWebSession {
|
|
419
|
+
private session?: AgentSession;
|
|
420
|
+
/** SessionManager backing the active AgentSession (disk-persisted). */
|
|
421
|
+
private sessionManager?: SessionManager;
|
|
422
|
+
private unsubscribe?: () => void;
|
|
423
|
+
private disposed = false;
|
|
424
|
+
/** Active LoRA adapter id for this connection (null = none/base model).
|
|
425
|
+
* Read by the before_provider_request hook; set via the set_adapter msg. */
|
|
426
|
+
private selectedAdapter: string | null = null;
|
|
427
|
+
|
|
428
|
+
/** Per-connection invariants, built once in start() and reused across
|
|
429
|
+
* session switches (new chat / resume / fork). */
|
|
430
|
+
private provider?: ReturnType<typeof buildPiProvider>;
|
|
431
|
+
private readonly cwd = process.cwd();
|
|
432
|
+
private readonly agentDir = join(homedir(), ".mlx-bun", "pi-sessions");
|
|
433
|
+
/** Where web-chat session files live (pi's own JSONL format). Shared by
|
|
434
|
+
* create/continueRecent/open/fork/list/delete so they all see one set.
|
|
435
|
+
* This is the durable transcript store the nightly memory pipeline reads. */
|
|
436
|
+
private readonly sessionDir = join(homedir(), ".mlx-bun", "sessions");
|
|
437
|
+
|
|
438
|
+
/** callId -> resolve(decision). Pending browser approvals in flight. */
|
|
439
|
+
private readonly pendingApprovals = new Map<string, (decision: ApprovalDecision) => void>();
|
|
440
|
+
/** callId -> timer handle, so we can clear on resolve/dispose. */
|
|
441
|
+
private readonly approvalTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
442
|
+
|
|
443
|
+
constructor(
|
|
444
|
+
private readonly ws: PiWs,
|
|
445
|
+
private readonly opts: { port: number | (() => number); modelId: string; contextWindow: number; readOnly: boolean; vision: boolean; thinking: boolean },
|
|
446
|
+
) {}
|
|
447
|
+
|
|
448
|
+
/** Build the provider, resume the most recent chat, and start streaming. */
|
|
449
|
+
async start(): Promise<void> {
|
|
450
|
+
// Resolve the port lazily: the WS handler is constructed before
|
|
451
|
+
// Bun.serve() binds, so an ephemeral (0) port is only known at the
|
|
452
|
+
// first connection. createServer passes `() => server.port`.
|
|
453
|
+
const port = typeof this.opts.port === "function" ? this.opts.port() : this.opts.port;
|
|
454
|
+
const baseUrl = `http://127.0.0.1:${port}/v1`;
|
|
455
|
+
|
|
456
|
+
// In-memory auth + registry shared with the terminal embed (pi-provider.ts
|
|
457
|
+
// so the wiring can't drift); built once and reused across session swaps.
|
|
458
|
+
this.provider = buildPiProvider(baseUrl, {
|
|
459
|
+
contextWindow: this.opts.contextWindow,
|
|
460
|
+
reasoning: this.opts.thinking,
|
|
461
|
+
});
|
|
462
|
+
mkdirSync(this.sessionDir, { recursive: true });
|
|
463
|
+
|
|
464
|
+
// Each connection starts its OWN fresh session. A prior continueRecent
|
|
465
|
+
// here meant every new WebSocket (another browser tab, a reconnect, or an
|
|
466
|
+
// external client) appended to the most-recent session — so tabs and even
|
|
467
|
+
// test clients wrote into each other's chats. Isolation is the correct
|
|
468
|
+
// model (and essential once concurrent slots land). Resuming a specific
|
|
469
|
+
// chat is explicit via the sidebar (open_session); the frontend re-opens
|
|
470
|
+
// its own session on a transient reconnect so a blip doesn't strand it on
|
|
471
|
+
// a blank backend session. Sessions persist to disk in pi's own format —
|
|
472
|
+
// the substrate for the recent-chats sidebar and the nightly memory pipeline.
|
|
473
|
+
await this.activate(SessionManager.create(this.cwd, this.sessionDir));
|
|
474
|
+
if (this.disposed) return;
|
|
475
|
+
|
|
476
|
+
this.send({ type: "ready", model: this.opts.modelId, vision: this.opts.vision, thinking: this.opts.thinking });
|
|
477
|
+
this.sendHistory();
|
|
478
|
+
await this.sendSessions();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Build a fresh AgentSession bound to `sm`. A new resource loader per
|
|
482
|
+
* build keeps the approval-gate extension cleanly scoped to this session. */
|
|
483
|
+
private async buildAgentSession(sm: SessionManager): Promise<AgentSession> {
|
|
484
|
+
const provider = this.provider;
|
|
485
|
+
if (!provider) throw new Error("provider not initialized");
|
|
486
|
+
|
|
487
|
+
// Inline extension carries the pre-execution approval gate. Registered
|
|
488
|
+
// via DefaultResourceLoader.extensionFactories so it loads in-process
|
|
489
|
+
// with no file on disk and no global ~/.pi extension discovery.
|
|
490
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
491
|
+
cwd: this.cwd,
|
|
492
|
+
agentDir: this.agentDir,
|
|
493
|
+
// Skip user/project resource discovery: the web agent is fully
|
|
494
|
+
// self-contained and must not inherit the user's pi extensions or
|
|
495
|
+
// context files. noContextFiles also keeps a stray CLAUDE.md /
|
|
496
|
+
// AGENTS.md in the launch directory out of the chat.
|
|
497
|
+
noExtensions: true,
|
|
498
|
+
noSkills: true,
|
|
499
|
+
noPromptTemplates: true,
|
|
500
|
+
noThemes: true,
|
|
501
|
+
noContextFiles: true,
|
|
502
|
+
// Our curated skills only (not the user's). materializeBundledSkills is
|
|
503
|
+
// memoized, so calling it per build just returns the path. See web/skills.ts.
|
|
504
|
+
additionalSkillPaths: [materializeBundledSkills()],
|
|
505
|
+
// Replace pi's default coding-agent prompt with our own helpful,
|
|
506
|
+
// eager-assistant persona (see buildWebChatSystemPrompt). Pass the
|
|
507
|
+
// served model id + any in-progress background download so the
|
|
508
|
+
// loading-screen assistant knows what it is and what's coming.
|
|
509
|
+
systemPrompt: buildWebChatSystemPrompt(this.opts.readOnly, {
|
|
510
|
+
modelId: this.opts.modelId,
|
|
511
|
+
downloadingModel: downloadsSnapshot().find(
|
|
512
|
+
(d) => d.state === "active" && d.repoId !== this.opts.modelId,
|
|
513
|
+
)?.repoId ?? null,
|
|
514
|
+
}),
|
|
515
|
+
extensionFactories: [
|
|
516
|
+
(pi) => this.installApprovalGate(pi),
|
|
517
|
+
(pi) => this.installAdapterHook(pi),
|
|
518
|
+
],
|
|
519
|
+
});
|
|
520
|
+
await resourceLoader.reload();
|
|
521
|
+
|
|
522
|
+
const { session } = await createAgentSession({
|
|
523
|
+
cwd: this.cwd,
|
|
524
|
+
agentDir: this.agentDir,
|
|
525
|
+
model: provider.model,
|
|
526
|
+
modelRegistry: provider.modelRegistry,
|
|
527
|
+
authStorage: provider.authStorage,
|
|
528
|
+
resourceLoader,
|
|
529
|
+
tools: ALL_TOOLS,
|
|
530
|
+
customTools: createWebTools(),
|
|
531
|
+
sessionManager: sm,
|
|
532
|
+
});
|
|
533
|
+
return session;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Tear down the active session (keeps the socket + provider invariants). */
|
|
537
|
+
private teardownActive(): void {
|
|
538
|
+
for (const settle of this.pendingApprovals.values()) settle("deny");
|
|
539
|
+
this.pendingApprovals.clear();
|
|
540
|
+
for (const timer of this.approvalTimers.values()) clearTimeout(timer);
|
|
541
|
+
this.approvalTimers.clear();
|
|
542
|
+
this.unsubscribe?.();
|
|
543
|
+
this.unsubscribe = undefined;
|
|
544
|
+
try {
|
|
545
|
+
this.session?.dispose();
|
|
546
|
+
} catch {
|
|
547
|
+
// Never let a dispose error escape teardown.
|
|
548
|
+
}
|
|
549
|
+
this.session = undefined;
|
|
550
|
+
this.sessionManager = undefined;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Swap the active session to `sm`: abort any current turn, build the new
|
|
554
|
+
* session, then dispose the old and subscribe the new. Build-before-tear
|
|
555
|
+
* so a build failure leaves the current session intact. */
|
|
556
|
+
private async activate(sm: SessionManager): Promise<void> {
|
|
557
|
+
try {
|
|
558
|
+
await this.session?.abort();
|
|
559
|
+
} catch {
|
|
560
|
+
// Old turn may already be done; ignore.
|
|
561
|
+
}
|
|
562
|
+
const session = await this.buildAgentSession(sm);
|
|
563
|
+
if (this.disposed) {
|
|
564
|
+
session.dispose();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
this.teardownActive();
|
|
568
|
+
this.session = session;
|
|
569
|
+
this.sessionManager = sm;
|
|
570
|
+
this.unsubscribe = session.subscribe((event) => this.onSessionEvent(event));
|
|
571
|
+
this.sendContextUsage();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Replay the active session's transcript to the browser (rebuilds thread). */
|
|
575
|
+
private sendHistory(): void {
|
|
576
|
+
const entries = this.sessionManager?.getEntries() ?? [];
|
|
577
|
+
this.send({ type: "history", items: serializeHistory(entries) });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Push current context-window usage to the browser (for the indicator). */
|
|
581
|
+
private sendContextUsage(): void {
|
|
582
|
+
const usage = this.session?.getContextUsage();
|
|
583
|
+
if (usage) {
|
|
584
|
+
this.send({ type: "context", tokens: usage.tokens, contextWindow: usage.contextWindow, percent: usage.percent });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Send the recent-chats list (sidebar), marking the active session. */
|
|
589
|
+
private async sendSessions(): Promise<void> {
|
|
590
|
+
let infos: SessionInfo[] = [];
|
|
591
|
+
try {
|
|
592
|
+
infos = await SessionManager.list(this.cwd, this.sessionDir);
|
|
593
|
+
} catch {
|
|
594
|
+
infos = [];
|
|
595
|
+
}
|
|
596
|
+
this.send({
|
|
597
|
+
type: "sessions",
|
|
598
|
+
items: toSessionListItems(infos),
|
|
599
|
+
activePath: this.sessionManager?.getSessionFile(),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Guard: only operate on session files under our session dir. */
|
|
604
|
+
private isUnderSessionDir(path: string): boolean {
|
|
605
|
+
const root = resolve(this.sessionDir);
|
|
606
|
+
const p = resolve(path);
|
|
607
|
+
return p === root || p.startsWith(root + "/");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Start a fresh chat (old one stays on disk, resumable from the sidebar). */
|
|
611
|
+
private async newSession(): Promise<void> {
|
|
612
|
+
await this.activate(SessionManager.create(this.cwd, this.sessionDir));
|
|
613
|
+
this.sendHistory();
|
|
614
|
+
await this.sendSessions();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Resume an existing chat by file path. */
|
|
618
|
+
private async openSession(path: string): Promise<void> {
|
|
619
|
+
if (!this.isUnderSessionDir(path)) {
|
|
620
|
+
this.send({ type: "error", message: "invalid session path" });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
await this.activate(SessionManager.open(path, this.sessionDir));
|
|
624
|
+
this.sendHistory();
|
|
625
|
+
await this.sendSessions();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Branch a new chat from an existing one (original stays untouched). */
|
|
629
|
+
private async forkSession(path: string): Promise<void> {
|
|
630
|
+
if (!this.isUnderSessionDir(path)) {
|
|
631
|
+
this.send({ type: "error", message: "invalid session path" });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
await this.activate(SessionManager.forkFrom(path, this.cwd, this.sessionDir));
|
|
635
|
+
this.sendHistory();
|
|
636
|
+
await this.sendSessions();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Delete a session file; if it was active, start a fresh chat. */
|
|
640
|
+
private deleteSession(path: string): void {
|
|
641
|
+
if (!this.isUnderSessionDir(path)) {
|
|
642
|
+
this.send({ type: "error", message: "invalid session path" });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const active = this.sessionManager?.getSessionFile();
|
|
646
|
+
const wasActive = !!active && resolve(active) === resolve(path);
|
|
647
|
+
try {
|
|
648
|
+
rmSync(path, { force: true });
|
|
649
|
+
} catch {
|
|
650
|
+
// Already gone or unreadable; the refreshed list will reflect reality.
|
|
651
|
+
}
|
|
652
|
+
if (wasActive) void this.newSession();
|
|
653
|
+
else void this.sendSessions();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Register the before_provider_request hook that injects the selected LoRA
|
|
657
|
+
* adapter into every provider request (Pi-native adapter control, mirrors the
|
|
658
|
+
* CLI extension). Default none = no injection (base model). */
|
|
659
|
+
private installAdapterHook(pi: ExtensionAPI): void {
|
|
660
|
+
pi.on("before_provider_request", (event) =>
|
|
661
|
+
injectAdapter(event.payload as Record<string, unknown>, this.selectedAdapter),
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Register the tool_call approval gate on the inline extension. */
|
|
666
|
+
private installApprovalGate(pi: ExtensionAPI): void {
|
|
667
|
+
pi.on("tool_call", async (event: ToolCallEvent) => {
|
|
668
|
+
const tool = event.toolName;
|
|
669
|
+
|
|
670
|
+
// Read-only tools never need approval.
|
|
671
|
+
if (READ_ONLY_TOOLS.has(tool)) return undefined;
|
|
672
|
+
|
|
673
|
+
// Anything mutating is denied outright in read-only mode.
|
|
674
|
+
if (this.opts.readOnly && GATED_TOOLS.has(tool)) {
|
|
675
|
+
return { block: true, reason: "Read-only session: mutating tools are disabled." };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Non-gated, non-read-only tools (shouldn't happen with ALL_TOOLS,
|
|
679
|
+
// but be safe): allow.
|
|
680
|
+
if (!GATED_TOOLS.has(tool)) return undefined;
|
|
681
|
+
|
|
682
|
+
const decision = await this.requestApproval(event);
|
|
683
|
+
if (decision === "deny") {
|
|
684
|
+
return { block: true, reason: "Denied by user." };
|
|
685
|
+
}
|
|
686
|
+
return undefined; // allow
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Ask the browser to approve a gated tool call and await its decision.
|
|
692
|
+
* Auto-denies after APPROVAL_TIMEOUT_MS or if the connection drops.
|
|
693
|
+
*/
|
|
694
|
+
private requestApproval(event: ToolCallEvent): Promise<ApprovalDecision> {
|
|
695
|
+
const callId = event.toolCallId;
|
|
696
|
+
return new Promise<ApprovalDecision>((resolve) => {
|
|
697
|
+
const settle = (decision: ApprovalDecision) => {
|
|
698
|
+
const timer = this.approvalTimers.get(callId);
|
|
699
|
+
if (timer) clearTimeout(timer);
|
|
700
|
+
this.approvalTimers.delete(callId);
|
|
701
|
+
this.pendingApprovals.delete(callId);
|
|
702
|
+
resolve(decision);
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
this.pendingApprovals.set(callId, settle);
|
|
706
|
+
const timer = setTimeout(() => settle("deny"), APPROVAL_TIMEOUT_MS);
|
|
707
|
+
this.approvalTimers.set(callId, timer);
|
|
708
|
+
|
|
709
|
+
this.send({
|
|
710
|
+
type: "tool_approval_request",
|
|
711
|
+
callId,
|
|
712
|
+
tool: event.toolName,
|
|
713
|
+
args: event.input,
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Resolve a pending approval from a browser `approval` frame. */
|
|
719
|
+
resolveApproval(callId: string, decision: ApprovalDecision): void {
|
|
720
|
+
const settle = this.pendingApprovals.get(callId);
|
|
721
|
+
if (settle) settle(decision);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private onSessionEvent(event: AgentSessionEvent): void {
|
|
725
|
+
if (process.env.MLX_BUN_PI_DEBUG) {
|
|
726
|
+
const extra = event.type === "message_update"
|
|
727
|
+
? `/${(event as { assistantMessageEvent?: { type?: string } }).assistantMessageEvent?.type}` : "";
|
|
728
|
+
console.error(`[pi-event] ${event.type}${extra}`);
|
|
729
|
+
}
|
|
730
|
+
for (const frame of mapEventToFrames(event)) this.send(frame);
|
|
731
|
+
// A completed turn is when pi flushes the session to disk (it defers
|
|
732
|
+
// writing until the first assistant reply), so refresh the sidebar then:
|
|
733
|
+
// a brand-new chat appears, and the active row's title/time update.
|
|
734
|
+
// Also refresh the context-usage indicator (it grows each turn, and
|
|
735
|
+
// drops sharply when auto-compaction fires).
|
|
736
|
+
if (event.type === "turn_end") {
|
|
737
|
+
void this.sendSessions();
|
|
738
|
+
this.sendContextUsage();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Handle one parsed client frame. */
|
|
743
|
+
async handle(msg: ClientMessage): Promise<void> {
|
|
744
|
+
// Session-management frames don't require (and may replace) the active
|
|
745
|
+
// session, so handle them before the readiness guard.
|
|
746
|
+
switch (msg.type) {
|
|
747
|
+
case "new_session":
|
|
748
|
+
await this.newSession();
|
|
749
|
+
return;
|
|
750
|
+
case "list_sessions":
|
|
751
|
+
await this.sendSessions();
|
|
752
|
+
return;
|
|
753
|
+
case "open_session":
|
|
754
|
+
await this.openSession(msg.path);
|
|
755
|
+
return;
|
|
756
|
+
case "fork_session":
|
|
757
|
+
await this.forkSession(msg.path);
|
|
758
|
+
return;
|
|
759
|
+
case "delete_session":
|
|
760
|
+
this.deleteSession(msg.path);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const session = this.session;
|
|
765
|
+
if (!session) {
|
|
766
|
+
this.send({ type: "error", message: "session not ready" });
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
switch (msg.type) {
|
|
771
|
+
// Both a new prompt and an explicit steer go through session.prompt() so
|
|
772
|
+
// that pi's own *atomic* isStreaming check (agent-session prompt(): it
|
|
773
|
+
// re-checks at call time) decides how to route — never our own external
|
|
774
|
+
// read, which can be stale in the instant a turn ends. That stale read
|
|
775
|
+
// was the bug: a message arriving right as a turn finished was sent with
|
|
776
|
+
// streamingBehavior "steer", injected into the already-ending turn, and
|
|
777
|
+
// nothing consumed it (a hang). With this routing, an idle session always
|
|
778
|
+
// runs a normal turn (streamingBehavior is ignored when not streaming),
|
|
779
|
+
// so a message can't be lost at the turn boundary.
|
|
780
|
+
case "prompt":
|
|
781
|
+
// New user message → its own turn. "followUp" = if a turn is still
|
|
782
|
+
// streaming, queue this as the NEXT turn; if idle, a normal prompt.
|
|
783
|
+
await session.prompt(msg.text, { streamingBehavior: "followUp", images: toPiImages(msg.images) });
|
|
784
|
+
return;
|
|
785
|
+
case "steer":
|
|
786
|
+
// Mid-turn steer (user typed while it streamed). "steer" injects into
|
|
787
|
+
// the live turn; if that turn just ended (same race), pi falls back to
|
|
788
|
+
// a normal prompt instead of dropping the message.
|
|
789
|
+
await session.prompt(msg.text, { streamingBehavior: "steer", images: toPiImages(msg.images) });
|
|
790
|
+
return;
|
|
791
|
+
case "abort":
|
|
792
|
+
await session.abort();
|
|
793
|
+
return;
|
|
794
|
+
case "approval":
|
|
795
|
+
this.resolveApproval(msg.callId, msg.decision);
|
|
796
|
+
return;
|
|
797
|
+
case "set_thinking":
|
|
798
|
+
// Pi clamps to the model's available levels; a no-op for models
|
|
799
|
+
// without a switchable reasoning channel.
|
|
800
|
+
session.setThinkingLevel(msg.enabled ? "medium" : "off");
|
|
801
|
+
return;
|
|
802
|
+
case "set_adapter":
|
|
803
|
+
// Pi-native adapter control: record the selection; the
|
|
804
|
+
// before_provider_request hook injects it into the outgoing payload.
|
|
805
|
+
// app.html has already mounted it server-side (POST /v1/adapters).
|
|
806
|
+
this.selectedAdapter = msg.id;
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Tear down the session and reject any in-flight approvals. */
|
|
812
|
+
dispose(): void {
|
|
813
|
+
this.disposed = true;
|
|
814
|
+
this.teardownActive();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Send a frame, swallowing errors on a closed socket. */
|
|
818
|
+
private send(msg: ServerMessage): void {
|
|
819
|
+
try {
|
|
820
|
+
this.ws.send(JSON.stringify(msg));
|
|
821
|
+
} catch {
|
|
822
|
+
// Socket closed mid-send; nothing actionable.
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ---- Public handler factory ------------------------------------------
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Build a Bun WebSocket handler that bridges browser connections to
|
|
831
|
+
* in-process pi AgentSessions driving mlx-bun's local model.
|
|
832
|
+
*
|
|
833
|
+
* @param opts.port Port the mlx-bun server listens on; the pi
|
|
834
|
+
* provider points at http://127.0.0.1:<port>/v1.
|
|
835
|
+
* @param opts.modelId Model id reported in the `ready` frame.
|
|
836
|
+
* Default: "local".
|
|
837
|
+
* @param opts.contextWindow Context window advertised to pi. Default: 32768.
|
|
838
|
+
* Source: ctx.model.config.text.maxPositionEmbeddings.
|
|
839
|
+
* @param opts.readOnly When true, bash/edit/write are denied outright.
|
|
840
|
+
*/
|
|
841
|
+
export function makePiWsHandler(opts: {
|
|
842
|
+
port: number | (() => number);
|
|
843
|
+
modelId?: string;
|
|
844
|
+
contextWindow?: number;
|
|
845
|
+
readOnly?: boolean;
|
|
846
|
+
/** Whether the loaded model can accept images (server's ctx.vision != null). */
|
|
847
|
+
vision?: boolean;
|
|
848
|
+
/** Whether the model has a switchable reasoning channel
|
|
849
|
+
* (server's ctx.template.supportsThinking). Drives the thinking toggle. */
|
|
850
|
+
thinking?: boolean;
|
|
851
|
+
}): WebSocketHandler<PiWsData> {
|
|
852
|
+
const resolved = {
|
|
853
|
+
port: opts.port,
|
|
854
|
+
modelId: opts.modelId ?? PI_LOCAL_MODEL_ID,
|
|
855
|
+
contextWindow: opts.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
856
|
+
readOnly: opts.readOnly ?? false,
|
|
857
|
+
vision: opts.vision ?? false,
|
|
858
|
+
thinking: opts.thinking ?? false,
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
return {
|
|
862
|
+
async open(ws) {
|
|
863
|
+
const session = new PiWebSession(ws, resolved);
|
|
864
|
+
sessions.set(ws, session);
|
|
865
|
+
try {
|
|
866
|
+
await session.start();
|
|
867
|
+
} catch (err) {
|
|
868
|
+
// Model not reachable yet (or any build failure): report and let
|
|
869
|
+
// the client retry. Keep the entry so close() still cleans up.
|
|
870
|
+
sessions.delete(ws);
|
|
871
|
+
session.dispose();
|
|
872
|
+
sendError(ws, err);
|
|
873
|
+
}
|
|
874
|
+
},
|
|
875
|
+
|
|
876
|
+
async message(ws, raw) {
|
|
877
|
+
const session = sessions.get(ws);
|
|
878
|
+
if (!session) {
|
|
879
|
+
sendError(ws, "no active session");
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
let msg: ClientMessage;
|
|
883
|
+
try {
|
|
884
|
+
msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8")) as ClientMessage;
|
|
885
|
+
} catch {
|
|
886
|
+
sendError(ws, "invalid JSON");
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
await session.handle(msg);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
sendError(ws, err);
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
|
|
896
|
+
close(ws) {
|
|
897
|
+
const session = sessions.get(ws);
|
|
898
|
+
sessions.delete(ws);
|
|
899
|
+
session?.dispose();
|
|
900
|
+
},
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Dispose every live pi session. Call on server shutdown. */
|
|
905
|
+
export async function disposeAllPiSessions(): Promise<void> {
|
|
906
|
+
for (const session of sessions.values()) {
|
|
907
|
+
try {
|
|
908
|
+
session.dispose();
|
|
909
|
+
} catch {
|
|
910
|
+
// Best-effort; keep disposing the rest.
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
sessions.clear();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Send a best-effort `error` frame from any thrown value. */
|
|
917
|
+
function sendError(ws: PiWs, err: unknown): void {
|
|
918
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
919
|
+
try {
|
|
920
|
+
ws.send(JSON.stringify({ type: "error", message } satisfies ServerMessage));
|
|
921
|
+
} catch {
|
|
922
|
+
// Socket already gone.
|
|
923
|
+
}
|
|
924
|
+
}
|