@mlx-bun/module-chat 0.0.0-stage → 1.0.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josh Rossi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,86 @@
1
- # Temporary Holding Version
1
+ # @mlx-bun/module-chat
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The default assistant as an application module (`AppModule<"modelHost" | "storage" | "registry">`, design:
4
+ [Modular application](../../ARCHITECTURE.md#modular-application)): a Pi agent over the served model, one session per
5
+ browser tab on a WebSocket, saved chats, durable tool approvals, the tools other modules contribute, and the browser
6
+ panel. Nothing here imports an app or another module; the model, the storage root and the registry come from the host.
7
+ The mlx-bun app installs it (`apps/mlx-bun/src/modules.ts`) and runs it in its persistent state.
8
+
9
+ ## What it serves
10
+
11
+ Declared in `src/manifest.ts`, at the paths the browser and external tools already use (all `mount: "root"`):
12
+ `GET /ws/chat` (the socket), `GET /api/sessions/search`, `GET /api/sessions/export` (read-only over the session
13
+ directory), and `GET`/`DELETE /api/settings/tool-approvals`. The host serves the socket (`createModuleSockets` in
14
+ `@mlx-bun/app-services`) and stops the module, which closes every chat, before it drains. A host that serves no
15
+ sockets refuses the module.
16
+
17
+ | Storage entry | Path under `MLX_BUN_HOME` | Holds |
18
+ | --- | --- | --- |
19
+ | `sessions` | `sessions/` | Saved chats, one Pi JSONL file each; the memory pipeline reads them too |
20
+ | `agent` | `pi-sessions/` | The Pi agent's own directory |
21
+ | `approvals` | `tool-approvals.json` | Tools the user chose to always allow (mode 0600; absent, corrupt or unknown-version data means ask again) |
22
+
23
+ Entries resolve when a chat or route first needs them, so a host that never chats writes nothing. An embedder's explicit
24
+ path for an entry wins (`createStorage`'s `explicit` map); `createChatModule({ readOnly, cwd })` carries what the host
25
+ decides beyond its services: a read-only server (no file-changing tool, every gated call denied) and Pi's working
26
+ directory.
27
+
28
+ ## The model
29
+
30
+ Every model access goes through `modelHost`. A connection describes the current model from its wire when it starts
31
+ (`src/describe.ts`: `GET /v1/models` for capabilities and sampling defaults, `/stats` for the enforced context), so
32
+ the same code serves a model in this process and one behind an isolation worker, and a switch is followed because each
33
+ request asks the host for the *current* model. The Pi SDK takes a base URL, not a transport, so the module runs a private
34
+ loopback (`src/model-bridge.ts`: `127.0.0.1`, an ephemeral port, started with the first chat, a per-run bearer token, only
35
+ `POST /v1/chat/completions`) that leases the current model's `generate` for each request and releases the lease when the
36
+ response body ends or is cancelled. Pi's own model id, `local`, is the host's to route.
37
+
38
+ ## What other modules contribute
39
+
40
+ `src/extensions.ts` reads the registry when each session is built: `chat.tool` contributions (a name, schema, `run`, and
41
+ the attestation `readOnly: true`; a tool that does not attest is never offered; an optional `available()` decides per
42
+ session) become Pi tools that run without an approval prompt, and `chat.guidance` contributions add a prompt hint and a
43
+ skill directory. Memory is the first contributor (`@mlx-bun/module-memory/chat`), offered only while the vault exists.
44
+
45
+ ## Browser panel
46
+
47
+ `src/panel/` defines `<mlx-chat-panel>`, the module's `panel` (`workspace: true`): the shell gives it the whole page, leads
48
+ the tabs with it, keeps it attached while another page shows (a turn in flight survives a visit elsewhere) and calls its
49
+ `enter()` and `leave()`. It renders into the light DOM with its own markup (`template.ts`) and stylesheet (`style.ts`),
50
+ takes the host page's design tokens and base element styles, and imports only its own files and `protocol.ts`, the data
51
+ protocol it shares with the backend. It carries its own markdown renderer, composer (attachments, sampling and system-prompt
52
+ popovers, `#` mentions, chat-with-files retrieval), recent-chats sidebar, adapter routing table, the hold-to-talk mic
53
+ (`voice.ts`, which calls transcription's `/v1/audio/sessions` routes over HTTP), and the app-aware assistant.
54
+
55
+ What the panel needs from its host page, beyond its connection, is the `host` property (`panel/host.ts`, all optional): a
56
+ `mounted` callback (memory attaches its sidebar entry and consent card), a `toolCard` hook (memory's provenance chips),
57
+ and the agent-tools `settings` section of the host's settings dialog. Its methods for the host are `newChat`,
58
+ `copyLastResponse`, `setCodingTools`, `refreshAdapters`, `focusComposer`, `toggleThinking`, `recentChats`, `openSession`,
59
+ `exportActiveChat`, the mobile drawer (`toggleDrawer`, `closeDrawer`, `drawerOpen`) and the popovers for the host's Escape
60
+ sweep (`popoverOpen`, `closePopover`). The host page also supplies a `#toasts` container and, for the assistant, its own
61
+ routes, overlays and spotlight targets (`panel/ui-catalog.ts` names them: the chat's knowledge of this app, which should
62
+ move to contributions once the shell lists them).
63
+
64
+ The assistant follows open shadow roots when it captures visible controls or resolves spotlight targets, so the job
65
+ modules' encapsulated forms remain available in its page context. Its wizard-step observer rebinds on navigation as
66
+ the shell mounts those panels, and skips controls inside hidden pages or assistant chrome. Routed spotlight requests
67
+ wait for navigation and layout before resolving a control, including a panel's first visit or return after detachment.
68
+
69
+ ## Tests
70
+
71
+ Model-free. [module](tests/module.test.ts) loads the module in a host with a scripted model host and drives a real chat on a
72
+ real listener with the real Pi SDK: the wire-described `ready`, a prompt, a contributed read-only tool, the answer, and the
73
+ saved chat reopened with the same transcript; the socket's close codes; the read-only policy.
74
+ [model-bridge](tests/model-bridge.test.ts), [describe](tests/describe.test.ts) and [extensions](tests/extensions.test.ts)
75
+ cover the loopback (authorization, leases), the description and the registry surface. The moved suites keep their
76
+ expectations: [chat-backend](tests/chat-backend.test.ts) (socket lifetime, Pi disposal, the approval gate, facts read at
77
+ connect), [chat-policy](tests/chat-policy.test.ts), [chat-runtime](tests/chat-runtime.test.ts) (the real SDK against fixed
78
+ loopback SSE, transcripts in app-supplied paths), [session-search](tests/session-search.test.ts),
79
+ [tool-approvals](tests/tool-approvals.test.ts), [web-tools](tests/web-tools.test.ts), [routes](tests/routes.test.ts), and
80
+ [panel](tests/panel.test.ts) (happy-dom: markdown streaming parity, the turn lifecycle through the real element, composer,
81
+ mentions, adapters, retrieval, the assistant). The app's boot test
82
+ ([boot](../../apps/mlx-bun/tests/web/boot.test.ts)) runs the built bundle against `app.html` and covers the panel mounted
83
+ by the shell, its host side and its drawer. The opt-in
84
+ [real-weights test](../../apps/mlx-bun/tests/engine/chat-native.test.ts) (`MLX_BUN_APP_TEST_MODEL=<snapshot directory>`)
85
+ serves a model through the app and runs a greedy chat with a read-only tool call and a reopened session; with
86
+ `MLX_BUN_CHAT_TRANSCRIPT=<file>` it writes the transcript's structure so two builds can be compared.
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@mlx-bun/module-chat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Chat module for mlx-bun applications: the default assistant, a Pi agent over the served model on a WebSocket, saved chats, tool approvals and the browser panel",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "bun": ">=1.4.2"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./manifest": "./src/manifest.ts",
13
+ "./panel": "./src/panel/index.ts"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "typecheck": "bun ../../scripts/typecheck.ts --noEmit",
21
+ "test": "bun test"
22
+ },
23
+ "dependencies": {
24
+ "@mlx-bun/app-core": "1.0.0",
25
+ "@mlx-bun/inference": "1.0.0",
26
+ "@earendil-works/pi-ai": "0.80.3",
27
+ "@earendil-works/pi-coding-agent": "0.80.3",
28
+ "typebox": "1.1.38"
29
+ },
30
+ "devDependencies": {
31
+ "@mlx-bun/app-host": "1.0.0",
32
+ "@mlx-bun/app-services": "1.0.0",
33
+ "happy-dom": "20.10.6"
34
+ },
35
+ "os": [
36
+ "darwin"
37
+ ],
38
+ "cpu": [
39
+ "arm64"
40
+ ]
41
+ }
@@ -0,0 +1,101 @@
1
+ import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { APP_ROUTE_IDS, type AppRouteId, type AppUiContext, type ServerMessage } from "./protocol";
4
+ import { resolveAppRoute } from "./policy";
5
+
6
+ function appAwareTextResult(text: string, details: unknown = {}): { content: [{ type: "text"; text: string }]; details: unknown } {
7
+ return { content: [{ type: "text", text }], details };
8
+ }
9
+
10
+ /** Build the three app-aware tools for one connection. `getContext` reads
11
+ * the connection's currentAppContext (last `context` ClientMessage, or
12
+ * null before the first one arrives); `notify` sends a ServerMessage
13
+ * (ui_navigate/ui_spotlight) to that same browser tab. Pure enough to
14
+ * unit test the tool -> frame mapping via a fake notify collecting sent
15
+ * frames (tests/chat-policy.test.ts). */
16
+ export function createAppAwareTools(
17
+ getContext: () => AppUiContext | null,
18
+ notify: (msg: ServerMessage) => void,
19
+ ): ToolDefinition[] {
20
+ const getCurrentAppContext = defineTool({
21
+ name: "get_current_app_context",
22
+ label: "Get Current App Context",
23
+ description:
24
+ "Returns the current web-app UI context: route, view, wizard step (if on one), and a uiSnapshot of visible interactive elements (ref, label, selector, spotlightId). Call this before spotlight_ui to pick the correct ref, label, or target — do not guess selectors blind.",
25
+ parameters: Type.Object({}),
26
+ execute: async () => {
27
+ const ctx = getContext();
28
+ if (!ctx) {
29
+ return appAwareTextResult(
30
+ "No app context received yet — the browser hasn't reported its current view. Ask the user what page they're on, or proceed without UI tools.",
31
+ );
32
+ }
33
+ return appAwareTextResult(JSON.stringify(ctx, null, 2), { context: ctx });
34
+ },
35
+ });
36
+
37
+ const navigateApp = defineTool({
38
+ name: "navigate_app",
39
+ label: "Navigate App",
40
+ description:
41
+ "Navigate the user's browser to a different page in the app. Use when they ask to be taken somewhere (e.g. \"take me to Quantize\", \"open the fine-tune wizard\"). Pass route or page as one of: chat, quantize, finetune, dataset, status (or \"#/quantize\"-style). Reversible and never needs approval. Does not highlight a specific control — use spotlight_ui separately for that.",
42
+ parameters: Type.Object({
43
+ route: Type.Optional(Type.String({ description: "chat | quantize | finetune | dataset | status (or #/quantize)" })),
44
+ page: Type.Optional(Type.String({ description: "Same as route — alternate param name" })),
45
+ }),
46
+ execute: async (_id, params) => {
47
+ const resolved = resolveAppRoute(params.route ?? params.page ?? "");
48
+ if (!resolved) {
49
+ return {
50
+ ...appAwareTextResult(`Unknown route. Valid routes: ${APP_ROUTE_IDS.join(", ")}.`),
51
+ isError: true,
52
+ };
53
+ }
54
+ notify({ type: "ui_navigate", route: resolved });
55
+ return appAwareTextResult(
56
+ `Navigating the user to ${resolved}. They can see the page now — use spotlight_ui if you need to point at a specific control.`,
57
+ { route: resolved },
58
+ );
59
+ },
60
+ });
61
+
62
+ const spotlightUi = defineTool({
63
+ name: "spotlight_ui",
64
+ label: "Spotlight UI",
65
+ description:
66
+ "Highlight a visible UI control with a brief, auto-dismissing spotlight (never blocks the user — they can keep working through it). Call get_current_app_context first and prefer ref from its uiSnapshot.elements; or pass label (visible text, e.g. \"Source model\"); or selector; or target (a curated catalog id, e.g. hub-browse, quantize-source). Optional route navigates there first. Optional message is a short hint shown in the popover.",
67
+ parameters: Type.Object({
68
+ ref: Type.Optional(Type.String({ description: "Element ref from uiSnapshot.elements[].ref (preferred)" })),
69
+ label: Type.Optional(Type.String({ description: "Visible label/text to match, e.g. Source model, New chat" })),
70
+ selector: Type.Optional(Type.String({ description: "CSS selector, when ref/label are unknown" })),
71
+ target: Type.Optional(Type.String({ description: "Curated catalog id, e.g. hub-browse, quantize-source, sampling-pill" })),
72
+ route: Type.Optional(Type.String({ description: "Navigate to this route first (chat, quantize, finetune, dataset, status)" })),
73
+ message: Type.Optional(Type.String({ description: "Short hint shown in the spotlight popover" })),
74
+ }),
75
+ execute: async (_id, params) => {
76
+ const hasLocator = !!params.ref?.trim() || !!params.label?.trim() || !!params.selector?.trim() || !!params.target?.trim();
77
+ if (!hasLocator) {
78
+ return { ...appAwareTextResult("Provide at least one of: ref (from uiSnapshot), label, selector, or target."), isError: true };
79
+ }
80
+ let route: AppRouteId | undefined;
81
+ if (params.route) {
82
+ route = resolveAppRoute(params.route) ?? undefined;
83
+ if (!route) {
84
+ return { ...appAwareTextResult(`Unknown route "${params.route}". Valid routes: ${APP_ROUTE_IDS.join(", ")}.`), isError: true };
85
+ }
86
+ }
87
+ notify({
88
+ type: "ui_spotlight",
89
+ ref: params.ref, label: params.label, selector: params.selector, target: params.target,
90
+ route, message: params.message,
91
+ });
92
+ const desc = params.ref ?? params.label ?? params.selector ?? params.target ?? "element";
93
+ return appAwareTextResult(
94
+ `Highlighting ${desc}${params.message ? `: ${params.message}` : ""}. The spotlight fades on its own in a few seconds.`,
95
+ { ...params, route },
96
+ );
97
+ },
98
+ });
99
+
100
+ return [getCurrentAppContext, navigateApp, spotlightUi];
101
+ }
package/src/backend.ts ADDED
@@ -0,0 +1,108 @@
1
+ import type { SocketHandler, SocketPeer } from "@mlx-bun/app-core";
2
+ import type { ClientMessage, ServerMessage } from "./protocol";
3
+
4
+ /** One connection owns one backend. Disposal cancels active turns and pending approvals.
5
+ * It may race start(); implementations must release resources acquired after disposal. */
6
+ export interface ChatBackend {
7
+ start(): Promise<void>;
8
+ handle(message: ClientMessage): Promise<void>;
9
+ dispose(): void | Promise<void>;
10
+ }
11
+ export type SendFrame = (message: ServerMessage) => void;
12
+ export type ChatBackendFactory = (send: SendFrame) => ChatBackend;
13
+
14
+ interface Connection {
15
+ backend: ChatBackend;
16
+ closed: boolean;
17
+ disposal?: Promise<void>;
18
+ }
19
+
20
+ /** Transport owns socket lifetime; the backend owns agent/session lifetime.
21
+ * Messages remain concurrent so abort/approval frames can interrupt an active prompt. */
22
+ export function createChatSocket(factory: ChatBackendFactory): SocketHandler & { dispose(): Promise<void>; readonly connections: number } {
23
+ const connections = new Map<SocketPeer, Connection>();
24
+ const disposing = new Set<Promise<void>>();
25
+ const starting = new Set<Promise<void>>();
26
+ const handling = new Set<Promise<void>>();
27
+ const cleanupErrors: unknown[] = [];
28
+ let closing: Promise<void> | undefined;
29
+ let stopped = false;
30
+ const send = (socket: SocketPeer, message: ServerMessage) => {
31
+ try { socket.send(JSON.stringify(message)); } catch { /* disconnected */ }
32
+ };
33
+ const error = (socket: SocketPeer, cause: unknown) => send(socket, {
34
+ type: "error", message: cause instanceof Error ? cause.message : String(cause),
35
+ });
36
+ const release = (connection: Connection): Promise<void> => {
37
+ if (connection.disposal) return connection.disposal;
38
+ connection.closed = true;
39
+ // Socket callbacks cannot return disposal errors to an owner. Retain them
40
+ // for handler shutdown while observing each rejection immediately.
41
+ const done = connection.disposal = Promise.resolve().then(() => connection.backend.dispose())
42
+ .catch(cause => { cleanupErrors.push(cause); });
43
+ disposing.add(done);
44
+ void done.finally(() => disposing.delete(done));
45
+ return done;
46
+ };
47
+ return {
48
+ /** Chats open now. */
49
+ get connections() { return connections.size; },
50
+ async open(socket) {
51
+ if (stopped) { socket.close(); return; }
52
+ let connection: Connection | undefined;
53
+ try {
54
+ const backend = factory(message => {
55
+ if (!stopped && connection && !connection.closed) send(socket, message);
56
+ });
57
+ connection = { backend, closed: false };
58
+ connections.set(socket, connection);
59
+ const start = backend.start();
60
+ starting.add(start);
61
+ try { await start; } finally { starting.delete(start); }
62
+ } catch (cause) {
63
+ if (!stopped && !connection?.closed) error(socket, cause);
64
+ connections.delete(socket);
65
+ if (connection) await release(connection);
66
+ // Failed startup must not leave an untracked transport blocking listener drain.
67
+ socket.close(1011, "Chat startup failed");
68
+ }
69
+ },
70
+ async message(socket, raw) {
71
+ const connection = connections.get(socket);
72
+ if (!connection || connection.closed) { if (!stopped) error(socket, "no active session"); return; }
73
+ let message: ClientMessage;
74
+ try {
75
+ const parsed: unknown = JSON.parse(raw);
76
+ if (!parsed || typeof parsed !== "object" || typeof (parsed as { type?: unknown }).type !== "string") throw new Error();
77
+ message = parsed as ClientMessage;
78
+ } catch { error(socket, "invalid JSON message"); return; }
79
+ try {
80
+ const operation = connection.backend.handle(message);
81
+ handling.add(operation);
82
+ try { await operation; } finally { handling.delete(operation); }
83
+ } catch (cause) { if (!connection.closed) error(socket, cause); }
84
+ },
85
+ close(socket) {
86
+ const connection = connections.get(socket);
87
+ connections.delete(socket);
88
+ if (connection) void release(connection);
89
+ },
90
+ dispose() {
91
+ return closing ??= (async () => {
92
+ stopped = true;
93
+ for (const [socket, connection] of [...connections]) {
94
+ void release(connection);
95
+ try { socket.close(1001, "Server shutting down"); } catch (error) { cleanupErrors.push(error); }
96
+ }
97
+ connections.clear();
98
+ await Promise.all([...disposing]);
99
+ // Cancel first, then join work that can acquire/release resources after
100
+ // an await. Messages remain concurrent during the connection lifetime.
101
+ await Promise.allSettled([...starting, ...handling]);
102
+ await Promise.all([...disposing]);
103
+ if (cleanupErrors.length === 1) throw cleanupErrors[0];
104
+ if (cleanupErrors.length) throw new AggregateError(cleanupErrors, "chat cleanup failed");
105
+ })();
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,47 @@
1
+ // What the served model is, as the browser needs it (`ready` frame). Read over the model host's
2
+ // own wire when each chat connects: the wire describes whichever model is current, in this process
3
+ // or behind an isolation worker, so chat never asks a model family anything.
4
+ import type { ModelHost } from "@mlx-bun/app-core";
5
+
6
+ export interface ServedModel {
7
+ readonly modelId: string;
8
+ /** The context the server enforces; undefined when it reports none. */
9
+ readonly contextWindow: number | undefined;
10
+ readonly vision: boolean;
11
+ readonly audio: boolean;
12
+ readonly thinking: boolean;
13
+ /** A speech-to-text checkpoint is available: the composer offers hold-to-talk. */
14
+ readonly transcription: boolean;
15
+ readonly genDefaults: { readonly temperature: number | null; readonly topP: number | null; readonly topK: number | null };
16
+ }
17
+
18
+ const number = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) ? value : undefined;
19
+
20
+ /** Reads the current model's discovery row and its enforced context. Rejects when no model is served or the wire answers with an error. */
21
+ export async function describeModel(models: Pick<ModelHost, "acquire" | "defaultFor">, signal?: AbortSignal): Promise<ServedModel> {
22
+ const id = await models.defaultFor("generate");
23
+ if (id === undefined) throw new Error("no model is served");
24
+ const lease = await models.acquire(id, { need: ["generate"], ...(signal ? { signal } : {}) });
25
+ try {
26
+ const generate = lease.operations.generate;
27
+ if (!generate) throw new Error(`model ${id} cannot generate`);
28
+ const read = async (path: string) => {
29
+ const response = await generate(new Request(`http://model.local${path}`, signal ? { signal } : {}));
30
+ if (!response.ok) { await response.body?.cancel().catch(() => {}); throw new Error(`the model host answered ${response.status} on ${path}`); }
31
+ return await response.json() as Record<string, unknown>;
32
+ };
33
+ const [models_, stats] = await Promise.all([read("/v1/models"), read("/stats")]);
34
+ const rows = (Array.isArray(models_.data) ? models_.data : []) as Record<string, unknown>[];
35
+ const row = rows.find(item => item.id === id) ?? rows.find(item => item.current === true) ?? rows.find(item => item.transcription !== true) ?? {};
36
+ const defaults = (row.gen_defaults ?? {}) as Record<string, unknown>;
37
+ const capabilities = (row.capabilities ?? {}) as Record<string, unknown>;
38
+ const admission = (stats.admission ?? {}) as Record<string, unknown>;
39
+ return {
40
+ modelId: id,
41
+ contextWindow: number(admission.enforced_context_tokens) ?? number(row.context_window),
42
+ vision: row.vision === true, audio: row.audio === true, thinking: row.reasoning === true,
43
+ transcription: capabilities.transcription === true,
44
+ genDefaults: { temperature: number(defaults.temperature) ?? null, topP: number(defaults.top_p) ?? null, topK: number(defaults.top_k) ?? null },
45
+ };
46
+ } finally { lease.release(); }
47
+ }
package/src/events.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+ import type { ServerMessage } from "./protocol";
3
+
4
+ /**
5
+ * Translate one pi AgentSessionEvent into zero or more WS frames.
6
+ *
7
+ * Pure and side-effect-free so it can be unit-tested without a live
8
+ * session. The approval gate is handled separately (in the tool_call
9
+ * extension handler), so the tool_execution_start event here simply
10
+ * renders the tool card; it does not itself request approval.
11
+ *
12
+ * Returns [] for events that have no browser-facing representation.
13
+ */
14
+ export function mapEventToFrames(event: AgentSessionEvent): ServerMessage[] {
15
+ switch (event.type) {
16
+ case "turn_start":
17
+ return [{ type: "turn_start" }];
18
+ case "turn_end": {
19
+ // A turn can complete with stopReason "error" (e.g. the model request
20
+ // 400'd) WITHOUT any error being thrown up to the WS message handler, so
21
+ // it would otherwise vanish — the browser just sees an empty turn ("no
22
+ // messages"). Surface it as an error frame so the UI can show it.
23
+ const msg = (event as { message?: { stopReason?: string; errorMessage?: string; responseId?: string } }).message;
24
+ if (msg?.stopReason === "error") {
25
+ return [
26
+ { type: "error", message: msg.errorMessage || "the model request failed" },
27
+ { type: "turn_end" },
28
+ ];
29
+ }
30
+ return [{ type: "turn_end" }];
31
+ }
32
+ case "message_update": {
33
+ const ame = event.assistantMessageEvent;
34
+ if (ame.type === "text_delta") return [{ type: "text_delta", delta: ame.delta }];
35
+ if (ame.type === "thinking_delta") return [{ type: "thinking_delta", delta: ame.delta }];
36
+ return [];
37
+ }
38
+ case "tool_execution_start":
39
+ return [
40
+ {
41
+ type: "tool_start",
42
+ callId: event.toolCallId,
43
+ tool: event.toolName,
44
+ args: event.args,
45
+ },
46
+ ];
47
+ case "tool_execution_update":
48
+ return [
49
+ {
50
+ type: "tool_update",
51
+ callId: event.toolCallId,
52
+ chunk: event.partialResult,
53
+ },
54
+ ];
55
+ case "tool_execution_end":
56
+ return [
57
+ {
58
+ type: "tool_end",
59
+ callId: event.toolCallId,
60
+ ok: !event.isError,
61
+ result: event.result,
62
+ },
63
+ ];
64
+ case "queue_update":
65
+ return [
66
+ {
67
+ type: "queue_update",
68
+ steering: event.steering,
69
+ followUp: event.followUp,
70
+ },
71
+ ];
72
+ default:
73
+ return [];
74
+ }
75
+ }
@@ -0,0 +1,44 @@
1
+ // What other modules contribute to a chat session through the registry: their tools (`chat.tool`)
2
+ // and the guidance for them (`chat.guidance`: a prompt hint and a skill). Read when each session is
3
+ // built, so a contributor that activates later, or whose tools become available later (a memory
4
+ // vault created after startup), is seen by the next chat. The chat never names a contributor.
5
+ import { defineTool } from "@earendil-works/pi-coding-agent";
6
+ import type { TSchema } from "typebox";
7
+ import type { ChatToolContribution, Registry } from "@mlx-bun/app-core";
8
+ import type { MemorySurface } from "./surface";
9
+
10
+ const available = async (contribution: { available?(): boolean | Promise<boolean> }): Promise<boolean> => {
11
+ try { return await contribution.available?.() ?? true; } catch { return false; }
12
+ };
13
+
14
+ /** A contributed tool as a Pi tool definition: the same name, schema and text result, run with the turn's abort signal. */
15
+ export function piTool(contribution: ChatToolContribution) {
16
+ return defineTool({
17
+ name: contribution.name,
18
+ label: contribution.label ?? contribution.name,
19
+ description: contribution.description,
20
+ parameters: contribution.parameters as unknown as TSchema,
21
+ execute: async (_id, params, signal) => ({
22
+ content: [{ type: "text" as const, text: await contribution.run(params as Record<string, unknown>, signal ?? new AbortController().signal) }],
23
+ details: {},
24
+ }),
25
+ });
26
+ }
27
+
28
+ /** The registry's tools and guidance as the surface a session is built with; undefined when nothing is available. Only tools that attest `readOnly` are offered. */
29
+ export function createExtensionSurface(registry: Pick<Registry, "list">): () => Promise<MemorySurface | undefined> {
30
+ return async () => {
31
+ const tools = (await Promise.all(registry.list("chat.tool").map(async entry =>
32
+ entry.contribution.readOnly === true && await available(entry.contribution) ? [entry.contribution] : []))).flat();
33
+ const guidance = (await Promise.all(registry.list("chat.guidance").map(async entry => await available(entry.contribution) ? [entry.contribution] : []))).flat();
34
+ if (!tools.length) return undefined;
35
+ const skillPaths: string[] = [], hints: string[] = [];
36
+ for (const item of guidance) {
37
+ const path = await item.skillPath?.();
38
+ if (path && !skillPaths.includes(path)) skillPaths.push(path);
39
+ const hint = await item.hint?.();
40
+ if (hint) hints.push(hint);
41
+ }
42
+ return { readOnly: true, toolNames: tools.map(tool => tool.name), customTools: tools.map(piTool), skillPaths, hint: hints.join("") };
43
+ };
44
+ }