@narumitw/pi-btw 0.11.0 → 0.13.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 +31 -0
  2. package/package.json +1 -1
  3. package/src/btw.ts +188 -18
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
+ - Inherits Pi's current thinking level or uses a fixed level from `pi-btw.json`.
14
15
  - Does not append the side question or answer to the main conversation.
15
16
  - Works as an independently installable npm Pi extension package.
16
17
 
@@ -51,6 +52,36 @@ Long answers open in a pager-style view. Use `↑`/`↓` or `k`/`j` to scroll by
51
52
  `Ctrl+U`/`Ctrl+D` to scroll by half page, and `Home`/`End` to jump. Close with
52
53
  `q`, `Esc`, `Enter`, or `Ctrl+C`.
53
54
 
55
+ ## ⚙️ Thinking level
56
+
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:
62
+
63
+ ```text
64
+ $PI_CODING_AGENT_DIR/pi-btw.json
65
+ ```
66
+
67
+ The normal location is `~/.pi/agent/pi-btw.json`. `PI_CODING_AGENT_DIR` is an existing Pi
68
+ setting; pi-btw does not add any environment variables.
69
+
70
+ ```json
71
+ {
72
+ "thinkingLevel": "low"
73
+ }
74
+ ```
75
+
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.
79
+
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
82
+ 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.
84
+
54
85
  ## 🧠 Why use pi-btw?
55
86
 
56
87
  Normal assistant messages become part of the main Pi conversation and can distract the coding agent from the task. `pi-btw` creates a lightweight side channel for context-aware questions, making it useful for pair programming, debugging, code review, and repository exploration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/btw.ts CHANGED
@@ -1,7 +1,53 @@
1
- import { complete, type UserMessage } from "@earendil-works/pi-ai/compat";
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type {
4
+ Api,
5
+ AssistantMessage,
6
+ Context,
7
+ Model,
8
+ SimpleStreamOptions,
9
+ UserMessage,
10
+ } from "@earendil-works/pi-ai";
11
+ // pi-ai 0.79 exports completeSimple from the root; 0.80 moved it to the compat subpath.
12
+ type CompleteSimpleFunction = <TApi extends Api>(
13
+ model: Model<TApi>,
14
+ context: Context,
15
+ options?: SimpleStreamOptions,
16
+ ) => Promise<AssistantMessage>;
17
+
18
+ function hasCompleteSimple(value: unknown): value is { completeSimple: CompleteSimpleFunction } {
19
+ return (
20
+ typeof value === "object" &&
21
+ value !== null &&
22
+ typeof Reflect.get(value, "completeSimple") === "function"
23
+ );
24
+ }
25
+
26
+ type ModuleImporter = (moduleId: string) => Promise<unknown>;
27
+
28
+ export async function loadCompleteSimple(
29
+ importModule: ModuleImporter = (moduleId) => import(moduleId),
30
+ ): Promise<CompleteSimpleFunction> {
31
+ let importError: unknown;
32
+ for (const moduleId of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
33
+ try {
34
+ const module = await importModule(moduleId);
35
+ if (hasCompleteSimple(module)) return module.completeSimple;
36
+ } catch (error: unknown) {
37
+ importError = error;
38
+ }
39
+ }
40
+
41
+ throw new Error("@earendil-works/pi-ai does not export completeSimple", {
42
+ cause: importError,
43
+ });
44
+ }
45
+
46
+ const completeSimple = await loadCompleteSimple();
2
47
  import {
3
48
  BorderedLoader,
4
49
  DynamicBorder,
50
+ getAgentDir,
5
51
  getMarkdownTheme,
6
52
  type ExtensionAPI,
7
53
  type ExtensionCommandContext,
@@ -21,6 +67,134 @@ const MAX_CONTEXT_CHARS = 40_000;
21
67
  const ANSWER_CHROME_LINES = 4;
22
68
  // Pi renders a spacer above the custom editor and a two-line built-in footer below it.
23
69
  const ANSWER_RESERVED_APP_LINES = 3;
70
+ export const BTW_SETTINGS_FILE = "pi-btw.json";
71
+ export const BTW_THINKING_LEVELS = [
72
+ "off",
73
+ "minimal",
74
+ "low",
75
+ "medium",
76
+ "high",
77
+ "xhigh",
78
+ ] as const;
79
+
80
+ export type BtwThinkingLevel = (typeof BTW_THINKING_LEVELS)[number];
81
+
82
+ export interface BtwSettings {
83
+ thinkingLevel?: BtwThinkingLevel;
84
+ }
85
+
86
+ export type BtwSettingsLoadResult =
87
+ | { kind: "missing" }
88
+ | { kind: "invalid"; reason: string }
89
+ | { kind: "loaded"; settings: BtwSettings };
90
+
91
+ interface LoadBtwThinkingLevelOptions {
92
+ settingsPath?: string;
93
+ warn?: (message: string) => void;
94
+ }
95
+
96
+ interface SideQuestionAuth {
97
+ apiKey: string;
98
+ headers?: Record<string, string>;
99
+ env?: Record<string, string>;
100
+ }
101
+
102
+ interface CompleteSideQuestionOptions {
103
+ model: Model<Api>;
104
+ question: string;
105
+ conversationContext: string;
106
+ thinkingLevel: BtwThinkingLevel;
107
+ auth: SideQuestionAuth;
108
+ signal?: AbortSignal;
109
+ completeSimple?: CompleteSimpleFunction;
110
+ }
111
+
112
+ export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
113
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
114
+ if (!Object.hasOwn(value, "thinkingLevel")) return {};
115
+
116
+ const thinkingLevel = Reflect.get(value, "thinkingLevel");
117
+ return isBtwThinkingLevel(thinkingLevel) ? { thinkingLevel } : undefined;
118
+ }
119
+
120
+ export async function readBtwSettings(
121
+ settingsPath = join(getAgentDir(), BTW_SETTINGS_FILE),
122
+ ): Promise<BtwSettingsLoadResult> {
123
+ let contents: string;
124
+ try {
125
+ contents = await readFile(settingsPath, "utf8");
126
+ } catch (error: unknown) {
127
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
128
+ return { kind: "invalid", reason: `${settingsPath}: ${formatError(error)}` };
129
+ }
130
+
131
+ try {
132
+ const settings = normalizeBtwSettings(JSON.parse(contents) as unknown);
133
+ if (settings) return { kind: "loaded", settings };
134
+ return { kind: "invalid", reason: `${settingsPath}: invalid settings shape` };
135
+ } catch (error: unknown) {
136
+ return { kind: "invalid", reason: `${settingsPath}: ${formatError(error)}` };
137
+ }
138
+ }
139
+
140
+ export async function loadBtwThinkingLevel(
141
+ currentThinkingLevel: BtwThinkingLevel,
142
+ options: LoadBtwThinkingLevelOptions = {},
143
+ ): Promise<BtwThinkingLevel> {
144
+ const settings = await readBtwSettings(options.settingsPath);
145
+ if (settings.kind === "missing") return currentThinkingLevel;
146
+ if (settings.kind === "loaded") {
147
+ return settings.settings.thinkingLevel ?? currentThinkingLevel;
148
+ }
149
+
150
+ options.warn?.(
151
+ `pi-btw settings ignored: ${settings.reason}; expected { "thinkingLevel"?: "${BTW_THINKING_LEVELS.join('" | "')}" }. Using current Pi thinking level.`,
152
+ );
153
+ return currentThinkingLevel;
154
+ }
155
+
156
+ export async function completeSideQuestion({
157
+ model,
158
+ question,
159
+ conversationContext,
160
+ thinkingLevel,
161
+ auth,
162
+ signal,
163
+ completeSimple: runCompleteSimple = completeSimple,
164
+ }: CompleteSideQuestionOptions): Promise<AssistantMessage> {
165
+ const userMessage: UserMessage = {
166
+ role: "user",
167
+ content: [
168
+ {
169
+ type: "text",
170
+ text: buildUserPrompt(question, conversationContext),
171
+ },
172
+ ],
173
+ timestamp: Date.now(),
174
+ };
175
+ const streamOptions: SimpleStreamOptions = {
176
+ apiKey: auth.apiKey,
177
+ headers: auth.headers,
178
+ env: auth.env,
179
+ signal,
180
+ };
181
+ if (thinkingLevel !== "off") streamOptions.reasoning = thinkingLevel;
182
+
183
+ return runCompleteSimple(model, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, streamOptions);
184
+ }
185
+
186
+ function isBtwThinkingLevel(value: unknown): value is BtwThinkingLevel {
187
+ return BTW_THINKING_LEVELS.includes(value as BtwThinkingLevel);
188
+ }
189
+
190
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
191
+ return error instanceof Error && "code" in error;
192
+ }
193
+
194
+ function formatError(error: unknown): string {
195
+ return error instanceof Error ? error.message : String(error);
196
+ }
197
+
24
198
  const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
25
199
 
26
200
  Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
@@ -64,7 +238,10 @@ export default function btw(pi: ExtensionAPI) {
64
238
  return;
65
239
  }
66
240
 
67
- const answer = await askSideQuestion(question, ctx);
241
+ const thinkingLevel = await loadBtwThinkingLevel(pi.getThinkingLevel(), {
242
+ warn: (message) => ctx.ui.notify(message, "warning"),
243
+ });
244
+ const answer = await askSideQuestion(question, thinkingLevel, ctx);
68
245
  if (answer === undefined) {
69
246
  ctx.ui.notify("Cancelled", "info");
70
247
  return;
@@ -77,6 +254,7 @@ export default function btw(pi: ExtensionAPI) {
77
254
 
78
255
  async function askSideQuestion(
79
256
  question: string,
257
+ thinkingLevel: BtwThinkingLevel,
80
258
  ctx: ExtensionCommandContext,
81
259
  ): Promise<string | undefined> {
82
260
  return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
@@ -90,22 +268,14 @@ async function askSideQuestion(
90
268
  }
91
269
 
92
270
  const conversationContext = buildConversationContext(ctx.sessionManager.getBranch());
93
- const userMessage: UserMessage = {
94
- role: "user",
95
- content: [
96
- {
97
- type: "text",
98
- text: buildUserPrompt(question, conversationContext),
99
- },
100
- ],
101
- timestamp: Date.now(),
102
- };
103
-
104
- const response = await complete(
105
- ctx.model!,
106
- { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
107
- { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
108
- );
271
+ const response = await completeSideQuestion({
272
+ model: ctx.model!,
273
+ question,
274
+ conversationContext,
275
+ thinkingLevel,
276
+ auth: { apiKey: auth.apiKey, headers: auth.headers, env: auth.env },
277
+ signal: loader.signal,
278
+ });
109
279
 
110
280
  if (response.stopReason === "aborted") {
111
281
  return undefined;