@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/src/policy.ts ADDED
@@ -0,0 +1,417 @@
1
+ import { createHash } from "node:crypto";
2
+ import { APP_ROUTE_IDS } from "./protocol";
3
+ import type { AppRouteId, AppUiContext, SamplingOverrides } from "./protocol";
4
+ import { PI_LOCAL_MODEL_ID } from "./provider";
5
+ import { WEB_TOOL_NAMES } from "./web-tools";
6
+
7
+ export const APP_AWARE_TOOL_NAMES = ["get_current_app_context", "navigate_app", "spotlight_ui"] as const;
8
+ export const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOL_NAMES, ...APP_AWARE_TOOL_NAMES]);
9
+ /** Tools that require explicit per-call browser approval. */
10
+ export const GATED_TOOLS = new Set(["bash", "edit", "write"]);
11
+
12
+ /** Built-in mutation names cannot be reclassified by an injected surface.
13
+ * Unknown tools require approval rather than inheriting read-only privileges. */
14
+ export function toolApprovalClass(tool: string, injectedReadOnly: ReadonlySet<string>): "read-only" | "approval" {
15
+ if (GATED_TOOLS.has(tool)) return "approval";
16
+ return READ_ONLY_TOOLS.has(tool) || injectedReadOnly.has(tool) ? "read-only" : "approval";
17
+ }
18
+
19
+
20
+ /**
21
+ * The welcome assistant's tool allowlist: exactly `read` (a local file the user
22
+ * points to) and `web_search` (current/external facts). Both are in
23
+ * READ_ONLY_TOOLS, so neither triggers the approval gate. Kept to two because a
24
+ * 1B model over-calls a larger toolset; widen this list (not the system prompt)
25
+ * to grant more.
26
+ */
27
+ export const WELCOME_TOOLS = ["read", "web_search"] as const;
28
+
29
+ /** The session exposes welcome/app tools and any injected read-only memory tools.
30
+ * Coding tools are opt-in; the caller also applies the server's read-only setting. */
31
+ export function webChatToolAllowlist(memoryToolNames: readonly string[] = [], codingTools = false): string[] {
32
+ return [
33
+ ...WELCOME_TOOLS,
34
+ ...APP_AWARE_TOOL_NAMES,
35
+ ...memoryToolNames,
36
+ ...(codingTools ? ["grep", "find", "ls", ...GATED_TOOLS] : []),
37
+ ];
38
+ }
39
+
40
+ export const LOOP_HYGIENE = {
41
+ /** After this many consecutive FAILED calls with the same signature
42
+ * (name + JSON-stable args), the nudge text asks the model to change
43
+ * approach instead of repeating verbatim. */
44
+ MAX_CONSECUTIVE_FAILURES: 3,
45
+ /** Cap on AgentSession's turnIndex (one assistant-response round, per
46
+ * agent-session.js's _turnIndex) within a single user prompt before
47
+ * every further tool call is blocked with a force-finish nudge. */
48
+ MAX_TOOL_TURNS: 25,
49
+ } as const;
50
+
51
+ /** Stable key for deduplication and retry accounting, independent of object key order. */
52
+ export function toolCallSignature(toolName: string, args: unknown): string {
53
+ return `${toolName}:${stableStringify(args)}`;
54
+ }
55
+
56
+ /** Deterministic (key-sorted) JSON.stringify so argument key order never
57
+ * causes two semantically-identical calls to hash differently. */
58
+ function stableStringify(value: unknown): string {
59
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
60
+ if (value && typeof value === "object") {
61
+ const keys = Object.keys(value as Record<string, unknown>).sort();
62
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`).join(",")}}`;
63
+ }
64
+ return JSON.stringify(value);
65
+ }
66
+
67
+ /** Pure decision for the tool_call (before) hook: given the current loop
68
+ * state and the incoming call's signature, decide whether to block it
69
+ * outright (dedup or turn-cap) and, if so, why. Returns undefined when
70
+ * the call should proceed to normal execution. Exported + pure so the
71
+ * three policies (dedup / turn cap / [failure budget is a tool_result-
72
+ * side concern, see nextFailureNudge]) are unit-testable without a live
73
+ * AgentSession. */
74
+ export interface LoopHygieneState {
75
+ /** Signature + result text of the last call that completed successfully
76
+ * (undefined if none yet this prompt). */
77
+ lastSuccess?: { signature: string; resultText: string };
78
+ /** Consecutive failure count keyed by call signature. */
79
+ failureStreaks: Map<string, number>;
80
+ /** Current AgentSession turnIndex (updated on every turn_start). */
81
+ turnIndex: number;
82
+ }
83
+
84
+ export function initialLoopHygieneState(): LoopHygieneState {
85
+ return { lastSuccess: undefined, failureStreaks: new Map(), turnIndex: 0 };
86
+ }
87
+
88
+ export interface LoopHygieneBlock {
89
+ reason: string;
90
+ }
91
+
92
+ /** Decide whether to block a tool call BEFORE it runs. Checked in this
93
+ * order: turn cap first (a model that's been looping for 25 rounds gets
94
+ * force-finish treatment regardless of what it's calling next), then
95
+ * consecutive-identical-successful-call dedup. */
96
+ export function decideBeforeToolCall(
97
+ state: LoopHygieneState,
98
+ toolName: string,
99
+ args: unknown,
100
+ ): LoopHygieneBlock | undefined {
101
+ if (state.turnIndex >= LOOP_HYGIENE.MAX_TOOL_TURNS) {
102
+ return {
103
+ reason:
104
+ `Tool-turn limit (${LOOP_HYGIENE.MAX_TOOL_TURNS}) reached for this message. ` +
105
+ "Stop calling tools now and answer the user directly with what you have so far.",
106
+ };
107
+ }
108
+ const signature = toolCallSignature(toolName, args);
109
+ if (state.lastSuccess && state.lastSuccess.signature === signature) {
110
+ return {
111
+ reason:
112
+ "This exact tool call already ran and succeeded; skipping re-execution. " +
113
+ `Its result was:\n\n${state.lastSuccess.resultText}\n\n` +
114
+ "Use that result instead of calling it again.",
115
+ };
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ /** Update loop-hygiene bookkeeping AFTER a call executes (or was
121
+ * immediately blocked/errored) and, on repeated failure, return nudge
122
+ * text to append to the error the model sees. `resultText` is the
123
+ * flattened text content of the tool's result (used to seed
124
+ * `lastSuccess` for the dedup check above). Exported + pure. */
125
+ export function recordToolCallOutcome(
126
+ state: LoopHygieneState,
127
+ toolName: string,
128
+ args: unknown,
129
+ outcome: { isError: boolean; resultText: string },
130
+ ): string | undefined {
131
+ const signature = toolCallSignature(toolName, args);
132
+ if (outcome.isError) {
133
+ const streak = (state.failureStreaks.get(signature) ?? 0) + 1;
134
+ state.failureStreaks.set(signature, streak);
135
+ if (streak >= LOOP_HYGIENE.MAX_CONSECUTIVE_FAILURES) {
136
+ return (
137
+ `\n\n(This exact call has now failed ${streak} times in a row. ` +
138
+ "Stop repeating it verbatim — try a different tool, different arguments, " +
139
+ "or explain to the user what's blocking you.)"
140
+ );
141
+ }
142
+ return undefined;
143
+ }
144
+ state.failureStreaks.delete(signature);
145
+ state.lastSuccess = { signature, resultText: outcome.resultText };
146
+ return undefined;
147
+ }
148
+
149
+ /** Flatten a tool result's content (TextContent[] shape) to plain text for
150
+ * dedup-cache display and diagnostics. Mirrors contentText's tolerance
151
+ * for non-array/string shapes. Exported for unit testing. */
152
+ export function toolResultText(content: unknown): string {
153
+ if (typeof content === "string") return content;
154
+ if (Array.isArray(content)) {
155
+ return content
156
+ .filter((p): p is { type: string; text: string } =>
157
+ !!p && (p as { type?: string }).type === "text" && typeof (p as { text?: unknown }).text === "string")
158
+ .map((p) => p.text)
159
+ .join("");
160
+ }
161
+ return "";
162
+ }
163
+
164
+ /**
165
+ * System prompt for the web chat assistant.
166
+ *
167
+ * This fully REPLACES pi's default coding-agent prompt (it flows to
168
+ * buildSystemPrompt's `customPrompt` via DefaultResourceLoader.systemPrompt),
169
+ * dropping pi's "operating inside a coding-agent harness" framing and its block
170
+ * of internal documentation paths. pi still auto-appends the current date and
171
+ * working directory, so we don't repeat them here.
172
+ *
173
+ * Deliberately SHORT. The default served model is a ~1B local model; a long,
174
+ * "make the user feel welcome / here is everything mlx-bun does" prompt made it
175
+ * fixate on greeting and ignore the user's actual message (verified against the
176
+ * live server: the same model answers correctly with a short prompt and drowns
177
+ * with the long one). So: state identity + privacy in one breath, then tell it
178
+ * plainly to answer what was asked and not to greet or recite capabilities.
179
+ */
180
+ export const WEB_CHAT_PROMPT_VERSION = "2026-07-07-app-aware-v1";
181
+
182
+ export function webChatPromptFingerprint(prompt: string): string {
183
+ return createHash("sha256").update(prompt).digest("hex").slice(0, 12);
184
+ }
185
+
186
+ export function buildWebChatSystemPrompt(
187
+ readOnly: boolean,
188
+ about?: { modelId?: string },
189
+ opts?: { hasTools?: boolean },
190
+ ): string {
191
+ const servedModel =
192
+ about?.modelId && about.modelId !== PI_LOCAL_MODEL_ID ? about.modelId : null;
193
+ const modelLine = servedModel ? ` You are running on the local model \`${servedModel}\`.` : "";
194
+
195
+ // A concise mlx-bun blurb so the welcome assistant can answer product
196
+ // questions from its own knowledge (no tool, no fragile cwd-relative reads).
197
+ // Kept SHORT on purpose — the old multi-paragraph product wall drowned the 1B
198
+ // model. The blurb + the two key commands cover "what is it / how do I start".
199
+ const aboutLine = ` mlx-bun runs open LLMs locally with MLX + Bun: a built-in chat, an OpenAI/Anthropic-compatible API, model download and serving, quantization, LoRA fine-tuning, and adapters — all on-device and private. Key commands: \`mlx-bun serve <model>\` to serve one, \`mlx-bun get <repo-id>\` to download one. You already know this, so answer questions about mlx-bun directly.`;
200
+
201
+ // The tool guidance MUST match the session's actual surface (read +
202
+ // web_search), or the model promises actions it can't take. Naming exactly
203
+ // those two — and telling it to answer from knowledge first — is what keeps a
204
+ // small model from reaching for a tool on math/writing/general questions.
205
+ const hasTools = opts?.hasTools ?? true;
206
+ // App-aware assistant: tell the model these tools exist and
207
+ // when to reach for them instead of describing the UI blind — "when the
208
+ // user asks where/how in the app, navigate or spotlight instead of
209
+ // describing blind" is the literal steer the task calls for. Included
210
+ // whenever the session has tools at all (these three are always in the
211
+ // allowlist alongside WELCOME_TOOLS — see webChatToolAllowlist).
212
+ const appAwareLine = hasTools
213
+ ? ` You can see and act on the app: \`navigate_app\` switches the view, \`spotlight_ui\` highlights a control (use \`get_current_app_context\` first for its ref). When asked where/how in the app, navigate or spotlight instead of describing it blind.`
214
+ : "";
215
+ const toolsLine = hasTools
216
+ ? ` Answer directly from your own knowledge whenever you can. Only call a tool when you truly need information you don't have: \`web_search\` for current or external facts (news, current events, prices, latest docs), \`read\` for a specific local file the user points you to. Never use a tool for general questions, explanations, math, or writing — just answer. When a tool returns results, pull the answer out of the result text and state it directly — never tell the user to go open the links or check the sources themselves; reading them is your job.${appAwareLine}`
217
+ : ` You have no tools in this session, so answer from your own knowledge; if something needs current or external data you can't reach, say so briefly instead of pretending to look it up.`;
218
+
219
+ return `You are mlx-bun's built-in assistant, running entirely on the user's own Apple-silicon Mac — nothing they type leaves the machine.${modelLine}${aboutLine}${toolsLine}
220
+
221
+ Respond to what the user actually said, concisely. Don't open with a generic greeting or recite your capabilities unless asked — just answer. If a request is genuinely ambiguous, ask one short clarifying question. Format with Markdown when it helps.`;
222
+ }
223
+
224
+ /** Validate assistant navigation against the shared browser/server route IDs. */
225
+ export function isAppRouteId(v: string): v is AppRouteId {
226
+ return (APP_ROUTE_IDS as readonly string[]).includes(v);
227
+ }
228
+
229
+ /** Resolve a free-form route/page string the way navigate_app's tool
230
+ * describes its param to the model: either a bare route id ("quantize")
231
+ * or a "#/quantize"-style hash. Returns null for anything else. */
232
+ export function resolveAppRoute(routeOrPage: string): AppRouteId | null {
233
+ const bare = routeOrPage.trim().replace(/^#\/?/, "");
234
+ return isAppRouteId(bare) ? bare : null;
235
+ }
236
+
237
+ /** Compact one-line ambient context ("never answer blind") mined from
238
+ * the stored AppUiContext — e.g. "[user is on: Quantize · step 2/4]".
239
+ * Never a snapshot dump; this is the ONLY thing auto-attached to every
240
+ * turn (the full snapshot stays on-demand via get_current_app_context).
241
+ * Returns null when there's no context yet (nothing pushed this
242
+ * connection — e.g. a non-browser WS client, or the very first frame
243
+ * race before chat.ts's first context push lands). Pure + exported for
244
+ * unit testing; mirrors assistant.ts's ambientLine so the two stay in
245
+ * lockstep even though the server never imports the browser module. */
246
+ const APP_ROUTE_LABELS: Record<AppRouteId, string> = {
247
+ chat: "Chat", quantize: "Quantize", finetune: "Fine-tune", dataset: "Build Dataset", status: "Status",
248
+ };
249
+
250
+ export function ambientContextLine(ctx: AppUiContext | null | undefined): string | null {
251
+ if (!ctx || !ctx.route) return null;
252
+ const place = ctx.view ?? APP_ROUTE_LABELS[ctx.route as AppRouteId] ?? ctx.route;
253
+ const stepPart = ctx.step ? ` · step ${ctx.step.index + 1}/${ctx.step.count}` : "";
254
+ return `[user is on: ${place}${stepPart}]`;
255
+ }
256
+
257
+ /** Body of the `before_provider_request` hook: inject the selected LoRA adapter
258
+ * into the outgoing provider payload. null/empty selection → return undefined so
259
+ * Pi keeps the payload unchanged (base model); Pi replaces the payload only when a
260
+ * handler returns a value. Pure + exported for unit testing. */
261
+ export function injectAdapter(
262
+ payload: Record<string, unknown>,
263
+ selected: string | null,
264
+ ): Record<string, unknown> | undefined {
265
+ if (!selected) return undefined;
266
+ return { ...payload, adapter: selected };
267
+ }
268
+
269
+ /** Every SamplingOverrides field name, in wire order — the single source of
270
+ * truth `injectSampling` and the `set_sampling` handler both walk, so a new
271
+ * field only needs adding here + the interface above. */
272
+ const SAMPLING_FIELDS = [
273
+ "temperature", "top_p", "top_k", "min_p", "xtc_probability", "xtc_threshold",
274
+ "repetition_penalty", "repetition_context_size", "presence_penalty",
275
+ "frequency_penalty", "seed",
276
+ ] as const satisfies readonly (keyof SamplingOverrides)[];
277
+
278
+ /** Inject the user's sampling overrides into the outgoing chat-completions
279
+ * payload (which reaches the server's toOptions, where an explicit
280
+ * request field always wins). Only finite numbers are injected; a
281
+ * null/undefined/unset field is left off so the server falls back to its
282
+ * mode-aware recommended default. Returns undefined when nothing is set so the
283
+ * hook can keep the payload unchanged. Pure + exported for unit testing. */
284
+ export function injectSampling(
285
+ payload: Record<string, unknown>,
286
+ s: SamplingOverrides | undefined,
287
+ ): Record<string, unknown> | undefined {
288
+ if (!s) return undefined;
289
+ const out: Record<string, unknown> = { ...payload };
290
+ let changed = false;
291
+ for (const field of SAMPLING_FIELDS) {
292
+ const v = s[field];
293
+ if (typeof v === "number" && Number.isFinite(v)) {
294
+ out[field] = v;
295
+ changed = true;
296
+ }
297
+ }
298
+ return changed ? out : undefined;
299
+ }
300
+
301
+ // ---- Per-message sampling scope -----------------------------------------
302
+ //
303
+ // set_sampling's optional `scope` field: "session" (default, today's
304
+ // behavior — persists on the connection until changed) or "next_turn" (a
305
+ // one-shot override for exactly the next prompt, then cleared). The
306
+ // composition rule is "next_turn OVER session": a next_turn field that's
307
+ // a real number wins over the session-level value for that SAME field;
308
+ // a next_turn field left null/undefined falls through to the session
309
+ // override (which itself falls through to the server default per
310
+ // injectSampling's existing null-means-unset contract). This mirrors
311
+ // optiq Lab's per-message temp/max-tokens/thinking granularity
312
+ // (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
313
+ // beat-matrix Axis 4), layered onto (not replacing) the session-level overrides.
314
+ //
315
+ // Lifecycle: armed by the set_sampling handler when scope is "next_turn"
316
+ // (stored separately from the session-level `sampling` field so a
317
+ // one-shot override never permanently clobbers the session default it's
318
+ // laid over). Consumed at the FIRST before_provider_request of the very
319
+ // next prompt — before_agent_start fires exactly once per session.prompt()
320
+ // call (see agent-session.js's prompt() body), which is the natural
321
+ // "one prompt" boundary, but the actual sampling injection happens in
322
+ // before_provider_request (fired once per model call, i.e. once per
323
+ // tool-loop turn) — so the override is consumed (cleared) the first time
324
+ // before_provider_request fires after being armed, guaranteeing it rides
325
+ // on exactly the next outgoing provider request and never leaks into a
326
+ // second turn within the same prompt, let alone the next user message.
327
+
328
+ /** Compose the effective sampling overrides for one outgoing provider
329
+ * request: every field in `nextTurn` that's a real number wins; anything
330
+ * left null/undefined in `nextTurn` falls back to `session`'s value for
331
+ * that field. Pure + exported for unit testing. When `nextTurn` is
332
+ * undefined (no one-shot override armed), returns `session` unchanged. */
333
+ export function composeSampling(
334
+ session: SamplingOverrides,
335
+ nextTurn: SamplingOverrides | undefined,
336
+ ): SamplingOverrides {
337
+ if (!nextTurn) return session;
338
+ const out: SamplingOverrides = { ...session };
339
+ for (const field of SAMPLING_FIELDS) {
340
+ const v = nextTurn[field];
341
+ if (typeof v === "number" && Number.isFinite(v)) out[field] = v;
342
+ }
343
+ return out;
344
+ }
345
+
346
+ /** Pure state machine for the arm/consume lifecycle above, factored out of
347
+ * PiWebSession so "set → one turn → cleared" is unit-testable without a
348
+ * live session. `applySetSampling` is the set_sampling handler body;
349
+ * `consumeForRequest` is the before_provider_request handler body — both
350
+ * mutate and return a NEW state object (no hidden mutation) so tests can
351
+ * assert on each step's output directly. */
352
+ export interface SamplingScopeState {
353
+ session: SamplingOverrides;
354
+ nextTurn: SamplingOverrides | undefined;
355
+ }
356
+
357
+ export function initialSamplingScopeState(): SamplingScopeState {
358
+ return { session: {}, nextTurn: undefined };
359
+ }
360
+
361
+ /** Apply a set_sampling message to scope state: scope "next_turn" arms the
362
+ * one-shot override (leaving `session` untouched); scope "session" or
363
+ * omitted replaces the session-level override (today's behavior). Pure. */
364
+ export function applySetSampling(
365
+ state: SamplingScopeState,
366
+ overrides: SamplingOverrides,
367
+ scope: "session" | "next_turn" | undefined,
368
+ ): SamplingScopeState {
369
+ return scope === "next_turn"
370
+ ? { session: state.session, nextTurn: overrides }
371
+ : { session: overrides, nextTurn: state.nextTurn };
372
+ }
373
+
374
+ /** Compute the effective overrides for ONE outgoing provider request and
375
+ * return the post-consumption state (nextTurn always cleared, whether or
376
+ * not one was armed — consuming "nothing armed" is a no-op clear). Pure. */
377
+ export function consumeForRequest(
378
+ state: SamplingScopeState,
379
+ ): { effective: SamplingOverrides; nextState: SamplingScopeState } {
380
+ return {
381
+ effective: composeSampling(state.session, state.nextTurn),
382
+ nextState: { session: state.session, nextTurn: undefined },
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Layer the user's custom system prompt onto (not replacing) the built-in
388
+ * surface prompt for one turn. Called from the `before_agent_start` hook
389
+ * body with `event.systemPrompt` (the fully-assembled base: identity +
390
+ * memoryHint) and the connection's stored override. Returns undefined when
391
+ * there's nothing to layer (null/empty override), so the hook can leave
392
+ * pi's base prompt alone — mirrors injectAdapter/injectSampling's "undefined
393
+ * means unchanged" contract. Pure + exported for unit testing.
394
+ */
395
+ export function injectSystemPrompt(base: string, custom: string | null | undefined): string | undefined {
396
+ const trimmed = custom?.trim();
397
+ if (!trimmed) return undefined;
398
+ return `${base}\n\n---\n\nThe user has set a custom instruction for this chat. Follow it alongside (not instead of) the guidance above:\n\n${trimmed}`;
399
+ }
400
+
401
+ /**
402
+ * Apply the browser's edited tool arguments onto pi's mutable
403
+ * ToolCallEvent.input IN PLACE, per the SDK's documented tool_call
404
+ * mutation contract (installApprovalGate's own doc comment quotes it: no
405
+ * return-value channel exists for modified args). A no-op when
406
+ * `editedArgs` is undefined (the common "approved as proposed" case).
407
+ * Replaces the input's keys wholesale (delete-then-assign) rather than a
408
+ * shallow merge, so a key the user removed from the textarea actually
409
+ * disappears from the call instead of lingering from the original
410
+ * proposal. Pure enough to unit test the mutation shape without a live
411
+ * ExtensionAPI/ToolCallEvent — takes any mutable record, not the SDK type.
412
+ */
413
+ export function applyEditedArgs(input: Record<string, unknown>, editedArgs: Record<string, unknown> | undefined): void {
414
+ if (!editedArgs) return;
415
+ for (const key of Object.keys(input)) delete input[key];
416
+ Object.assign(input, editedArgs);
417
+ }