@mlx-bun/module-chat 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/package.json +39 -4
- package/src/app-tools.ts +101 -0
- package/src/backend.ts +108 -0
- package/src/describe.ts +47 -0
- package/src/events.ts +75 -0
- package/src/extensions.ts +44 -0
- package/src/history.ts +188 -0
- package/src/index.ts +74 -0
- package/src/manifest.ts +26 -0
- package/src/model-bridge.ts +75 -0
- package/src/panel/adapters-panel.ts +287 -0
- package/src/panel/assistant.ts +469 -0
- package/src/panel/chat.ts +1076 -0
- package/src/panel/composer.ts +947 -0
- package/src/panel/dom.ts +94 -0
- package/src/panel/host.ts +28 -0
- package/src/panel/index.ts +100 -0
- package/src/panel/markdown.ts +547 -0
- package/src/panel/popovers.ts +17 -0
- package/src/panel/rag.ts +274 -0
- package/src/panel/sessions.ts +470 -0
- package/src/panel/style.ts +532 -0
- package/src/panel/template.ts +189 -0
- package/src/panel/ui-catalog.ts +77 -0
- package/src/panel/voice.ts +141 -0
- package/src/pi-backend.ts +989 -0
- package/src/policy.ts +417 -0
- package/src/protocol.ts +265 -0
- package/src/provider.ts +72 -0
- package/src/routes.ts +49 -0
- package/src/session-files.ts +36 -0
- package/src/session-search.ts +175 -0
- package/src/surface.ts +26 -0
- package/src/tool-approvals.ts +108 -0
- package/src/web-tools.ts +478 -0
package/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
|
+
}
|