@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.
@@ -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
+ }