@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 +21 -0
- package/README.md +85 -2
- package/package.json +39 -4
- package/src/app-tools.ts +101 -0
- package/src/backend.ts +108 -0
- package/src/describe.ts +47 -0
- package/src/events.ts +75 -0
- package/src/extensions.ts +44 -0
- package/src/history.ts +188 -0
- package/src/index.ts +74 -0
- package/src/manifest.ts +26 -0
- package/src/model-bridge.ts +75 -0
- package/src/panel/adapters-panel.ts +287 -0
- package/src/panel/assistant.ts +469 -0
- package/src/panel/chat.ts +1076 -0
- package/src/panel/composer.ts +947 -0
- package/src/panel/dom.ts +94 -0
- package/src/panel/host.ts +28 -0
- package/src/panel/index.ts +100 -0
- package/src/panel/markdown.ts +547 -0
- package/src/panel/popovers.ts +17 -0
- package/src/panel/rag.ts +274 -0
- package/src/panel/sessions.ts +470 -0
- package/src/panel/style.ts +532 -0
- package/src/panel/template.ts +189 -0
- package/src/panel/ui-catalog.ts +77 -0
- package/src/panel/voice.ts +141 -0
- package/src/pi-backend.ts +989 -0
- package/src/policy.ts +417 -0
- package/src/protocol.ts +265 -0
- package/src/provider.ts +72 -0
- package/src/routes.ts +49 -0
- package/src/session-files.ts +36 -0
- package/src/session-search.ts +175 -0
- package/src/surface.ts +26 -0
- package/src/tool-approvals.ts +108 -0
- package/src/web-tools.ts +478 -0
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
|
-
#
|
|
1
|
+
# @mlx-bun/module-chat
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/app-tools.ts
ADDED
|
@@ -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
|
+
}
|
package/src/describe.ts
ADDED
|
@@ -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
|
+
}
|