@narumitw/pi-btw 0.12.0 → 0.13.1

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 +157 -28
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.12.0",
3
+ "version": "0.13.1",
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,48 +1,53 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
1
3
  import type {
2
4
  Api,
3
5
  AssistantMessage,
4
6
  Context,
5
7
  Model,
6
- ProviderStreamOptions,
8
+ SimpleStreamOptions,
7
9
  UserMessage,
8
10
  } from "@earendil-works/pi-ai";
9
- // pi-ai 0.79 exports complete from the root; 0.80 moved it to the compat subpath.
10
- type CompleteFunction = <TApi extends Api>(
11
+ // pi-ai 0.79 exports completeSimple from the root; 0.80 moved it to the compat subpath.
12
+ type CompleteSimpleFunction = <TApi extends Api>(
11
13
  model: Model<TApi>,
12
14
  context: Context,
13
- options?: ProviderStreamOptions,
15
+ options?: SimpleStreamOptions,
14
16
  ) => Promise<AssistantMessage>;
15
17
 
16
- function hasComplete(value: unknown): value is { complete: CompleteFunction } {
18
+ function hasCompleteSimple(value: unknown): value is { completeSimple: CompleteSimpleFunction } {
17
19
  return (
18
20
  typeof value === "object" &&
19
21
  value !== null &&
20
- typeof Reflect.get(value, "complete") === "function"
22
+ typeof Reflect.get(value, "completeSimple") === "function"
21
23
  );
22
24
  }
23
25
 
24
26
  type ModuleImporter = (moduleId: string) => Promise<unknown>;
25
27
 
26
- export async function loadComplete(
28
+ export async function loadCompleteSimple(
27
29
  importModule: ModuleImporter = (moduleId) => import(moduleId),
28
- ): Promise<CompleteFunction> {
30
+ ): Promise<CompleteSimpleFunction> {
29
31
  let importError: unknown;
30
32
  for (const moduleId of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
31
33
  try {
32
34
  const module = await importModule(moduleId);
33
- if (hasComplete(module)) return module.complete;
35
+ if (hasCompleteSimple(module)) return module.completeSimple;
34
36
  } catch (error: unknown) {
35
37
  importError = error;
36
38
  }
37
39
  }
38
40
 
39
- throw new Error("@earendil-works/pi-ai does not export complete", { cause: importError });
41
+ throw new Error("@earendil-works/pi-ai does not export completeSimple", {
42
+ cause: importError,
43
+ });
40
44
  }
41
45
 
42
- const complete = await loadComplete();
46
+ const completeSimple = await loadCompleteSimple();
43
47
  import {
44
48
  BorderedLoader,
45
49
  DynamicBorder,
50
+ getAgentDir,
46
51
  getMarkdownTheme,
47
52
  type ExtensionAPI,
48
53
  type ExtensionCommandContext,
@@ -62,6 +67,134 @@ const MAX_CONTEXT_CHARS = 40_000;
62
67
  const ANSWER_CHROME_LINES = 4;
63
68
  // Pi renders a spacer above the custom editor and a two-line built-in footer below it.
64
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
+
65
198
  const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
66
199
 
67
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.`;
@@ -105,7 +238,10 @@ export default function btw(pi: ExtensionAPI) {
105
238
  return;
106
239
  }
107
240
 
108
- 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);
109
245
  if (answer === undefined) {
110
246
  ctx.ui.notify("Cancelled", "info");
111
247
  return;
@@ -118,6 +254,7 @@ export default function btw(pi: ExtensionAPI) {
118
254
 
119
255
  async function askSideQuestion(
120
256
  question: string,
257
+ thinkingLevel: BtwThinkingLevel,
121
258
  ctx: ExtensionCommandContext,
122
259
  ): Promise<string | undefined> {
123
260
  return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
@@ -131,22 +268,14 @@ async function askSideQuestion(
131
268
  }
132
269
 
133
270
  const conversationContext = buildConversationContext(ctx.sessionManager.getBranch());
134
- const userMessage: UserMessage = {
135
- role: "user",
136
- content: [
137
- {
138
- type: "text",
139
- text: buildUserPrompt(question, conversationContext),
140
- },
141
- ],
142
- timestamp: Date.now(),
143
- };
144
-
145
- const response = await complete(
146
- ctx.model!,
147
- { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
148
- { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
149
- );
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
+ });
150
279
 
151
280
  if (response.stopReason === "aborted") {
152
281
  return undefined;