@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.
- package/README.md +31 -0
- package/package.json +1 -1
- 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
package/src/btw.ts
CHANGED
|
@@ -1,7 +1,53 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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;
|