pi-better-btw-plus 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,21 @@
1
+ import { resolve } from "node:path";
2
+
3
+ export class FileActivityTracker {
4
+ private written = new Set<string>();
5
+
6
+ trackWrite(path: string, cwd: string) {
7
+ this.written.add(this.normalize(path, cwd));
8
+ }
9
+
10
+ hasWritten(path: string, cwd: string): boolean {
11
+ return this.written.has(this.normalize(path, cwd));
12
+ }
13
+
14
+ get writeCount(): number {
15
+ return this.written.size;
16
+ }
17
+
18
+ private normalize(path: string, cwd: string): string {
19
+ return resolve(cwd, path);
20
+ }
21
+ }
@@ -0,0 +1,106 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { ToolResultMessage } from "@earendil-works/pi-ai";
3
+
4
+ /** Approved wording (#12): synthesized result for a tool call that was still
5
+ * running on the main lane when the btw side chat forked. */
6
+ export const FORKED_MID_EXECUTION_TEXT =
7
+ "[forked mid-execution — the main lane was still running this tool call when the btw side chat opened]";
8
+
9
+ /**
10
+ * Fork surgery (#12/#16): make the trailing tool exchange gateway-legal on
11
+ * the fork snapshot, tail-only, never rewriting existing messages.
12
+ *
13
+ * The gateway rejects any request with an unanswered `assistant.tool_calls`
14
+ * (HTTP 400), and the fork snapshot can genuinely end mid-execution (the
15
+ * main lane was still running the tool when the btw side chat opened). Rules:
16
+ *
17
+ * - S1/S2 dangling tool_calls (no matching toolResult in the kept list) get
18
+ * one synthesized toolResult each, appended after the trailing exchange.
19
+ * Real landed results are never touched.
20
+ * - Orphan toolResults (no matching tool_call anywhere in the kept list) are
21
+ * defensively dropped.
22
+ * - S5 carry-cut (#16, reverses S4): a trailing run of user messages is cut
23
+ * entirely, and the user message that triggered the trailing tool exchange
24
+ * is cut with it — the cite never ends on a user message. "The
25
+ * newest-looking question in the cite" is the lane-confusion source
26
+ * (2026-08-13 export: the model answered the main lane's trailing user
27
+ * message instead of the btw message). Tail cuts keep the btw request a
28
+ * token prefix of the main request, so shared-prefix caching is unaffected.
29
+ * - branchSummary/compactionSummary, images and `excludeFromContext` messages
30
+ * pass through untouched (shared prefix).
31
+ *
32
+ * Matching against the whole kept list (not just the region) keeps valid
33
+ * answered exchanges intact in pathological orderings; only true orphans and
34
+ * true dangling calls are touched.
35
+ */
36
+ export function forkSurgery(messages: AgentMessage[], forkTimestamp = Date.now()): AgentMessage[] {
37
+ // (1) Locate the trailing exchange region with S5 carry-cut (#16):
38
+ // - a trailing run of user messages is cut entirely (S4 reversal),
39
+ // - the user message that triggered the trailing tool exchange is cut with
40
+ // it (carryStart), so the cite never ends on a user message.
41
+ let end = messages.length;
42
+ while (end > 0 && messages[end - 1].role === "user") end--;
43
+ let start = end;
44
+ while (start > 0) {
45
+ const message = messages[start - 1];
46
+ if (message.role === "toolResult") {
47
+ start--;
48
+ continue;
49
+ }
50
+ if (message.role === "assistant" && hasToolCalls(message)) {
51
+ start--;
52
+ continue;
53
+ }
54
+ break;
55
+ }
56
+ const carryStart = start > 0 && messages[start - 1].role === "user" ? start - 1 : start;
57
+ const region = messages.slice(start, end);
58
+ const cut = end < messages.length || carryStart < start;
59
+ if (!cut && region.length === 0) return messages;
60
+
61
+ // (2) Index every tool_call in the whole list (id → tool name, for
62
+ // synthesis) and every toolResult id.
63
+ const callNames = new Map<string, string>();
64
+ const resultIds = new Set<string>();
65
+ for (const message of messages) {
66
+ if (message.role === "assistant") {
67
+ for (const block of message.content) {
68
+ if (block.type === "toolCall") callNames.set(block.id, block.name);
69
+ }
70
+ } else if (message.role === "toolResult") {
71
+ resultIds.add(message.toolCallId);
72
+ }
73
+ }
74
+
75
+ // (3) Orphan toolResults in the region are dropped; everything else in the
76
+ // region passes through byte-identical.
77
+ const kept = region.filter((message) => message.role !== "toolResult" || callNames.has(message.toolCallId));
78
+
79
+ // (4) Dangling calls in the region get one synthesized toolResult each.
80
+ const regionCallIds = new Set<string>();
81
+ for (const message of region) {
82
+ if (message.role !== "assistant") continue;
83
+ for (const block of message.content) {
84
+ if (block.type === "toolCall") regionCallIds.add(block.id);
85
+ }
86
+ }
87
+ const synthesized: ToolResultMessage[] = [];
88
+ for (const id of regionCallIds) {
89
+ if (resultIds.has(id)) continue;
90
+ synthesized.push({
91
+ role: "toolResult",
92
+ toolCallId: id,
93
+ toolName: callNames.get(id) ?? "unknown",
94
+ content: [{ type: "text", text: FORKED_MID_EXECUTION_TEXT }],
95
+ isError: false,
96
+ timestamp: forkTimestamp,
97
+ });
98
+ }
99
+
100
+ if (!cut && kept.length === region.length && synthesized.length === 0) return messages;
101
+ return [...messages.slice(0, carryStart), ...kept, ...synthesized];
102
+ }
103
+
104
+ function hasToolCalls(message: AgentMessage): boolean {
105
+ return message.role === "assistant" && message.content.some((block) => block.type === "toolCall");
106
+ }
package/srcs/index.ts ADDED
@@ -0,0 +1,381 @@
1
+ import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ ExtensionUIContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { OverlayHandle, Terminal, TUI } from "@earendil-works/pi-tui";
8
+ import {
9
+ buildSessionContext,
10
+ ExtensionRunner,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { FileActivityTracker } from "./file-activity-tracker.ts";
13
+ import { loadConfig, loadRetryPolicy } from "./config.ts";
14
+ import { getExtensionDir, loadPromptPack } from "./prompt-pack.ts";
15
+ import {
16
+ SideChatOverlay,
17
+ SIDE_CHAT_OVERLAY_MARGIN_TOP,
18
+ SIDE_CHAT_OVERLAY_MAX_HEIGHT,
19
+ type ForkContext,
20
+ } from "./side-chat-overlay.ts";
21
+ import { SIDE_CHAT_SHORTCUT } from "./shortcuts.ts";
22
+ import {
23
+ disableMouseReporting,
24
+ enableMouseReporting,
25
+ parseSgrMouseEvent,
26
+ } from "./side-chat-mouse.ts";
27
+ import { extractWritePaths } from "./tool-wrapper.ts";
28
+ // Patch to capture the runner instance for extension tool access in side chat.
29
+ let capturedRunner: ExtensionRunner | null = null;
30
+ // Patch once (module reloads re-execute this file; re-patching would nest the
31
+ // wrapper one level per /reload and eventually blow the stack). The marker
32
+ // lives in the Symbol.for registry (global across module reloads and other
33
+ // extensions) so re-entry is a no-op without a string-keyed prototype prop
34
+ // another extension could collide with.
35
+ const RUNNER_CAPTURED = Symbol.for("__btwRunnerCaptured");
36
+ if (!(ExtensionRunner.prototype as unknown as Record<symbol, unknown>)[RUNNER_CAPTURED]) {
37
+ const origGetAllRegisteredTools =
38
+ ExtensionRunner.prototype.getAllRegisteredTools;
39
+ (ExtensionRunner.prototype as unknown as Record<symbol, unknown>)[RUNNER_CAPTURED] = true;
40
+ ExtensionRunner.prototype.getAllRegisteredTools = function () {
41
+ capturedRunner = this;
42
+ return origGetAllRegisteredTools.call(this);
43
+ };
44
+ }
45
+
46
+ function getExtensionAgentTools(): AgentTool[] {
47
+ if (!capturedRunner) return [];
48
+ return capturedRunner.getAllRegisteredTools().map((rt): AgentTool => {
49
+ const { definition } = rt;
50
+ return {
51
+ name: definition.name,
52
+ label: definition.label,
53
+ description: definition.description,
54
+ parameters: definition.parameters,
55
+ execute: (toolCallId, params, signal, onUpdate) =>
56
+ definition.execute(
57
+ toolCallId,
58
+ params,
59
+ signal,
60
+ onUpdate,
61
+ capturedRunner!.createContext(),
62
+ ),
63
+ };
64
+ });
65
+ }
66
+
67
+ const OVERLAY_BLOCKED_ERROR = "PI_SIDE_CHAT_OVERLAY_BLOCKED";
68
+
69
+ /**
70
+ * Log an open failure that escapes openSideChat's own state cleanup
71
+ * (fire-and-forget /btw & Alt+W paths). Error-only channel: never called on
72
+ * the happy path, so it can't pollute the TUI's normal rendering.
73
+ */
74
+ function logOpenFailure(error: unknown): void {
75
+ console.error("[btw] failed to open side chat:", error);
76
+ }
77
+
78
+ /** Extension directory: base for the bundle config.json and prompt-pack paths. */
79
+ const extensionDir = getExtensionDir();
80
+
81
+ export default function sideChatExtension(pi: ExtensionAPI) {
82
+ const tracker = new FileActivityTracker();
83
+ let activeOverlay: SideChatOverlay | null = null;
84
+ let overlayHandle: OverlayHandle | null = null;
85
+ let lastMessages: AgentMessage[] | null = null;
86
+ let mouseTerminal: Terminal | null = null;
87
+ let removeMouseListener: (() => void) | null = null;
88
+
89
+ /**
90
+ * Enable xterm mouse reporting + SGR while the side chat is open and route
91
+ * overlay events (wheel scroll, drag-select, copy) to the chat. Mouse
92
+ * sequences are always consumed so they never leak into the editor as
93
+ * garbage input.
94
+ */
95
+ const installMouseHandler = (tui: TUI) => {
96
+ if (removeMouseListener) return;
97
+ enableMouseReporting(tui.terminal);
98
+ mouseTerminal = tui.terminal;
99
+ removeMouseListener = tui.addInputListener((data) => {
100
+ const event = parseSgrMouseEvent(data);
101
+ if (!event) return undefined;
102
+ const overlay = activeOverlay;
103
+ if (overlay && !overlayHandle?.isHidden()) {
104
+ // An in-flight drag keeps consuming events even when the pointer
105
+ // leaves the overlay, so the selection clamps to the edges.
106
+ if (overlay.isMouseDragging()) {
107
+ overlay.handleMouseEvent(event);
108
+ return { consume: true };
109
+ }
110
+ const viewport = overlay.getViewport();
111
+ const overOverlay =
112
+ viewport !== null &&
113
+ event.row >= viewport.topRow &&
114
+ event.row < viewport.topRow + viewport.height;
115
+ if (overOverlay) {
116
+ // A press on the chat focuses the overlay, so the subsequent
117
+ // Ctrl+C / Ctrl+Shift+C lands in the overlay (not the main editor)
118
+ // and re-copies the selection.
119
+ if (
120
+ !event.isRelease &&
121
+ (event.button & 3) === 0 &&
122
+ (event.button & 32) === 0
123
+ ) {
124
+ overlayHandle?.focus();
125
+ }
126
+ overlay.handleMouseEvent(event);
127
+ }
128
+ }
129
+ // Always consume: mouse sequences must never leak into the editor as
130
+ // garbage input. (In fullscreen mode the alt-screen handler already
131
+ // consumed every SGR sequence before us, so this branch only fires in
132
+ // regular mode.)
133
+ return { consume: true };
134
+ });
135
+ };
136
+
137
+ const uninstallMouseHandler = () => {
138
+ if (!removeMouseListener) return;
139
+ removeMouseListener();
140
+ removeMouseListener = null;
141
+ if (mouseTerminal) {
142
+ disableMouseReporting(mouseTerminal);
143
+ mouseTerminal = null;
144
+ }
145
+ };
146
+
147
+ /**
148
+ * Keep terminal mouse reporting bound to overlay *visibility* (issue #17
149
+ * Q8): backgrounding the chat (hide) must release the terminal's native
150
+ * selection, restoring it on show. Focus is irrelevant — wheel scroll
151
+ * keeps working while visible-but-unfocused.
152
+ */
153
+ const syncMouseReporting = () => {
154
+ if (!removeMouseListener || !mouseTerminal) return;
155
+ if (overlayHandle?.isHidden()) {
156
+ disableMouseReporting(mouseTerminal);
157
+ // Reporting is off, so no release will arrive: abort any in-flight
158
+ // drag so a stale capture cannot swallow later events.
159
+ activeOverlay?.cancelMouseDrag();
160
+ } else {
161
+ enableMouseReporting(mouseTerminal);
162
+ }
163
+ };
164
+
165
+ /** Restore a hidden overlay to the foreground and re-enable mouse reporting. */
166
+ const restoreOverlay = (handle: OverlayHandle) => {
167
+ handle.setHidden(false);
168
+ handle.focus();
169
+ syncMouseReporting();
170
+ };
171
+
172
+ /** Background a visible overlay: release focus, hide, and disable mouse reporting. */
173
+ const hideOverlay = (handle: OverlayHandle) => {
174
+ handle.unfocus();
175
+ handle.setHidden(true);
176
+ syncMouseReporting();
177
+ };
178
+
179
+ /** Toggle the side chat between hidden (backgrounded) and visible. Opens it if needed. */
180
+ const backgroundSideChat = async (ctx: ExtensionContext) => {
181
+ if (!activeOverlay) {
182
+ void openSideChat(ctx).catch(logOpenFailure); // fire-and-forget, see toggleSideChat
183
+ return;
184
+ }
185
+ const handle = overlayHandle;
186
+ if (!handle) return;
187
+ if (handle.isHidden()) {
188
+ restoreOverlay(handle);
189
+ } else {
190
+ hideOverlay(handle);
191
+ }
192
+ };
193
+
194
+ pi.on("tool_execution_start", (event, ctx) => {
195
+ if (["write", "edit", "bash"].includes(event.toolName)) {
196
+ const paths = extractWritePaths(event.toolName, event.args);
197
+ paths.forEach((p) => tracker.trackWrite(p, ctx.cwd));
198
+ }
199
+ });
200
+
201
+ const toggleSideChat = async (ctx: ExtensionContext) => {
202
+ if (activeOverlay) {
203
+ const handle = overlayHandle;
204
+ if (!handle) return;
205
+ if (handle.isHidden()) {
206
+ // Hidden in the background: restore and focus.
207
+ restoreOverlay(handle);
208
+ return;
209
+ }
210
+ // The Alt+/ focus toggle was dropped (Alt+W owns background/restore):
211
+ // the open key only ever brings the chat to the front — a visible but
212
+ // unfocused overlay (e.g. after a mouse refocus) comes back this way,
213
+ // and pressing it while focused is a no-op.
214
+ if (!handle.isFocused()) {
215
+ handle.focus();
216
+ }
217
+ return;
218
+ }
219
+ // Fire-and-forget: the custom overlay lifecycle outlives this command
220
+ // handler. Awaiting it blocks the main agent's input loop (the prompt()
221
+ // call chain suspends on ctx.ui.custom until the overlay closes), so every
222
+ // later slash command — including a second /btw meant to re-open the
223
+ // hidden overlay — queues in pendingUserInputs and never runs. The
224
+ // overlay's own close/refork/clear handling happens inside openSideChat.
225
+ void openSideChat(ctx).catch(logOpenFailure);
226
+ };
227
+
228
+ const openSideChat = async (ctx: ExtensionContext, clear = false) => {
229
+ if (!ctx.model) {
230
+ ctx.ui.notify("Cannot open side chat: no model configured", "error");
231
+ return;
232
+ }
233
+
234
+ const sessionContext = buildSessionContext(
235
+ ctx.sessionManager.getEntries(),
236
+ ctx.sessionManager.getLeafId(),
237
+ );
238
+ // Layered config (#18): bundle <ExtensionDir>/config.json defaults, then
239
+ // ~/.pi/agent/pi-better-btw/config.json (user), then
240
+ // <cwd>/.pi/pi-better-btw/config.json (project) — allowlists union,
241
+ // promptPack merges per key.
242
+ // Read fresh at every open (no cache), consistent with the prompt pack.
243
+ const config = loadConfig({
244
+ extensionDir,
245
+ cwd: ctx.cwd,
246
+ onWarning: (message) => ctx.ui.notify(message, "warning"),
247
+ });
248
+ // Retry budget (#8, D8): pi's own settings.retry (global settings.json
249
+ // merged with <cwd>/.pi/settings.json), read fresh at every fork.
250
+ const retryPolicy = loadRetryPolicy({
251
+ cwd: ctx.cwd,
252
+ onWarning: (message) => ctx.ui.notify(message, "warning"),
253
+ });
254
+ // Prompt pack (#13): read fresh at every fork (no cache) so edits to the
255
+ // manifest files apply on the next fork; per-key fallback + notify.
256
+ const promptPack = loadPromptPack(config.promptPack, {
257
+ extensionDir,
258
+ notify: (message) => ctx.ui.notify(message, "warning"),
259
+ });
260
+ const forkContext: ForkContext = {
261
+ messages: clear ? [] : (lastMessages ?? sessionContext.messages),
262
+ model: ctx.model,
263
+ systemPrompt: ctx.getSystemPrompt(),
264
+ thinkingLevel: pi.getThinkingLevel(),
265
+ cwd: ctx.cwd,
266
+ extensionTools: getExtensionAgentTools(),
267
+ };
268
+
269
+ try {
270
+ const action = await ctx.ui.custom<"close" | "refork" | "clear">(
271
+ (tui, theme, _keybindings, done) => {
272
+ if (tui.hasOverlay()) {
273
+ setTimeout(() => {
274
+ ctx.ui.notify(
275
+ "Close or background the current overlay first",
276
+ "warning",
277
+ );
278
+ }, 0);
279
+ throw new Error(OVERLAY_BLOCKED_ERROR);
280
+ }
281
+
282
+ activeOverlay = new SideChatOverlay({
283
+ tui,
284
+ theme,
285
+ forkContext,
286
+ tracker,
287
+ modelRegistry: ctx.modelRegistry,
288
+ scopedModels: ctx.scopedModels,
289
+ sessionManager: ctx.sessionManager,
290
+ promptPack,
291
+ readOnlyExtensionAllowlist: config.readOnlyExtensionAllowlist,
292
+ features: config.features,
293
+ retryPolicy,
294
+ onOverlapWarning: (path) => showOverlapWarning(ctx.ui, path),
295
+ onBackground: () => {
296
+ // Defer past the current input dispatch: hiding synchronously
297
+ // while the overlay is inside handleInput races the TUI's focus
298
+ // bookkeeping (the overlay stays focused-but-hidden, and the
299
+ // next keystroke gets eaten by the focus-redirect path — the
300
+ // reported "/btw won't re-open after Alt+W" bug). The Alt+W
301
+ // shortcut handler runs asynchronously from the editor and is
302
+ // unaffected; this mirrors it.
303
+ const scheduledHandle = overlayHandle;
304
+ setTimeout(() => {
305
+ // Identity check: if the overlay closed and a new one opened
306
+ // within the 0ms window, only act on the handle we scheduled
307
+ // for — never toggle a brand-new overlay.
308
+ if (!scheduledHandle || overlayHandle !== scheduledHandle) return;
309
+ if (scheduledHandle.isHidden()) {
310
+ restoreOverlay(scheduledHandle);
311
+ } else {
312
+ hideOverlay(scheduledHandle);
313
+ }
314
+ }, 0);
315
+ },
316
+ onExport: (path) =>
317
+ ctx.ui.notify(`btw chat exported → ${path}`, "info"),
318
+ onClose: (action, messages) => {
319
+ lastMessages = action === "close" ? messages : null;
320
+ activeOverlay = null;
321
+ overlayHandle = null;
322
+ uninstallMouseHandler();
323
+ done(action);
324
+ },
325
+ });
326
+ installMouseHandler(tui);
327
+ return activeOverlay;
328
+ },
329
+ {
330
+ overlay: true,
331
+ overlayOptions: {
332
+ width: "85%",
333
+ maxHeight: SIDE_CHAT_OVERLAY_MAX_HEIGHT,
334
+ anchor: "top-center",
335
+ margin: { top: SIDE_CHAT_OVERLAY_MARGIN_TOP, left: 2, right: 2 },
336
+ nonCapturing: true,
337
+ },
338
+ onHandle: (handle) => {
339
+ overlayHandle = handle;
340
+ handle.focus();
341
+ },
342
+ },
343
+ );
344
+ if (action === "refork") return openSideChat(ctx);
345
+ if (action === "clear") return openSideChat(ctx, true);
346
+ } catch (error) {
347
+ if (error instanceof Error && error.message === OVERLAY_BLOCKED_ERROR) {
348
+ return;
349
+ }
350
+ activeOverlay = null;
351
+ overlayHandle = null;
352
+ uninstallMouseHandler();
353
+ throw error;
354
+ }
355
+ };
356
+
357
+ pi.registerShortcut(SIDE_CHAT_SHORTCUT, {
358
+ description: "Open / background / restore the side chat (keeps it running)",
359
+ handler: backgroundSideChat,
360
+ });
361
+
362
+ pi.registerCommand("side", {
363
+ description: "Open side chat (fork conversation)",
364
+ handler: (_, ctx) => toggleSideChat(ctx),
365
+ });
366
+
367
+ pi.registerCommand("btw", {
368
+ description: "Open side chat (fork conversation) — alias for /side",
369
+ handler: (_, ctx) => toggleSideChat(ctx),
370
+ });
371
+ }
372
+
373
+ function showOverlapWarning(
374
+ ui: ExtensionUIContext,
375
+ path: string,
376
+ ): Promise<boolean> {
377
+ return ui.confirm(
378
+ "File Overlap",
379
+ `Main agent has modified:\n ${path}\n\nEditing may cause conflicts. Proceed?`,
380
+ );
381
+ }
@@ -0,0 +1,60 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { Model } from "@earendil-works/pi-ai";
3
+ import { clampThinkingLevel } from "@earendil-works/pi-ai/compat";
4
+ import type { ScopedModel } from "@earendil-works/pi-coding-agent";
5
+
6
+ /**
7
+ * Fork model switching (issue #5): pure list-building and thinking-level
8
+ * clamping. The overlay applies the chosen model to its own agent only
9
+ * (fork-local, ADR 0002) — the main session's model is never touched.
10
+ */
11
+
12
+ /** A selectable fork model: the model plus an optional scoped thinking level. */
13
+ export interface ModelChoice {
14
+ model: Model<any>;
15
+ /**
16
+ * Thinking level pinned by the scoped pattern (e.g. "model:high"), when the
17
+ * session scoped one explicitly. Undefined otherwise — the current level is
18
+ * kept and clamped to the new model's capabilities.
19
+ */
20
+ thinkingLevel?: ThinkingLevel;
21
+ }
22
+
23
+ /**
24
+ * Build the pickable model list (D6):
25
+ * - scoped models win when the session configured any (`--models` /
26
+ * `enabledModels`); an empty scoped set falls back to the available
27
+ * catalogue;
28
+ * - models without configured auth are dropped outright ("只显示"而非置灰 —
29
+ * choices that would inevitably fail are absent, not disabled).
30
+ */
31
+ export function buildModelChoices(
32
+ scoped: readonly ScopedModel[],
33
+ available: readonly Model<any>[],
34
+ hasAuth: (model: Model<any>) => boolean,
35
+ ): ModelChoice[] {
36
+ const source: ModelChoice[] =
37
+ scoped.length > 0
38
+ ? scoped.map((s) => ({ model: s.model, thinkingLevel: s.thinkingLevel }))
39
+ : available.map((model) => ({ model }));
40
+ return source.filter((choice) => hasAuth(choice.model));
41
+ }
42
+
43
+ /**
44
+ * Clamp a thinking level to what the new model supports, mirroring the main
45
+ * session's `setThinkingLevel` semantics (it delegates to the same
46
+ * `clampThinkingLevel` from pi-ai): a model without reasoning clamps any
47
+ * level to `"off"`, and pi's request builders map `"off"` to no reasoning
48
+ * request. Levels above a model's `thinkingLevelMap` ceiling clamp down.
49
+ */
50
+ export function clampThinkingLevelForModel(
51
+ model: Model<any>,
52
+ level: ThinkingLevel,
53
+ ): ThinkingLevel {
54
+ return clampThinkingLevel(model, level);
55
+ }
56
+
57
+ /** Canonical identity key for a model (provider + id). */
58
+ export function modelKey(model: { provider: string; id: string }): string {
59
+ return `${model.provider}\0${model.id}`;
60
+ }