mlx-bun 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +11 -2
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/memory-system.md +193 -295
  4. package/docs/design/orpo-dynamic-lambda.md +179 -0
  5. package/docs/design/orpo-future-enhancements.md +42 -0
  6. package/docs/design/orpo-training.md +13 -0
  7. package/docs/design/parity-tier-dag.md +143 -0
  8. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  9. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  10. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  11. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  12. package/docs/investigations/steel-flash-cce-handoff.md +58 -0
  13. package/docs/investigations/trainer-validation-experiment.md +173 -0
  14. package/docs/reference/memory.md +77 -0
  15. package/docs/reference/orpo-quickstart.md +44 -5
  16. package/docs/reference/training.md +9 -5
  17. package/package.json +1 -1
  18. package/src/cli.ts +651 -18
  19. package/src/eval/runner.ts +76 -6
  20. package/src/eval/tasks/bfcl.ts +49 -15
  21. package/src/eval/tasks/gsm8k.ts +21 -9
  22. package/src/eval/tasks/hashhop.ts +33 -4
  23. package/src/eval/tasks/humaneval.ts +30 -8
  24. package/src/eval/tasks/ifeval.ts +52 -18
  25. package/src/eval/tasks/mmlu.ts +85 -74
  26. package/src/jobs/runner.ts +6 -0
  27. package/src/memory/chunk.ts +25 -0
  28. package/src/memory/cluster.ts +24 -0
  29. package/src/memory/db.ts +114 -0
  30. package/src/memory/ingest.ts +22 -0
  31. package/src/memory/pipeline.ts +63 -0
  32. package/src/memory/prompts.ts +23 -0
  33. package/src/memory/schedule.ts +163 -0
  34. package/src/memory/synthesize.ts +29 -0
  35. package/src/memory/tools.ts +399 -0
  36. package/src/memory/vault.ts +694 -0
  37. package/src/memory/wikify.ts +23 -0
  38. package/src/mlx/metal-kernel.ts +13 -0
  39. package/src/model/flash-attention.ts +5 -3
  40. package/src/pi-provider.ts +29 -13
  41. package/src/pi-session.ts +81 -0
  42. package/src/pi-terminal.ts +13 -24
  43. package/src/pi-web.ts +246 -166
  44. package/src/quantize/job.ts +17 -2
  45. package/src/quantize/quantizer.ts +26 -2
  46. package/src/server.ts +213 -44
  47. package/src/tokenizer.ts +6 -2
  48. package/src/train/dataset.ts +11 -3
  49. package/src/train/flash-cce.ts +18 -5
  50. package/src/train/job.ts +41 -4
  51. package/src/train/loss.ts +10 -4
  52. package/src/train/trainer.ts +62 -3
  53. package/src/train/watch.ts +542 -0
  54. package/src/web/app.html +264 -14
  55. package/src/web/skills/memory/SKILL.md +97 -0
  56. package/src/web/skills/web-research/SKILL.md +3 -1
  57. package/src/web/skills.ts +46 -8
package/src/pi-web.ts CHANGED
@@ -23,43 +23,50 @@
23
23
  // reason } to deny. Read-only tools (read/grep/find/ls) auto-allow.
24
24
  // tool_execution_* events still drive the tool cards (start/update/end).
25
25
 
26
+ import { createHash } from "node:crypto";
26
27
  import { mkdirSync, rmSync } from "node:fs";
27
28
  import { homedir } from "node:os";
28
29
  import { join, resolve } from "node:path";
29
30
  import type { ServerWebSocket, WebSocketHandler } from "bun";
30
31
  import {
31
- createAgentSession,
32
- DefaultResourceLoader,
32
+ createAgentSessionFromServices,
33
+ createAgentSessionRuntime,
34
+ createAgentSessionServices,
33
35
  SessionManager,
34
36
  type AgentSession,
35
37
  type AgentSessionEvent,
38
+ type AgentSessionRuntime,
39
+ type CreateAgentSessionRuntimeFactory,
36
40
  type ExtensionAPI,
37
41
  type SessionEntry,
38
42
  type SessionInfo,
39
43
  type ToolCallEvent,
40
44
  } from "@earendil-works/pi-coding-agent";
41
45
  import type { ImageContent } from "@earendil-works/pi-ai";
42
- import { createWebTools, WEB_TOOL_NAMES } from "./web-tools";
43
- import { materializeBundledSkills } from "./web/skills";
46
+ import { WEB_TOOL_NAMES } from "./web-tools";
47
+ import { MEMORY_TOOL_NAMES, REFERENCE_TOOL_NAMES } from "./memory/tools";
48
+ import { buildPiAgentSurface } from "./pi-session";
44
49
  import { buildPiProvider, DEFAULT_CONTEXT_WINDOW, PI_LOCAL_MODEL_ID } from "./pi-provider";
45
50
  import { downloadsSnapshot } from "./download";
46
51
 
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
52
  /**
55
53
  * Tools that never mutate the user's machine; auto-allowed without a browser
56
54
  * round-trip. The web tools make outbound network requests but change nothing
57
55
  * locally, so they're auto-allowed too (and remain usable in read-only mode).
58
56
  */
59
- const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOL_NAMES]);
57
+ const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOL_NAMES, ...MEMORY_TOOL_NAMES, ...REFERENCE_TOOL_NAMES]);
60
58
  /** Tools that require explicit per-call browser approval. */
61
59
  const GATED_TOOLS = new Set(["bash", "edit", "write"]);
62
60
 
61
+ /**
62
+ * The welcome assistant's tool allowlist: exactly `read` (a local file the user
63
+ * points to) and `web_search` (current/external facts). Both are in
64
+ * READ_ONLY_TOOLS, so neither triggers the approval gate. Kept to two because a
65
+ * 1B model over-calls a larger toolset; widen this list (not the system prompt)
66
+ * to grant more.
67
+ */
68
+ const WELCOME_TOOLS = ["read", "web_search"] as const;
69
+
63
70
  /** Auto-deny a pending approval after this long with no browser decision. */
64
71
  const APPROVAL_TIMEOUT_MS = 120_000;
65
72
 
@@ -67,72 +74,51 @@ const APPROVAL_TIMEOUT_MS = 120_000;
67
74
  * System prompt for the web chat assistant.
68
75
  *
69
76
  * 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.
77
+ * buildSystemPrompt's `customPrompt` via DefaultResourceLoader.systemPrompt),
78
+ * dropping pi's "operating inside a coding-agent harness" framing and its block
79
+ * of internal documentation paths. pi still auto-appends the current date and
80
+ * working directory, so we don't repeat them here.
75
81
  *
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.
82
+ * Deliberately SHORT. The default served model is a ~1B local model; a long,
83
+ * "make the user feel welcome / here is everything mlx-bun does" prompt made it
84
+ * fixate on greeting and ignore the user's actual message (verified against the
85
+ * live server: the same model answers correctly with a short prompt and drowns
86
+ * with the long one). So: state identity + privacy in one breath, then tell it
87
+ * plainly to answer what was asked and not to greet or recite capabilities.
79
88
  */
89
+ export const WEB_CHAT_PROMPT_VERSION = "2026-06-21-minimal-v1";
90
+
91
+ export function webChatPromptFingerprint(prompt: string): string {
92
+ return createHash("sha256").update(prompt).digest("hex").slice(0, 12);
93
+ }
94
+
80
95
  export function buildWebChatSystemPrompt(
81
96
  readOnly: boolean,
82
97
  about?: { modelId?: string; downloadingModel?: string | null },
98
+ opts?: { hasTools?: boolean },
83
99
  ): 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
100
  const servedModel =
108
101
  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.`;
102
+ const modelLine = servedModel ? ` You are running on the local model \`${servedModel}\`.` : "";
103
+
104
+ // A concise mlx-bun blurb so the welcome assistant can answer product
105
+ // questions from its own knowledge (no tool, no fragile cwd-relative reads).
106
+ // Kept SHORT on purpose — the old multi-paragraph product wall drowned the 1B
107
+ // model. The blurb + the two key commands cover "what is it / how do I start".
108
+ const aboutLine = ` mlx-bun runs open LLMs locally with MLX + Bun: a built-in chat, an OpenAI/Anthropic-compatible API, model download and serving, quantization, LoRA fine-tuning, and adapters — all on-device and private. Key commands: \`mlx-bun serve <model>\` to serve one, \`mlx-bun get <repo-id>\` to download one. You already know this, so answer questions about mlx-bun directly.`;
109
+
110
+ // The tool guidance MUST match the session's actual surface (read +
111
+ // web_search), or the model promises actions it can't take. Naming exactly
112
+ // those two — and telling it to answer from knowledge first — is what keeps a
113
+ // small model from reaching for a tool on math/writing/general questions.
114
+ const hasTools = opts?.hasTools ?? true;
115
+ const toolsLine = hasTools
116
+ ? ` Answer directly from your own knowledge whenever you can. Only call a tool when you truly need information you don't have: \`web_search\` for current or external facts (news, current events, prices, latest docs), \`read\` for a specific local file the user points you to. Never use a tool for general questions, explanations, math, or writing — just answer. When a tool returns results, pull the answer out of the result text and state it directly — never tell the user to go open the links or check the sources themselves; reading them is your job.`
117
+ : ` You have no tools in this session, so answer from your own knowledge; if something needs current or external data you can't reach, say so briefly instead of pretending to look it up.`;
118
+
119
+ return `You are mlx-bun's built-in assistant, running entirely on the user's own Apple-silicon Mac — nothing they type leaves the machine.${modelLine}${aboutLine}${toolsLine}
120
+
121
+ Respond to what the user actually said, concisely. Don't open with a generic greeting or recite your capabilities unless asked — just answer. If a request is genuinely ambiguous, ask one short clarifying question. Format with Markdown when it helps.`;
136
122
  }
137
123
 
138
124
  /** Per-connection data the server attaches at upgrade time. */
@@ -156,7 +142,6 @@ interface ImageAttachment {
156
142
  /** Client -> server frames. */
157
143
  type ClientMessage =
158
144
  | { type: "prompt"; text: string; images?: ImageAttachment[] }
159
- | { type: "steer"; text: string; images?: ImageAttachment[] }
160
145
  | { type: "abort" }
161
146
  | { type: "approval"; callId: string; decision: ApprovalDecision }
162
147
  // Toggle the model's reasoning channel on/off (only meaningful when the
@@ -167,6 +152,12 @@ type ClientMessage =
167
152
  // app.html mounts it (POST /v1/adapters) before sending this; the
168
153
  // before_provider_request hook injects it into the provider payload.
169
154
  | { type: "set_adapter"; id: string | null }
155
+ // Per-request sampling overrides for subsequent turns. Each field is
156
+ // optional; null/undefined means "leave it to the server default"
157
+ // (the mode-aware recommended value resolved in toOptions). A present
158
+ // numeric value is injected into the provider payload and always wins.
159
+ // The before_provider_request hook injects whatever is set here.
160
+ | { type: "set_sampling"; temperature?: number | null; top_p?: number | null; top_k?: number | null }
170
161
  // Session management (recent-chats sidebar + new chat).
171
162
  | { type: "new_session" }
172
163
  | { type: "list_sessions" }
@@ -411,11 +402,49 @@ export function injectAdapter(
411
402
  return { ...payload, adapter: selected };
412
403
  }
413
404
 
405
+ /** Per-request sampling overrides carried on the session and injected into the
406
+ * provider payload by the before_provider_request hook. Each field is either a
407
+ * user-set override or null/undefined ("use the server's mode-aware default"). */
408
+ export interface SamplingOverrides {
409
+ temperature?: number | null;
410
+ top_p?: number | null;
411
+ top_k?: number | null;
412
+ }
413
+
414
+ /** Inject the user's sampling overrides into the outgoing chat-completions
415
+ * payload (which reaches the server's toOptions, where an explicit
416
+ * temperature/top_p/top_k always wins). Only finite numbers are injected; a
417
+ * null/undefined/unset field is left off so the server falls back to its
418
+ * mode-aware recommended default. Returns undefined when nothing is set so the
419
+ * hook can keep the payload unchanged. Pure + exported for unit testing. */
420
+ export function injectSampling(
421
+ payload: Record<string, unknown>,
422
+ s: SamplingOverrides | undefined,
423
+ ): Record<string, unknown> | undefined {
424
+ if (!s) return undefined;
425
+ const out: Record<string, unknown> = { ...payload };
426
+ let changed = false;
427
+ if (typeof s.temperature === "number" && Number.isFinite(s.temperature)) {
428
+ out.temperature = s.temperature;
429
+ changed = true;
430
+ }
431
+ if (typeof s.top_p === "number" && Number.isFinite(s.top_p)) {
432
+ out.top_p = s.top_p;
433
+ changed = true;
434
+ }
435
+ if (typeof s.top_k === "number" && Number.isFinite(s.top_k)) {
436
+ out.top_k = s.top_k;
437
+ changed = true;
438
+ }
439
+ return changed ? out : undefined;
440
+ }
441
+
414
442
  /**
415
443
  * One browser connection's pi agent. Owns the AgentSession, the event
416
444
  * subscription, and the pending tool-approval handshakes.
417
445
  */
418
446
  class PiWebSession {
447
+ private runtime?: AgentSessionRuntime;
419
448
  private session?: AgentSession;
420
449
  /** SessionManager backing the active AgentSession (disk-persisted). */
421
450
  private sessionManager?: SessionManager;
@@ -424,6 +453,10 @@ class PiWebSession {
424
453
  /** Active LoRA adapter id for this connection (null = none/base model).
425
454
  * Read by the before_provider_request hook; set via the set_adapter msg. */
426
455
  private selectedAdapter: string | null = null;
456
+ /** Per-request sampling overrides for this connection (set via set_sampling).
457
+ * Read by the before_provider_request hook; unset fields fall back to the
458
+ * server's mode-aware recommended defaults. */
459
+ private sampling: SamplingOverrides = {};
427
460
 
428
461
  /** Per-connection invariants, built once in start() and reused across
429
462
  * session switches (new chat / resume / fork). */
@@ -458,6 +491,7 @@ class PiWebSession {
458
491
  this.provider = buildPiProvider(baseUrl, {
459
492
  contextWindow: this.opts.contextWindow,
460
493
  reasoning: this.opts.thinking,
494
+ vision: this.opts.vision,
461
495
  });
462
496
  mkdirSync(this.sessionDir, { recursive: true });
463
497
 
@@ -470,7 +504,7 @@ class PiWebSession {
470
504
  // its own session on a transient reconnect so a blip doesn't strand it on
471
505
  // a blank backend session. Sessions persist to disk in pi's own format —
472
506
  // the substrate for the recent-chats sidebar and the nightly memory pipeline.
473
- await this.activate(SessionManager.create(this.cwd, this.sessionDir));
507
+ await this.replaceRuntime(SessionManager.create(this.cwd, this.sessionDir));
474
508
  if (this.disposed) return;
475
509
 
476
510
  this.send({ type: "ready", model: this.opts.modelId, vision: this.opts.vision, thinking: this.opts.thinking });
@@ -478,97 +512,116 @@ class PiWebSession {
478
512
  await this.sendSessions();
479
513
  }
480
514
 
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, {
515
+ /** Runtime factory used for initial session creation and SDK-managed replacements. */
516
+ private createRuntimeFactory(): CreateAgentSessionRuntimeFactory {
517
+ return async ({ cwd, sessionManager, sessionStartEvent }) => {
518
+ const provider = this.provider;
519
+ if (!provider) throw new Error("provider not initialized");
520
+
521
+ // Welcome-assistant tool surface: exactly two read-only tools —
522
+ // `web_search` (current/external facts) and `read` (a local file the user
523
+ // points to). Both auto-allow (no approval round-trip). We deliberately
524
+ // do NOT expose web_fetch/weather/bash/edit/write/grep/find/ls: a 1B model
525
+ // over-calls a big toolset. With thinking ON (the web chat default) it
526
+ // uses these two appropriately; build the web tools (for the web_search
527
+ // definition) then restrict the allowlist to the two names below.
528
+ const surface = await buildPiAgentSurface({ webTools: true, codingTools: false });
529
+ const webPrompt = buildWebChatSystemPrompt(this.opts.readOnly, {
510
530
  modelId: this.opts.modelId,
511
531
  downloadingModel: downloadsSnapshot().find(
512
532
  (d) => d.state === "active" && d.repoId !== this.opts.modelId,
513
533
  )?.repoId ?? null,
514
- }),
515
- extensionFactories: [
516
- (pi) => this.installApprovalGate(pi),
517
- (pi) => this.installAdapterHook(pi),
518
- ],
519
- });
520
- await resourceLoader.reload();
534
+ }, { hasTools: WELCOME_TOOLS.length > 0 }) + surface.memoryHint;
535
+ if (process.env.MLX_BUN_PI_DEBUG) {
536
+ console.error(`[pi-web] prompt ${WEB_CHAT_PROMPT_VERSION} sha=${webChatPromptFingerprint(webPrompt)} memory=${surface.memoryEnabled ? "on" : "off"}`);
537
+ }
538
+ const services = await createAgentSessionServices({
539
+ cwd,
540
+ agentDir: this.agentDir,
541
+ authStorage: provider.authStorage,
542
+ modelRegistry: provider.modelRegistry,
543
+ resourceLoaderOptions: {
544
+ noExtensions: true,
545
+ noSkills: true,
546
+ noPromptTemplates: true,
547
+ noThemes: true,
548
+ noContextFiles: true,
549
+ additionalSkillPaths: surface.skillPaths,
550
+ systemPrompt: webPrompt,
551
+ extensionFactories: [
552
+ (pi) => this.installApprovalGate(pi),
553
+ (pi) => this.installAdapterHook(pi),
554
+ ],
555
+ },
556
+ });
521
557
 
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;
558
+ return {
559
+ ...(await createAgentSessionFromServices({
560
+ services,
561
+ sessionManager,
562
+ sessionStartEvent,
563
+ model: provider.model,
564
+ // Allowlist (not surface.tools): `read` is a pi built-in enabled by
565
+ // name; `web_search` is the custom tool from surface.customTools. pi
566
+ // exposes only names in this list, so web_fetch/weather stay defined
567
+ // but hidden.
568
+ tools: [...WELCOME_TOOLS],
569
+ customTools: surface.customTools,
570
+ })),
571
+ services,
572
+ diagnostics: services.diagnostics,
573
+ };
574
+ };
534
575
  }
535
576
 
536
- /** Tear down the active session (keeps the socket + provider invariants). */
537
- private teardownActive(): void {
577
+ /** Tear down UI/session bindings owned by this WebSocket. Runtime.dispose()
578
+ * owns the AgentSession itself; this method only detaches browser state. */
579
+ private teardownBindings(): void {
538
580
  for (const settle of this.pendingApprovals.values()) settle("deny");
539
581
  this.pendingApprovals.clear();
540
582
  for (const timer of this.approvalTimers.values()) clearTimeout(timer);
541
583
  this.approvalTimers.clear();
542
584
  this.unsubscribe?.();
543
585
  this.unsubscribe = undefined;
544
- try {
545
- this.session?.dispose();
546
- } catch {
547
- // Never let a dispose error escape teardown.
548
- }
549
586
  this.session = undefined;
550
587
  this.sessionManager = undefined;
551
588
  }
552
589
 
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> {
590
+ /** Bind browser event plumbing to the current runtime.session. */
591
+ private async bindRuntimeSession(): Promise<void> {
592
+ const session = this.runtime?.session;
593
+ if (!session) return;
594
+ this.unsubscribe?.();
595
+ this.session = session;
596
+ this.sessionManager = session.sessionManager;
597
+ await session.bindExtensions({ mode: "rpc" });
598
+ this.unsubscribe = session.subscribe((event) => this.onSessionEvent(event));
599
+ this.sendContextUsage();
600
+ }
601
+
602
+ /** Replace the whole pi runtime with a target SessionManager. Used for the
603
+ * initial session and for file-level fork, while open/new use runtime APIs. */
604
+ private async replaceRuntime(sm: SessionManager): Promise<void> {
557
605
  try {
558
606
  await this.session?.abort();
559
607
  } catch {
560
608
  // Old turn may already be done; ignore.
561
609
  }
562
- const session = await this.buildAgentSession(sm);
610
+ const next = await createAgentSessionRuntime(this.createRuntimeFactory(), {
611
+ cwd: this.cwd,
612
+ agentDir: this.agentDir,
613
+ sessionManager: sm,
614
+ });
615
+ next.setRebindSession(async () => this.bindRuntimeSession());
563
616
  if (this.disposed) {
564
- session.dispose();
617
+ await next.dispose();
565
618
  return;
566
619
  }
567
- this.teardownActive();
568
- this.session = session;
569
- this.sessionManager = sm;
570
- this.unsubscribe = session.subscribe((event) => this.onSessionEvent(event));
571
- this.sendContextUsage();
620
+ const previous = this.runtime;
621
+ this.teardownBindings();
622
+ this.runtime = next;
623
+ await this.bindRuntimeSession();
624
+ if (previous) await previous.dispose();
572
625
  }
573
626
 
574
627
  /** Replay the active session's transcript to the browser (rebuilds thread). */
@@ -609,7 +662,11 @@ class PiWebSession {
609
662
 
610
663
  /** Start a fresh chat (old one stays on disk, resumable from the sidebar). */
611
664
  private async newSession(): Promise<void> {
612
- await this.activate(SessionManager.create(this.cwd, this.sessionDir));
665
+ if (!this.runtime) {
666
+ await this.replaceRuntime(SessionManager.create(this.cwd, this.sessionDir));
667
+ } else {
668
+ await this.runtime.newSession();
669
+ }
613
670
  this.sendHistory();
614
671
  await this.sendSessions();
615
672
  }
@@ -620,7 +677,8 @@ class PiWebSession {
620
677
  this.send({ type: "error", message: "invalid session path" });
621
678
  return;
622
679
  }
623
- await this.activate(SessionManager.open(path, this.sessionDir));
680
+ if (!this.runtime) await this.replaceRuntime(SessionManager.open(path, this.sessionDir));
681
+ else await this.runtime.switchSession(path);
624
682
  this.sendHistory();
625
683
  await this.sendSessions();
626
684
  }
@@ -631,7 +689,9 @@ class PiWebSession {
631
689
  this.send({ type: "error", message: "invalid session path" });
632
690
  return;
633
691
  }
634
- await this.activate(SessionManager.forkFrom(path, this.cwd, this.sessionDir));
692
+ // File-level fork is not a runtime primitive, so create the target
693
+ // SessionManager then replace the runtime through the same SDK factory.
694
+ await this.replaceRuntime(SessionManager.forkFrom(path, this.cwd, this.sessionDir));
635
695
  this.sendHistory();
636
696
  await this.sendSessions();
637
697
  }
@@ -657,15 +717,24 @@ class PiWebSession {
657
717
  * adapter into every provider request (Pi-native adapter control, mirrors the
658
718
  * CLI extension). Default none = no injection (base model). */
659
719
  private installAdapterHook(pi: ExtensionAPI): void {
660
- pi.on("before_provider_request", (event) =>
661
- injectAdapter(event.payload as Record<string, unknown>, this.selectedAdapter),
662
- );
720
+ pi.on("before_provider_request", (event) => {
721
+ let payload = event.payload as Record<string, unknown>;
722
+ // Layer both injections: adapter selection, then sampling overrides.
723
+ // Each returns undefined when it has nothing to change, so we keep the
724
+ // prior payload in that case.
725
+ payload = injectAdapter(payload, this.selectedAdapter) ?? payload;
726
+ payload = injectSampling(payload, this.sampling) ?? payload;
727
+ return payload;
728
+ });
663
729
  }
664
730
 
665
731
  /** Register the tool_call approval gate on the inline extension. */
666
732
  private installApprovalGate(pi: ExtensionAPI): void {
667
733
  pi.on("tool_call", async (event: ToolCallEvent) => {
668
734
  const tool = event.toolName;
735
+ if (process.env.MLX_BUN_PI_DEBUG) {
736
+ console.error(`[pi-web] tool_call ${tool} args=${JSON.stringify(event.input)}`);
737
+ }
669
738
 
670
739
  // Read-only tools never need approval.
671
740
  if (READ_ONLY_TOOLS.has(tool)) return undefined;
@@ -675,8 +744,8 @@ class PiWebSession {
675
744
  return { block: true, reason: "Read-only session: mutating tools are disabled." };
676
745
  }
677
746
 
678
- // Non-gated, non-read-only tools (shouldn't happen with ALL_TOOLS,
679
- // but be safe): allow.
747
+ // Non-gated, non-read-only tools (shouldn't happen with the shared pi
748
+ // surface, but be safe): allow.
680
749
  if (!GATED_TOOLS.has(tool)) return undefined;
681
750
 
682
751
  const decision = await this.requestApproval(event);
@@ -768,26 +837,24 @@ class PiWebSession {
768
837
  }
769
838
 
770
839
  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) });
840
+ // The whole chat is just this: hand the user's message to pi's
841
+ // AgentSession and let pi run the turn (reply or tool calls, which pi
842
+ // executes). The ONLY decision we make is the canonical idle-vs-streaming
843
+ // branch from pi's own prompt() contract (core/agent-session.ts): an idle
844
+ // session runs a normal turn; if a turn is already streaming (the user
845
+ // typed again before it finished), the message is queued as a follow-up
846
+ // so it becomes the next turn instead of being dropped. pi re-checks
847
+ // isStreaming atomically inside prompt(), so this read can't strand a
848
+ // message at the turn boundary.
849
+ case "prompt": {
850
+ if (process.env.MLX_BUN_PI_DEBUG) {
851
+ console.error(`[pi-web] prompt text=${JSON.stringify(msg.text.slice(0, 500))} images=${msg.images?.length ?? 0} streaming=${session.isStreaming} adapter=${this.selectedAdapter ?? "base"}`);
852
+ }
853
+ const images = toPiImages(msg.images);
854
+ if (session.isStreaming) await session.prompt(msg.text, { streamingBehavior: "followUp", images });
855
+ else await session.prompt(msg.text, { images });
790
856
  return;
857
+ }
791
858
  case "abort":
792
859
  await session.abort();
793
860
  return;
@@ -805,13 +872,26 @@ class PiWebSession {
805
872
  // app.html has already mounted it server-side (POST /v1/adapters).
806
873
  this.selectedAdapter = msg.id;
807
874
  return;
875
+ case "set_sampling":
876
+ // Record the per-request sampling overrides; the
877
+ // before_provider_request hook injects any set fields into the
878
+ // outgoing payload. A null/undefined field clears the override so the
879
+ // server's mode-aware default applies again.
880
+ this.sampling = {
881
+ temperature: msg.temperature ?? null,
882
+ top_p: msg.top_p ?? null,
883
+ top_k: msg.top_k ?? null,
884
+ };
885
+ return;
808
886
  }
809
887
  }
810
888
 
811
889
  /** Tear down the session and reject any in-flight approvals. */
812
890
  dispose(): void {
813
891
  this.disposed = true;
814
- this.teardownActive();
892
+ this.teardownBindings();
893
+ void this.runtime?.dispose();
894
+ this.runtime = undefined;
815
895
  }
816
896
 
817
897
  /** Send a frame, swallowing errors on a closed socket. */
@@ -52,14 +52,29 @@ export const quantizeRunner: JobRunner = async (emit: Emit, config) => {
52
52
 
53
53
  const srcDir = resolveSrcDir(config);
54
54
 
55
+ // Mixed-precision (OptiQ sensitivity sweep + knapsack) is triggered by
56
+ // targetBpw. The server/CLI send these as snake_case in the job config — they
57
+ // MUST be forwarded into opts or quantizeModelDir silently runs uniform.
58
+ const targetBpw = config.target_bpw != null ? Number(config.target_bpw) : undefined;
59
+ const mixed = targetBpw !== undefined;
60
+
55
61
  emit({
56
62
  type: "stage",
57
63
  stage: "starting",
58
64
  progress: 0.01,
59
- message: `Quantizing ${srcDir} → ${bits}-bit (g${groupSize})`,
65
+ message: mixed
66
+ ? `Quantizing ${srcDir} → mixed ${targetBpw} bpw (OptiQ sensitivity sweep, ~minutes)`
67
+ : `Quantizing ${srcDir} → ${bits}-bit (g${groupSize})`,
60
68
  });
61
69
 
62
- const opts: QuantizeOptions = { bits, groupSize, mode: String(config.mode ?? "affine") };
70
+ const opts: QuantizeOptions = {
71
+ bits, groupSize, mode: String(config.mode ?? "affine"),
72
+ ...(targetBpw !== undefined ? { targetBpw } : {}),
73
+ ...(Array.isArray(config.candidate_bits) ? { candidateBits: (config.candidate_bits as number[]).map(Number) } : {}),
74
+ ...(config.reference ? { reference: String(config.reference) } : {}),
75
+ ...(config.calibration_mix ? { calibrationMix: String(config.calibration_mix) } : {}),
76
+ ...(config.n_calibration != null ? { nCalibration: Number(config.n_calibration) } : {}),
77
+ };
63
78
 
64
79
  const r = await quantizeModelDir(srcDir, outDir, opts, (e) =>
65
80
  emit({ type: "stage", stage: e.stage, progress: e.progress, message: e.message }),