@narumitw/pi-btw 0.13.1 → 0.14.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.
Files changed (3) hide show
  1. package/README.md +21 -12
  2. package/package.json +2 -2
  3. package/src/btw.ts +157 -19
package/README.md CHANGED
@@ -11,6 +11,7 @@ Use it when you want to ask a temporary question, inspect context, or get a shor
11
11
  - Adds a `/btw <question>` command to Pi.
12
12
  - Answers side questions in a temporary, scrollable UI.
13
13
  - Uses the current session branch as context.
14
+ - Uses Pi's current model or an independent model selected in `pi-btw.json`.
14
15
  - Inherits Pi's current thinking level or uses a fixed level from `pi-btw.json`.
15
16
  - Does not append the side question or answer to the main conversation.
16
17
  - Works as an independently installable npm Pi extension package.
@@ -52,13 +53,10 @@ Long answers open in a pager-style view. Use `↑`/`↓` or `k`/`j` to scroll by
52
53
  `Ctrl+U`/`Ctrl+D` to scroll by half page, and `Home`/`End` to jump. Close with
53
54
  `q`, `Esc`, `Enter`, or `Ctrl+C`.
54
55
 
55
- ## ⚙️ Thinking level
56
+ ## ⚙️ Model and thinking level
56
57
 
57
- Pi calls its reasoning setting the **thinking level**. By default, `/btw` inherits the
58
- current runtime level, including changes made through `/settings` or `Shift+Tab`. It does
59
- not read or change `defaultThinkingLevel` directly.
60
-
61
- To request a fixed level for side questions, create:
58
+ By default, `/btw` uses the current session model. To use an independent model for side
59
+ questions, create:
62
60
 
63
61
  ```text
64
62
  $PI_CODING_AGENT_DIR/pi-btw.json
@@ -69,18 +67,29 @@ setting; pi-btw does not add any environment variables.
69
67
 
70
68
  ```json
71
69
  {
70
+ "model": "anthropic/claude-sonnet-4-5",
72
71
  "thinkingLevel": "low"
73
72
  }
74
73
  ```
75
74
 
76
- Supported values are `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. The selected
77
- value affects only `/btw` requests and does not change the main session. Pi's provider
78
- layer may clamp a requested level when the active model does not support it.
75
+ The `model` value uses `provider/model-id` format. Only the first `/` is the separator, so
76
+ model IDs may contain additional slashes, such as `openrouter/anthropic/claude-sonnet`.
77
+ The configured model must exist in Pi's model registry and have usable credentials. If it
78
+ cannot be found or authenticated, pi-btw warns and falls back to the current session model.
79
+ If neither model is available, `/btw` reports an error and stops. This selection affects only
80
+ `/btw`; it does not change the main session model.
81
+
82
+ Pi calls its reasoning setting the **thinking level**. By default, `/btw` inherits the
83
+ current runtime level, including changes made through `/settings` or `Shift+Tab`. It does
84
+ not read or change `defaultThinkingLevel` directly. Supported fixed values are `off`,
85
+ `minimal`, `low`, `medium`, `high`, and `xhigh`. The selected value applies to the model
86
+ actually used by `/btw` and does not change the main session. Pi's provider layer may clamp
87
+ a requested level when that model does not support it.
79
88
 
80
- The settings file is optional and is never created automatically. A missing file, `{}`,
81
- or an omitted `thinkingLevel` silently inherits the current Pi level. The file is read for
89
+ The settings file is optional and is never created automatically. A missing file, `{}`, or
90
+ omitted fields silently inherit the current Pi model and thinking level. The file is read for
82
91
  each `/btw` invocation, so edits apply to the next side question without `/reload`. Invalid
83
- or unreadable settings produce a warning and fall back to the current Pi level.
92
+ or unreadable settings produce a warning and fall back to the current Pi defaults.
84
93
 
85
94
  ## 🧠 Why use pi-btw?
86
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "devDependencies": {
31
- "@biomejs/biome": "2.5.2",
31
+ "@biomejs/biome": "2.5.3",
32
32
  "@earendil-works/pi-ai": "0.80.3",
33
33
  "@earendil-works/pi-coding-agent": "0.80.3",
34
34
  "@earendil-works/pi-tui": "0.80.3",
package/src/btw.ts CHANGED
@@ -80,6 +80,7 @@ export const BTW_THINKING_LEVELS = [
80
80
  export type BtwThinkingLevel = (typeof BTW_THINKING_LEVELS)[number];
81
81
 
82
82
  export interface BtwSettings {
83
+ model?: string;
83
84
  thinkingLevel?: BtwThinkingLevel;
84
85
  }
85
86
 
@@ -94,11 +95,31 @@ interface LoadBtwThinkingLevelOptions {
94
95
  }
95
96
 
96
97
  interface SideQuestionAuth {
97
- apiKey: string;
98
+ apiKey?: string;
98
99
  headers?: Record<string, string>;
99
100
  env?: Record<string, string>;
100
101
  }
101
102
 
103
+ interface BtwModelRegistry {
104
+ find(provider: string, modelId: string): Model<Api> | undefined;
105
+ getApiKeyAndHeaders(model: Model<Api>): Promise<
106
+ | { ok: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> }
107
+ | { ok: false; error: string }
108
+ >;
109
+ }
110
+
111
+ interface ResolveBtwModelOptions {
112
+ settings: BtwSettings;
113
+ currentModel: Model<Api> | undefined;
114
+ modelRegistry: BtwModelRegistry;
115
+ warn?: (message: string) => void;
116
+ }
117
+
118
+ interface ResolvedBtwModel {
119
+ model: Model<Api>;
120
+ auth: SideQuestionAuth;
121
+ }
122
+
102
123
  interface CompleteSideQuestionOptions {
103
124
  model: Model<Api>;
104
125
  question: string;
@@ -111,10 +132,83 @@ interface CompleteSideQuestionOptions {
111
132
 
112
133
  export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
113
134
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
114
- if (!Object.hasOwn(value, "thinkingLevel")) return {};
115
135
 
116
- const thinkingLevel = Reflect.get(value, "thinkingLevel");
117
- return isBtwThinkingLevel(thinkingLevel) ? { thinkingLevel } : undefined;
136
+ const settings: BtwSettings = {};
137
+ if (Object.hasOwn(value, "model")) {
138
+ const model = Reflect.get(value, "model");
139
+ if (typeof model !== "string" || !parseBtwModelReference(model)) return undefined;
140
+ settings.model = model;
141
+ }
142
+ if (Object.hasOwn(value, "thinkingLevel")) {
143
+ const thinkingLevel = Reflect.get(value, "thinkingLevel");
144
+ if (!isBtwThinkingLevel(thinkingLevel)) return undefined;
145
+ settings.thinkingLevel = thinkingLevel;
146
+ }
147
+ return settings;
148
+ }
149
+
150
+ export function parseBtwModelReference(
151
+ reference: string,
152
+ ): { provider: string; modelId: string } | undefined {
153
+ if (/\s/.test(reference)) return undefined;
154
+ const separator = reference.indexOf("/");
155
+ if (separator <= 0 || separator === reference.length - 1) return undefined;
156
+ return { provider: reference.slice(0, separator), modelId: reference.slice(separator + 1) };
157
+ }
158
+
159
+ export async function resolveBtwModel({
160
+ settings,
161
+ currentModel,
162
+ modelRegistry,
163
+ warn,
164
+ }: ResolveBtwModelOptions): Promise<ResolvedBtwModel | undefined> {
165
+ if (settings.model) {
166
+ const fallback = currentModel
167
+ ? `${currentModel.provider}/${currentModel.id}`
168
+ : "the current model";
169
+ const reference = parseBtwModelReference(settings.model)!;
170
+ const configuredModel = modelRegistry.find(reference.provider, reference.modelId);
171
+ if (!configuredModel) {
172
+ warn?.(`pi-btw model ${settings.model} was not found; falling back to ${fallback}.`);
173
+ } else {
174
+ const sameAsCurrent =
175
+ configuredModel === currentModel ||
176
+ (configuredModel.provider === currentModel?.provider && configuredModel.id === currentModel.id);
177
+ const fallbackAction = sameAsCurrent
178
+ ? "no distinct current model is available"
179
+ : `falling back to ${fallback}`;
180
+ try {
181
+ const auth = await modelRegistry.getApiKeyAndHeaders(configuredModel);
182
+ if (auth.ok && hasRequestAuth(auth)) return { model: configuredModel, auth };
183
+ const reason = auth.ok ? "has no request credentials" : auth.error;
184
+ warn?.(
185
+ `pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`,
186
+ );
187
+ } catch (error: unknown) {
188
+ warn?.(
189
+ `pi-btw model ${settings.model} credentials failed (${formatError(error)}); ${fallbackAction}.`,
190
+ );
191
+ }
192
+ if (sameAsCurrent) return undefined;
193
+ }
194
+ }
195
+
196
+ if (!currentModel) return undefined;
197
+ try {
198
+ const auth = await modelRegistry.getApiKeyAndHeaders(currentModel);
199
+ if (auth.ok && hasRequestAuth(auth)) return { model: currentModel, auth };
200
+ } catch {
201
+ // The caller reports the final lack of an available model.
202
+ }
203
+ return undefined;
204
+ }
205
+
206
+ function hasRequestAuth(auth: SideQuestionAuth): boolean {
207
+ return Boolean(
208
+ auth.apiKey ||
209
+ (auth.headers && Object.keys(auth.headers).length > 0) ||
210
+ (auth.env && Object.keys(auth.env).length > 0),
211
+ );
118
212
  }
119
213
 
120
214
  export async function readBtwSettings(
@@ -148,7 +242,7 @@ export async function loadBtwThinkingLevel(
148
242
  }
149
243
 
150
244
  options.warn?.(
151
- `pi-btw settings ignored: ${settings.reason}; expected { "thinkingLevel"?: "${BTW_THINKING_LEVELS.join('" | "')}" }. Using current Pi thinking level.`,
245
+ `pi-btw settings ignored: ${settings.reason}; expected optional model "provider/model-id" and thinkingLevel "${BTW_THINKING_LEVELS.join('" | "')}". Using current Pi thinking level.`,
152
246
  );
153
247
  return currentThinkingLevel;
154
248
  }
@@ -233,15 +327,26 @@ export default function btw(pi: ExtensionAPI) {
233
327
  return;
234
328
  }
235
329
 
236
- if (!ctx.model) {
237
- ctx.ui.notify("No model selected", "error");
330
+ const settingsResult = await readBtwSettings();
331
+ let settings: BtwSettings = {};
332
+ if (settingsResult.kind === "loaded") {
333
+ settings = settingsResult.settings;
334
+ } else if (settingsResult.kind === "invalid") {
335
+ ctx.ui.notify(`pi-btw settings ignored: ${settingsResult.reason}`, "warning");
336
+ }
337
+
338
+ const resolution = await resolveBtwModelWithLoader(settings, ctx);
339
+ if (resolution.kind === "cancelled") {
340
+ ctx.ui.notify("Cancelled", "info");
341
+ return;
342
+ }
343
+ if (resolution.kind === "unavailable") {
344
+ ctx.ui.notify("No available model for /btw", "error");
238
345
  return;
239
346
  }
240
347
 
241
- const thinkingLevel = await loadBtwThinkingLevel(pi.getThinkingLevel(), {
242
- warn: (message) => ctx.ui.notify(message, "warning"),
243
- });
244
- const answer = await askSideQuestion(question, thinkingLevel, ctx);
348
+ const thinkingLevel = settings.thinkingLevel ?? pi.getThinkingLevel();
349
+ const answer = await askSideQuestion(question, resolution.selected, thinkingLevel, ctx);
245
350
  if (answer === undefined) {
246
351
  ctx.ui.notify("Cancelled", "info");
247
352
  return;
@@ -252,28 +357,61 @@ export default function btw(pi: ExtensionAPI) {
252
357
  });
253
358
  }
254
359
 
360
+ type ModelResolutionOutcome =
361
+ | { kind: "cancelled" }
362
+ | { kind: "unavailable" }
363
+ | { kind: "selected"; selected: ResolvedBtwModel };
364
+
365
+ async function resolveBtwModelWithLoader(
366
+ settings: BtwSettings,
367
+ ctx: ExtensionCommandContext,
368
+ ): Promise<ModelResolutionOutcome> {
369
+ return ctx.ui.custom<ModelResolutionOutcome>((tui, theme, _keybindings, done) => {
370
+ const loader = new BorderedLoader(tui, theme, "Resolving /btw model credentials...");
371
+ let cancelled = false;
372
+ loader.onAbort = () => {
373
+ cancelled = true;
374
+ done({ kind: "cancelled" });
375
+ };
376
+
377
+ resolveBtwModel({
378
+ settings,
379
+ currentModel: ctx.model,
380
+ modelRegistry: ctx.modelRegistry,
381
+ warn: (message) => {
382
+ if (!cancelled) ctx.ui.notify(message, "warning");
383
+ },
384
+ }).then((selected) => {
385
+ if (cancelled) return;
386
+ done(selected ? { kind: "selected", selected } : { kind: "unavailable" });
387
+ });
388
+
389
+ return loader;
390
+ });
391
+ }
392
+
255
393
  async function askSideQuestion(
256
394
  question: string,
395
+ selected: ResolvedBtwModel,
257
396
  thinkingLevel: BtwThinkingLevel,
258
397
  ctx: ExtensionCommandContext,
259
398
  ): Promise<string | undefined> {
260
399
  return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
261
- const loader = new BorderedLoader(tui, theme, `Answering /btw with ${ctx.model!.id}...`);
400
+ const loader = new BorderedLoader(
401
+ tui,
402
+ theme,
403
+ `Answering /btw with ${selected.model.provider}/${selected.model.id}...`,
404
+ );
262
405
  loader.onAbort = () => done(undefined);
263
406
 
264
407
  const ask = async () => {
265
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
266
- if (!auth.ok || !auth.apiKey) {
267
- throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error);
268
- }
269
-
270
408
  const conversationContext = buildConversationContext(ctx.sessionManager.getBranch());
271
409
  const response = await completeSideQuestion({
272
- model: ctx.model!,
410
+ model: selected.model,
273
411
  question,
274
412
  conversationContext,
275
413
  thinkingLevel,
276
- auth: { apiKey: auth.apiKey, headers: auth.headers, env: auth.env },
414
+ auth: selected.auth,
277
415
  signal: loader.signal,
278
416
  });
279
417