pi-bro 0.22.2 → 0.23.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.
package/src/bro.ts ADDED
@@ -0,0 +1,424 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { ADVISOR_STEERING_FILE, ADVISOR_TOOL_NAME, registerAdvisorTool } from "./advisor.ts";
3
+ import {
4
+ type BtwThread,
5
+ bindBtwBackend,
6
+ openBtwModal,
7
+ parseBtwArguments,
8
+ resolveBtwThread,
9
+ } from "./btw.ts";
10
+ import { doctorReport, showBroConfigModal } from "./doctor.ts";
11
+ import {
12
+ type BroResult,
13
+ type ModalResult,
14
+ helpText,
15
+ latestAssistant,
16
+ showAdvisorSteerModal,
17
+ showBroModal,
18
+ showPreferencesModal,
19
+ simplify,
20
+ } from "./explain-modal.ts";
21
+ import { BRO_MODES, type BroMode, parseBroMode } from "./prompt.ts";
22
+ import { openGuidedReview, stopGuidedReviews } from "./review-ui.ts";
23
+ import {
24
+ type BroSettings,
25
+ capabilityBackend,
26
+ ensureSettingsFile,
27
+ readPreferences,
28
+ readSettings,
29
+ writeSettings,
30
+ } from "./settings.ts";
31
+ import {
32
+ captureShowTranscript,
33
+ parseShowArguments,
34
+ runShowExplanation,
35
+ showDiagramPath,
36
+ } from "./show.ts";
37
+ import {
38
+ type BroSource,
39
+ extractDocumentText,
40
+ extractWebPage,
41
+ isWorkspaceFile,
42
+ looksLikeWebUrl,
43
+ } from "./sources.ts";
44
+ import { hasBroCustomUi } from "./ui-capabilities.ts";
45
+ import { errorMessage, unquote, withDoctor } from "./util.ts";
46
+
47
+ export * from "./util.ts";
48
+ export * from "./ui-capabilities.ts";
49
+ export * from "./settings.ts";
50
+ export * from "./sources.ts";
51
+ export * from "./config-ui.ts";
52
+ export * from "./show.ts";
53
+ export * from "./advisor.ts";
54
+ export * from "./doctor.ts";
55
+ export * from "./btw.ts";
56
+ export * from "./explain-modal.ts";
57
+
58
+ const COMMANDS = [
59
+ { value: "simplify", label: "simplify", description: "Explain the latest completed assistant reply (same as /bro)" },
60
+ { value: "text", label: "text", description: "Explain pasted text, or the latest reply when text is omitted" },
61
+ { value: "file", label: "file", description: "Explain a local document" },
62
+ { value: "url", label: "url", description: "Explain a public webpage" },
63
+ { value: "open", label: "open", description: "Reopen the last explanation" },
64
+ { value: "doctor", label: "doctor", description: "Check whether Bro is ready" },
65
+ { value: "show", label: "show", description: "Draw what happened in recent session turns as shapes" },
66
+ { value: "mode", label: "mode", description: "View or choose explanation mode (brief, balanced, faithful)" },
67
+ { value: "preferences", label: "preferences", description: "View or edit what Bro knows about you and how you like answers" },
68
+ { value: "guided-review", label: "guided-review", description: "Understand and assess a PR with captured code and optional private questions" },
69
+ { value: "btw", label: "btw", description: "Open a side conversation (starts conversation-only; /mode toggles full permission)" },
70
+ { value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
71
+ { value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
72
+ { value: "advisor-steer", label: "advisor-steer", description: "View, edit, save, or clear the advisor's persistent steering brief" },
73
+ { value: "help", label: "help", description: "Learn what Bro does and what it can access" },
74
+ ];
75
+ const KNOWN_ACTIONS = new Set(COMMANDS.map((command) => command.value));
76
+
77
+ export default async function bro(pi: ExtensionAPI) {
78
+ let lastResult: ModalResult | undefined;
79
+ let lastShowSteering: string | undefined;
80
+ let btwThread: BtwThread | undefined;
81
+ const remember = (result: ModalResult, showSteering?: string) => {
82
+ if (result.source) {
83
+ lastResult = result;
84
+ lastShowSteering = showSteering;
85
+ }
86
+ };
87
+
88
+ pi.on("session_shutdown", async () => { stopGuidedReviews(); });
89
+
90
+ pi.on("session_start", async (_event, _ctx) => {
91
+ lastResult = undefined;
92
+ btwThread = undefined;
93
+ });
94
+
95
+ registerAdvisorTool(pi);
96
+
97
+ pi.registerCommand("bro", {
98
+ description: "Explain text, documents, and webpages; draw session turns; open a side conversation; configure Bro and the executor's advisor tool",
99
+ getArgumentCompletions: (prefix) => {
100
+ const normalized = prefix.trim().toLowerCase();
101
+ const matches = COMMANDS.filter((command) => command.value.startsWith(normalized));
102
+ return matches.length ? matches : null;
103
+ },
104
+ handler: async (args, ctx) => {
105
+ const raw = args.trim();
106
+ const normalized = raw.toLowerCase();
107
+ const parts = normalized ? normalized.split(/\s+/) : [];
108
+ let action = parts[0] ?? "";
109
+ let value = raw.slice(raw.split(/\s+/, 1)[0]?.length ?? 0).trim();
110
+
111
+ if (action === "simplify") {
112
+ if (value) { ctx.ui.notify("Use /bro simplify for the latest reply, or /bro text <text> for pasted text.", "warning"); return; }
113
+ action = "";
114
+ }
115
+
116
+ // Removed commands must never fall through to a paid text explanation.
117
+ if (action === "model" || action === "effort") {
118
+ ctx.ui.notify(`/bro ${action} was removed. Use /bro config to choose the shared default and per-capability ${action}; use /bro text <text> to explain text.`, "warning");
119
+ return;
120
+ }
121
+
122
+ // An unknown first word means the whole input is the source: route it by shape.
123
+ if (action && !KNOWN_ACTIONS.has(action)) {
124
+ const candidate = unquote(raw);
125
+ const quoted = candidate !== raw;
126
+ action = quoted || !/\s/.test(candidate)
127
+ ? looksLikeWebUrl(candidate)
128
+ ? "url"
129
+ : (await isWorkspaceFile(candidate, ctx.cwd))
130
+ ? "file"
131
+ : "text"
132
+ : "text";
133
+ value = raw;
134
+ }
135
+
136
+ if (action === "show") {
137
+ const { requested, steering, invalid } = parseShowArguments(value);
138
+ if (invalid) {
139
+ ctx.ui.notify("Use /bro show [n-turns] [query].", "warning");
140
+ return;
141
+ }
142
+ const runShow = async (
143
+ signal: AbortSignal,
144
+ source?: BroSource,
145
+ onProgress?: (text: string) => void,
146
+ ): Promise<ModalResult> => {
147
+ const captured =
148
+ source ?? captureShowTranscript(ctx, requested ? Number(requested) : (await readSettings()).showTurns);
149
+ if (!captured) {
150
+ return { text: "**Nothing to show yet**\n\nThis session has no conversation turns to draw. Run something first, then press **R**." };
151
+ }
152
+ let result: Awaited<ReturnType<typeof runShowExplanation>>;
153
+ try {
154
+ result = await runShowExplanation(captured.text, steering, signal, await readSettings(), onProgress);
155
+ } catch (error) {
156
+ throw new Error(withDoctor(error));
157
+ }
158
+ const htmlPath = await showDiagramPath(result.text, (message) => ctx.ui.notify(message, "warning"));
159
+ return { source: captured, ...result, ...(htmlPath ? { htmlPath } : {}) };
160
+ };
161
+ try {
162
+ await showBroModal(ctx, {
163
+ loadingText: "Drawing what happened…",
164
+ retryLabel: "show again",
165
+ run: runShow,
166
+ onResult: (result) => remember(result, steering),
167
+ });
168
+ } catch (error) {
169
+ ctx.ui.notify(withDoctor(error), "error");
170
+ }
171
+ return;
172
+ }
173
+
174
+ if (action === "file" || action === "url") {
175
+ if (!value) {
176
+ ctx.ui.notify(`Use /bro ${action} <${action === "file" ? "path" : "url"}>.`, "warning");
177
+ return;
178
+ }
179
+ const runInput = async (
180
+ signal: AbortSignal,
181
+ source?: BroSource,
182
+ onProgress?: (text: string) => void,
183
+ mode?: BroMode,
184
+ ): Promise<BroResult> => {
185
+ const target = source ?? (action === "url"
186
+ ? await extractWebPage(value, signal)
187
+ : { text: await extractDocumentText(value, ctx.cwd, signal), label: unquote(value) });
188
+ try {
189
+ return {
190
+ source: target,
191
+ ...(await simplify(target.text, signal, await readSettings(), onProgress, mode)),
192
+ };
193
+ } catch (error) {
194
+ throw new Error(withDoctor(error));
195
+ }
196
+ };
197
+ try {
198
+ await showBroModal(ctx, {
199
+ loadingText: action === "url" ? "Fetching and simplifying webpage…" : "Reading and simplifying document…",
200
+ run: runInput,
201
+ onResult: remember,
202
+ });
203
+ } catch (error) {
204
+ ctx.ui.notify(errorMessage(error), "error");
205
+ }
206
+ return;
207
+ }
208
+
209
+ if (action === "doctor") {
210
+ if (parts.length !== 1) {
211
+ ctx.ui.notify("Use /bro doctor.", "warning");
212
+ return;
213
+ }
214
+ try {
215
+ await showBroModal(ctx, {
216
+ loadingText: "Checking Bro setup…",
217
+ retryable: true,
218
+ retryLabel: "check again",
219
+ run: async (signal) => ({ text: await doctorReport(pi, ctx, signal) }),
220
+ });
221
+ } catch (error) {
222
+ ctx.ui.notify(errorMessage(error), "error");
223
+ }
224
+ return;
225
+ }
226
+
227
+ if (action === "mode") {
228
+ const requested = parts[1];
229
+ if (parts.length > 2 || (requested && !parseBroMode(requested))) {
230
+ ctx.ui.notify("Use /bro mode, or choose brief, balanced, or faithful.", "warning");
231
+ return;
232
+ }
233
+ try {
234
+ const settings = await readSettings();
235
+ let selected = parseBroMode(requested);
236
+ if (!selected) {
237
+ if (!hasBroCustomUi(ctx)) {
238
+ ctx.ui.notify("Use /bro mode <brief|balanced|faithful> outside Pi's interactive UI.", "warning");
239
+ return;
240
+ }
241
+ const modes = [...BRO_MODES].sort((a, b) => Number(b === settings.mode) - Number(a === settings.mode));
242
+ const choices = modes.map((mode) => `${mode}${mode === settings.mode ? " (current)" : ""}`);
243
+ const choice = await ctx.ui.select(`Bro mode (current: ${settings.mode})`, choices);
244
+ if (!choice) return;
245
+ selected = modes[choices.indexOf(choice)];
246
+ }
247
+ if (!selected) return;
248
+ await writeSettings({ ...settings, mode: selected });
249
+ ctx.ui.notify(`Bro mode: ${selected}`, "info");
250
+ } catch (error) {
251
+ ctx.ui.notify(withDoctor(error), "error");
252
+ }
253
+ return;
254
+ }
255
+
256
+ if (action === "guided-review") {
257
+ try { await openGuidedReview(ctx, value, await readPreferences()); }
258
+ catch (error) { ctx.ui.notify(errorMessage(error), "error"); }
259
+ return;
260
+ }
261
+
262
+ if (action === "btw") {
263
+ if (!hasBroCustomUi(ctx)) {
264
+ ctx.ui.notify("Use /bro btw in Pi's interactive UI.", "warning");
265
+ return;
266
+ }
267
+ const parsed = parseBtwArguments(value);
268
+ if (parsed.invalid) {
269
+ ctx.ui.notify(parsed.invalid, "warning");
270
+ return;
271
+ }
272
+ try {
273
+ const btwBackend = capabilityBackend(await readSettings(), "btw");
274
+ const thread = resolveBtwThread(btwThread);
275
+ if (bindBtwBackend(thread, btwBackend)) ctx.ui.notify("Backend changed — started a fresh side thread.", "info");
276
+ btwThread = thread;
277
+ await openBtwModal(ctx, { thread, initialQuestion: parsed.question });
278
+ } catch (error) {
279
+ ctx.ui.notify(withDoctor(error), "error");
280
+ }
281
+ return;
282
+ }
283
+
284
+ if (action === "config") {
285
+ if (parts.length !== 1) {
286
+ ctx.ui.notify("Use /bro config.", "warning");
287
+ return;
288
+ }
289
+ await showBroConfigModal(ctx, pi);
290
+ return;
291
+ }
292
+
293
+ if (action === "advisor") {
294
+ // bro_advisor has no on/off/status controls of its own anymore -- it is always registered
295
+ // and gated only by this host's own tool restrictions. An old on/off/status argument gets
296
+ // an actionable notice pointing at the commands that replaced it, not a silent no-op.
297
+ if (parts.length > 1) {
298
+ ctx.ui.notify(
299
+ "/bro advisor no longer has on/off/status controls -- bro_advisor is always registered and available whenever this host exposes and activates it. Use /bro config for its model/effort, /bro advisor-steer for session steering, or /bro doctor for full diagnostics.",
300
+ "warning",
301
+ );
302
+ return;
303
+ }
304
+ const active = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
305
+ ctx.ui.notify(
306
+ active
307
+ ? `Bro advisor is available -- the executor agent can call bro_advisor. Use /bro config for its model/effort, /bro advisor-steer for session steering, ${ADVISOR_STEERING_FILE} for standing steering, or /bro doctor for full diagnostics.`
308
+ : "Bro advisor is unavailable in this runtime. Use /bro doctor for full diagnostics, /bro config for its model/effort, or /bro advisor-steer for session steering.",
309
+ active ? "info" : "warning",
310
+ );
311
+ return;
312
+ }
313
+
314
+ if (action === "advisor-steer") {
315
+ if (parts.length !== 1) {
316
+ ctx.ui.notify("Use /bro advisor-steer.", "warning");
317
+ return;
318
+ }
319
+ await showAdvisorSteerModal(ctx, pi);
320
+ return;
321
+ }
322
+
323
+ if (action === "preferences") {
324
+ if (parts.length !== 1) {
325
+ ctx.ui.notify("Use /bro preferences.", "warning");
326
+ return;
327
+ }
328
+ await showPreferencesModal(ctx);
329
+ return;
330
+ }
331
+
332
+ if (action === "help") {
333
+ if (parts.length !== 1) {
334
+ ctx.ui.notify("Use /bro help.", "warning");
335
+ return;
336
+ }
337
+ let settings: BroSettings | undefined;
338
+ let settingsError: string | undefined;
339
+ try {
340
+ settings = await readSettings();
341
+ } catch (error) {
342
+ settingsError = errorMessage(error);
343
+ }
344
+ let preferences: string;
345
+ try {
346
+ const text = await readPreferences();
347
+ preferences = text ? `on (${text.length.toLocaleString("en-US")} characters)` : "off";
348
+ } catch (error) {
349
+ preferences = `error: ${errorMessage(error)}`;
350
+ }
351
+ await showBroModal(ctx, { text: helpText(settings, settingsError, preferences), kind: "help", copyable: true });
352
+ return;
353
+ }
354
+
355
+ const run = async (
356
+ signal: AbortSignal,
357
+ source?: BroSource,
358
+ onProgress?: (text: string) => void,
359
+ mode?: BroMode,
360
+ ): Promise<BroResult> => {
361
+ let target = source ?? (action === "text" && value ? { text: value } : undefined);
362
+ if (!target) {
363
+ await ctx.waitForIdle();
364
+ target = latestAssistant(ctx);
365
+ }
366
+ if (!target) throw new Error("No completed assistant response found.");
367
+ try {
368
+ const settings = await readSettings();
369
+ return {
370
+ source: target,
371
+ ...(await simplify(target.text, signal, settings, onProgress, mode)),
372
+ };
373
+ } catch (error) {
374
+ throw new Error(withDoctor(error));
375
+ }
376
+ };
377
+
378
+ if (action === "open") {
379
+ if (parts.length !== 1) {
380
+ ctx.ui.notify("Use /bro open.", "warning");
381
+ return;
382
+ }
383
+ if (!lastResult) {
384
+ await showBroModal(ctx, {
385
+ text: "# Nothing to open yet\n\nUse `/bro text <text>`, run `/bro` after an assistant response, use `/bro file <path>`, use `/bro url <url>`, or run `/bro show` to draw recent turns.",
386
+ kind: "empty",
387
+ });
388
+ return;
389
+ }
390
+
391
+ const steering = lastShowSteering;
392
+ if (steering !== undefined) {
393
+ // Temp files may have been cleaned by the OS or another Bro session.
394
+ if (ctx.mode === "tui") lastResult.htmlPath = await showDiagramPath(lastResult.text, (message) => ctx.ui.notify(message, "warning"));
395
+ }
396
+ await showBroModal(ctx, {
397
+ result: lastResult,
398
+ loadingText: steering !== undefined ? "Drawing what happened…" : undefined,
399
+ retryLabel: steering !== undefined ? "show again" : undefined,
400
+ run: steering === undefined ? run : async (signal, source, onProgress) => {
401
+ if (!source) throw new Error("No captured Show source.");
402
+ const result = await runShowExplanation(source.text, steering, signal, await readSettings(), onProgress);
403
+ const htmlPath = await showDiagramPath(result.text, (message) => ctx.ui.notify(message, "warning"));
404
+ return { source, ...result, ...(htmlPath ? { htmlPath } : {}) };
405
+ },
406
+ onResult: (result) => remember(result, steering),
407
+ });
408
+ return;
409
+ }
410
+
411
+ try {
412
+ await showBroModal(ctx, {
413
+ run,
414
+ onResult: remember,
415
+ });
416
+ } catch (error) {
417
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
418
+ }
419
+ },
420
+ });
421
+
422
+ // Keep the command available even when Bro cannot create its settings file; Doctor can then explain the problem.
423
+ await ensureSettingsFile().catch(() => undefined);
424
+ }