@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
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
import { recordedSessionCwd } from "./session-files";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { runtimeValue } from "@mlx-bun/inference/runtime/config";
|
|
5
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, SessionManager,
|
|
6
|
+
type AgentSession, type AgentSessionEvent, type AgentSessionRuntime, type CreateAgentSessionRuntimeFactory,
|
|
7
|
+
type ExtensionAPI, type SessionInfo, type ToolCallEvent } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { ApprovalDecision, ClientMessage, AppUiContext, ReadyGenDefaults, ServerMessage, SamplingOverrides } from "./protocol";
|
|
10
|
+
import type { ChatBackend, ChatBackendFactory, SendFrame } from "./backend";
|
|
11
|
+
import { buildPiAgentSurface, type MemorySurface } from "./surface";
|
|
12
|
+
import { buildPiProvider, DEFAULT_CONTEXT_WINDOW, PI_LOCAL_MODEL_ID } from "./provider";
|
|
13
|
+
import { isToolAlwaysAllowed, setToolAlwaysAllowed, listAlwaysAllowedTools } from "./tool-approvals";
|
|
14
|
+
import { initialSamplingScopeState, initialLoopHygieneState, buildWebChatSystemPrompt, WELCOME_TOOLS,
|
|
15
|
+
WEB_CHAT_PROMPT_VERSION, webChatPromptFingerprint, webChatToolAllowlist, consumeForRequest,
|
|
16
|
+
injectAdapter, injectSampling, injectSystemPrompt, ambientContextLine, decideBeforeToolCall,
|
|
17
|
+
recordToolCallOutcome, toolResultText, toolApprovalClass, applyEditedArgs,
|
|
18
|
+
applySetSampling, type SamplingScopeState, type LoopHygieneState } from "./policy";
|
|
19
|
+
import { serializeHistory, findLastUserMessageEntry, userMessageSiblings, toSessionListItems,
|
|
20
|
+
deepestLeafFrom, toPiImages } from "./history";
|
|
21
|
+
import { createAppAwareTools } from "./app-tools";
|
|
22
|
+
import { mapEventToFrames } from "./events";
|
|
23
|
+
|
|
24
|
+
const APPROVAL_TIMEOUT_MS = 120_000;
|
|
25
|
+
|
|
26
|
+
class PiBackend implements ChatBackend {
|
|
27
|
+
private runtime?: AgentSessionRuntime;
|
|
28
|
+
private session?: AgentSession;
|
|
29
|
+
/** SessionManager backing the active AgentSession (disk-persisted). */
|
|
30
|
+
private sessionManager?: SessionManager;
|
|
31
|
+
private unsubscribe?: () => void;
|
|
32
|
+
private disposed = false;
|
|
33
|
+
private disposal?: Promise<void>;
|
|
34
|
+
private readonly runtimeChanges = new Set<Promise<unknown>>();
|
|
35
|
+
/** Active LoRA adapter id for this connection (null = none/base model).
|
|
36
|
+
* Read by the before_provider_request hook; set via the set_adapter msg. */
|
|
37
|
+
private selectedAdapter: string | null = null;
|
|
38
|
+
/** Session-level + one-shot-next-turn sampling overrides for this
|
|
39
|
+
* connection (set via set_sampling; scope "session"/omitted mutates
|
|
40
|
+
* .session, scope "next_turn" arms .nextTurn). Read and consumed by
|
|
41
|
+
* the before_provider_request hook via consumeForRequest — see the
|
|
42
|
+
* "Per-message sampling scope" block comment above composeSampling for
|
|
43
|
+
* the full lifecycle. Wrapped in SamplingScopeState (not two loose
|
|
44
|
+
* fields) so the exact same applySetSampling/consumeForRequest pure
|
|
45
|
+
* functions run here and in tests/chat-policy.test.ts. */
|
|
46
|
+
private samplingScope: SamplingScopeState = initialSamplingScopeState();
|
|
47
|
+
/** User's custom system-prompt text for this connection (set via
|
|
48
|
+
* set_system_prompt), null = none. Read by the before_agent_start hook
|
|
49
|
+
* and layered onto the built-in surface prompt on the NEXT turn — see
|
|
50
|
+
* installSystemPromptHook/injectSystemPrompt. */
|
|
51
|
+
private systemPrompt: string | null = null;
|
|
52
|
+
/** Requested codingTools state (set via set_coding_tools), read fresh by
|
|
53
|
+
* createRuntimeFactory every time the SDK invokes it. Starts false — the
|
|
54
|
+
* toggle in settings defaults OFF, and a connection that never sends
|
|
55
|
+
* set_coding_tools must never get bash/edit/write. */
|
|
56
|
+
private codingToolsRequested = false;
|
|
57
|
+
/** What the CURRENT (already-built) session's tool surface actually
|
|
58
|
+
* contains — set only inside createRuntimeFactory, so it always reflects
|
|
59
|
+
* reality rather than the latest request. Read by the `coding_tools`
|
|
60
|
+
* ServerMessage and by installApprovalGate (whether GATED_TOOLS is even
|
|
61
|
+
* reachable this session, informational only — the allowlist itself is
|
|
62
|
+
* the real enforcement). */
|
|
63
|
+
private codingToolsActive = false;
|
|
64
|
+
|
|
65
|
+
/** Last `context` ClientMessage the browser pushed (route/view/step/
|
|
66
|
+
* snapshot) — null before the first one arrives (a non-
|
|
67
|
+
* browser WS client, or the race before chat.ts's first push). Read by
|
|
68
|
+
* get_current_app_context's tool and by installAppContextHook's
|
|
69
|
+
* ambient-line injection. Set wholesale (not merged) on every "context"
|
|
70
|
+
* ClientMessage — the browser always sends the full current picture. */
|
|
71
|
+
private currentAppContext: AppUiContext | null = null;
|
|
72
|
+
|
|
73
|
+
/** Dedup/retry-budget/turn-cap bookkeeping (see LOOP_HYGIENE above).
|
|
74
|
+
* Reset at the start of every new agent turn (turn_start with
|
|
75
|
+
* turnIndex 0) so a dedup hit or failure streak from a PREVIOUS user
|
|
76
|
+
* message never bleeds into the next one. */
|
|
77
|
+
private loopHygiene: LoopHygieneState = initialLoopHygieneState();
|
|
78
|
+
|
|
79
|
+
/** Per-connection invariants, built once in start() and reused across
|
|
80
|
+
* session switches (new chat / resume / fork). */
|
|
81
|
+
private provider?: ReturnType<typeof buildPiProvider>;
|
|
82
|
+
private readonly cwd: string;
|
|
83
|
+
private readonly agentDir: string;
|
|
84
|
+
/** Where web-chat session files live (pi's own JSONL format). Shared by
|
|
85
|
+
* create/continueRecent/open/fork/list/delete so they all see one set.
|
|
86
|
+
* This is the durable transcript store the nightly memory pipeline reads. */
|
|
87
|
+
private readonly sessionDir: string;
|
|
88
|
+
|
|
89
|
+
/** callId -> resolve(decision). Pending browser approvals in flight. */
|
|
90
|
+
private readonly pendingApprovals = new Map<string, (decision: ApprovalDecision, editedArgs?: Record<string, unknown>, alwaysAllow?: boolean) => void>();
|
|
91
|
+
/** callId -> timer handle, so we can clear on resolve/dispose. */
|
|
92
|
+
private readonly approvalTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
private readonly emit: SendFrame,
|
|
96
|
+
private readonly opts: {
|
|
97
|
+
paths: PiBackendPaths & { cwd: string };
|
|
98
|
+
port: number | (() => number); apiKey?: string; modelId: string; contextWindow: number; readOnly: boolean;
|
|
99
|
+
vision: boolean; audio: boolean; thinking: boolean; genDefaults: ReadyGenDefaults;
|
|
100
|
+
transcription: () => Promise<boolean>;
|
|
101
|
+
memory?: () => Promise<MemorySurface | undefined>;
|
|
102
|
+
},
|
|
103
|
+
/** What the served model is, read when this chat connects (see PiBackendOptions.model). */
|
|
104
|
+
private readonly modelFacts?: PiBackendOptions["model"],
|
|
105
|
+
) {
|
|
106
|
+
this.cwd = opts.paths.cwd;
|
|
107
|
+
this.agentDir = opts.paths.agentDir;
|
|
108
|
+
this.sessionDir = opts.paths.sessionDir;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Build the provider, resume the most recent chat, and start streaming. */
|
|
112
|
+
async start(): Promise<void> {
|
|
113
|
+
if (this.disposed) return;
|
|
114
|
+
// The model that answers may have changed since the backend was composed.
|
|
115
|
+
Object.assign(this.opts, definedOnly(await this.modelFacts?.()));
|
|
116
|
+
if (this.disposed) return;
|
|
117
|
+
// Resolve the port lazily: the WS handler is constructed before
|
|
118
|
+
// Bun.serve() binds, so an ephemeral (0) port is only known at the
|
|
119
|
+
// first connection. createServer passes `() => server.port`.
|
|
120
|
+
const port = typeof this.opts.port === "function" ? this.opts.port() : this.opts.port;
|
|
121
|
+
const baseUrl = `http://127.0.0.1:${port}/v1`;
|
|
122
|
+
|
|
123
|
+
// In-memory provider wiring is reused across this connection's session swaps.
|
|
124
|
+
this.provider = buildPiProvider(baseUrl, {
|
|
125
|
+
contextWindow: this.opts.contextWindow,
|
|
126
|
+
reasoning: this.opts.thinking,
|
|
127
|
+
vision: this.opts.vision,
|
|
128
|
+
...(this.opts.apiKey ? { apiKey: this.opts.apiKey } : {}),
|
|
129
|
+
});
|
|
130
|
+
mkdirSync(this.sessionDir, { recursive: true });
|
|
131
|
+
|
|
132
|
+
// Each connection starts its OWN fresh session. A prior continueRecent
|
|
133
|
+
// here meant every new WebSocket (another browser tab, a reconnect, or an
|
|
134
|
+
// external client) appended to the most-recent session — so tabs and even
|
|
135
|
+
// test clients wrote into each other's chats. Isolation is the correct
|
|
136
|
+
// model (and essential once concurrent slots land). Resuming a specific
|
|
137
|
+
// chat is explicit via the sidebar (open_session); the frontend re-opens
|
|
138
|
+
// its own session on a transient reconnect so a blip doesn't strand it on
|
|
139
|
+
// a blank backend session. Sessions persist to disk in pi's own format —
|
|
140
|
+
// the substrate for the recent-chats sidebar and the nightly memory pipeline.
|
|
141
|
+
await this.replaceRuntime(SessionManager.create(this.cwd, this.sessionDir));
|
|
142
|
+
if (this.disposed) return;
|
|
143
|
+
|
|
144
|
+
let transcription = false;
|
|
145
|
+
try { transcription = await this.opts.transcription(); } catch { /* no whisper → no mic button */ }
|
|
146
|
+
this.send({
|
|
147
|
+
type: "ready", model: this.opts.modelId, vision: this.opts.vision, audio: this.opts.audio,
|
|
148
|
+
thinking: this.opts.thinking, genDefaults: this.opts.genDefaults, transcription,
|
|
149
|
+
});
|
|
150
|
+
this.sendCodingToolsState();
|
|
151
|
+
this.sendToolApprovals();
|
|
152
|
+
this.sendHistory();
|
|
153
|
+
await this.sendSessions();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Runtime factory used for initial session creation and SDK-managed replacements. */
|
|
157
|
+
private createRuntimeFactory(): CreateAgentSessionRuntimeFactory {
|
|
158
|
+
return async ({ cwd, sessionManager, sessionStartEvent }) => {
|
|
159
|
+
const provider = this.provider;
|
|
160
|
+
if (!provider) throw new Error("provider not initialized");
|
|
161
|
+
|
|
162
|
+
// Definitions come from this domain and the optional memory owner.
|
|
163
|
+
// The allowlist below controls which definitions the session can invoke.
|
|
164
|
+
const surface = await buildPiAgentSurface(await this.opts.memory?.());
|
|
165
|
+
// Snapshot what THIS session is actually being built with — read fresh
|
|
166
|
+
// here (not at set_coding_tools time) so `coding_tools`'s `active`
|
|
167
|
+
// field is always true reality, never a stale request.
|
|
168
|
+
this.codingToolsActive = this.codingToolsRequested;
|
|
169
|
+
const webPrompt = buildWebChatSystemPrompt(this.opts.readOnly, { modelId: this.opts.modelId },
|
|
170
|
+
{ hasTools: WELCOME_TOOLS.length > 0 }) + surface.memoryHint;
|
|
171
|
+
if (runtimeValue("MLX_BUN_PI_DEBUG")) {
|
|
172
|
+
console.error(`[pi-web] prompt ${WEB_CHAT_PROMPT_VERSION} sha=${webChatPromptFingerprint(webPrompt)} memory=${surface.memoryEnabled ? "on" : "off"} codingTools=${this.codingToolsActive ? "on" : "off"}`);
|
|
173
|
+
}
|
|
174
|
+
const services = await createAgentSessionServices({
|
|
175
|
+
cwd,
|
|
176
|
+
agentDir: this.agentDir,
|
|
177
|
+
authStorage: provider.authStorage,
|
|
178
|
+
modelRegistry: provider.modelRegistry,
|
|
179
|
+
resourceLoaderOptions: {
|
|
180
|
+
noExtensions: true,
|
|
181
|
+
noSkills: true,
|
|
182
|
+
noPromptTemplates: true,
|
|
183
|
+
noThemes: true,
|
|
184
|
+
noContextFiles: true,
|
|
185
|
+
additionalSkillPaths: surface.skillPaths,
|
|
186
|
+
systemPrompt: webPrompt,
|
|
187
|
+
extensionFactories: [
|
|
188
|
+
// Loop hygiene runs FIRST: a deduped/turn-capped call is
|
|
189
|
+
// blocked before the approval gate ever sees it (see
|
|
190
|
+
// installLoopHygieneHooks's doc comment).
|
|
191
|
+
(pi) => this.installLoopHygieneHooks(pi),
|
|
192
|
+
(pi) => this.installApprovalGate(pi, new Set(surface.readOnlyToolNames)),
|
|
193
|
+
(pi) => this.installAdapterHook(pi),
|
|
194
|
+
(pi) => this.installSystemPromptHook(pi),
|
|
195
|
+
(pi) => this.installAppContextHook(pi),
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
...(await createAgentSessionFromServices({
|
|
202
|
+
services,
|
|
203
|
+
sessionManager,
|
|
204
|
+
sessionStartEvent,
|
|
205
|
+
model: provider.model,
|
|
206
|
+
// Allowlist (not surface.tools): `read` is a pi built-in enabled by
|
|
207
|
+
// name; `web_search` is the custom tool from surface.customTools. pi
|
|
208
|
+
// exposes only names in this list, so web_fetch/weather stay defined
|
|
209
|
+
// but hidden. Memory/reference tools ride along when the surface has
|
|
210
|
+
// memory enabled — the prompt + memory skill advertise them, so they
|
|
211
|
+
// must be callable (webChatToolAllowlist). codingTools is further
|
|
212
|
+
// ANDed with !readOnly here: a read-only server (--read-only) never
|
|
213
|
+
// advertises bash/edit/write even if the browser opted in, since
|
|
214
|
+
// installApprovalGate would deny every one of them anyway — no
|
|
215
|
+
// point offering (and prompting the model to attempt) a tool that
|
|
216
|
+
// can only ever fail.
|
|
217
|
+
tools: webChatToolAllowlist(surface.memoryToolNames, this.codingToolsActive && !this.opts.readOnly),
|
|
218
|
+
// App-aware assistant tools are PER-CONNECTION (close
|
|
219
|
+
// over this.currentAppContext + this.send), unlike surface.customTools
|
|
220
|
+
// which is a stateless singleton shared across every session — see
|
|
221
|
+
// createAppAwareTools' doc comment.
|
|
222
|
+
customTools: [...surface.customTools, ...createAppAwareTools(() => this.currentAppContext, (m) => this.send(m))],
|
|
223
|
+
})),
|
|
224
|
+
services,
|
|
225
|
+
diagnostics: services.diagnostics,
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Tear down UI/session bindings owned by this WebSocket. Runtime.dispose()
|
|
231
|
+
* owns the AgentSession itself; this method only detaches browser state. */
|
|
232
|
+
private teardownBindings(): void {
|
|
233
|
+
for (const settle of this.pendingApprovals.values()) settle("deny");
|
|
234
|
+
this.pendingApprovals.clear();
|
|
235
|
+
for (const timer of this.approvalTimers.values()) clearTimeout(timer);
|
|
236
|
+
this.approvalTimers.clear();
|
|
237
|
+
this.unsubscribe?.();
|
|
238
|
+
this.unsubscribe = undefined;
|
|
239
|
+
this.session = undefined;
|
|
240
|
+
this.sessionManager = undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Bind browser event plumbing to the current runtime.session. */
|
|
244
|
+
private async bindRuntimeSession(): Promise<void> {
|
|
245
|
+
const session = this.runtime?.session;
|
|
246
|
+
if (!session) return;
|
|
247
|
+
this.unsubscribe?.();
|
|
248
|
+
this.session = session;
|
|
249
|
+
this.sessionManager = session.sessionManager;
|
|
250
|
+
await session.bindExtensions({ mode: "rpc" });
|
|
251
|
+
if (this.disposed) return;
|
|
252
|
+
this.unsubscribe = session.subscribe((event) => this.onSessionEvent(event));
|
|
253
|
+
this.sendContextUsage();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Replace the whole pi runtime with a target SessionManager. Used for the
|
|
257
|
+
* initial session and for file-level fork, while open/new use runtime APIs. */
|
|
258
|
+
private async replaceRuntime(sm: SessionManager): Promise<void> {
|
|
259
|
+
try {
|
|
260
|
+
await this.session?.abort();
|
|
261
|
+
} catch {
|
|
262
|
+
// Old turn may already be done; ignore.
|
|
263
|
+
}
|
|
264
|
+
const next = await createAgentSessionRuntime(this.createRuntimeFactory(), {
|
|
265
|
+
cwd: this.cwd,
|
|
266
|
+
agentDir: this.agentDir,
|
|
267
|
+
sessionManager: sm,
|
|
268
|
+
});
|
|
269
|
+
next.setRebindSession(async () => this.bindRuntimeSession());
|
|
270
|
+
if (this.disposed) {
|
|
271
|
+
await next.dispose();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const previous = this.runtime;
|
|
275
|
+
this.teardownBindings();
|
|
276
|
+
this.runtime = next;
|
|
277
|
+
try {
|
|
278
|
+
await this.bindRuntimeSession();
|
|
279
|
+
} finally {
|
|
280
|
+
if (previous) await previous.dispose();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Replay the active session's transcript to the browser (rebuilds thread).
|
|
285
|
+
* Always followed by sendSiblings(): the sibling group for "the last user
|
|
286
|
+
* message" is a derived view of the same entries and goes stale under
|
|
287
|
+
* exactly the same conditions history does (new session, switch, fork). */
|
|
288
|
+
private sendHistory(): void {
|
|
289
|
+
const entries = this.sessionManager?.getEntries() ?? [];
|
|
290
|
+
this.send({ type: "history", items: serializeHistory(entries) });
|
|
291
|
+
this.sendSiblings();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Push the sibling-group info for the current last-user-message (drives
|
|
295
|
+
* the browser's `< i/n >` edit toggle). Called by sendHistory() and after
|
|
296
|
+
* each completed turn, since either can change which message is "last" or
|
|
297
|
+
* how many siblings it has. */
|
|
298
|
+
private sendSiblings(): void {
|
|
299
|
+
const entries = this.sessionManager?.getEntries() ?? [];
|
|
300
|
+
const last = findLastUserMessageEntry(entries);
|
|
301
|
+
if (!last) {
|
|
302
|
+
this.send({ type: "siblings", index: 0, count: 0, siblingIds: [] });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const group = userMessageSiblings(entries, last.id);
|
|
306
|
+
this.send({
|
|
307
|
+
type: "siblings",
|
|
308
|
+
entryId: last.id,
|
|
309
|
+
index: group?.index ?? 1,
|
|
310
|
+
count: group?.siblingIds.length ?? 1,
|
|
311
|
+
siblingIds: group?.siblingIds ?? [last.id],
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Push current context-window usage to the browser (for the indicator). */
|
|
316
|
+
private sendContextUsage(): void {
|
|
317
|
+
const usage = this.session?.getContextUsage();
|
|
318
|
+
if (usage) {
|
|
319
|
+
this.send({ type: "context", tokens: usage.tokens, contextWindow: usage.contextWindow, percent: usage.percent });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Send the recent-chats list (sidebar), marking the active session. Every
|
|
324
|
+
* chat in the session directory is listed, whatever directory it recorded:
|
|
325
|
+
* a server started by brew or launchd has no meaningful cwd to filter by. */
|
|
326
|
+
private async sendSessions(): Promise<void> {
|
|
327
|
+
let infos: SessionInfo[] = [];
|
|
328
|
+
try {
|
|
329
|
+
infos = await SessionManager.listAll(this.sessionDir);
|
|
330
|
+
} catch {
|
|
331
|
+
infos = [];
|
|
332
|
+
}
|
|
333
|
+
this.send({
|
|
334
|
+
type: "sessions",
|
|
335
|
+
items: toSessionListItems(infos),
|
|
336
|
+
activePath: this.sessionManager?.getSessionFile(),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Guard: only operate on session files under our session dir. */
|
|
341
|
+
private isUnderSessionDir(path: string): boolean {
|
|
342
|
+
const root = resolve(this.sessionDir);
|
|
343
|
+
const p = resolve(path);
|
|
344
|
+
return p === root || p.startsWith(root + "/");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Start a fresh chat (old one stays on disk, resumable from the sidebar).
|
|
348
|
+
* Re-invokes createRuntimeFactory (either directly via replaceRuntime, or
|
|
349
|
+
* through AgentSessionRuntime.newSession()'s own internal call to the
|
|
350
|
+
* SAME factory) — so this is the earliest point a codingTools toggle
|
|
351
|
+
* flipped mid-session actually takes effect (see set_coding_tools). */
|
|
352
|
+
private async newSession(): Promise<void> {
|
|
353
|
+
if (!this.runtime) {
|
|
354
|
+
await this.replaceRuntime(SessionManager.create(this.cwd, this.sessionDir));
|
|
355
|
+
} else {
|
|
356
|
+
const runtime = this.runtime;
|
|
357
|
+
const change = runtime.newSession();
|
|
358
|
+
this.runtimeChanges.add(change);
|
|
359
|
+
try { await change; } finally { this.runtimeChanges.delete(change); }
|
|
360
|
+
}
|
|
361
|
+
this.sendCodingToolsState();
|
|
362
|
+
this.sendHistory();
|
|
363
|
+
await this.sendSessions();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** A chat recorded in a directory that no longer exists (a moved or deleted
|
|
367
|
+
* checkout) continues in this server's directory, the SDK's "continue in
|
|
368
|
+
* current cwd" choice, which the browser cannot be prompted for. Otherwise
|
|
369
|
+
* the SDK refuses to open it. The file keeps its recorded header. */
|
|
370
|
+
private missingCwdOverride(path: string): string | undefined {
|
|
371
|
+
let recorded: string | undefined;
|
|
372
|
+
try { recorded = recordedSessionCwd(readFileSync(path, "utf8")); } catch { return undefined; }
|
|
373
|
+
return recorded && !existsSync(recorded) ? this.cwd : undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Resume an existing chat by file path. Same "factory re-runs, codingTools
|
|
377
|
+
* becomes active if pending" note as newSession above. */
|
|
378
|
+
private async openSession(path: string): Promise<void> {
|
|
379
|
+
if (!this.isUnderSessionDir(path)) {
|
|
380
|
+
this.send({ type: "error", message: "invalid session path" });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const cwdOverride = this.missingCwdOverride(path);
|
|
384
|
+
if (!this.runtime) await this.replaceRuntime(SessionManager.open(path, this.sessionDir, cwdOverride));
|
|
385
|
+
else {
|
|
386
|
+
const runtime = this.runtime;
|
|
387
|
+
const change = runtime.switchSession(path, cwdOverride ? { cwdOverride } : undefined);
|
|
388
|
+
this.runtimeChanges.add(change);
|
|
389
|
+
try { await change; } finally { this.runtimeChanges.delete(change); }
|
|
390
|
+
}
|
|
391
|
+
this.sendCodingToolsState();
|
|
392
|
+
this.sendHistory();
|
|
393
|
+
await this.sendSessions();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Branch a new chat from an existing one (original stays untouched). */
|
|
397
|
+
private async forkSession(path: string): Promise<void> {
|
|
398
|
+
if (!this.isUnderSessionDir(path)) {
|
|
399
|
+
this.send({ type: "error", message: "invalid session path" });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// File-level fork is not a runtime primitive, so create the target
|
|
403
|
+
// SessionManager then replace the runtime through the same SDK factory.
|
|
404
|
+
await this.replaceRuntime(SessionManager.forkFrom(path, this.cwd, this.sessionDir));
|
|
405
|
+
this.sendCodingToolsState();
|
|
406
|
+
this.sendHistory();
|
|
407
|
+
await this.sendSessions();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Delete a session file; if it was active, start a fresh chat. */
|
|
411
|
+
private deleteSession(path: string): void {
|
|
412
|
+
if (!this.isUnderSessionDir(path)) {
|
|
413
|
+
this.send({ type: "error", message: "invalid session path" });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const active = this.sessionManager?.getSessionFile();
|
|
417
|
+
const wasActive = !!active && resolve(active) === resolve(path);
|
|
418
|
+
try {
|
|
419
|
+
rmSync(path, { force: true });
|
|
420
|
+
} catch {
|
|
421
|
+
// Already gone or unreadable; the refreshed list will reflect reality.
|
|
422
|
+
}
|
|
423
|
+
if (wasActive) void this.newSession();
|
|
424
|
+
else void this.sendSessions();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Clear an armed one-shot next_turn sampling override after a prompt()
|
|
428
|
+
* call throws before ever reaching before_provider_request (the hook
|
|
429
|
+
* that normally consumes it via consumeForRequest — see the
|
|
430
|
+
* "Per-message sampling scope" comment above composeSampling). Reuses
|
|
431
|
+
* that same pure helper so the clearing logic has exactly one
|
|
432
|
+
* implementation; discards its `effective` composition since there's no
|
|
433
|
+
* outgoing request to apply it to. A no-op when nothing was armed. */
|
|
434
|
+
private clearArmedOneShotOnFailedPrompt(): void {
|
|
435
|
+
this.samplingScope = consumeForRequest(this.samplingScope).nextState;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Move the session leaf to `entryId`'s parent via navigateTree, then
|
|
439
|
+
* re-prompt with {text, images}. This is the shared mechanism behind
|
|
440
|
+
* regenerate and edit_resend: navigateTree does NOT delete the old
|
|
441
|
+
* message/reply (they stay on disk as a sibling branch), and pi resyncs
|
|
442
|
+
* agent.state.messages from the new leaf internally, so the next
|
|
443
|
+
* session.prompt() call appends cleanly. Returns false (and sends an
|
|
444
|
+
* error frame) if the entry can't be found or a turn is already
|
|
445
|
+
* streaming (mirrors prompt()'s own idle precondition — regenerate/edit
|
|
446
|
+
* are not queueable follow-ups, unlike a normal composer submit). */
|
|
447
|
+
private async resendFrom(entryId: string, text: string, images: ImageContent[]): Promise<boolean> {
|
|
448
|
+
const session = this.session;
|
|
449
|
+
if (!session) {
|
|
450
|
+
this.send({ type: "error", message: "session not ready" });
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
if (session.isStreaming) {
|
|
454
|
+
this.send({ type: "error", message: "wait for the current reply to finish first" });
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
if (!text.trim()) {
|
|
458
|
+
this.send({ type: "error", message: "message text can't be empty" });
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
await session.navigateTree(entryId);
|
|
462
|
+
try {
|
|
463
|
+
await session.prompt(text, images.length ? { images } : {});
|
|
464
|
+
} catch (err) {
|
|
465
|
+
// Same pre-flight-failure guard as the "prompt" case above: prompt()
|
|
466
|
+
// can throw before before_provider_request ever fires (no model
|
|
467
|
+
// selected, auth failure), which is the only place a one-shot
|
|
468
|
+
// next_turn sampling override normally gets consumed. Without this,
|
|
469
|
+
// an override armed right before a failed regenerate/edit-resend
|
|
470
|
+
// would silently ride along on a later, unrelated successful turn.
|
|
471
|
+
this.clearArmedOneShotOnFailedPrompt();
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Regenerate: re-send the last user message's ORIGINAL content verbatim
|
|
478
|
+
* as a new sibling branch. Works after abort/stop too — an aborted turn
|
|
479
|
+
* still leaves the triggering user message entry in place to find. */
|
|
480
|
+
private async regenerate(): Promise<void> {
|
|
481
|
+
const entries = this.sessionManager?.getEntries() ?? [];
|
|
482
|
+
const last = findLastUserMessageEntry(entries);
|
|
483
|
+
if (!last) {
|
|
484
|
+
this.send({ type: "error", message: "no message to regenerate" });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
await this.resendFrom(last.id, last.text, last.images);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Edit-and-resend: re-send the LAST user message with edited text
|
|
491
|
+
* (editing is scoped to the last message only — the `< i/n >` toggle is
|
|
492
|
+
* a linear sibling list, not a tree view). Original images are preserved
|
|
493
|
+
* since editing text shouldn't silently drop an attachment. */
|
|
494
|
+
private async editResend(text: string): Promise<void> {
|
|
495
|
+
const entries = this.sessionManager?.getEntries() ?? [];
|
|
496
|
+
const last = findLastUserMessageEntry(entries);
|
|
497
|
+
if (!last) {
|
|
498
|
+
this.send({ type: "error", message: "no message to edit" });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
await this.resendFrom(last.id, text, last.images);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Switch the active leaf to a previously-created sibling (an earlier
|
|
505
|
+
* edit/regeneration of the last user message) WITHOUT sending anything
|
|
506
|
+
* new — pure navigation. Walks down to that sibling's own deepest
|
|
507
|
+
* descendant (its assistant reply, if any) so the replayed history shows
|
|
508
|
+
* that branch's actual conversation, not just the bare user message. */
|
|
509
|
+
private switchSibling(entryId: string): void {
|
|
510
|
+
const sm = this.sessionManager;
|
|
511
|
+
const session = this.session;
|
|
512
|
+
if (!sm || !session) {
|
|
513
|
+
this.send({ type: "error", message: "session not ready" });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (session.isStreaming) {
|
|
517
|
+
this.send({ type: "error", message: "wait for the current reply to finish first" });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const entry = sm.getEntry(entryId);
|
|
521
|
+
if (!entry) {
|
|
522
|
+
this.send({ type: "error", message: "message not found" });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const leaf = deepestLeafFrom(entryId, (parentId) => sm.getChildren(parentId));
|
|
526
|
+
sm.branch(leaf);
|
|
527
|
+
// Mirror what AgentSession.navigateTree() does internally after moving
|
|
528
|
+
// the leaf: resync agent.state.messages from the new leaf's path so the
|
|
529
|
+
// in-memory agent state and the persisted leaf pointer never diverge.
|
|
530
|
+
session.agent.state.messages = sm.buildSessionContext().messages;
|
|
531
|
+
this.sendHistory();
|
|
532
|
+
this.sendContextUsage();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Register the before_provider_request hook that injects the selected LoRA
|
|
536
|
+
* adapter and the effective sampling overrides into every provider
|
|
537
|
+
* request (Pi-native adapter control, mirrors the CLI extension).
|
|
538
|
+
* Default none = no injection (base model). */
|
|
539
|
+
private installAdapterHook(pi: ExtensionAPI): void {
|
|
540
|
+
pi.on("before_provider_request", (event) => {
|
|
541
|
+
let payload = event.payload as Record<string, unknown>;
|
|
542
|
+
// Layer both injections: adapter selection, then sampling overrides.
|
|
543
|
+
// Each returns undefined when it has nothing to change, so we keep the
|
|
544
|
+
// prior payload in that case.
|
|
545
|
+
payload = injectAdapter(payload, this.selectedAdapter) ?? payload;
|
|
546
|
+
// Compose next_turn OVER session, then CONSUME the one-shot override
|
|
547
|
+
// immediately — this is the very first outgoing provider request
|
|
548
|
+
// since it was armed (before_provider_request fires once per model
|
|
549
|
+
// call), so clearing it here guarantees it rides on exactly one
|
|
550
|
+
// request and never leaks into a second tool-loop turn of the same
|
|
551
|
+
// prompt, let alone the next user message.
|
|
552
|
+
const { effective, nextState } = consumeForRequest(this.samplingScope);
|
|
553
|
+
this.samplingScope = nextState;
|
|
554
|
+
payload = injectSampling(payload, effective) ?? payload;
|
|
555
|
+
return payload;
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Register the before_agent_start hook that layers the user's custom
|
|
560
|
+
* system prompt onto the built-in surface prompt. This fires fresh inside
|
|
561
|
+
* EVERY session.prompt() call (pi passes the freshly-built base prompt
|
|
562
|
+
* each time, see agent-session.js's emitBeforeAgentStart call site), so a
|
|
563
|
+
* set_system_prompt sent mid-session takes effect starting with the very
|
|
564
|
+
* next turn — there is no "only at session creation" limitation here, and
|
|
565
|
+
* the UI copy should say so plainly (it applies going forward, not
|
|
566
|
+
* retroactively to already-sent turns). Returning undefined leaves pi's
|
|
567
|
+
* base prompt (identity + memoryHint) untouched. */
|
|
568
|
+
private installSystemPromptHook(pi: ExtensionAPI): void {
|
|
569
|
+
pi.on("before_agent_start", (event) => {
|
|
570
|
+
const merged = injectSystemPrompt(event.systemPrompt, this.systemPrompt);
|
|
571
|
+
return merged !== undefined ? { systemPrompt: merged } : undefined;
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Register the before_agent_start hook that auto-prepends the compact
|
|
576
|
+
* one-line ambient context ("never answer blind") to every
|
|
577
|
+
* turn's system prompt — the SAME layering mechanism
|
|
578
|
+
* installSystemPromptHook uses for the user's custom prompt (pi's
|
|
579
|
+
* BeforeAgentStartEventResult can only replace systemPrompt, chained
|
|
580
|
+
* across multiple extensions — there's no channel to rewrite the raw
|
|
581
|
+
* user prompt text itself). Fires fresh on every session.prompt() call,
|
|
582
|
+
* so it always reflects the LATEST context push, never a stale one from
|
|
583
|
+
* session-creation time. Returns undefined (no-op) when nothing has
|
|
584
|
+
* been pushed yet — a non-browser WS client, or the race before chat.ts's
|
|
585
|
+
* first `context` frame lands, must never inject a made-up location. */
|
|
586
|
+
private installAppContextHook(pi: ExtensionAPI): void {
|
|
587
|
+
pi.on("before_agent_start", (event) => {
|
|
588
|
+
const line = ambientContextLine(this.currentAppContext);
|
|
589
|
+
if (!line) return undefined;
|
|
590
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${line}` };
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Register the tool_call approval gate on the inline extension.
|
|
596
|
+
*
|
|
597
|
+
* Approval-fatigue defaults (`02d723a:docs/design/web-chat-redesign.md` §2.3 — verified
|
|
598
|
+
* here, not just asserted): READ_ONLY_TOOLS NEVER prompt, checked first
|
|
599
|
+
* and unconditionally — no config, always-allow list, or codingTools
|
|
600
|
+
* state can make a read-only tool gated, and no combination of state can
|
|
601
|
+
* make a GATED_TOOLS member skip the card except the durable
|
|
602
|
+
* tool-approvals config (isToolAlwaysAllowed) or read-only-mode's
|
|
603
|
+
* outright denial. This keeps the fatigue budget spent only on tools
|
|
604
|
+
* that actually mutate the machine.
|
|
605
|
+
*/
|
|
606
|
+
/**
|
|
607
|
+
* Register loop-hygiene bookkeeping: turnIndex tracking (turn_start),
|
|
608
|
+
* the before-execution dedup/turn-cap block (tool_call), and the
|
|
609
|
+
* after-execution failure-streak nudge (tool_result). Registered FIRST
|
|
610
|
+
* among the tool_call handlers (see extensionFactories order in
|
|
611
|
+
* createRuntimeFactory) so a deduped or turn-capped call never even
|
|
612
|
+
* reaches the approval gate — no point prompting the user to approve
|
|
613
|
+
* a call we're about to skip. See the LOOP_HYGIENE block comment above
|
|
614
|
+
* for what the SDK already does vs. what this closes.
|
|
615
|
+
*/
|
|
616
|
+
private installLoopHygieneHooks(pi: ExtensionAPI): void {
|
|
617
|
+
pi.on("turn_start", (event) => {
|
|
618
|
+
this.loopHygiene.turnIndex = event.turnIndex;
|
|
619
|
+
// A fresh user prompt always restarts at turnIndex 0 (AgentSession
|
|
620
|
+
// resets _turnIndex on agent_start, before the first turn_start) —
|
|
621
|
+
// treat that as "new tool loop" and clear dedup/failure state so a
|
|
622
|
+
// previous message's history never suppresses/nudges this one.
|
|
623
|
+
if (event.turnIndex === 0) this.loopHygiene = initialLoopHygieneState();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
pi.on("tool_call", (event: ToolCallEvent) => {
|
|
627
|
+
const block = decideBeforeToolCall(this.loopHygiene, event.toolName, event.input);
|
|
628
|
+
if (block && runtimeValue("MLX_BUN_PI_DEBUG")) {
|
|
629
|
+
console.error(`[pi-web] loop-hygiene blocked ${event.toolName}: ${block.reason.split("\n")[0]}`);
|
|
630
|
+
}
|
|
631
|
+
return block ? { block: true, reason: block.reason } : undefined;
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
pi.on("tool_result", (event) => {
|
|
635
|
+
const nudge = recordToolCallOutcome(this.loopHygiene, event.toolName, event.input, {
|
|
636
|
+
isError: event.isError,
|
|
637
|
+
resultText: toolResultText(event.content),
|
|
638
|
+
});
|
|
639
|
+
if (!nudge) return undefined;
|
|
640
|
+
// Append the course-correction nudge to the existing error content
|
|
641
|
+
// rather than replacing it, so the model still sees the real
|
|
642
|
+
// failure reason plus the "stop repeating this" instruction.
|
|
643
|
+
const existingText = toolResultText(event.content);
|
|
644
|
+
return {
|
|
645
|
+
content: [{ type: "text", text: existingText + nudge }],
|
|
646
|
+
isError: event.isError,
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private installApprovalGate(pi: ExtensionAPI, readOnlyTools: ReadonlySet<string>): void {
|
|
652
|
+
pi.on("tool_call", async (event: ToolCallEvent) => {
|
|
653
|
+
const tool = event.toolName;
|
|
654
|
+
if (runtimeValue("MLX_BUN_PI_DEBUG")) {
|
|
655
|
+
console.error(`[pi-web] tool_call ${tool} args=${JSON.stringify(event.input)}`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Read-only tools never need approval.
|
|
659
|
+
if (toolApprovalClass(tool, readOnlyTools) === "read-only") return undefined;
|
|
660
|
+
|
|
661
|
+
// Read-only sessions reject mutations and unclassified tools before
|
|
662
|
+
// consulting durable approvals. Injected names are scoped to this runtime.
|
|
663
|
+
if (this.opts.readOnly) {
|
|
664
|
+
return { block: true, reason: "Read-only session: only read-only tools are allowed." };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Durable "always allow this tool" (risk #6): a prior approval card on
|
|
668
|
+
// THIS machine checked the box, persisting to ~/.mlx-bun/tool-
|
|
669
|
+
// approvals.json (chat/tool-approvals.ts). Skip the round-trip entirely
|
|
670
|
+
// — no card, no wait, matching every session and every browser tab.
|
|
671
|
+
if (isToolAlwaysAllowed(tool, this.opts.paths.toolApprovalsFile)) return undefined;
|
|
672
|
+
|
|
673
|
+
const decision = await this.requestApproval(event);
|
|
674
|
+
if (decision.decision === "deny") {
|
|
675
|
+
return { block: true, reason: "Denied by user." };
|
|
676
|
+
}
|
|
677
|
+
// Editable arguments (LM Studio's pattern): the SDK's documented
|
|
678
|
+
// mutation contract is "mutate event.input in place before returning"
|
|
679
|
+
// — there is no return-value channel for modified args (ToolCallEventResult
|
|
680
|
+
// is `{block?, reason?}` only). event.input is asserted as a mutable
|
|
681
|
+
// Record here since ToolCallEvent's per-tool variants type it as their
|
|
682
|
+
// specific input shape, but the mutation contract is untyped-record at
|
|
683
|
+
// the wire level (this mirrors how pi's own bundled permission-gate
|
|
684
|
+
// example extension does it).
|
|
685
|
+
applyEditedArgs(event.input as Record<string, unknown>, decision.editedArgs);
|
|
686
|
+
if (decision.alwaysAllow) {
|
|
687
|
+
setToolAlwaysAllowed(tool, this.opts.paths.toolApprovalsFile);
|
|
688
|
+
this.sendToolApprovals();
|
|
689
|
+
}
|
|
690
|
+
return undefined; // allow
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Ask the browser to approve a gated tool call and await its decision.
|
|
696
|
+
* Auto-denies after APPROVAL_TIMEOUT_MS or if the connection drops.
|
|
697
|
+
*/
|
|
698
|
+
private requestApproval(event: ToolCallEvent): Promise<{ decision: ApprovalDecision; editedArgs?: Record<string, unknown>; alwaysAllow?: boolean }> {
|
|
699
|
+
const callId = event.toolCallId;
|
|
700
|
+
return new Promise((resolve) => {
|
|
701
|
+
const settle = (decision: ApprovalDecision, editedArgs?: Record<string, unknown>, alwaysAllow?: boolean) => {
|
|
702
|
+
const timer = this.approvalTimers.get(callId);
|
|
703
|
+
if (timer) clearTimeout(timer);
|
|
704
|
+
this.approvalTimers.delete(callId);
|
|
705
|
+
this.pendingApprovals.delete(callId);
|
|
706
|
+
resolve({ decision, editedArgs, alwaysAllow });
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
this.pendingApprovals.set(callId, settle);
|
|
710
|
+
const timer = setTimeout(() => settle("deny"), APPROVAL_TIMEOUT_MS);
|
|
711
|
+
this.approvalTimers.set(callId, timer);
|
|
712
|
+
|
|
713
|
+
this.send({
|
|
714
|
+
type: "tool_approval_request",
|
|
715
|
+
callId,
|
|
716
|
+
tool: event.toolName,
|
|
717
|
+
args: event.input,
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Resolve a pending approval from a browser `approval` frame. */
|
|
723
|
+
resolveApproval(callId: string, decision: ApprovalDecision, editedArgs?: Record<string, unknown>, alwaysAllow?: boolean): void {
|
|
724
|
+
const settle = this.pendingApprovals.get(callId);
|
|
725
|
+
if (settle) settle(decision, editedArgs, alwaysAllow);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Push the effective-vs-pending codingTools state (sent on ready and
|
|
729
|
+
* after every set_coding_tools). */
|
|
730
|
+
private sendCodingToolsState(): void {
|
|
731
|
+
this.send({ type: "coding_tools", active: this.codingToolsActive, pending: this.codingToolsRequested });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Push the full durable always-allow set (sent on ready and after any
|
|
735
|
+
* change), so the settings panel can list/forget approvals without a
|
|
736
|
+
* separate REST round-trip. */
|
|
737
|
+
private sendToolApprovals(): void {
|
|
738
|
+
this.send({ type: "tool_approvals", alwaysAllow: listAlwaysAllowedTools(this.opts.paths.toolApprovalsFile) });
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private onSessionEvent(event: AgentSessionEvent): void {
|
|
742
|
+
if (runtimeValue("MLX_BUN_PI_DEBUG")) {
|
|
743
|
+
const extra = event.type === "message_update"
|
|
744
|
+
? `/${(event as { assistantMessageEvent?: { type?: string } }).assistantMessageEvent?.type}` : "";
|
|
745
|
+
console.error(`[pi-event] ${event.type}${extra}`);
|
|
746
|
+
}
|
|
747
|
+
for (const frame of mapEventToFrames(event)) this.send(frame);
|
|
748
|
+
// A completed turn is when pi flushes the session to disk (it defers
|
|
749
|
+
// writing until the first assistant reply), so refresh the sidebar then:
|
|
750
|
+
// a brand-new chat appears, and the active row's title/time update.
|
|
751
|
+
// Also refresh the context-usage indicator (it grows each turn, and
|
|
752
|
+
// drops sharply when auto-compaction fires).
|
|
753
|
+
if (event.type === "turn_end") {
|
|
754
|
+
void this.sendSessions();
|
|
755
|
+
this.sendContextUsage();
|
|
756
|
+
// A completed turn (including regenerate/edit_resend's re-prompt) can
|
|
757
|
+
// change the last user message's sibling count/index.
|
|
758
|
+
this.sendSiblings();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Handle one parsed client frame. */
|
|
763
|
+
async handle(msg: ClientMessage): Promise<void> {
|
|
764
|
+
if (this.disposed) return;
|
|
765
|
+
// Session-management frames don't require (and may replace) the active
|
|
766
|
+
// session, so handle them before the readiness guard.
|
|
767
|
+
switch (msg.type) {
|
|
768
|
+
case "new_session":
|
|
769
|
+
await this.newSession();
|
|
770
|
+
return;
|
|
771
|
+
case "list_sessions":
|
|
772
|
+
await this.sendSessions();
|
|
773
|
+
return;
|
|
774
|
+
case "open_session":
|
|
775
|
+
await this.openSession(msg.path);
|
|
776
|
+
return;
|
|
777
|
+
case "fork_session":
|
|
778
|
+
await this.forkSession(msg.path);
|
|
779
|
+
return;
|
|
780
|
+
case "delete_session":
|
|
781
|
+
this.deleteSession(msg.path);
|
|
782
|
+
return;
|
|
783
|
+
case "set_coding_tools":
|
|
784
|
+
// Record the request; doesn't touch the ALREADY-BUILT session's tool
|
|
785
|
+
// surface (pi bakes tools/customTools in at createAgentSessionServices
|
|
786
|
+
// time — there is no live "add a tool to this running session" hook).
|
|
787
|
+
// Applied by createRuntimeFactory the next time the SDK calls it:
|
|
788
|
+
// new_session, open_session, fork_session, or a reconnect's initial
|
|
789
|
+
// start(). sendCodingToolsState reports both active (this session)
|
|
790
|
+
// and pending (the request just recorded) so the UI can be honest
|
|
791
|
+
// about "applies to your next new chat" rather than implying it's
|
|
792
|
+
// live now.
|
|
793
|
+
this.codingToolsRequested = !!msg.enabled;
|
|
794
|
+
this.sendCodingToolsState();
|
|
795
|
+
return;
|
|
796
|
+
case "context":
|
|
797
|
+
// App-aware assistant: stored wholesale, no session
|
|
798
|
+
// required — chat.ts pushes this on route/step change, which can
|
|
799
|
+
// race the very first `ready` frame on initial connect. Read by
|
|
800
|
+
// get_current_app_context (live) and installAppContextHook (the
|
|
801
|
+
// ambient one-liner auto-prepended to the NEXT turn).
|
|
802
|
+
this.currentAppContext = msg.context;
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const session = this.session;
|
|
807
|
+
if (!session) {
|
|
808
|
+
this.send({ type: "error", message: "session not ready" });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
switch (msg.type) {
|
|
813
|
+
// The whole chat is just this: hand the user's message to pi's
|
|
814
|
+
// AgentSession and let pi run the turn (reply or tool calls, which pi
|
|
815
|
+
// executes). The ONLY decision we make is the canonical idle-vs-streaming
|
|
816
|
+
// branch from pi's own prompt() contract (core/agent-session.ts): an idle
|
|
817
|
+
// session runs a normal turn; if a turn is already streaming (the user
|
|
818
|
+
// typed again before it finished), the message is queued as a follow-up
|
|
819
|
+
// so it becomes the next turn instead of being dropped. pi re-checks
|
|
820
|
+
// isStreaming atomically inside prompt(), so this read can't strand a
|
|
821
|
+
// message at the turn boundary.
|
|
822
|
+
case "prompt": {
|
|
823
|
+
if (runtimeValue("MLX_BUN_PI_DEBUG")) {
|
|
824
|
+
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"}`);
|
|
825
|
+
}
|
|
826
|
+
const images = toPiImages(msg.images);
|
|
827
|
+
// Guard a one-shot next_turn override against prompt() throwing
|
|
828
|
+
// BEFORE it ever issues a provider request (no model selected, auth
|
|
829
|
+
// failure, etc.) — the normal consumer, installAdapterHook's
|
|
830
|
+
// before_provider_request hook, never fires in that case, so
|
|
831
|
+
// without this the armed override would silently survive to land
|
|
832
|
+
// on some later, unrelated successful turn instead of this one.
|
|
833
|
+
// sendError (the WS layer's outer catch) still reports the error;
|
|
834
|
+
// this only prevents the override from outliving its intended turn.
|
|
835
|
+
try {
|
|
836
|
+
if (session.isStreaming) await session.prompt(msg.text, { streamingBehavior: "followUp", images });
|
|
837
|
+
else await session.prompt(msg.text, { images });
|
|
838
|
+
} catch (err) {
|
|
839
|
+
this.clearArmedOneShotOnFailedPrompt();
|
|
840
|
+
throw err;
|
|
841
|
+
}
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
case "abort":
|
|
845
|
+
await session.abort();
|
|
846
|
+
return;
|
|
847
|
+
case "approval":
|
|
848
|
+
this.resolveApproval(msg.callId, msg.decision, msg.editedArgs, msg.alwaysAllow);
|
|
849
|
+
return;
|
|
850
|
+
case "set_thinking":
|
|
851
|
+
// Pi clamps to the model's available levels; a no-op for models
|
|
852
|
+
// without a switchable reasoning channel.
|
|
853
|
+
session.setThinkingLevel(msg.enabled ? "medium" : "off");
|
|
854
|
+
return;
|
|
855
|
+
case "set_adapter":
|
|
856
|
+
// Pi-native adapter control: record the selection; the
|
|
857
|
+
// before_provider_request hook injects it into the outgoing payload.
|
|
858
|
+
// app.html has already mounted it server-side (POST /v1/adapters).
|
|
859
|
+
this.selectedAdapter = msg.id;
|
|
860
|
+
return;
|
|
861
|
+
case "set_sampling": {
|
|
862
|
+
// The full mlx_lm.server sampler extension set (min_p/XTC/penalty
|
|
863
|
+
// families/seed), not just temperature/top_p/top_k
|
|
864
|
+
// (`02d723a:docs/archive/planning/web-ui-pass-plan.md` #8), for either scope.
|
|
865
|
+
const overrides: SamplingOverrides = {
|
|
866
|
+
temperature: msg.temperature ?? null,
|
|
867
|
+
top_p: msg.top_p ?? null,
|
|
868
|
+
top_k: msg.top_k ?? null,
|
|
869
|
+
min_p: msg.min_p ?? null,
|
|
870
|
+
xtc_probability: msg.xtc_probability ?? null,
|
|
871
|
+
xtc_threshold: msg.xtc_threshold ?? null,
|
|
872
|
+
repetition_penalty: msg.repetition_penalty ?? null,
|
|
873
|
+
repetition_context_size: msg.repetition_context_size ?? null,
|
|
874
|
+
presence_penalty: msg.presence_penalty ?? null,
|
|
875
|
+
frequency_penalty: msg.frequency_penalty ?? null,
|
|
876
|
+
seed: msg.seed ?? null,
|
|
877
|
+
};
|
|
878
|
+
// scope "next_turn": one-shot, armed via applySetSampling and
|
|
879
|
+
// consumed by installAdapterHook's before_provider_request handler
|
|
880
|
+
// on the very next outgoing provider request — never touches the
|
|
881
|
+
// session-level value, so a later plain set_sampling (scope
|
|
882
|
+
// "session" or omitted) still sees whatever was there before this
|
|
883
|
+
// message. scope "session"/omitted: today's behavior, persists on
|
|
884
|
+
// the connection until changed; a null/undefined field clears the
|
|
885
|
+
// override so the server's mode-aware default applies again.
|
|
886
|
+
this.samplingScope = applySetSampling(this.samplingScope, overrides, msg.scope);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
case "set_system_prompt":
|
|
890
|
+
// Store verbatim (including whitespace-only/empty -> effectively
|
|
891
|
+
// cleared by injectSystemPrompt's trim+empty check); applied by the
|
|
892
|
+
// before_agent_start hook starting with the next turn.
|
|
893
|
+
this.systemPrompt = msg.text;
|
|
894
|
+
return;
|
|
895
|
+
case "regenerate":
|
|
896
|
+
await this.regenerate();
|
|
897
|
+
return;
|
|
898
|
+
case "edit_resend":
|
|
899
|
+
await this.editResend(msg.text);
|
|
900
|
+
return;
|
|
901
|
+
case "switch_sibling":
|
|
902
|
+
this.switchSibling(msg.entryId);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Tear down the session and reject any in-flight approvals. */
|
|
908
|
+
dispose(): Promise<void> {
|
|
909
|
+
return this.disposal ??= (async () => {
|
|
910
|
+
this.disposed = true;
|
|
911
|
+
const runtime = this.runtime;
|
|
912
|
+
const session = runtime?.session ?? this.session;
|
|
913
|
+
this.runtime = undefined;
|
|
914
|
+
const errors: unknown[] = [];
|
|
915
|
+
try { this.teardownBindings(); } catch (error) { errors.push(error); }
|
|
916
|
+
// SDK runtime.dispose requests cancellation through synchronous session
|
|
917
|
+
// disposal; only session.abort waits for the active agent turn to stop.
|
|
918
|
+
try { await session?.abort(); } catch (error) { errors.push(error); }
|
|
919
|
+
// SDK replacements can publish a new session after cancellation. Join
|
|
920
|
+
// them before releasing the captured runtime so its latest session closes.
|
|
921
|
+
await Promise.allSettled([...this.runtimeChanges]);
|
|
922
|
+
try { await runtime?.dispose(); } catch (error) { errors.push(error); }
|
|
923
|
+
if (errors.length === 1) throw errors[0];
|
|
924
|
+
if (errors.length) throw new AggregateError(errors, "Pi cleanup failed");
|
|
925
|
+
})();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Send a frame, swallowing errors on a closed socket. */
|
|
929
|
+
private send(msg: ServerMessage): void {
|
|
930
|
+
if (this.disposed) return;
|
|
931
|
+
try {
|
|
932
|
+
this.emit(msg);
|
|
933
|
+
} catch {
|
|
934
|
+
// Socket closed mid-send; nothing actionable.
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Where a chat persists: the module's storage entries (`sessions`, `agent`, `approvals`), or the paths an embedder chose. `cwd` is the directory Pi's
|
|
940
|
+
* `read` tool and the session header record; default the process's. */
|
|
941
|
+
export interface PiBackendPaths {
|
|
942
|
+
cwd?: string;
|
|
943
|
+
/** Pi's own agent directory (its settings and caches). */
|
|
944
|
+
agentDir: string;
|
|
945
|
+
/** Where session files live: the durable transcript store the nightly memory pipeline reads. */
|
|
946
|
+
sessionDir: string;
|
|
947
|
+
toolApprovalsFile: string;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
export interface PiBackendOptions {
|
|
951
|
+
paths: PiBackendPaths;
|
|
952
|
+
/** Resolved on connection so a server binding port zero can report its actual port. */
|
|
953
|
+
port: number | (() => number);
|
|
954
|
+
/** The bearer token Pi sends to that loopback; default the shared local key. */
|
|
955
|
+
apiKey?: string;
|
|
956
|
+
modelId?: string;
|
|
957
|
+
contextWindow?: number;
|
|
958
|
+
/** What the served model is, read when each chat connects; overrides the static fields below. A host that swaps models supplies it. */
|
|
959
|
+
model?: () => Pick<PiBackendOptions, "modelId" | "contextWindow" | "vision" | "audio" | "thinking" | "genDefaults"> |
|
|
960
|
+
Promise<Pick<PiBackendOptions, "modelId" | "contextWindow" | "vision" | "audio" | "thinking" | "genDefaults">>;
|
|
961
|
+
readOnly?: boolean;
|
|
962
|
+
vision?: boolean;
|
|
963
|
+
audio?: boolean;
|
|
964
|
+
thinking?: boolean;
|
|
965
|
+
genDefaults?: ReadyGenDefaults;
|
|
966
|
+
transcription?: () => Promise<boolean>;
|
|
967
|
+
/** The app memory owner supplies its tools and skill paths; absent means disabled. */
|
|
968
|
+
memory?: () => Promise<MemorySurface | undefined>;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const definedOnly = <T extends object>(value: T | undefined): Partial<T> =>
|
|
972
|
+
Object.fromEntries(Object.entries(value ?? {}).filter(([, item]) => item !== undefined)) as Partial<T>;
|
|
973
|
+
|
|
974
|
+
/** Creates isolated chat sessions over the application's loopback HTTP API. */
|
|
975
|
+
export function createPiBackend(options: PiBackendOptions): ChatBackendFactory {
|
|
976
|
+
const resolved = {
|
|
977
|
+
...options,
|
|
978
|
+
paths: { ...options.paths, cwd: options.paths.cwd ?? process.cwd() },
|
|
979
|
+
modelId: options.modelId ?? PI_LOCAL_MODEL_ID,
|
|
980
|
+
contextWindow: options.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
981
|
+
readOnly: options.readOnly ?? false,
|
|
982
|
+
vision: options.vision ?? false,
|
|
983
|
+
audio: options.audio ?? false,
|
|
984
|
+
thinking: options.thinking ?? false,
|
|
985
|
+
genDefaults: options.genDefaults ?? { temperature: null, topP: null, topK: null },
|
|
986
|
+
transcription: options.transcription ?? (async () => false),
|
|
987
|
+
};
|
|
988
|
+
return (send) => new PiBackend(send, { ...resolved }, options.model);
|
|
989
|
+
}
|