pi-bro 0.20.0 → 0.22.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/CHANGELOG.md +71 -0
- package/README.md +140 -64
- package/backend.ts +158 -232
- package/bro.ts +411 -1421
- package/config-ui.ts +325 -0
- package/docs/README.md +2 -0
- package/docs/guided-review.md +131 -0
- package/docs/pig-compatibility.md +1 -0
- package/package.json +13 -4
- package/prompt.ts +65 -11
- package/review-modal.ts +1272 -0
- package/review-source.ts +296 -0
- package/review-ui.ts +519 -0
- package/review.ts +586 -0
- package/settings.ts +505 -0
- package/sources.ts +367 -0
- package/util.ts +26 -0
package/bro.ts
CHANGED
|
@@ -1,123 +1,84 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { hasBroCustomUi, broModalRows, canBroInsertIntoEditor, insertBroDesktopText } from "./ui-capabilities.ts";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import {
|
|
5
|
-
import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
-
import { request as httpRequest, type IncomingMessage } from "node:http";
|
|
7
|
-
import { request as httpsRequest } from "node:https";
|
|
8
|
-
import { BlockList, isIP } from "node:net";
|
|
4
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
9
5
|
import { tmpdir } from "node:os";
|
|
10
|
-
import {
|
|
6
|
+
import { join } from "node:path";
|
|
11
7
|
import { stripVTControlCharacters } from "node:util";
|
|
12
8
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { Container, Editor, Input, Markdown,
|
|
14
|
-
import { convertToLlm, copyToClipboard, getAgentDir, getMarkdownTheme, getSelectListTheme
|
|
15
|
-
import { Defuddle } from "defuddle/node";
|
|
16
|
-
import { parseHTML } from "linkedom";
|
|
17
|
-
import mammoth from "mammoth";
|
|
9
|
+
import { Container, Editor, Input, Markdown, Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type EditorTheme, type Focusable, type TUI } from "@earendil-works/pi-tui";
|
|
10
|
+
import { convertToLlm, copyToClipboard, getAgentDir, getMarkdownTheme, getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
18
11
|
import { Type } from "typebox";
|
|
19
|
-
import {
|
|
20
|
-
import { BRO_MODES, DEFAULT_BRO_MODE, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, nextBroMode, parseBroMode, type BroMode } from "./prompt.ts";
|
|
12
|
+
import { BRO_MODES, MAX_PREFERENCES_CHARS, MAX_ADVISOR_STEERING_CHARS, STARTER_PREFERENCES, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, nextBroMode, parseBroMode, type BroMode } from "./prompt.ts";
|
|
21
13
|
import {
|
|
22
14
|
agyFailureMessage,
|
|
23
|
-
|
|
24
|
-
advisorFlagErrorHint,
|
|
25
|
-
CLAUDE_EFFORTS,
|
|
26
|
-
GROK_EFFORTS,
|
|
27
|
-
CODEX_EFFORTS,
|
|
28
|
-
MUSE_EFFORTS,
|
|
15
|
+
reviewBackendError,
|
|
29
16
|
execute as executeBackend,
|
|
30
|
-
parseBtwAgyLine,
|
|
31
|
-
type AgySelection,
|
|
32
17
|
type BackendProgress,
|
|
33
18
|
type BackendSelection,
|
|
34
19
|
} from "./backend.ts";
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
20
|
+
import { isRecord, errorMessage, withDoctor, unquote } from "./util.ts";
|
|
21
|
+
import {
|
|
22
|
+
type BackendName,
|
|
23
|
+
type BroSettings,
|
|
24
|
+
type AgyModelFamily,
|
|
25
|
+
type AgyEffort,
|
|
26
|
+
EFFORTS,
|
|
27
|
+
CAPABILITIES,
|
|
28
|
+
CAPABILITY_LABELS,
|
|
29
|
+
capabilityBackend,
|
|
30
|
+
capabilityOverride,
|
|
31
|
+
capabilityPair,
|
|
32
|
+
ensureSettingsFile,
|
|
33
|
+
isClaudeEffort,
|
|
34
|
+
isGrokEffort,
|
|
35
|
+
isCodexEffort,
|
|
36
|
+
isMuseEffort,
|
|
37
|
+
readSettings,
|
|
38
|
+
resolveCapabilitySettings,
|
|
39
|
+
resolveModelEffort,
|
|
40
|
+
selectionForCapability,
|
|
41
|
+
selectionLabel,
|
|
42
|
+
settingsFile,
|
|
43
|
+
writeSettings,
|
|
44
|
+
} from "./settings.ts";
|
|
45
|
+
import {
|
|
46
|
+
type BroSource,
|
|
47
|
+
extractDocumentText,
|
|
48
|
+
extractWebPage,
|
|
49
|
+
isWorkspaceFile,
|
|
50
|
+
looksLikeWebUrl,
|
|
51
|
+
MAX_TEXT_LENGTH,
|
|
52
|
+
} from "./sources.ts";
|
|
53
|
+
import { createConfigModal, type Theme, type TuiLike } from "./config-ui.ts";
|
|
54
|
+
|
|
55
|
+
import { openGuidedReview, stopGuidedReviews } from "./review-ui.ts";
|
|
56
|
+
|
|
57
|
+
export * from "./util.ts";
|
|
58
|
+
export * from "./settings.ts";
|
|
59
|
+
export * from "./sources.ts";
|
|
60
|
+
export * from "./config-ui.ts";
|
|
61
|
+
|
|
62
|
+
const PREFERENCES_FILE = join(getAgentDir(), "bro-preferences.md");
|
|
63
|
+
const ADVISOR_STEERING_FILE = join(getAgentDir(), "bro-advisor.md");
|
|
64
|
+
// Larger files are rejected before reading, even by the editor; the prompt limit is MAX_PREFERENCES_CHARS.
|
|
65
|
+
const MAX_PREFERENCES_FILE_BYTES = 64 * 1024;
|
|
66
|
+
const MAX_ADVISOR_STEERING_FILE_BYTES = 64 * 1024;
|
|
43
67
|
const LOADING_TEXT = "Simplifying for my bro…";
|
|
44
|
-
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
45
|
-
const MAX_WEB_BYTES = 5 * 1024 * 1024;
|
|
46
|
-
const MAX_WEB_ELEMENTS = 100_000;
|
|
47
|
-
const MAX_WEB_REDIRECTS = 5;
|
|
48
|
-
const WEB_TIMEOUT_MS = 25_000;
|
|
49
|
-
const MAX_TEXT_LENGTH = 100_000;
|
|
50
68
|
const BTW_CONTEXT_TURNS = 8;
|
|
51
69
|
const BTW_CONTEXT_MAX = 40_000;
|
|
52
|
-
const DEFAULT_SHOW_TURNS = 1;
|
|
53
70
|
const SHOW_HTML_FILE_PATTERN = /^bro-show-[0-9a-f]{8}\.html$/;
|
|
54
|
-
|
|
55
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
56
|
-
|
|
57
|
-
type Theme = ExtensionCommandContext["ui"]["theme"];
|
|
58
|
-
type TuiLike = {
|
|
59
|
-
readonly mode: "regular" | "fullscreen";
|
|
60
|
-
readonly terminal?: { rows?: number; write?: (data: string) => void };
|
|
61
|
-
requestRender(): void;
|
|
62
|
-
};
|
|
71
|
+
|
|
63
72
|
type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
|
|
64
|
-
|
|
65
|
-
// `
|
|
66
|
-
type BroResult = { source: BroSource; text: string; model?: string; mode?: BroMode };
|
|
67
|
-
type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string; mode?: BroMode };
|
|
73
|
+
// `mode` is the built-in explain mode that produced the text; absent for Show and Doctor.
|
|
74
|
+
// `preferences` records whether bro-preferences.md shaped this result; like `mode`, it belongs to the result.
|
|
75
|
+
type BroResult = { source: BroSource; text: string; model?: string; mode?: BroMode; preferences?: boolean };
|
|
76
|
+
type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string; mode?: BroMode; preferences?: boolean };
|
|
68
77
|
type BtwTurn = { question: string; answer: string };
|
|
69
78
|
// `context` keeps the main-session seed so a fresh native session can be reseeded with the whole
|
|
70
|
-
// thread; `sessionFull`
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const BACKENDS = ["agy", "claude", "grok", "codex", "muse"] as const;
|
|
74
|
-
type BackendName = (typeof BACKENDS)[number];
|
|
75
|
-
type BroEffort = "default" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
76
|
-
type AgyEffort = Exclude<BroEffort, "default" | "minimal" | "xhigh" | "max">;
|
|
77
|
-
type ClaudeEffort = (typeof CLAUDE_EFFORTS)[number];
|
|
78
|
-
export { GROK_EFFORTS, CODEX_EFFORTS, MUSE_EFFORTS };
|
|
79
|
-
type GrokEffort = (typeof GROK_EFFORTS)[number];
|
|
80
|
-
type CodexEffort = (typeof CODEX_EFFORTS)[number];
|
|
81
|
-
type MuseEffort = (typeof MUSE_EFFORTS)[number];
|
|
82
|
-
// "advisor" is a real capability (command, Agy invocation, Doctor check) like the other three;
|
|
83
|
-
// see docs/plans/2026-09-19-bro-advisor-design.md. All four share one model/effort resolution,
|
|
84
|
-
// override, and Doctor-check path via this single list -- there is no configuration-only tier.
|
|
85
|
-
const CAPABILITIES = ["explain", "show", "btw", "advisor"] as const;
|
|
86
|
-
type Capability = (typeof CAPABILITIES)[number];
|
|
87
|
-
const CAPABILITY_LABELS: Record<Capability, string> = { explain: "Explain", show: "Show", btw: "Btw", advisor: "Advisor" };
|
|
88
|
-
type ModelEffortPair = { backend?: BackendName; model: string; effort: BroEffort };
|
|
89
|
-
type BroSettings = {
|
|
90
|
-
backend?: BackendName;
|
|
91
|
-
model: string;
|
|
92
|
-
effort: BroEffort;
|
|
93
|
-
mode: BroMode;
|
|
94
|
-
showTurns: number;
|
|
95
|
-
overrides: Partial<Record<Capability, ModelEffortPair>>;
|
|
96
|
-
};
|
|
97
|
-
// BackendSelection (shared execution routing selection) lives in backend.ts; the backend
|
|
98
|
-
// field stays optional there so every legacy backend-less pair keeps meaning Agy.
|
|
99
|
-
export const CLAUDE_MODELS = [
|
|
100
|
-
{ id: "sonnet", label: "Claude Sonnet" },
|
|
101
|
-
{ id: "opus", label: "Claude Opus" },
|
|
102
|
-
] as const;
|
|
103
|
-
export const GROK_MODELS = [
|
|
104
|
-
{ id: "grok-4.7", label: "Grok 4.7" },
|
|
105
|
-
{ id: "grok-4.7-build-fast", label: "Grok 4.7 Build Fast" },
|
|
106
|
-
] as const;
|
|
107
|
-
export const CODEX_MODELS = [
|
|
108
|
-
{ id: "gpt-5.5", label: "GPT-5.5" },
|
|
109
|
-
{ id: "gpt-5.4", label: "GPT-5.4" },
|
|
110
|
-
] as const;
|
|
111
|
-
export const MUSE_MODELS = [
|
|
112
|
-
{ id: "muse-spark-1.3-contributor", label: "Muse Spark 1.3 Contributor" },
|
|
113
|
-
{ id: "muse-spark-1.3", label: "Muse Spark 1.3" },
|
|
114
|
-
] as const;
|
|
115
|
-
type AgyModelFamily = {
|
|
116
|
-
id: string;
|
|
117
|
-
label: string;
|
|
118
|
-
efforts: AgyEffort[];
|
|
119
|
-
variants: Array<{ id: string; effort?: AgyEffort }>;
|
|
120
|
-
};
|
|
79
|
+
// thread; `sessionFull` and `sessionPreferences` are the access mode and preferences the native
|
|
80
|
+
// session last ran with. `preferences` records whether the latest turn used them (header tag).
|
|
81
|
+
type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string; preferences?: boolean; context?: string; sessionFull?: boolean; sessionPreferences?: string };
|
|
121
82
|
export function wheelDelta(data: string): number {
|
|
122
83
|
const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
|
|
123
84
|
if (!match) return 0;
|
|
@@ -139,6 +100,8 @@ const COMMANDS = [
|
|
|
139
100
|
{ value: "doctor", label: "doctor", description: "Check whether Bro is ready" },
|
|
140
101
|
{ value: "show", label: "show", description: "Draw what happened in recent session turns as shapes" },
|
|
141
102
|
{ value: "mode", label: "mode", description: "View or choose explanation mode (brief, balanced, faithful)" },
|
|
103
|
+
{ value: "preferences", label: "preferences", description: "View or edit what Bro knows about you and how you like answers" },
|
|
104
|
+
{ value: "guided-review", label: "guided-review", description: "Understand and assess a PR with captured code and optional private questions" },
|
|
142
105
|
{ value: "btw", label: "btw", description: "Open a side conversation (starts conversation-only; /mode toggles full permission)" },
|
|
143
106
|
{ value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
|
|
144
107
|
{ value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
|
|
@@ -147,652 +110,6 @@ const COMMANDS = [
|
|
|
147
110
|
];
|
|
148
111
|
const KNOWN_ACTIONS = new Set(COMMANDS.map((command) => command.value));
|
|
149
112
|
|
|
150
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
151
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function errorMessage(error: unknown): string {
|
|
155
|
-
return error instanceof Error ? error.message : String(error);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function withDoctor(error: unknown): string {
|
|
159
|
-
const message = errorMessage(error);
|
|
160
|
-
return message.includes("/bro doctor") ? message : `${message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function fileError(path: string, error: unknown): Error {
|
|
164
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
165
|
-
if (code === "ENOENT") return new Error(`File not found: ${path}`);
|
|
166
|
-
if (code === "EACCES" || code === "EPERM") return new Error(`File is not readable: ${path}`);
|
|
167
|
-
return new Error(`Could not read ${path}: ${errorMessage(error)}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function unquote(value: string): string {
|
|
171
|
-
if (value.length >= 2 && ((value[0] === '"' && value.at(-1) === '"') || (value[0] === "'" && value.at(-1) === "'"))) {
|
|
172
|
-
return value.slice(1, -1);
|
|
173
|
-
}
|
|
174
|
-
return value;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export async function extractDocumentText(input: string, cwd: string, signal?: AbortSignal): Promise<string> {
|
|
178
|
-
const requested = unquote(input.trim());
|
|
179
|
-
if (!requested) throw new Error("Use /bro file <path>.");
|
|
180
|
-
|
|
181
|
-
let root: string;
|
|
182
|
-
let path: string;
|
|
183
|
-
try {
|
|
184
|
-
root = await realpath(cwd);
|
|
185
|
-
path = await realpath(resolve(cwd, requested));
|
|
186
|
-
} catch (error) {
|
|
187
|
-
throw fileError(requested, error);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const fromRoot = relative(root, path);
|
|
191
|
-
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
192
|
-
throw new Error("Bro can read only files inside the current workspace.");
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
let info;
|
|
196
|
-
try {
|
|
197
|
-
info = await stat(path);
|
|
198
|
-
} catch (error) {
|
|
199
|
-
throw fileError(requested, error);
|
|
200
|
-
}
|
|
201
|
-
if (!info.isFile()) throw new Error(`Not a regular file: ${requested}`);
|
|
202
|
-
if (info.size > MAX_FILE_BYTES) throw new Error("File is larger than Bro's 10 MiB limit.");
|
|
203
|
-
|
|
204
|
-
let buffer: Buffer;
|
|
205
|
-
try {
|
|
206
|
-
buffer = await readFile(path, { signal });
|
|
207
|
-
} catch (error) {
|
|
208
|
-
if (signal?.aborted) throw new Error("Canceled.");
|
|
209
|
-
throw fileError(requested, error);
|
|
210
|
-
}
|
|
211
|
-
if (buffer.byteLength > MAX_FILE_BYTES) throw new Error("File is larger than Bro's 10 MiB limit.");
|
|
212
|
-
if (signal?.aborted) throw new Error("Canceled.");
|
|
213
|
-
|
|
214
|
-
const extension = extname(path).toLowerCase();
|
|
215
|
-
let text: string;
|
|
216
|
-
try {
|
|
217
|
-
if (TEXT_EXTENSIONS.has(extension)) {
|
|
218
|
-
text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
219
|
-
} else if (extension === ".pdf") {
|
|
220
|
-
text = (await extractText(new Uint8Array(buffer), { mergePages: true })).text;
|
|
221
|
-
} else if (extension === ".docx") {
|
|
222
|
-
text = (await mammoth.extractRawText({ buffer })).value;
|
|
223
|
-
} else {
|
|
224
|
-
throw new Error("Unsupported file type. Use .md, .markdown, .txt, .pdf, or .docx.");
|
|
225
|
-
}
|
|
226
|
-
} catch (error) {
|
|
227
|
-
if (error instanceof Error && error.message.startsWith("Unsupported file type.")) throw error;
|
|
228
|
-
throw new Error(`Could not extract text from ${requested}: ${errorMessage(error)}`);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
text = text.trim();
|
|
232
|
-
if (!text) throw new Error("No readable text found. Scanned PDFs need OCR, which Bro does not support.");
|
|
233
|
-
if (text.length > MAX_TEXT_LENGTH) throw new Error("Extracted text is longer than Bro's 100,000-character limit.");
|
|
234
|
-
return text;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const SNIFFABLE_FILE_EXTENSIONS = new Set([...TEXT_EXTENSIONS, ".pdf", ".docx"]);
|
|
238
|
-
|
|
239
|
-
async function isWorkspaceFile(input: string, cwd: string): Promise<boolean> {
|
|
240
|
-
// ponytail: duplicates extractDocumentText's workspace guard rather than sharing its error semantics.
|
|
241
|
-
try {
|
|
242
|
-
const path = await realpath(resolve(cwd, input));
|
|
243
|
-
const fromRoot = relative(await realpath(cwd), path);
|
|
244
|
-
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
247
|
-
const info = await stat(path);
|
|
248
|
-
return info.isFile() && SNIFFABLE_FILE_EXTENSIONS.has(extname(path).toLowerCase());
|
|
249
|
-
} catch {
|
|
250
|
-
return false;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const NON_PUBLIC_ADDRESSES = new BlockList();
|
|
255
|
-
for (const [network, prefix] of [
|
|
256
|
-
["0.0.0.0", 8],
|
|
257
|
-
["10.0.0.0", 8],
|
|
258
|
-
["100.64.0.0", 10],
|
|
259
|
-
["127.0.0.0", 8],
|
|
260
|
-
["169.254.0.0", 16],
|
|
261
|
-
["172.16.0.0", 12],
|
|
262
|
-
["192.0.0.0", 24],
|
|
263
|
-
["192.0.2.0", 24],
|
|
264
|
-
["192.31.196.0", 24],
|
|
265
|
-
["192.52.193.0", 24],
|
|
266
|
-
["192.88.99.0", 24],
|
|
267
|
-
["192.168.0.0", 16],
|
|
268
|
-
["192.175.48.0", 24],
|
|
269
|
-
["198.18.0.0", 15],
|
|
270
|
-
["198.51.100.0", 24],
|
|
271
|
-
["203.0.113.0", 24],
|
|
272
|
-
["224.0.0.0", 4],
|
|
273
|
-
["240.0.0.0", 4],
|
|
274
|
-
] as const) {
|
|
275
|
-
NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv4");
|
|
276
|
-
}
|
|
277
|
-
for (const [network, prefix] of [
|
|
278
|
-
["::", 128],
|
|
279
|
-
["::1", 128],
|
|
280
|
-
["64:ff9b::", 96],
|
|
281
|
-
["64:ff9b:1::", 48],
|
|
282
|
-
["100::", 64],
|
|
283
|
-
["2001::", 23],
|
|
284
|
-
["2001:db8::", 32],
|
|
285
|
-
["2002::", 16],
|
|
286
|
-
["3fff::", 20],
|
|
287
|
-
["5f00::", 16],
|
|
288
|
-
["fc00::", 7],
|
|
289
|
-
["fe80::", 10],
|
|
290
|
-
["ff00::", 8],
|
|
291
|
-
] as const) {
|
|
292
|
-
NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv6");
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
export function isPublicWebAddress(address: string): boolean {
|
|
296
|
-
const family = isIP(address);
|
|
297
|
-
return family === 4
|
|
298
|
-
? !NON_PUBLIC_ADDRESSES.check(address, "ipv4")
|
|
299
|
-
: family === 6
|
|
300
|
-
? !NON_PUBLIC_ADDRESSES.check(address, "ipv6")
|
|
301
|
-
: false;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export function parseWebUrl(input: string): URL {
|
|
305
|
-
const requested = unquote(input.trim());
|
|
306
|
-
if (!requested) throw new Error("Use /bro url <url>.");
|
|
307
|
-
|
|
308
|
-
let url: URL;
|
|
309
|
-
try {
|
|
310
|
-
url = new URL(requested);
|
|
311
|
-
} catch {
|
|
312
|
-
throw new Error("That is not a valid URL. Use /bro url https://example.com/article.");
|
|
313
|
-
}
|
|
314
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
315
|
-
throw new Error("Bro can read only public HTTP or HTTPS webpages.");
|
|
316
|
-
}
|
|
317
|
-
if (url.username || url.password) {
|
|
318
|
-
throw new Error("Bro does not accept URLs containing usernames or passwords.");
|
|
319
|
-
}
|
|
320
|
-
url.hash = "";
|
|
321
|
-
return url;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export function looksLikeWebUrl(input: string): boolean {
|
|
325
|
-
// Structurally http(s) only: credential or syntax problems must surface as url errors, not text leaks.
|
|
326
|
-
let url: URL;
|
|
327
|
-
try {
|
|
328
|
-
url = new URL(input);
|
|
329
|
-
} catch {
|
|
330
|
-
return false;
|
|
331
|
-
}
|
|
332
|
-
return url.protocol === "http:" || url.protocol === "https:";
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export function parseWebRedirect(current: URL, location: string): URL {
|
|
336
|
-
const next = parseWebUrl(new URL(location, current).href);
|
|
337
|
-
if (current.protocol === "https:" && next.protocol !== "https:") {
|
|
338
|
-
throw new Error("Bro refused an insecure HTTPS-to-HTTP redirect.");
|
|
339
|
-
}
|
|
340
|
-
return next;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function headerValue(value: string | string[] | undefined): string {
|
|
344
|
-
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
async function resolvePublicAddress(hostname: string): Promise<{ address: string; family: 4 | 6 }> {
|
|
348
|
-
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
349
|
-
let addresses: Array<{ address: string; family: number }>;
|
|
350
|
-
try {
|
|
351
|
-
addresses = await lookup(host, { all: true, verbatim: true });
|
|
352
|
-
} catch (error) {
|
|
353
|
-
throw new Error(`Could not resolve webpage host: ${errorMessage(error)}`);
|
|
354
|
-
}
|
|
355
|
-
if (!addresses.length) throw new Error("The webpage host has no network address.");
|
|
356
|
-
if (addresses.some((item) => !isPublicWebAddress(item.address))) {
|
|
357
|
-
throw new Error("Bro cannot connect to local, private, or reserved network addresses.");
|
|
358
|
-
}
|
|
359
|
-
return { address: addresses[0].address, family: addresses[0].family === 6 ? 6 : 4 };
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function requestWebPage(url: URL, address: { address: string; family: 4 | 6 }, signal: AbortSignal): Promise<IncomingMessage> {
|
|
363
|
-
return new Promise((resolveResponse, rejectResponse) => {
|
|
364
|
-
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
|
|
365
|
-
url,
|
|
366
|
-
{
|
|
367
|
-
method: "GET",
|
|
368
|
-
signal,
|
|
369
|
-
headers: {
|
|
370
|
-
Accept: "text/html,application/xhtml+xml",
|
|
371
|
-
"Accept-Encoding": "identity",
|
|
372
|
-
"User-Agent": "pi-bro URL reader (+https://github.com/tranhoangnguyen03/pi-bro)",
|
|
373
|
-
},
|
|
374
|
-
lookup: (_hostname, options, callback) => {
|
|
375
|
-
if (options.all) callback(null, [address]);
|
|
376
|
-
else callback(null, address.address, address.family);
|
|
377
|
-
},
|
|
378
|
-
},
|
|
379
|
-
resolveResponse,
|
|
380
|
-
);
|
|
381
|
-
request.once("error", rejectResponse);
|
|
382
|
-
request.end();
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
async function readWebBody(response: IncomingMessage): Promise<Buffer> {
|
|
387
|
-
const contentEncoding = headerValue(response.headers["content-encoding"]).trim().toLowerCase();
|
|
388
|
-
if (contentEncoding && contentEncoding !== "identity") {
|
|
389
|
-
response.destroy();
|
|
390
|
-
throw new Error(`Bro cannot read this page's ${contentEncoding} response encoding.`);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const contentLength = Number.parseInt(headerValue(response.headers["content-length"]), 10);
|
|
394
|
-
if (Number.isFinite(contentLength) && contentLength > MAX_WEB_BYTES) {
|
|
395
|
-
response.destroy();
|
|
396
|
-
throw new Error("Webpage is larger than Bro's 5 MiB download limit.");
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const chunks: Buffer[] = [];
|
|
400
|
-
let size = 0;
|
|
401
|
-
try {
|
|
402
|
-
for await (const chunk of response) {
|
|
403
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
404
|
-
size += buffer.byteLength;
|
|
405
|
-
if (size > MAX_WEB_BYTES) throw new Error("Webpage is larger than Bro's 5 MiB download limit.");
|
|
406
|
-
chunks.push(buffer);
|
|
407
|
-
}
|
|
408
|
-
} catch (error) {
|
|
409
|
-
response.destroy();
|
|
410
|
-
throw error;
|
|
411
|
-
}
|
|
412
|
-
return Buffer.concat(chunks, size);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function decodeWebHtml(buffer: Buffer, contentType: string): string {
|
|
416
|
-
const headerCharset = /charset\s*=\s*["']?([^\s;"']+)/i.exec(contentType)?.[1];
|
|
417
|
-
const head = new TextDecoder("latin1").decode(buffer.subarray(0, 2048));
|
|
418
|
-
const metaCharset = /<meta[^>]+charset\s*=\s*["']?([^\s;"'>]+)/i.exec(head)?.[1]
|
|
419
|
-
?? /<meta[^>]+content\s*=\s*["'][^"']*charset=([^\s;"']+)/i.exec(head)?.[1];
|
|
420
|
-
const charset = headerCharset ?? metaCharset ?? "utf-8";
|
|
421
|
-
try {
|
|
422
|
-
return new TextDecoder(charset).decode(buffer);
|
|
423
|
-
} catch {
|
|
424
|
-
throw new Error(`Bro does not support this page's ${charset} character encoding.`);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function assertWebElementLimit(html: string): void {
|
|
429
|
-
let count = 0;
|
|
430
|
-
for (let index = 0; index < html.length - 1; index++) {
|
|
431
|
-
if (html.charCodeAt(index) !== 60) continue;
|
|
432
|
-
const next = html.charCodeAt(index + 1) | 32;
|
|
433
|
-
if (next >= 97 && next <= 122 && ++count > MAX_WEB_ELEMENTS) {
|
|
434
|
-
throw new Error("Webpage is too complex for Bro to read safely.");
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
async function fetchPublicHtml(startUrl: URL, signal: AbortSignal): Promise<{ html: string; url: URL }> {
|
|
440
|
-
let url = startUrl;
|
|
441
|
-
const visited = new Set<string>();
|
|
442
|
-
|
|
443
|
-
for (let redirects = 0; ; redirects++) {
|
|
444
|
-
if (visited.has(url.href)) throw new Error("Webpage redirect loop detected.");
|
|
445
|
-
visited.add(url.href);
|
|
446
|
-
const address = await resolvePublicAddress(url.hostname);
|
|
447
|
-
let response: IncomingMessage;
|
|
448
|
-
try {
|
|
449
|
-
response = await requestWebPage(url, address, signal);
|
|
450
|
-
} catch (error) {
|
|
451
|
-
throw new Error(`Could not fetch webpage: ${errorMessage(error)}`);
|
|
452
|
-
}
|
|
453
|
-
const status = response.statusCode ?? 0;
|
|
454
|
-
|
|
455
|
-
if (REDIRECT_STATUSES.has(status)) {
|
|
456
|
-
response.destroy();
|
|
457
|
-
if (redirects >= MAX_WEB_REDIRECTS) throw new Error("Webpage redirected too many times.");
|
|
458
|
-
const location = headerValue(response.headers.location);
|
|
459
|
-
if (!location) throw new Error(`Webpage returned HTTP ${status} without a redirect location.`);
|
|
460
|
-
url = parseWebRedirect(url, location);
|
|
461
|
-
continue;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (status < 200 || status >= 300) {
|
|
465
|
-
response.destroy();
|
|
466
|
-
if (status === 401 || status === 403) {
|
|
467
|
-
throw new Error(`Webpage returned HTTP ${status}. It may require a login or block automated readers.`);
|
|
468
|
-
}
|
|
469
|
-
if (status === 429) throw new Error("Webpage returned HTTP 429 and is limiting automated requests.");
|
|
470
|
-
throw new Error(`Webpage returned HTTP ${status}.`);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const contentType = headerValue(response.headers["content-type"]);
|
|
474
|
-
const mime = contentType.split(";", 1)[0].trim().toLowerCase();
|
|
475
|
-
if (mime !== "text/html" && mime !== "application/xhtml+xml") {
|
|
476
|
-
response.destroy();
|
|
477
|
-
throw new Error(`Unsupported webpage content type: ${mime || "missing"}.`);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const html = decodeWebHtml(await readWebBody(response), contentType);
|
|
481
|
-
assertWebElementLimit(html);
|
|
482
|
-
return { html, url };
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
export async function extractWebHtml(html: string, url: string): Promise<BroSource> {
|
|
487
|
-
assertWebElementLimit(html);
|
|
488
|
-
const parsedUrl = parseWebUrl(url);
|
|
489
|
-
const { document } = parseHTML(html);
|
|
490
|
-
const result = await Defuddle(document, parsedUrl.href, {
|
|
491
|
-
markdown: true,
|
|
492
|
-
removeImages: true,
|
|
493
|
-
includeReplies: false,
|
|
494
|
-
useAsync: false,
|
|
495
|
-
});
|
|
496
|
-
const text = (result.contentMarkdown || result.content || "").trim();
|
|
497
|
-
if (!text) {
|
|
498
|
-
throw new Error("Bro found no readable page content. The page may require JavaScript, a login, or block automated readers.");
|
|
499
|
-
}
|
|
500
|
-
if (text.length > MAX_TEXT_LENGTH) {
|
|
501
|
-
throw new Error("Extracted webpage text is longer than Bro's 100,000-character limit.");
|
|
502
|
-
}
|
|
503
|
-
const title = result.title
|
|
504
|
-
? stripVTControlCharacters(result.title).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 200)
|
|
505
|
-
: undefined;
|
|
506
|
-
return { text, label: [parsedUrl.hostname, title].filter(Boolean).join(" · ") };
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
export async function extractWebPage(input: string, signal?: AbortSignal): Promise<BroSource> {
|
|
510
|
-
const timeout = AbortSignal.timeout(WEB_TIMEOUT_MS);
|
|
511
|
-
const combinedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
512
|
-
try {
|
|
513
|
-
const fetched = await fetchPublicHtml(parseWebUrl(input), combinedSignal);
|
|
514
|
-
return await extractWebHtml(fetched.html, fetched.url.href);
|
|
515
|
-
} catch (error) {
|
|
516
|
-
if (signal?.aborted) throw new Error("Canceled.");
|
|
517
|
-
if (timeout.aborted) throw new Error("Webpage took longer than 25 seconds to respond.");
|
|
518
|
-
throw error;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
function parseBackend(value: unknown, context: string): BackendName {
|
|
523
|
-
if (typeof value !== "string" || !BACKENDS.some((backend) => backend === value)) {
|
|
524
|
-
throw new Error(`${context} backend must be "agy", "claude", "grok", "codex", or "muse".`);
|
|
525
|
-
}
|
|
526
|
-
return value as BackendName;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function isAgyEffort(effort: unknown): effort is AgyEffort | "default" {
|
|
530
|
-
return EFFORTS.some((item) => item === effort);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function isClaudeEffort(effort: unknown): effort is ClaudeEffort | "default" {
|
|
534
|
-
return effort === "default" || CLAUDE_EFFORTS.some((item) => item === effort);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function isGrokEffort(effort: unknown): effort is GrokEffort | "default" {
|
|
538
|
-
return effort === "default" || GROK_EFFORTS.some((item) => item === effort);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
function isCodexEffort(effort: unknown): effort is CodexEffort | "default" {
|
|
542
|
-
return effort === "default" || CODEX_EFFORTS.some((item) => item === effort);
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function isMuseEffort(effort: unknown): effort is MuseEffort | "default" {
|
|
546
|
-
return effort === "default" || MUSE_EFFORTS.some((item) => item === effort);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
|
|
550
|
-
if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim()) {
|
|
551
|
-
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
|
|
552
|
-
}
|
|
553
|
-
const backend = value.backend === undefined ? undefined : parseBackend(value.backend, context);
|
|
554
|
-
if (backend === "claude") {
|
|
555
|
-
if (!isClaudeEffort(value.effort)) {
|
|
556
|
-
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", "xhigh", or "max".`);
|
|
557
|
-
}
|
|
558
|
-
return { backend, model: value.model.trim(), effort: value.effort };
|
|
559
|
-
}
|
|
560
|
-
if (backend === "grok") {
|
|
561
|
-
if (!isGrokEffort(value.effort)) {
|
|
562
|
-
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".`);
|
|
563
|
-
}
|
|
564
|
-
return { backend, model: value.model.trim(), effort: value.effort };
|
|
565
|
-
}
|
|
566
|
-
if (backend === "codex") {
|
|
567
|
-
if (!isCodexEffort(value.effort)) {
|
|
568
|
-
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".`);
|
|
569
|
-
}
|
|
570
|
-
return { backend, model: value.model.trim(), effort: value.effort };
|
|
571
|
-
}
|
|
572
|
-
if (backend === "muse") {
|
|
573
|
-
if (!isMuseEffort(value.effort)) {
|
|
574
|
-
throw new Error(`${context} must contain a model and effort set to "default", "minimal", "low", "medium", "high", "xhigh", or "max".`);
|
|
575
|
-
}
|
|
576
|
-
return { backend, model: value.model.trim(), effort: value.effort };
|
|
577
|
-
}
|
|
578
|
-
if (!isAgyEffort(value.effort)) {
|
|
579
|
-
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
|
|
580
|
-
}
|
|
581
|
-
const pair: ModelEffortPair = { model: value.model.trim(), effort: value.effort };
|
|
582
|
-
if (backend !== undefined) pair.backend = backend;
|
|
583
|
-
return pair;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
function parseOverrides(value: unknown): Partial<Record<Capability, ModelEffortPair>> {
|
|
587
|
-
if (value === undefined) return {};
|
|
588
|
-
if (!isRecord(value)) throw new Error("Settings overrides must be an object.");
|
|
589
|
-
const overrides: Partial<Record<Capability, ModelEffortPair>> = {};
|
|
590
|
-
for (const capability of CAPABILITIES) {
|
|
591
|
-
if (value[capability] === undefined) continue;
|
|
592
|
-
overrides[capability] = parseModelEffortPair(value[capability], `Settings overrides.${capability}`);
|
|
593
|
-
}
|
|
594
|
-
return overrides;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
export function parseBroSettings(value: unknown): BroSettings {
|
|
598
|
-
if (!isRecord(value)) {
|
|
599
|
-
throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
|
|
600
|
-
}
|
|
601
|
-
// v2 disk shape: { version: 2, default: { backend, model, effort }, mode, showTurns, overrides }.
|
|
602
|
-
// Reads never rewrite: a legacy flat file keeps parsing into backend-less (Agy) settings.
|
|
603
|
-
if (value.version !== undefined && value.version !== 2) throw new Error(`Unsupported settings version ${JSON.stringify(value.version)}.`);
|
|
604
|
-
if (value.version === 2 && value.default === undefined) throw new Error("Settings version 2 requires default.");
|
|
605
|
-
if (value.default !== undefined) {
|
|
606
|
-
if (!isRecord(value.default)) throw new Error("Settings default must contain a model and effort.");
|
|
607
|
-
if (value.version !== undefined && value.version !== 2) {
|
|
608
|
-
throw new Error(`Unsupported settings version ${JSON.stringify(value.version)}.`);
|
|
609
|
-
}
|
|
610
|
-
const pair = parseModelEffortPair(value.default, "Settings default");
|
|
611
|
-
const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
|
|
612
|
-
if (!mode) throw new Error('Settings mode must be "brief", "balanced", or "faithful".');
|
|
613
|
-
const showTurns = value.showTurns === undefined ? DEFAULT_SHOW_TURNS : value.showTurns;
|
|
614
|
-
if (typeof showTurns !== "number" || !Number.isInteger(showTurns) || showTurns < 1) {
|
|
615
|
-
throw new Error("Settings showTurns must be a positive whole number of turns.");
|
|
616
|
-
}
|
|
617
|
-
const overrides = parseOverrides(value.overrides);
|
|
618
|
-
const settings: BroSettings = { model: pair.model, effort: pair.effort, mode, showTurns, overrides };
|
|
619
|
-
if (pair.backend !== undefined) settings.backend = pair.backend;
|
|
620
|
-
return settings;
|
|
621
|
-
}
|
|
622
|
-
if (typeof value.model !== "string" || !value.model.trim()) {
|
|
623
|
-
throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
|
|
624
|
-
}
|
|
625
|
-
const backend = value.backend === undefined ? undefined : parseBackend(value.backend, "Settings");
|
|
626
|
-
const effortOk =
|
|
627
|
-
backend === "claude"
|
|
628
|
-
? isClaudeEffort(value.effort)
|
|
629
|
-
: backend === "grok"
|
|
630
|
-
? isGrokEffort(value.effort)
|
|
631
|
-
: backend === "codex"
|
|
632
|
-
? isCodexEffort(value.effort)
|
|
633
|
-
: backend === "muse"
|
|
634
|
-
? isMuseEffort(value.effort)
|
|
635
|
-
: isAgyEffort(value.effort);
|
|
636
|
-
if (!effortOk) {
|
|
637
|
-
throw new Error(
|
|
638
|
-
backend === "claude"
|
|
639
|
-
? 'Settings must contain a model and effort set to "default", "low", "medium", "high", "xhigh", or "max".'
|
|
640
|
-
: backend === "grok" || backend === "codex"
|
|
641
|
-
? 'Settings must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".'
|
|
642
|
-
: backend === "muse"
|
|
643
|
-
? 'Settings must contain a model and effort set to "default", "minimal", "low", "medium", "high", "xhigh", or "max".'
|
|
644
|
-
: 'Settings must contain a model and effort set to "default", "low", "medium", or "high".',
|
|
645
|
-
);
|
|
646
|
-
}
|
|
647
|
-
const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
|
|
648
|
-
if (!mode) throw new Error('Settings mode must be "brief", "balanced", or "faithful".');
|
|
649
|
-
const showTurns = value.showTurns === undefined ? DEFAULT_SHOW_TURNS : value.showTurns;
|
|
650
|
-
if (typeof showTurns !== "number" || !Number.isInteger(showTurns) || showTurns < 1) {
|
|
651
|
-
throw new Error("Settings showTurns must be a positive whole number of turns.");
|
|
652
|
-
}
|
|
653
|
-
const overrides = parseOverrides(value.overrides);
|
|
654
|
-
const settings: BroSettings = {
|
|
655
|
-
model: value.model.trim(),
|
|
656
|
-
effort: value.effort as BroSettings["effort"],
|
|
657
|
-
mode,
|
|
658
|
-
showTurns,
|
|
659
|
-
overrides,
|
|
660
|
-
};
|
|
661
|
-
if (backend !== undefined) settings.backend = backend;
|
|
662
|
-
return settings;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
function capabilityOverride(settings: BroSettings, capability: Capability): ModelEffortPair | undefined {
|
|
666
|
-
return settings.overrides[capability];
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
function capabilityPair(settings: BroSettings, capability: Capability): ModelEffortPair {
|
|
670
|
-
return capabilityOverride(settings, capability) ?? { ...(settings.backend ? { backend: settings.backend } : {}), model: settings.model, effort: settings.effort };
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// An override always pins both model and effort together (never just one), so a capability's
|
|
674
|
-
// setting is either fully inherited or fully its own — no partial-inheritance edge cases.
|
|
675
|
-
//
|
|
676
|
-
// An override is cleared ONLY by an explicit "Default" selection (pair === undefined), never
|
|
677
|
-
// automatically because it happens to match the shared default: a user who deliberately pins a
|
|
678
|
-
// capability to the model that currently IS the shared default must keep that pin — unchanged —
|
|
679
|
-
// if the shared default is later changed to something else. Silently dropping an override that
|
|
680
|
-
// merely coincides with the default would make that pin impossible to express.
|
|
681
|
-
export function withCapabilityOverride(
|
|
682
|
-
settings: BroSettings,
|
|
683
|
-
capability: Capability,
|
|
684
|
-
pair: ModelEffortPair | undefined,
|
|
685
|
-
): BroSettings {
|
|
686
|
-
const overrides = { ...settings.overrides };
|
|
687
|
-
if (!pair) delete overrides[capability];
|
|
688
|
-
else overrides[capability] = pair;
|
|
689
|
-
return { ...settings, overrides };
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
export function resolveModelEffort(
|
|
693
|
-
pair: ModelEffortPair,
|
|
694
|
-
families: AgyModelFamily[],
|
|
695
|
-
): { pair: ModelEffortPair; family?: AgyModelFamily } {
|
|
696
|
-
// Claude/Grok/Codex/Muse selections never resolve through the Agy catalog; they pass through untouched.
|
|
697
|
-
if (pair.backend === "claude" || pair.backend === "grok" || pair.backend === "codex" || pair.backend === "muse") return { pair };
|
|
698
|
-
const family = families.find((item) => item.id === pair.model || item.variants.some((variant) => variant.id === pair.model));
|
|
699
|
-
if (!family) return { pair };
|
|
700
|
-
const variant = family.variants.find((item) => item.id === pair.model);
|
|
701
|
-
const resolved: ModelEffortPair = {
|
|
702
|
-
model: family.id,
|
|
703
|
-
effort: pair.effort === "default" && variant?.effort ? variant.effort : pair.effort,
|
|
704
|
-
};
|
|
705
|
-
if (pair.backend !== undefined) resolved.backend = pair.backend;
|
|
706
|
-
return { family, pair: resolved };
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// A capability's effective backend: an explicit override is a complete selection, so a
|
|
710
|
-
// backend-less override still means Agy (all legacy stays Agy) and never inherits the
|
|
711
|
-
// shared default's backend. Only a capability without an override inherits the default.
|
|
712
|
-
export function capabilityBackend(settings: BroSettings, capability: Capability): BackendName {
|
|
713
|
-
const override = settings.overrides[capability];
|
|
714
|
-
if (override) return override.backend ?? "agy";
|
|
715
|
-
return settings.backend ?? "agy";
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// Sonnet/opus aliases resolve case-insensitively; any other non-empty string passes
|
|
719
|
-
// through untouched as an explicit user-entered model ID.
|
|
720
|
-
export function resolveClaudeModel(input: string): string {
|
|
721
|
-
const trimmed = input.trim();
|
|
722
|
-
if (!trimmed) throw new Error("Claude model must be a non-empty model ID (sonnet, opus, or an explicit model ID).");
|
|
723
|
-
const alias = CLAUDE_MODELS.find((model) => model.id === trimmed.toLowerCase());
|
|
724
|
-
return alias ? alias.id : trimmed;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
// Grok seeds resolve to themselves; any other non-empty string passes through
|
|
728
|
-
// untouched as an explicit user-entered model ID (installed `grok models` confirms).
|
|
729
|
-
export function resolveGrokModel(input: string): string {
|
|
730
|
-
const trimmed = input.trim();
|
|
731
|
-
if (!trimmed) throw new Error("Grok model must be a non-empty model ID (grok-4.7, grok-4.7-build-fast, or an explicit model ID).");
|
|
732
|
-
return trimmed;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// Codex seeds resolve to themselves; any other non-empty string passes through
|
|
736
|
-
// untouched as an explicit user-entered model ID.
|
|
737
|
-
export function resolveCodexModel(input: string): string {
|
|
738
|
-
const trimmed = input.trim();
|
|
739
|
-
if (!trimmed) throw new Error("Codex model must be a non-empty model ID (gpt-5.5, gpt-5.4, or an explicit model ID).");
|
|
740
|
-
return trimmed;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// Muse seeds resolve to themselves; any other non-empty string passes through
|
|
744
|
-
// untouched as an explicit user-entered model ID.
|
|
745
|
-
export function resolveMuseModel(input: string): string {
|
|
746
|
-
const trimmed = input.trim();
|
|
747
|
-
if (!trimmed) throw new Error("Muse model must be a non-empty model ID (muse-spark-1.3-contributor, muse-spark-1.3, or an explicit model ID).");
|
|
748
|
-
return trimmed;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
// Routes one capability's whole pair to its backend execution selection. Agy keeps the
|
|
752
|
-
// existing model/effort split; Claude/Grok/Codex/Muse carry the model plus an optional effort
|
|
753
|
-
// ("default" means the CLI's own default and is omitted). Throws for an effort the
|
|
754
|
-
// resolved backend does not support instead of silently sending a mismatched pair.
|
|
755
|
-
export function selectionForCapability(settings: BroSettings, capability: Capability): BackendSelection {
|
|
756
|
-
const pair = capabilityPair(settings, capability);
|
|
757
|
-
const backend = capabilityBackend(settings, capability);
|
|
758
|
-
if (backend === "claude" || backend === "grok" || backend === "codex" || backend === "muse") {
|
|
759
|
-
const valid =
|
|
760
|
-
backend === "claude"
|
|
761
|
-
? isClaudeEffort(pair.effort)
|
|
762
|
-
: backend === "grok"
|
|
763
|
-
? isGrokEffort(pair.effort)
|
|
764
|
-
: backend === "codex"
|
|
765
|
-
? isCodexEffort(pair.effort)
|
|
766
|
-
: isMuseEffort(pair.effort);
|
|
767
|
-
if (!valid) {
|
|
768
|
-
const label = backend === "claude" ? "Claude" : backend === "grok" ? "Grok" : backend === "codex" ? "Codex" : "Muse";
|
|
769
|
-
throw new Error(`\`${pair.effort}\` is not supported on the ${label} backend. Run \`/bro config\` to fix this.`);
|
|
770
|
-
}
|
|
771
|
-
return (
|
|
772
|
-
pair.effort === "default"
|
|
773
|
-
? { backend, model: pair.model }
|
|
774
|
-
: { backend, model: pair.model, effort: pair.effort }
|
|
775
|
-
) as BackendSelection;
|
|
776
|
-
}
|
|
777
|
-
if (!isAgyEffort(pair.effort)) {
|
|
778
|
-
throw new Error(`\`${pair.effort}\` is not supported on the Agy backend. Run \`/bro config\` to fix this.`);
|
|
779
|
-
}
|
|
780
|
-
return agySelection({ model: pair.model, effort: pair.effort });
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// The modal label for the exact selection a request runs with: model plus effort, where an
|
|
784
|
-
// omitted effort (the model's own default) reads simply "default".
|
|
785
|
-
export function selectionLabel(selection: BackendSelection): string {
|
|
786
|
-
return `${selection.model} · ${selection.effort ?? "default"}`;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
function resolveCapabilitySettings(
|
|
790
|
-
settings: BroSettings,
|
|
791
|
-
capability: Capability,
|
|
792
|
-
families: AgyModelFamily[],
|
|
793
|
-
): { pair: ModelEffortPair; family?: AgyModelFamily } {
|
|
794
|
-
return resolveModelEffort(capabilityPair(settings, capability), families);
|
|
795
|
-
}
|
|
796
113
|
|
|
797
114
|
// The steering brief is stored as a session `custom` entry — extension state that never
|
|
798
115
|
// participates in LLM context (see docs/plans/2026-09-19-bro-advisor-design.md). Writes go
|
|
@@ -825,52 +142,6 @@ export function resolveAdvisorState(branch: readonly SessionEntry[]): AdvisorSta
|
|
|
825
142
|
return { steering };
|
|
826
143
|
}
|
|
827
144
|
|
|
828
|
-
async function ensureSettingsFile(): Promise<void> {
|
|
829
|
-
await mkdir(AGENT_DIR, { recursive: true });
|
|
830
|
-
try {
|
|
831
|
-
await writeFile(
|
|
832
|
-
SETTINGS_FILE,
|
|
833
|
-
`${JSON.stringify({ model: DEFAULT_MODEL, effort: ENV_MODEL ? "default" : "low", mode: DEFAULT_BRO_MODE, showTurns: DEFAULT_SHOW_TURNS }, null, 2)}\n`,
|
|
834
|
-
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
835
|
-
);
|
|
836
|
-
} catch (error) {
|
|
837
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
async function readSettings(): Promise<BroSettings> {
|
|
842
|
-
await ensureSettingsFile();
|
|
843
|
-
try {
|
|
844
|
-
return parseBroSettings(JSON.parse(await readFile(SETTINGS_FILE, "utf8")));
|
|
845
|
-
} catch (error) {
|
|
846
|
-
if (error instanceof SyntaxError) throw new Error(`${SETTINGS_FILE} is not valid JSON.`);
|
|
847
|
-
if (error instanceof Error) throw new Error(`${SETTINGS_FILE}: ${error.message}`);
|
|
848
|
-
throw error;
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
// Exported so persistence stays testable without touching the filesystem. This only ever omits
|
|
853
|
-
// the `overrides` key itself when there are no overrides at all — it does NOT deduplicate or drop
|
|
854
|
-
// any individual override that happens to match the shared default; see withCapabilityOverride
|
|
855
|
-
// for why an explicit override is always kept until the user clears it back to "Default".
|
|
856
|
-
export function settingsPayload(settings: BroSettings): Record<string, unknown> {
|
|
857
|
-
const overrides: Record<string, unknown> = {};
|
|
858
|
-
for (const [capability, pair] of Object.entries(settings.overrides)) {
|
|
859
|
-
if (pair) overrides[capability] = { backend: pair.backend ?? "agy", model: pair.model, effort: pair.effort };
|
|
860
|
-
}
|
|
861
|
-
return {
|
|
862
|
-
version: 2,
|
|
863
|
-
default: { backend: settings.backend ?? "agy", model: settings.model, effort: settings.effort },
|
|
864
|
-
mode: settings.mode,
|
|
865
|
-
showTurns: settings.showTurns,
|
|
866
|
-
...(Object.keys(overrides).length ? { overrides } : {}),
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
async function writeSettings(settings: BroSettings): Promise<void> {
|
|
871
|
-
// ponytail: last writer wins across concurrent Pi processes; add locking only if that becomes a common workflow.
|
|
872
|
-
await writeFile(SETTINGS_FILE, `${JSON.stringify(settingsPayload(settings), null, 2)}\n`, "utf8");
|
|
873
|
-
}
|
|
874
145
|
|
|
875
146
|
export function formatAgyUsage(value: unknown): string {
|
|
876
147
|
if (!isRecord(value) || value.status !== "SUCCESS" || typeof value.response !== "string") {
|
|
@@ -972,25 +243,21 @@ function resolveCatalogSettings(
|
|
|
972
243
|
return { family: resolved.family, settings: { ...settings, ...resolved.pair } };
|
|
973
244
|
}
|
|
974
245
|
|
|
975
|
-
function
|
|
976
|
-
return family.efforts.includes("low") ? "low" : (family.efforts[0] ?? "default");
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
async function checkClaudeVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
246
|
+
async function checkCliVersion(pi: ExtensionAPI, binary: string, displayName: string, signal: AbortSignal): Promise<string> {
|
|
980
247
|
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
981
248
|
try {
|
|
982
|
-
const result = await pi.exec(
|
|
249
|
+
const result = await pi.exec(binary, ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
|
|
983
250
|
if (signal.aborted) throw new Error("Canceled.");
|
|
984
251
|
if (result.killed || result.code !== 0) {
|
|
985
252
|
const detail = result.stderr.trim() || result.stdout.trim();
|
|
986
253
|
throw new Error(
|
|
987
254
|
detail
|
|
988
|
-
?
|
|
989
|
-
:
|
|
255
|
+
? `${displayName} could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
256
|
+
: `${displayName} could not start. Make sure ${displayName} is installed and on PATH, then run \`/bro doctor\`.`,
|
|
990
257
|
);
|
|
991
258
|
}
|
|
992
259
|
const version = result.stdout.trim() || result.stderr.trim();
|
|
993
|
-
if (!version) throw new Error(
|
|
260
|
+
if (!version) throw new Error(`${displayName} returned no version information. Update ${displayName}, then run \`/bro doctor\` again.`);
|
|
994
261
|
return version;
|
|
995
262
|
} finally {
|
|
996
263
|
await rm(runDirectory, { recursive: true, force: true });
|
|
@@ -1017,48 +284,6 @@ async function checkClaudeAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<s
|
|
|
1017
284
|
}
|
|
1018
285
|
}
|
|
1019
286
|
|
|
1020
|
-
async function checkGrokVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
1021
|
-
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
1022
|
-
try {
|
|
1023
|
-
const result = await pi.exec("grok", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
|
|
1024
|
-
if (signal.aborted) throw new Error("Canceled.");
|
|
1025
|
-
if (result.killed || result.code !== 0) {
|
|
1026
|
-
const detail = result.stderr.trim() || result.stdout.trim();
|
|
1027
|
-
throw new Error(
|
|
1028
|
-
detail
|
|
1029
|
-
? `Grok could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
1030
|
-
: "Grok could not start. Make sure Grok is installed and on PATH, then run `/bro doctor`.",
|
|
1031
|
-
);
|
|
1032
|
-
}
|
|
1033
|
-
const version = result.stdout.trim() || result.stderr.trim();
|
|
1034
|
-
if (!version) throw new Error("Grok returned no version information. Update Grok, then run `/bro doctor` again.");
|
|
1035
|
-
return version;
|
|
1036
|
-
} finally {
|
|
1037
|
-
await rm(runDirectory, { recursive: true, force: true });
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
async function checkCodexVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
1042
|
-
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
1043
|
-
try {
|
|
1044
|
-
const result = await pi.exec("codex", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
|
|
1045
|
-
if (signal.aborted) throw new Error("Canceled.");
|
|
1046
|
-
if (result.killed || result.code !== 0) {
|
|
1047
|
-
const detail = result.stderr.trim() || result.stdout.trim();
|
|
1048
|
-
throw new Error(
|
|
1049
|
-
detail
|
|
1050
|
-
? `Codex could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
1051
|
-
: "Codex could not start. Make sure Codex is installed and on PATH, then run `/bro doctor`.",
|
|
1052
|
-
);
|
|
1053
|
-
}
|
|
1054
|
-
const version = result.stdout.trim() || result.stderr.trim();
|
|
1055
|
-
if (!version) throw new Error("Codex returned no version information. Update Codex, then run `/bro doctor` again.");
|
|
1056
|
-
return version;
|
|
1057
|
-
} finally {
|
|
1058
|
-
await rm(runDirectory, { recursive: true, force: true });
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
287
|
// Auth status only: a version/auth answer without a model request. A passing answer here
|
|
1063
288
|
// says the CLI starts and reports signed-in state -- it does not imply connectivity.
|
|
1064
289
|
async function checkCodexAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
@@ -1078,27 +303,6 @@ async function checkCodexAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<st
|
|
|
1078
303
|
}
|
|
1079
304
|
}
|
|
1080
305
|
|
|
1081
|
-
async function checkMuseVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
1082
|
-
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
1083
|
-
try {
|
|
1084
|
-
const result = await pi.exec("muse", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
|
|
1085
|
-
if (signal.aborted) throw new Error("Canceled.");
|
|
1086
|
-
if (result.killed || result.code !== 0) {
|
|
1087
|
-
const detail = result.stderr.trim() || result.stdout.trim();
|
|
1088
|
-
throw new Error(
|
|
1089
|
-
detail
|
|
1090
|
-
? `Muse could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
1091
|
-
: "Muse could not start. Make sure Muse is installed and on PATH, then run `/bro doctor`.",
|
|
1092
|
-
);
|
|
1093
|
-
}
|
|
1094
|
-
const version = result.stdout.trim() || result.stderr.trim();
|
|
1095
|
-
if (!version) throw new Error("Muse returned no version information. Update Muse, then run `/bro doctor` again.");
|
|
1096
|
-
return version;
|
|
1097
|
-
} finally {
|
|
1098
|
-
await rm(runDirectory, { recursive: true, force: true });
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
306
|
async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
1103
307
|
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
1104
308
|
try {
|
|
@@ -1119,7 +323,7 @@ async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<str
|
|
|
1119
323
|
}
|
|
1120
324
|
}
|
|
1121
325
|
|
|
1122
|
-
async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, signal: AbortSignal): Promise<string> {
|
|
326
|
+
export async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, signal: AbortSignal): Promise<string> {
|
|
1123
327
|
const lines: string[] = [];
|
|
1124
328
|
let failed = false;
|
|
1125
329
|
let settings: BroSettings | undefined;
|
|
@@ -1139,19 +343,22 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1139
343
|
}
|
|
1140
344
|
|
|
1141
345
|
try {
|
|
1142
|
-
const
|
|
1143
|
-
pass("
|
|
346
|
+
const preferences = await readPreferences();
|
|
347
|
+
pass("Preferences", preferences ? `${preferences.length.toLocaleString("en-US")} characters · used by explain, show, btw` : "none — /bro preferences to add some");
|
|
1144
348
|
} catch (error) {
|
|
1145
|
-
fail("
|
|
349
|
+
fail("Preferences", error);
|
|
1146
350
|
}
|
|
1147
351
|
|
|
1148
352
|
// Probe only the backends some feature actually selects: a Claude/Grok/Codex/Muse-only setup never
|
|
1149
353
|
// requires Agy to be installed, and vice versa.
|
|
1150
|
-
const
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const
|
|
1154
|
-
const
|
|
354
|
+
const backendInUse = (backend: BackendName) => settings
|
|
355
|
+
? CAPABILITIES.some(capability => capabilityBackend(settings, capability) === backend)
|
|
356
|
+
: backend === "agy";
|
|
357
|
+
const agyInUse = backendInUse("agy");
|
|
358
|
+
const claudeInUse = backendInUse("claude");
|
|
359
|
+
const grokInUse = backendInUse("grok");
|
|
360
|
+
const codexInUse = backendInUse("codex");
|
|
361
|
+
const museInUse = backendInUse("muse");
|
|
1155
362
|
|
|
1156
363
|
let agyStarted = false;
|
|
1157
364
|
if (agyInUse) {
|
|
@@ -1189,7 +396,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1189
396
|
// starts and reports signed-in state -- it does not imply connectivity.
|
|
1190
397
|
if (claudeInUse) {
|
|
1191
398
|
try {
|
|
1192
|
-
pass("Claude", await
|
|
399
|
+
pass("Claude", await checkCliVersion(pi, "claude", "Claude", signal));
|
|
1193
400
|
} catch (error) {
|
|
1194
401
|
if (signal.aborted) throw error;
|
|
1195
402
|
fail("Claude", error);
|
|
@@ -1208,7 +415,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1208
415
|
// version says the CLI starts — it says nothing about auth or connectivity.
|
|
1209
416
|
if (grokInUse) {
|
|
1210
417
|
try {
|
|
1211
|
-
pass("Grok", await
|
|
418
|
+
pass("Grok", await checkCliVersion(pi, "grok", "Grok", signal));
|
|
1212
419
|
} catch (error) {
|
|
1213
420
|
if (signal.aborted) throw error;
|
|
1214
421
|
fail("Grok", error);
|
|
@@ -1221,7 +428,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1221
428
|
// Version and auth status only, never a model request.
|
|
1222
429
|
if (codexInUse) {
|
|
1223
430
|
try {
|
|
1224
|
-
pass("Codex", await
|
|
431
|
+
pass("Codex", await checkCliVersion(pi, "codex", "Codex", signal));
|
|
1225
432
|
} catch (error) {
|
|
1226
433
|
if (signal.aborted) throw error;
|
|
1227
434
|
fail("Codex", error);
|
|
@@ -1240,7 +447,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1240
447
|
// version says the CLI starts — it says nothing about auth or connectivity.
|
|
1241
448
|
if (museInUse) {
|
|
1242
449
|
try {
|
|
1243
|
-
pass("Muse", await
|
|
450
|
+
pass("Muse", await checkCliVersion(pi, "muse", "Muse", signal));
|
|
1244
451
|
} catch (error) {
|
|
1245
452
|
if (signal.aborted) throw error;
|
|
1246
453
|
fail("Muse", error);
|
|
@@ -1310,6 +517,8 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1310
517
|
const override = capabilityOverride(settings, capability);
|
|
1311
518
|
const backend = capabilityBackend(settings, capability);
|
|
1312
519
|
const pair = capabilityPair(settings, capability);
|
|
520
|
+
const reviewError = capability === "review" ? reviewBackendError(backend) : undefined;
|
|
521
|
+
if (reviewError) { lines.push(`- ℹ **${label}:** needs a Claude or Muse override in \`/bro config\` for model calls; saved reviews remain readable.`); continue; }
|
|
1313
522
|
if (backend === "claude") {
|
|
1314
523
|
if (!isClaudeEffort(pair.effort)) {
|
|
1315
524
|
fail(label, `\`${pair.effort}\` is unsupported for claude \`${pair.model}\`. Run \`/bro config\` to fix this.`);
|
|
@@ -1391,7 +600,14 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1391
600
|
const advisorActive = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
|
|
1392
601
|
if (advisorExposed && !advisorActive) fail("Advisor tool", "bro_advisor is exposed but not active in this session. Run /reload.");
|
|
1393
602
|
else pass("Advisor tool", advisorExposed ? "bro_advisor is exposed and active" : "bro_advisor is not exposed by this host (tool restriction, or the extension has not finished loading)");
|
|
1394
|
-
|
|
603
|
+
const sessionSteering = resolveAdvisorState(ctx.sessionManager.getBranch()).steering.trim();
|
|
604
|
+
pass("Advisor steering (session)", sessionSteering ? `${sessionSteering.length.toLocaleString("en-US")} characters · /bro advisor-steer` : "none — /bro advisor-steer to set for this session");
|
|
605
|
+
try {
|
|
606
|
+
const durable = await readAdvisorSteering();
|
|
607
|
+
pass("Advisor steering (durable)", durable ? `${durable.length.toLocaleString("en-US")} characters · ${ADVISOR_STEERING_FILE}` : `none — ${ADVISOR_STEERING_FILE}`);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
fail("Advisor steering (durable)", error);
|
|
610
|
+
}
|
|
1395
611
|
if (settings && capabilityBackend(settings, "advisor") === "claude") {
|
|
1396
612
|
pass("Advisor compatibility", "Claude backend — no Agy version floor applies");
|
|
1397
613
|
} else if (settings && capabilityBackend(settings, "advisor") === "grok") {
|
|
@@ -1596,20 +812,69 @@ function openShowHtml(path: string): boolean {
|
|
|
1596
812
|
return result.status === 0;
|
|
1597
813
|
}
|
|
1598
814
|
|
|
1599
|
-
|
|
1600
|
-
|
|
815
|
+
// Raw file text for the editor, undefined when there is no file: never rejects for length, so an
|
|
816
|
+
// oversize file can be opened and trimmed.
|
|
817
|
+
export async function readPreferencesRaw(): Promise<string | undefined> {
|
|
818
|
+
let size: number;
|
|
1601
819
|
try {
|
|
1602
|
-
|
|
820
|
+
size = (await stat(PREFERENCES_FILE)).size;
|
|
1603
821
|
} catch (error) {
|
|
1604
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
822
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
823
|
+
throw new Error(`Cannot read ${PREFERENCES_FILE}: ${errorMessage(error)}`);
|
|
824
|
+
}
|
|
825
|
+
if (size > MAX_PREFERENCES_FILE_BYTES) throw new Error(`${PREFERENCES_FILE} is larger than 64 KB. Edit or delete it directly.`);
|
|
826
|
+
try {
|
|
827
|
+
return (await readFile(PREFERENCES_FILE, "utf8")).replace(/^\uFEFF/, "");
|
|
828
|
+
} catch (error) {
|
|
829
|
+
throw new Error(`Cannot read ${PREFERENCES_FILE}: ${errorMessage(error)}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Preferences for prompts: "" when absent or blank. Re-read on every request. An oversize file stops
|
|
834
|
+
// the request before any backend call instead of being silently truncated.
|
|
835
|
+
export async function readPreferences(): Promise<string> {
|
|
836
|
+
const text = ((await readPreferencesRaw()) ?? "").trim();
|
|
837
|
+
if (text.length > MAX_PREFERENCES_CHARS) {
|
|
838
|
+
throw new Error(`${PREFERENCES_FILE} is ${text.length.toLocaleString("en-US")} characters; keep it under ${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} (it's sent with every request). Use /bro preferences to trim it.`);
|
|
839
|
+
}
|
|
840
|
+
return text;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Raw durable advisor steering text, undefined when there is no file. Rejects non-regular files and
|
|
844
|
+
// files > 64 KB before read.
|
|
845
|
+
export async function readAdvisorSteeringRaw(): Promise<string | undefined> {
|
|
846
|
+
let stats: Awaited<ReturnType<typeof stat>>;
|
|
847
|
+
try {
|
|
848
|
+
stats = await stat(ADVISOR_STEERING_FILE);
|
|
849
|
+
} catch (error) {
|
|
850
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
851
|
+
throw new Error(`Cannot read ${ADVISOR_STEERING_FILE}: ${errorMessage(error)}`);
|
|
852
|
+
}
|
|
853
|
+
if (!stats.isFile()) {
|
|
854
|
+
throw new Error(`${ADVISOR_STEERING_FILE} is not a regular file. Edit or delete it directly.`);
|
|
855
|
+
}
|
|
856
|
+
if (stats.size > MAX_ADVISOR_STEERING_FILE_BYTES) {
|
|
857
|
+
throw new Error(`${ADVISOR_STEERING_FILE} is larger than 64 KB. Edit or delete it directly.`);
|
|
1608
858
|
}
|
|
859
|
+
try {
|
|
860
|
+
const bytes = await readFile(ADVISOR_STEERING_FILE);
|
|
861
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/^\uFEFF/, "");
|
|
862
|
+
} catch (error) {
|
|
863
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
864
|
+
throw new Error(`Cannot read ${ADVISOR_STEERING_FILE}: ${errorMessage(error)}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
1609
867
|
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
868
|
+
// Durable steering for the advisor tool: "" when absent or blank. Re-read on every consultation.
|
|
869
|
+
// An oversize or unreadable file throws an actionable message for the executor agent before spawn.
|
|
870
|
+
export async function readAdvisorSteering(): Promise<string> {
|
|
871
|
+
const text = ((await readAdvisorSteeringRaw()) ?? "").trim();
|
|
872
|
+
if (text.length > MAX_ADVISOR_STEERING_CHARS) {
|
|
873
|
+
throw new Error(
|
|
874
|
+
`Bro advisor steering file ${ADVISOR_STEERING_FILE} is ${text.length.toLocaleString("en-US")} characters (keep under ${MAX_ADVISOR_STEERING_CHARS.toLocaleString("en-US")}). Tell the human to edit or delete it; retrying won't help.`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
return text;
|
|
1613
878
|
}
|
|
1614
879
|
|
|
1615
880
|
async function simplify(
|
|
@@ -1618,10 +883,11 @@ async function simplify(
|
|
|
1618
883
|
settings: BroSettings,
|
|
1619
884
|
onProgress?: (text: string) => void,
|
|
1620
885
|
mode = settings.mode,
|
|
1621
|
-
): Promise<{ text: string; model: string; mode
|
|
886
|
+
): Promise<{ text: string; model: string; mode: BroMode; preferences: boolean }> {
|
|
1622
887
|
const selection = selectionForCapability(settings, "explain");
|
|
1623
|
-
const
|
|
1624
|
-
|
|
888
|
+
const preferences = await readPreferences();
|
|
889
|
+
const text = await runBackendText(buildDefaultPrompt(response, mode, preferences), selection, signal, onProgress);
|
|
890
|
+
return { text, model: selectionLabel(selection), mode, preferences: Boolean(preferences) };
|
|
1625
891
|
}
|
|
1626
892
|
|
|
1627
893
|
async function runShowExplanation(
|
|
@@ -1630,15 +896,17 @@ async function runShowExplanation(
|
|
|
1630
896
|
signal: AbortSignal,
|
|
1631
897
|
settings: BroSettings,
|
|
1632
898
|
onProgress?: (text: string) => void,
|
|
1633
|
-
): Promise<{ text: string; model: string }> {
|
|
899
|
+
): Promise<{ text: string; model: string; preferences: boolean }> {
|
|
1634
900
|
const selection = selectionForCapability(settings, "show");
|
|
1635
|
-
|
|
901
|
+
const preferences = await readPreferences();
|
|
902
|
+
const text = await runBackendText(buildShowPrompt(transcript, steering, preferences), selection, signal, onProgress, "show");
|
|
903
|
+
return { text, model: selectionLabel(selection), preferences: Boolean(preferences) };
|
|
1636
904
|
}
|
|
1637
905
|
|
|
1638
906
|
// Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
|
|
1639
|
-
// existing 75ms cadence
|
|
1640
|
-
//
|
|
1641
|
-
async function
|
|
907
|
+
// existing 75ms cadence and translates the backend's tagged outcome back into this function's
|
|
908
|
+
// existing throw-on-failure contract.
|
|
909
|
+
async function runBackendText(
|
|
1642
910
|
prompt: string,
|
|
1643
911
|
selection: BackendSelection,
|
|
1644
912
|
signal: AbortSignal,
|
|
@@ -1889,10 +1157,11 @@ export type AdvisorToolDetails = {
|
|
|
1889
1157
|
durationMs?: number;
|
|
1890
1158
|
cwd?: string;
|
|
1891
1159
|
steeringIncluded?: boolean;
|
|
1160
|
+
steeringSources?: { durable: boolean; session: boolean };
|
|
1892
1161
|
snapshotChars?: number;
|
|
1893
1162
|
broTruncated?: false;
|
|
1894
1163
|
omissions?: string;
|
|
1895
|
-
// Real
|
|
1164
|
+
// Real backend-reported activity for the current attempt only -- reset to empty whenever a new
|
|
1896
1165
|
// attempt (including a retry) starts, never carried over from a prior failed attempt.
|
|
1897
1166
|
activity?: string[];
|
|
1898
1167
|
lastActivityAt?: number;
|
|
@@ -1910,9 +1179,9 @@ export type AdvisorRunResult = { advice: string; attempts: number; durationMs: n
|
|
|
1910
1179
|
// keeps ticking independently.
|
|
1911
1180
|
const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
|
|
1912
1181
|
|
|
1913
|
-
// `consult`/`delayFn` are injectable so tests can swap in a fake
|
|
1182
|
+
// `consult`/`delayFn` are injectable so tests can swap in a fake process spawn and a fake clock
|
|
1914
1183
|
// instead of spawning real processes and waiting 15 real seconds. Every attempt sends the
|
|
1915
|
-
// identical prompt/selection/cwd to a fresh, standalone
|
|
1184
|
+
// identical prompt/selection/cwd to a fresh, standalone backend process — never resumed via
|
|
1916
1185
|
// --conversation, even across retries.
|
|
1917
1186
|
export async function runAdvisorWithRetries(
|
|
1918
1187
|
prompt: string,
|
|
@@ -2033,475 +1302,6 @@ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
|
|
|
2033
1302
|
return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · attempt ${details.attempt}/${details.of} · ${activityLabel}`;
|
|
2034
1303
|
}
|
|
2035
1304
|
|
|
2036
|
-
const SHOW_TURNS_PRESETS = [1, 2, 3, 5, 8];
|
|
2037
|
-
|
|
2038
|
-
function showTurnsValues(current: number): string[] {
|
|
2039
|
-
return [...new Set([...SHOW_TURNS_PRESETS, current])].sort((a, b) => a - b).map(String);
|
|
2040
|
-
}
|
|
2041
|
-
|
|
2042
|
-
// A resolved pair's display string: distinguishes a model that is genuinely fixed-effort from
|
|
2043
|
-
// one that simply isn't in the current catalog (both used to render as "fixed", which read as
|
|
2044
|
-
// falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
|
|
2045
|
-
// resolved family's supported efforts instead of silently showing it as if it were valid.
|
|
2046
|
-
function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamily }, backend: BackendName): string {
|
|
2047
|
-
// Claude/Grok/Codex/Muse selections never touch the Agy catalog: the stored effort is valid exactly
|
|
2048
|
-
// when it is one of that backend's levels (or "default" for the CLI's own default).
|
|
2049
|
-
if (backend === "claude") return isClaudeEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
|
|
2050
|
-
if (backend === "grok") return isGrokEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
|
|
2051
|
-
if (backend === "codex") return isCodexEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
|
|
2052
|
-
if (backend === "muse") return isMuseEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
|
|
2053
|
-
if (!resolved.family) return "unavailable";
|
|
2054
|
-
const fixed = !resolved.family.efforts.length;
|
|
2055
|
-
const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
|
|
2056
|
-
if (!valid) return `${resolved.pair.effort} (unsupported)`;
|
|
2057
|
-
return fixed ? "fixed" : resolved.pair.effort;
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
// Model-picker values that switch backend carry a "claude:"/"grok:"/"codex:"/"muse:" prefix so one
|
|
2061
|
-
// atomic picker commit changes backend+model together -- cancelling the picker (Esc)
|
|
2062
|
-
// leaves both unchanged via the existing submenu-cancel path.
|
|
2063
|
-
const CLAUDE_OPTION_PREFIX = "claude:";
|
|
2064
|
-
function parseClaudeOption(value: string): string | undefined {
|
|
2065
|
-
return value.startsWith(CLAUDE_OPTION_PREFIX) && value.length > CLAUDE_OPTION_PREFIX.length
|
|
2066
|
-
? value.slice(CLAUDE_OPTION_PREFIX.length)
|
|
2067
|
-
: undefined;
|
|
2068
|
-
}
|
|
2069
|
-
const GROK_OPTION_PREFIX = "grok:";
|
|
2070
|
-
function parseGrokOption(value: string): string | undefined {
|
|
2071
|
-
return value.startsWith(GROK_OPTION_PREFIX) && value.length > GROK_OPTION_PREFIX.length
|
|
2072
|
-
? value.slice(GROK_OPTION_PREFIX.length)
|
|
2073
|
-
: undefined;
|
|
2074
|
-
}
|
|
2075
|
-
const CODEX_OPTION_PREFIX = "codex:";
|
|
2076
|
-
function parseCodexOption(value: string): string | undefined {
|
|
2077
|
-
return value.startsWith(CODEX_OPTION_PREFIX) && value.length > CODEX_OPTION_PREFIX.length
|
|
2078
|
-
? value.slice(CODEX_OPTION_PREFIX.length)
|
|
2079
|
-
: undefined;
|
|
2080
|
-
}
|
|
2081
|
-
const MUSE_OPTION_PREFIX = "muse:";
|
|
2082
|
-
function parseMuseOption(value: string): string | undefined {
|
|
2083
|
-
return value.startsWith(MUSE_OPTION_PREFIX) && value.length > MUSE_OPTION_PREFIX.length
|
|
2084
|
-
? value.slice(MUSE_OPTION_PREFIX.length)
|
|
2085
|
-
: undefined;
|
|
2086
|
-
}
|
|
2087
|
-
function parseBackendOption(value: string): { backend: "claude" | "grok" | "codex" | "muse"; id: string } | undefined {
|
|
2088
|
-
const claudeId = parseClaudeOption(value);
|
|
2089
|
-
if (claudeId !== undefined) return { backend: "claude", id: claudeId };
|
|
2090
|
-
const grokId = parseGrokOption(value);
|
|
2091
|
-
if (grokId !== undefined) return { backend: "grok", id: grokId };
|
|
2092
|
-
const codexId = parseCodexOption(value);
|
|
2093
|
-
if (codexId !== undefined) return { backend: "codex", id: codexId };
|
|
2094
|
-
const museId = parseMuseOption(value);
|
|
2095
|
-
if (museId !== undefined) return { backend: "muse", id: museId };
|
|
2096
|
-
return undefined;
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
// Testable core: takes settings/catalog/persist as plain arguments so smoke tests can drive
|
|
2100
|
-
// the exact interaction (submenus, cancel, cycling, save failure) without a real Agy process
|
|
2101
|
-
// or settings file. showBroConfigModal below wires this to the real ctx/pi/filesystem.
|
|
2102
|
-
export function createConfigModal(
|
|
2103
|
-
initialSettings: BroSettings,
|
|
2104
|
-
families: AgyModelFamily[],
|
|
2105
|
-
persistSettings: (settings: BroSettings) => Promise<void>,
|
|
2106
|
-
): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
2107
|
-
return (tui, theme, _keybindings, done) => {
|
|
2108
|
-
let settings = initialSettings;
|
|
2109
|
-
// The last settings actually confirmed on disk. A failed save reverts `settings` (and the
|
|
2110
|
-
// whole displayed row set) back to this, so the screen never shows state that doesn't exist.
|
|
2111
|
-
let savedSettings = initialSettings;
|
|
2112
|
-
// Only one persistSettings call is ever in flight. A change that arrives while one is
|
|
2113
|
-
// already running is coalesced into `queued` (overwriting any earlier queued change) rather
|
|
2114
|
-
// than firing a second concurrent write — this is what keeps writes serialized and makes
|
|
2115
|
-
// sure the on-disk file always converges on the latest intent instead of a stale one that
|
|
2116
|
-
// happened to finish last.
|
|
2117
|
-
let saving = false;
|
|
2118
|
-
let queued: BroSettings | undefined;
|
|
2119
|
-
// Esc while a save is in flight must not close past an unshown result: it requests a close
|
|
2120
|
-
// that only actually happens once the in-flight (and any coalesced) save has settled, and
|
|
2121
|
-
// only if it succeeded — a failure cancels the pending close so its notice stays visible.
|
|
2122
|
-
let closeRequested = false;
|
|
2123
|
-
|
|
2124
|
-
const findFamily = (modelId: string) =>
|
|
2125
|
-
families.find((item) => item.id === modelId || item.variants.some((variant) => variant.id === modelId));
|
|
2126
|
-
|
|
2127
|
-
const modelPicker = (current: string, pickerDone: (value?: string) => void, capability?: Capability) => {
|
|
2128
|
-
const defaultResolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
|
|
2129
|
-
const options: SelectItem[] = [
|
|
2130
|
-
...(capability ? [{ value: "__default__", label: `Default (${defaultResolved.family?.label ?? settings.model})` }] : []),
|
|
2131
|
-
...families.map((family) => ({
|
|
2132
|
-
value: family.id,
|
|
2133
|
-
label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
|
|
2134
|
-
})),
|
|
2135
|
-
// Alternative CLI entries stay last so existing Agy keyboard navigation is unaffected.
|
|
2136
|
-
...CLAUDE_MODELS.map((model) => ({
|
|
2137
|
-
value: `${CLAUDE_OPTION_PREFIX}${model.id}`,
|
|
2138
|
-
label: `${model.label} · claude`,
|
|
2139
|
-
})),
|
|
2140
|
-
...GROK_MODELS.map((model) => ({
|
|
2141
|
-
value: `${GROK_OPTION_PREFIX}${model.id}`,
|
|
2142
|
-
label: `${model.label} · grok`,
|
|
2143
|
-
})),
|
|
2144
|
-
...CODEX_MODELS.map((model) => ({
|
|
2145
|
-
value: `${CODEX_OPTION_PREFIX}${model.id}`,
|
|
2146
|
-
label: `${model.label} · codex`,
|
|
2147
|
-
})),
|
|
2148
|
-
...MUSE_MODELS.map((model) => ({
|
|
2149
|
-
value: `${MUSE_OPTION_PREFIX}${model.id}`,
|
|
2150
|
-
label: `${model.label} · muse`,
|
|
2151
|
-
})),
|
|
2152
|
-
];
|
|
2153
|
-
options.push({ value: "__claude_custom__", label: "Claude · custom model ID…" });
|
|
2154
|
-
options.push({ value: "__grok_custom__", label: "Grok · custom model ID…" });
|
|
2155
|
-
options.push({ value: "__codex_custom__", label: "Codex · custom model ID…" });
|
|
2156
|
-
options.push({ value: "__muse_custom__", label: "Muse · custom model ID…" });
|
|
2157
|
-
const input = new Input();
|
|
2158
|
-
let enteringBackend: "claude" | "grok" | "codex" | "muse" | undefined;
|
|
2159
|
-
input.onSubmit = (value) => {
|
|
2160
|
-
if (value.trim() && enteringBackend) {
|
|
2161
|
-
const prefix =
|
|
2162
|
-
enteringBackend === "claude"
|
|
2163
|
-
? CLAUDE_OPTION_PREFIX
|
|
2164
|
-
: enteringBackend === "grok"
|
|
2165
|
-
? GROK_OPTION_PREFIX
|
|
2166
|
-
: enteringBackend === "codex"
|
|
2167
|
-
? CODEX_OPTION_PREFIX
|
|
2168
|
-
: MUSE_OPTION_PREFIX;
|
|
2169
|
-
pickerDone(`${prefix}${value.trim()}`);
|
|
2170
|
-
}
|
|
2171
|
-
};
|
|
2172
|
-
const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
|
|
2173
|
-
const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
|
|
2174
|
-
picker.setSelectedIndex(Math.max(0, selectedIndex));
|
|
2175
|
-
picker.onSelect = (item) => {
|
|
2176
|
-
if (item.value === "__claude_custom__") { enteringBackend = "claude"; tui.requestRender(); }
|
|
2177
|
-
else if (item.value === "__grok_custom__") { enteringBackend = "grok"; tui.requestRender(); }
|
|
2178
|
-
else if (item.value === "__codex_custom__") { enteringBackend = "codex"; tui.requestRender(); }
|
|
2179
|
-
else if (item.value === "__muse_custom__") { enteringBackend = "muse"; tui.requestRender(); }
|
|
2180
|
-
else pickerDone(item.value);
|
|
2181
|
-
};
|
|
2182
|
-
picker.onCancel = () => pickerDone();
|
|
2183
|
-
const customLabel = () => enteringBackend === "claude" ? "Claude" : enteringBackend === "grok" ? "Grok" : enteringBackend === "codex" ? "Codex" : "Muse";
|
|
2184
|
-
return {
|
|
2185
|
-
render: (width: number) => enteringBackend ? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
|
|
2186
|
-
invalidate: () => { picker.invalidate(); input.invalidate(); },
|
|
2187
|
-
handleInput: (data: string) => {
|
|
2188
|
-
if (enteringBackend && matchesKey(data, "escape")) pickerDone();
|
|
2189
|
-
else if (enteringBackend) input.handleInput(data);
|
|
2190
|
-
else picker.handleInput(data);
|
|
2191
|
-
},
|
|
2192
|
-
};
|
|
2193
|
-
};
|
|
2194
|
-
|
|
2195
|
-
const modelItem: SettingItem = { id: "model", label: "Default model", currentValue: settings.model, submenu: modelPicker };
|
|
2196
|
-
const effortItem: SettingItem = { id: "effort", label: "Default effort", currentValue: "" };
|
|
2197
|
-
const modeItem: SettingItem = { id: "mode", label: "Explain mode", currentValue: settings.mode, values: [...BRO_MODES] };
|
|
2198
|
-
const showTurnsItem: SettingItem = {
|
|
2199
|
-
id: "showTurns",
|
|
2200
|
-
label: "Show turns",
|
|
2201
|
-
currentValue: String(settings.showTurns),
|
|
2202
|
-
values: showTurnsValues(settings.showTurns),
|
|
2203
|
-
};
|
|
2204
|
-
const capabilityItems = Object.fromEntries(
|
|
2205
|
-
CAPABILITIES.map((capability) => [
|
|
2206
|
-
capability,
|
|
2207
|
-
{
|
|
2208
|
-
model: {
|
|
2209
|
-
id: `${capability}Model`,
|
|
2210
|
-
label: `${CAPABILITY_LABELS[capability]} model`,
|
|
2211
|
-
currentValue: "Default",
|
|
2212
|
-
submenu: (current: string, pickerDone: (value?: string) => void) => modelPicker(current, pickerDone, capability),
|
|
2213
|
-
} as SettingItem,
|
|
2214
|
-
effort: {
|
|
2215
|
-
id: `${capability}Effort`,
|
|
2216
|
-
label: `${CAPABILITY_LABELS[capability]} effort`,
|
|
2217
|
-
currentValue: "",
|
|
2218
|
-
} as SettingItem,
|
|
2219
|
-
},
|
|
2220
|
-
]),
|
|
2221
|
-
) as Record<Capability, { model: SettingItem; effort: SettingItem }>;
|
|
2222
|
-
|
|
2223
|
-
function refresh(): void {
|
|
2224
|
-
const defaultBackend = settings.backend ?? "agy";
|
|
2225
|
-
const def = resolveModelEffort({ backend: settings.backend, model: settings.model, effort: settings.effort }, families);
|
|
2226
|
-
modelItem.currentValue =
|
|
2227
|
-
defaultBackend === "claude"
|
|
2228
|
-
? `${CLAUDE_OPTION_PREFIX}${settings.model}`
|
|
2229
|
-
: defaultBackend === "grok"
|
|
2230
|
-
? `${GROK_OPTION_PREFIX}${settings.model}`
|
|
2231
|
-
: defaultBackend === "codex"
|
|
2232
|
-
? `${CODEX_OPTION_PREFIX}${settings.model}`
|
|
2233
|
-
: defaultBackend === "muse"
|
|
2234
|
-
? `${MUSE_OPTION_PREFIX}${settings.model}`
|
|
2235
|
-
: (def.family?.id ?? settings.model);
|
|
2236
|
-
effortItem.currentValue = effortDisplay(def, defaultBackend);
|
|
2237
|
-
effortItem.values =
|
|
2238
|
-
defaultBackend === "claude"
|
|
2239
|
-
? ["default", ...CLAUDE_EFFORTS]
|
|
2240
|
-
: defaultBackend === "grok"
|
|
2241
|
-
? ["default", ...GROK_EFFORTS]
|
|
2242
|
-
: defaultBackend === "codex"
|
|
2243
|
-
? ["default", ...CODEX_EFFORTS]
|
|
2244
|
-
: defaultBackend === "muse"
|
|
2245
|
-
? ["default", ...MUSE_EFFORTS]
|
|
2246
|
-
: def.family?.efforts.length
|
|
2247
|
-
? [...def.family.efforts]
|
|
2248
|
-
: undefined;
|
|
2249
|
-
modeItem.currentValue = settings.mode;
|
|
2250
|
-
showTurnsItem.currentValue = String(settings.showTurns);
|
|
2251
|
-
showTurnsItem.values = showTurnsValues(settings.showTurns);
|
|
2252
|
-
|
|
2253
|
-
for (const capability of CAPABILITIES) {
|
|
2254
|
-
const override = capabilityOverride(settings, capability);
|
|
2255
|
-
const backend = capabilityBackend(settings, capability);
|
|
2256
|
-
const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
|
|
2257
|
-
const rows = capabilityItems[capability];
|
|
2258
|
-
rows.model.currentValue = !override
|
|
2259
|
-
? "Default"
|
|
2260
|
-
: backend === "claude"
|
|
2261
|
-
? `${CLAUDE_OPTION_PREFIX}${override.model}`
|
|
2262
|
-
: backend === "grok"
|
|
2263
|
-
? `${GROK_OPTION_PREFIX}${override.model}`
|
|
2264
|
-
: backend === "codex"
|
|
2265
|
-
? `${CODEX_OPTION_PREFIX}${override.model}`
|
|
2266
|
-
: backend === "muse"
|
|
2267
|
-
? `${MUSE_OPTION_PREFIX}${override.model}`
|
|
2268
|
-
: (resolved.family?.id ?? override.model);
|
|
2269
|
-
rows.effort.currentValue = effortDisplay(resolved, backend);
|
|
2270
|
-
rows.effort.values =
|
|
2271
|
-
backend === "claude"
|
|
2272
|
-
? ["default", ...CLAUDE_EFFORTS]
|
|
2273
|
-
: backend === "grok"
|
|
2274
|
-
? ["default", ...GROK_EFFORTS]
|
|
2275
|
-
: backend === "codex"
|
|
2276
|
-
? ["default", ...CODEX_EFFORTS]
|
|
2277
|
-
: backend === "muse"
|
|
2278
|
-
? ["default", ...MUSE_EFFORTS]
|
|
2279
|
-
: resolved.family?.efforts.length
|
|
2280
|
-
? [...resolved.family.efforts]
|
|
2281
|
-
: undefined;
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
refresh();
|
|
2285
|
-
|
|
2286
|
-
const items: SettingItem[] = [
|
|
2287
|
-
modelItem,
|
|
2288
|
-
effortItem,
|
|
2289
|
-
modeItem,
|
|
2290
|
-
showTurnsItem,
|
|
2291
|
-
...CAPABILITIES.flatMap((capability) => [capabilityItems[capability].model, capabilityItems[capability].effort]),
|
|
2292
|
-
];
|
|
2293
|
-
|
|
2294
|
-
const noticeText = new Text("");
|
|
2295
|
-
|
|
2296
|
-
function runSave(toSave: BroSettings): void {
|
|
2297
|
-
saving = true;
|
|
2298
|
-
void persistSettings(toSave)
|
|
2299
|
-
.then(() => {
|
|
2300
|
-
savedSettings = toSave;
|
|
2301
|
-
noticeText.setText("");
|
|
2302
|
-
})
|
|
2303
|
-
.catch((error: unknown) => {
|
|
2304
|
-
// Restore the last state that is actually on disk: showing the failed, unsaved
|
|
2305
|
-
// value would let the screen claim a setting that doesn't really exist.
|
|
2306
|
-
settings = savedSettings;
|
|
2307
|
-
queued = undefined;
|
|
2308
|
-
closeRequested = false;
|
|
2309
|
-
refresh();
|
|
2310
|
-
noticeText.setText(theme.fg("warning", `Could not save settings: ${errorMessage(error)}. Reverted to the last saved settings.`));
|
|
2311
|
-
})
|
|
2312
|
-
.finally(() => {
|
|
2313
|
-
saving = false;
|
|
2314
|
-
tui.requestRender();
|
|
2315
|
-
if (queued !== undefined) {
|
|
2316
|
-
const next = queued;
|
|
2317
|
-
queued = undefined;
|
|
2318
|
-
runSave(next);
|
|
2319
|
-
} else if (closeRequested) {
|
|
2320
|
-
closeRequested = false;
|
|
2321
|
-
done(undefined);
|
|
2322
|
-
}
|
|
2323
|
-
});
|
|
2324
|
-
}
|
|
2325
|
-
|
|
2326
|
-
function persist(toSave: BroSettings): void {
|
|
2327
|
-
if (saving) {
|
|
2328
|
-
queued = toSave;
|
|
2329
|
-
return;
|
|
2330
|
-
}
|
|
2331
|
-
runSave(toSave);
|
|
2332
|
-
}
|
|
2333
|
-
|
|
2334
|
-
const onChange = (id: string, newValue: string) => {
|
|
2335
|
-
if (id === "model") {
|
|
2336
|
-
const switched = parseBackendOption(newValue);
|
|
2337
|
-
if (switched !== undefined) {
|
|
2338
|
-
if (switched.backend === "claude") {
|
|
2339
|
-
const effort = settings.backend === "claude" && isClaudeEffort(settings.effort) ? settings.effort : "default";
|
|
2340
|
-
settings = { ...settings, backend: "claude", model: resolveClaudeModel(switched.id), effort };
|
|
2341
|
-
} else if (switched.backend === "grok") {
|
|
2342
|
-
const effort = settings.backend === "grok" && isGrokEffort(settings.effort) ? settings.effort : "default";
|
|
2343
|
-
settings = { ...settings, backend: "grok", model: resolveGrokModel(switched.id), effort };
|
|
2344
|
-
} else if (switched.backend === "codex") {
|
|
2345
|
-
const effort = settings.backend === "codex" && isCodexEffort(settings.effort) ? settings.effort : "default";
|
|
2346
|
-
settings = { ...settings, backend: "codex", model: resolveCodexModel(switched.id), effort };
|
|
2347
|
-
} else {
|
|
2348
|
-
const effort = settings.backend === "muse" && isMuseEffort(settings.effort) ? settings.effort : "default";
|
|
2349
|
-
settings = { ...settings, backend: "muse", model: resolveMuseModel(switched.id), effort };
|
|
2350
|
-
}
|
|
2351
|
-
} else {
|
|
2352
|
-
const family = findFamily(newValue);
|
|
2353
|
-
if (!family) return;
|
|
2354
|
-
const keepCurrent = (settings.backend ?? "agy") === "agy" && (settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort));
|
|
2355
|
-
// Backend-less internal pairs mean Agy; disk saves still tag them explicitly.
|
|
2356
|
-
const { backend: _dropped, ...rest } = settings;
|
|
2357
|
-
settings = { ...rest, model: family.id, effort: keepCurrent ? settings.effort : preferredEffort(family) };
|
|
2358
|
-
}
|
|
2359
|
-
} else if (id === "effort") {
|
|
2360
|
-
if ((settings.backend ?? "agy") === "claude") {
|
|
2361
|
-
if (!isClaudeEffort(newValue)) return;
|
|
2362
|
-
settings = { ...settings, effort: newValue };
|
|
2363
|
-
} else if ((settings.backend ?? "agy") === "grok") {
|
|
2364
|
-
if (!isGrokEffort(newValue)) return;
|
|
2365
|
-
settings = { ...settings, effort: newValue };
|
|
2366
|
-
} else if ((settings.backend ?? "agy") === "codex") {
|
|
2367
|
-
if (!isCodexEffort(newValue)) return;
|
|
2368
|
-
settings = { ...settings, effort: newValue };
|
|
2369
|
-
} else if ((settings.backend ?? "agy") === "muse") {
|
|
2370
|
-
if (!isMuseEffort(newValue)) return;
|
|
2371
|
-
settings = { ...settings, effort: newValue };
|
|
2372
|
-
} else {
|
|
2373
|
-
// Effort-only edit: pin the resolved family's canonical id, same reasoning as the
|
|
2374
|
-
// capability-override effort-only edit below -- otherwise a shared default created
|
|
2375
|
-
// from a suffixed variant id (e.g. "gemini-x-low") would end up paired with an
|
|
2376
|
-
// unrelated effort instead of its actual family id.
|
|
2377
|
-
const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
|
|
2378
|
-
settings = { ...settings, model: resolved.family?.id ?? settings.model, effort: newValue as BroEffort };
|
|
2379
|
-
}
|
|
2380
|
-
} else if (id === "mode") {
|
|
2381
|
-
const mode = parseBroMode(newValue);
|
|
2382
|
-
if (!mode) return;
|
|
2383
|
-
settings = { ...settings, mode };
|
|
2384
|
-
} else if (id === "showTurns") {
|
|
2385
|
-
const turns = Number(newValue);
|
|
2386
|
-
if (!Number.isInteger(turns) || turns < 1) return;
|
|
2387
|
-
settings = { ...settings, showTurns: turns };
|
|
2388
|
-
} else {
|
|
2389
|
-
const capability = CAPABILITIES.find((item) => id === `${item}Model` || id === `${item}Effort`);
|
|
2390
|
-
if (!capability) return;
|
|
2391
|
-
if (id === `${capability}Model`) {
|
|
2392
|
-
if (newValue === "__default__") {
|
|
2393
|
-
settings = withCapabilityOverride(settings, capability, undefined);
|
|
2394
|
-
} else {
|
|
2395
|
-
const switched = parseBackendOption(newValue);
|
|
2396
|
-
if (switched !== undefined) {
|
|
2397
|
-
const currentEffort = capabilityPair(settings, capability).effort;
|
|
2398
|
-
if (switched.backend === "claude") {
|
|
2399
|
-
settings = withCapabilityOverride(settings, capability, {
|
|
2400
|
-
backend: "claude",
|
|
2401
|
-
model: resolveClaudeModel(switched.id),
|
|
2402
|
-
effort: capabilityBackend(settings, capability) === "claude" && isClaudeEffort(currentEffort) ? currentEffort : "default",
|
|
2403
|
-
});
|
|
2404
|
-
} else if (switched.backend === "grok") {
|
|
2405
|
-
settings = withCapabilityOverride(settings, capability, {
|
|
2406
|
-
backend: "grok",
|
|
2407
|
-
model: resolveGrokModel(switched.id),
|
|
2408
|
-
effort: capabilityBackend(settings, capability) === "grok" && isGrokEffort(currentEffort) ? currentEffort : "default",
|
|
2409
|
-
});
|
|
2410
|
-
} else if (switched.backend === "codex") {
|
|
2411
|
-
settings = withCapabilityOverride(settings, capability, {
|
|
2412
|
-
backend: "codex",
|
|
2413
|
-
model: resolveCodexModel(switched.id),
|
|
2414
|
-
effort: capabilityBackend(settings, capability) === "codex" && isCodexEffort(currentEffort) ? currentEffort : "default",
|
|
2415
|
-
});
|
|
2416
|
-
} else {
|
|
2417
|
-
settings = withCapabilityOverride(settings, capability, {
|
|
2418
|
-
backend: "muse",
|
|
2419
|
-
model: resolveMuseModel(switched.id),
|
|
2420
|
-
effort: capabilityBackend(settings, capability) === "muse" && isMuseEffort(currentEffort) ? currentEffort : "default",
|
|
2421
|
-
});
|
|
2422
|
-
}
|
|
2423
|
-
} else {
|
|
2424
|
-
const family = findFamily(newValue);
|
|
2425
|
-
if (!family) return;
|
|
2426
|
-
const currentEffort = capabilityPair(settings, capability).effort;
|
|
2427
|
-
const keepCurrent = capabilityBackend(settings, capability) === "agy" && (currentEffort === "default" ? !family.efforts.length : family.efforts.includes(currentEffort as AgyEffort));
|
|
2428
|
-
settings = withCapabilityOverride(settings, capability, {
|
|
2429
|
-
model: family.id,
|
|
2430
|
-
effort: keepCurrent ? currentEffort : preferredEffort(family),
|
|
2431
|
-
});
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
} else if (capabilityBackend(settings, capability) === "claude") {
|
|
2435
|
-
if (!isClaudeEffort(newValue)) return;
|
|
2436
|
-
const existing = capabilityOverride(settings, capability);
|
|
2437
|
-
const model = existing?.model ?? settings.model;
|
|
2438
|
-
settings = withCapabilityOverride(settings, capability, { backend: "claude", model, effort: newValue });
|
|
2439
|
-
} else if (capabilityBackend(settings, capability) === "grok") {
|
|
2440
|
-
if (!isGrokEffort(newValue)) return;
|
|
2441
|
-
const existing = capabilityOverride(settings, capability);
|
|
2442
|
-
const model = existing?.model ?? settings.model;
|
|
2443
|
-
settings = withCapabilityOverride(settings, capability, { backend: "grok", model, effort: newValue });
|
|
2444
|
-
} else if (capabilityBackend(settings, capability) === "codex") {
|
|
2445
|
-
if (!isCodexEffort(newValue)) return;
|
|
2446
|
-
const existing = capabilityOverride(settings, capability);
|
|
2447
|
-
const model = existing?.model ?? settings.model;
|
|
2448
|
-
settings = withCapabilityOverride(settings, capability, { backend: "codex", model, effort: newValue });
|
|
2449
|
-
} else if (capabilityBackend(settings, capability) === "muse") {
|
|
2450
|
-
if (!isMuseEffort(newValue)) return;
|
|
2451
|
-
const existing = capabilityOverride(settings, capability);
|
|
2452
|
-
const model = existing?.model ?? settings.model;
|
|
2453
|
-
settings = withCapabilityOverride(settings, capability, { backend: "muse", model, effort: newValue });
|
|
2454
|
-
} else {
|
|
2455
|
-
// Effort-only edit: pin the resolved family's canonical id, never whatever raw
|
|
2456
|
-
// string happens to sit in settings.model/override.model (which — for a shared
|
|
2457
|
-
// default created from a suffixed variant id such as "gemini-x-low" with
|
|
2458
|
-
// effort "default" — is not the family id). Storing the raw string here would
|
|
2459
|
-
// pair a mismatched model/effort (e.g. a "-low"-suffixed id with effort "high").
|
|
2460
|
-
const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
|
|
2461
|
-
const existing = capabilityOverride(settings, capability);
|
|
2462
|
-
const model = resolved.family?.id ?? existing?.model ?? settings.model;
|
|
2463
|
-
settings = withCapabilityOverride(settings, capability, { model, effort: newValue as BroEffort });
|
|
2464
|
-
}
|
|
2465
|
-
}
|
|
2466
|
-
refresh();
|
|
2467
|
-
tui.requestRender();
|
|
2468
|
-
persist(settings);
|
|
2469
|
-
};
|
|
2470
|
-
|
|
2471
|
-
const requestClose = () => {
|
|
2472
|
-
if (saving) {
|
|
2473
|
-
closeRequested = true;
|
|
2474
|
-
return;
|
|
2475
|
-
}
|
|
2476
|
-
done(undefined);
|
|
2477
|
-
};
|
|
2478
|
-
|
|
2479
|
-
const settingsList = new SettingsList(items, Math.min(items.length + 2, 18), getSettingsListTheme(), onChange, requestClose);
|
|
2480
|
-
const container = new Container();
|
|
2481
|
-
container.addChild(new Text(theme.fg("accent", theme.bold("Bro · config"))));
|
|
2482
|
-
container.addChild(new Text(theme.fg("dim", "Shared defaults, with optional overrides per capability")));
|
|
2483
|
-
container.addChild(settingsList);
|
|
2484
|
-
container.addChild(noticeText);
|
|
2485
|
-
container.addChild(new Text(theme.fg("dim", "↑/↓ navigate · Enter select/change · Esc back/close")));
|
|
2486
|
-
|
|
2487
|
-
return {
|
|
2488
|
-
render: (w: number) => {
|
|
2489
|
-
const inner = Math.max(1, w - 4);
|
|
2490
|
-
const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
|
|
2491
|
-
return [border("┌", "┐"), ...container.render(inner).map(line => {
|
|
2492
|
-
const text = truncateToWidth(line, inner, "");
|
|
2493
|
-
return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
|
|
2494
|
-
}), border("└", "┘")];
|
|
2495
|
-
},
|
|
2496
|
-
invalidate: () => container.invalidate(),
|
|
2497
|
-
handleInput: (data: string) => {
|
|
2498
|
-
settingsList.handleInput?.(data);
|
|
2499
|
-
tui.requestRender();
|
|
2500
|
-
},
|
|
2501
|
-
};
|
|
2502
|
-
};
|
|
2503
|
-
}
|
|
2504
|
-
|
|
2505
1305
|
export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
2506
1306
|
if (!hasBroCustomUi(ctx)) {
|
|
2507
1307
|
ctx.ui.notify("Use /bro config in Pi's interactive UI.", "warning");
|
|
@@ -2534,31 +1334,71 @@ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: Exten
|
|
|
2534
1334
|
});
|
|
2535
1335
|
}
|
|
2536
1336
|
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
initialText: string
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
1337
|
+
type TextEditorModalOptions = {
|
|
1338
|
+
title: string;
|
|
1339
|
+
subtitle: string;
|
|
1340
|
+
initialText: string;
|
|
1341
|
+
initialNotice?: string;
|
|
1342
|
+
// Returns an error message to refuse the save without calling onSave.
|
|
1343
|
+
validate?: (text: string) => string | undefined;
|
|
1344
|
+
// Either may be async; the modal then waits, ignores further edits, and reports success only once it settles.
|
|
1345
|
+
onSave: (text: string) => void | Promise<void>;
|
|
1346
|
+
onClear: () => void | Promise<void>;
|
|
1347
|
+
copy?: (text: string) => Promise<void>;
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
// Testable core shared by /bro advisor-steer and /bro preferences: persistence only happens on
|
|
1351
|
+
// Ctrl+S/Ctrl+K. Esc leaves the stored text untouched; only the in-memory draft is discarded.
|
|
1352
|
+
export function createTextEditorModal(
|
|
1353
|
+
options: TextEditorModalOptions,
|
|
2544
1354
|
): (tui: TUI, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
1355
|
+
const { onSave, onClear, validate, copy = copyToClipboard } = options;
|
|
2545
1356
|
return (tui, theme, _keybindings, done) => {
|
|
2546
1357
|
const editorTheme: EditorTheme = { borderColor: (s: string) => theme.fg("border", s), selectList: getSelectListTheme() };
|
|
2547
1358
|
const editor = new Editor(tui, editorTheme);
|
|
2548
1359
|
editor.focused = true;
|
|
2549
|
-
editor.setText(initialText);
|
|
1360
|
+
editor.setText(options.initialText);
|
|
2550
1361
|
let disposed = false;
|
|
2551
|
-
|
|
1362
|
+
let pending = false;
|
|
1363
|
+
const notice = new Text(options.initialNotice ? theme.fg("accent", options.initialNotice) : "");
|
|
2552
1364
|
const showNotice = (message: string, color: "success" | "error") => {
|
|
2553
1365
|
if (disposed) return;
|
|
2554
1366
|
notice.setText(theme.fg(color, message));
|
|
2555
1367
|
tui.requestRender();
|
|
2556
1368
|
};
|
|
2557
1369
|
editor.onChange = () => notice.setText("");
|
|
1370
|
+
const persist = (action: () => void | Promise<void>, success: string, failure: string, after?: () => void) => {
|
|
1371
|
+
const fail = (error: unknown) => showNotice(`${failure}: ${errorMessage(error)}`, "error");
|
|
1372
|
+
let result: void | Promise<void>;
|
|
1373
|
+
try {
|
|
1374
|
+
result = action();
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
fail(error);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (!(result instanceof Promise)) {
|
|
1380
|
+
after?.();
|
|
1381
|
+
showNotice(success, "success");
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
pending = true;
|
|
1385
|
+
result.then(
|
|
1386
|
+
() => {
|
|
1387
|
+
pending = false;
|
|
1388
|
+
if (disposed) return;
|
|
1389
|
+
after?.();
|
|
1390
|
+
showNotice(success, "success");
|
|
1391
|
+
},
|
|
1392
|
+
(error) => {
|
|
1393
|
+
pending = false;
|
|
1394
|
+
fail(error);
|
|
1395
|
+
},
|
|
1396
|
+
);
|
|
1397
|
+
};
|
|
2558
1398
|
|
|
2559
1399
|
const container = new Container();
|
|
2560
|
-
container.addChild(new Text(theme.fg("accent", theme.bold(
|
|
2561
|
-
container.addChild(new Text(theme.fg("dim",
|
|
1400
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(options.title))));
|
|
1401
|
+
container.addChild(new Text(theme.fg("dim", options.subtitle)));
|
|
2562
1402
|
container.addChild(editor);
|
|
2563
1403
|
container.addChild(notice);
|
|
2564
1404
|
container.addChild(new Text(theme.fg("dim", "Ctrl+S save · Enter newline · Ctrl+K clear · Ctrl+C copy · Esc close")));
|
|
@@ -2579,23 +1419,19 @@ export function createAdvisorSteerModal(
|
|
|
2579
1419
|
done(undefined);
|
|
2580
1420
|
return;
|
|
2581
1421
|
}
|
|
1422
|
+
if (pending) return;
|
|
2582
1423
|
if (matchesKey(data, "ctrl+s")) {
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
1424
|
+
const text = editor.getExpandedText();
|
|
1425
|
+
const invalid = validate?.(text);
|
|
1426
|
+
if (invalid) {
|
|
1427
|
+
showNotice(invalid, "error");
|
|
1428
|
+
return;
|
|
2588
1429
|
}
|
|
1430
|
+
persist(() => onSave(text), "Saved", "Save failed");
|
|
2589
1431
|
return;
|
|
2590
1432
|
}
|
|
2591
1433
|
if (matchesKey(data, "ctrl+k")) {
|
|
2592
|
-
|
|
2593
|
-
onClear();
|
|
2594
|
-
editor.setText("");
|
|
2595
|
-
showNotice("Cleared", "success");
|
|
2596
|
-
} catch (error) {
|
|
2597
|
-
showNotice(`Clear failed: ${errorMessage(error)}`, "error");
|
|
2598
|
-
}
|
|
1434
|
+
persist(onClear, "Cleared", "Clear failed", () => editor.setText(""));
|
|
2599
1435
|
return;
|
|
2600
1436
|
}
|
|
2601
1437
|
if (matchesKey(data, "ctrl+c")) {
|
|
@@ -2619,6 +1455,83 @@ export function createAdvisorSteerModal(
|
|
|
2619
1455
|
};
|
|
2620
1456
|
}
|
|
2621
1457
|
|
|
1458
|
+
export function createAdvisorSteerModal(
|
|
1459
|
+
initialText: string,
|
|
1460
|
+
onSave: (text: string) => void,
|
|
1461
|
+
onClear: () => void,
|
|
1462
|
+
copy: (text: string) => Promise<void> = copyToClipboard,
|
|
1463
|
+
): ReturnType<typeof createTextEditorModal> {
|
|
1464
|
+
return createTextEditorModal({
|
|
1465
|
+
title: "Bro · advisor steer",
|
|
1466
|
+
subtitle: `Session-specific steering brief for the advisor (takes precedence over ${ADVISOR_STEERING_FILE}) — never sent to the main model.`,
|
|
1467
|
+
initialText,
|
|
1468
|
+
onSave,
|
|
1469
|
+
onClear,
|
|
1470
|
+
copy,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function preferencesTooLong(text: string): string | undefined {
|
|
1475
|
+
const length = text.replace(/^\uFEFF/, "").trim().length;
|
|
1476
|
+
return length > MAX_PREFERENCES_CHARS
|
|
1477
|
+
? `${length.toLocaleString("en-US")}/${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} characters — trim before saving`
|
|
1478
|
+
: undefined;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Testable core for /bro preferences. A missing file opens with the unsaved starter text; an
|
|
1482
|
+
// unreadable or oversize-on-disk file opens empty with the error, and Clear still works.
|
|
1483
|
+
export function createPreferencesModal(
|
|
1484
|
+
loaded: { text?: string; error?: string },
|
|
1485
|
+
persist: { save: (text: string) => Promise<void>; clear: () => Promise<void> },
|
|
1486
|
+
copy?: (text: string) => Promise<void>,
|
|
1487
|
+
): ReturnType<typeof createTextEditorModal> {
|
|
1488
|
+
const starter = loaded.text === undefined && !loaded.error;
|
|
1489
|
+
return createTextEditorModal({
|
|
1490
|
+
title: "Bro · preferences",
|
|
1491
|
+
subtitle: "About you and how you like answers. Sent to the selected backend with every explain, show, btw, and Guided Review request — never to the advisor or Pi's main model.",
|
|
1492
|
+
initialText: starter ? STARTER_PREFERENCES : (loaded.text ?? ""),
|
|
1493
|
+
initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it; Esc leaves no file." : undefined),
|
|
1494
|
+
validate: preferencesTooLong,
|
|
1495
|
+
onSave: persist.save,
|
|
1496
|
+
onClear: persist.clear,
|
|
1497
|
+
copy,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Saves and deletes run one at a time, even across a closed and reopened editor, so a slow delete
|
|
1502
|
+
// from an earlier editor can never remove a file saved by a later one.
|
|
1503
|
+
let preferencesWrites: Promise<void> = Promise.resolve();
|
|
1504
|
+
export function queuePreferencesWrite(write: () => Promise<void>): Promise<void> {
|
|
1505
|
+
const next = preferencesWrites.then(write);
|
|
1506
|
+
preferencesWrites = next.catch(() => {});
|
|
1507
|
+
return next;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
export async function showPreferencesModal(ctx: ExtensionCommandContext): Promise<void> {
|
|
1511
|
+
if (!hasBroCustomUi(ctx)) {
|
|
1512
|
+
ctx.ui.notify(`Edit ${PREFERENCES_FILE} directly.`, "warning");
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
let loaded: { text?: string; error?: string };
|
|
1516
|
+
try {
|
|
1517
|
+
const text = await readPreferencesRaw();
|
|
1518
|
+
loaded = text === undefined ? {} : { text };
|
|
1519
|
+
} catch (error) {
|
|
1520
|
+
loaded = { error: `${errorMessage(error)} Ctrl+K deletes it.` };
|
|
1521
|
+
}
|
|
1522
|
+
await ctx.ui.custom<void>(
|
|
1523
|
+
createPreferencesModal(loaded, {
|
|
1524
|
+
// ponytail: last writer wins against external edits while the editor is open, like writeSettings.
|
|
1525
|
+
save: (text) => queuePreferencesWrite(() => writeFile(PREFERENCES_FILE, `${text.trim()}\n`, "utf8")),
|
|
1526
|
+
clear: () => queuePreferencesWrite(() => rm(PREFERENCES_FILE, { force: true })),
|
|
1527
|
+
}),
|
|
1528
|
+
{
|
|
1529
|
+
overlay: true,
|
|
1530
|
+
overlayOptions: { width: "78%", minWidth: 48, maxHeight: "60%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
|
|
1531
|
+
},
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
2622
1535
|
export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
2623
1536
|
if (!hasBroCustomUi(ctx)) {
|
|
2624
1537
|
ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
|
|
@@ -2638,7 +1551,9 @@ export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: Ex
|
|
|
2638
1551
|
);
|
|
2639
1552
|
}
|
|
2640
1553
|
|
|
2641
|
-
|
|
1554
|
+
// `preferences` is a ready status ("off", "on (312 characters)", or "error: …") so a preferences
|
|
1555
|
+
// problem never hides Help or mixes with settings errors.
|
|
1556
|
+
export function helpText(settings?: BroSettings, settingsError?: string, preferences = "off"): string {
|
|
2642
1557
|
const overrideLines = settings
|
|
2643
1558
|
? CAPABILITIES.map((capability) => {
|
|
2644
1559
|
const override = capabilityOverride(settings, capability);
|
|
@@ -2664,6 +1579,13 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
|
|
|
2664
1579
|
- \`/bro open\` — reopen the latest explanation without a new request
|
|
2665
1580
|
- \`/bro show [n-turns] [query]\` — draw recent turns' conversation text as shapes; an optional query steers the focus
|
|
2666
1581
|
|
|
1582
|
+
## Guided Review
|
|
1583
|
+
|
|
1584
|
+
- \`/bro guided-review <PR number or URL>\` — automatically explain and assess a PR with captured code and optional private questions (local Pi interactive only; Git and authenticated gh required).
|
|
1585
|
+
- Your work is saved automatically. Open the same PR to return without a model call; \`/bro guided-review\` lists saved reviews (\`resume\` is an alias). Reopening keeps the captured revision, not newer commits.
|
|
1586
|
+
- Enter opens an item or focuses actions; Enter on a highlighted action activates it. Tab changes focus. Esc goes back, then closes from Contents. Close saves automatically; Stop and close stops active work first.
|
|
1587
|
+
- Copy finding copies to the system clipboard, not GitHub. Regenerate guide is optional and makes a confirmed call about the same captured code. Review generation/questions require Claude or Muse file-only tools: shell, writes and web tools are disabled. Set a review override in /bro config; other backends can still read saved reviews. Repository text can still mislead the model; citations are not proof.
|
|
1588
|
+
|
|
2667
1589
|
## Side conversation
|
|
2668
1590
|
|
|
2669
1591
|
- \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
|
|
@@ -2674,18 +1596,21 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
|
|
|
2674
1596
|
- \`bro_advisor\` — a tool the executor agent may call for a second opinion from a fresh backend process with real, auto-approved workspace access; it is told to advise, not edit, but that is not enforced
|
|
2675
1597
|
- \`/bro advisor\` — whether \`bro_advisor\` is available right now
|
|
2676
1598
|
- \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
|
|
1599
|
+
- Standing advisor steering: \`${ADVISOR_STEERING_FILE}\` sets standing priorities across sessions (session steering takes precedence)
|
|
2677
1600
|
|
|
2678
1601
|
## Configure and check
|
|
2679
1602
|
|
|
2680
|
-
- \`/bro
|
|
1603
|
+
- \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
|
|
1604
|
+
- \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately.
|
|
2681
1605
|
- \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
|
|
2682
|
-
- \`/bro doctor\` — check settings,
|
|
1606
|
+
- \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
|
|
2683
1607
|
|
|
2684
1608
|
## Current settings
|
|
2685
1609
|
|
|
2686
1610
|
${settingsSummary}
|
|
1611
|
+
- **Preferences:** ${preferences} — \`/bro preferences\`
|
|
2687
1612
|
|
|
2688
|
-
Saved in \`${
|
|
1613
|
+
Saved in \`${settingsFile()}\`, \`${PREFERENCES_FILE}\`, and \`${ADVISOR_STEERING_FILE}\`.
|
|
2689
1614
|
|
|
2690
1615
|
## Explanation modes
|
|
2691
1616
|
|
|
@@ -2695,7 +1620,7 @@ Saved in \`${SETTINGS_FILE}\`.
|
|
|
2695
1620
|
|
|
2696
1621
|
Press **M** in an explanation to re-simplify it in the next mode without changing the saved one.
|
|
2697
1622
|
|
|
2698
|
-
|
|
1623
|
+
The mode decides how much of the source to keep; your preferences decide who it's written for, its tone, and its language. Neither overrides Bro's source rules.
|
|
2699
1624
|
|
|
2700
1625
|
## Controls
|
|
2701
1626
|
|
|
@@ -2708,7 +1633,7 @@ A valid \`${PROMPT_FILE}\` (with \`{{response}}\` exactly once) overrides the mo
|
|
|
2708
1633
|
|
|
2709
1634
|
## Privacy
|
|
2710
1635
|
|
|
2711
|
-
Bro sends the captured source (or, for the advisor, the executor's instructions, tools, and conversation) to the selected backend and its model provider, which may retain it under their own policies. Nothing is added to Pi's conversation unless you insert it. Access controls differ by backend; see the README.`;
|
|
1636
|
+
Bro sends the captured source (or, for the advisor, the executor's instructions, tools, and conversation) to the selected backend and its model provider, which may retain it under their own policies. Your preferences go with every explain, show, btw, and Guided Review request. Guided Review sends PR data/diff and relevant private questions; records/source remain locally under getAgentDir()/bro-reviews. Copy finding goes to the system clipboard, not GitHub. Nothing is added to Pi's conversation unless you insert it. Access controls differ by backend; see the README.`;
|
|
2712
1637
|
}
|
|
2713
1638
|
|
|
2714
1639
|
// The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
|
|
@@ -2729,6 +1654,8 @@ class BroModal implements Focusable {
|
|
|
2729
1654
|
private htmlPath = "";
|
|
2730
1655
|
// Survives loading/streaming so the header names the mode being produced; empty disables M.
|
|
2731
1656
|
private modeLabel = "";
|
|
1657
|
+
// Set only with a result, so the tag never claims preferences for work still in flight.
|
|
1658
|
+
private preferencesUsed = false;
|
|
2732
1659
|
|
|
2733
1660
|
constructor(
|
|
2734
1661
|
private readonly tui: TuiLike,
|
|
@@ -2764,6 +1691,11 @@ class BroModal implements Focusable {
|
|
|
2764
1691
|
this.tui.requestRender();
|
|
2765
1692
|
}
|
|
2766
1693
|
|
|
1694
|
+
setPreferences(used: boolean): void {
|
|
1695
|
+
this.preferencesUsed = used;
|
|
1696
|
+
this.tui.requestRender();
|
|
1697
|
+
}
|
|
1698
|
+
|
|
2767
1699
|
setStatic(kind: "help" | "empty", text: string, copyable: boolean): void {
|
|
2768
1700
|
this.setContent(kind, text, text, copyable, false);
|
|
2769
1701
|
}
|
|
@@ -2849,7 +1781,7 @@ class BroModal implements Focusable {
|
|
|
2849
1781
|
this.borderLine(innerWidth, "top"),
|
|
2850
1782
|
this.frameLine(
|
|
2851
1783
|
this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
|
|
2852
|
-
this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${scroll}`),
|
|
1784
|
+
this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll}`),
|
|
2853
1785
|
innerWidth,
|
|
2854
1786
|
),
|
|
2855
1787
|
this.ruleLine(innerWidth),
|
|
@@ -2996,6 +1928,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
|
|
|
2996
1928
|
modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model);
|
|
2997
1929
|
modal.setHtmlPath(result.htmlPath ?? "");
|
|
2998
1930
|
modal.setMode(result.mode);
|
|
1931
|
+
modal.setPreferences(Boolean(result.preferences));
|
|
2999
1932
|
};
|
|
3000
1933
|
|
|
3001
1934
|
execute = (source?: BroSource, mode?: BroMode, notice = "") => {
|
|
@@ -3006,6 +1939,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
|
|
|
3006
1939
|
inFlightMode = mode;
|
|
3007
1940
|
modal.setLoading(options.loadingText);
|
|
3008
1941
|
modal.setMode(mode);
|
|
1942
|
+
modal.setPreferences(false);
|
|
3009
1943
|
|
|
3010
1944
|
void options
|
|
3011
1945
|
.run(nextController.signal, source, (text) => {
|
|
@@ -3080,7 +2014,7 @@ export function resolveBtwThread(existing: BtwThread | undefined): BtwThread {
|
|
|
3080
2014
|
|
|
3081
2015
|
export function bindBtwBackend(thread: BtwThread, backend: BackendName): boolean {
|
|
3082
2016
|
const changed = thread.backend !== undefined && thread.backend !== backend;
|
|
3083
|
-
if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; }
|
|
2017
|
+
if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; thread.sessionPreferences = undefined; }
|
|
3084
2018
|
thread.backend = backend;
|
|
3085
2019
|
return changed;
|
|
3086
2020
|
}
|
|
@@ -3099,13 +2033,20 @@ export function toggleBtwMode(thread: BtwThread): void {
|
|
|
3099
2033
|
// /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
|
|
3100
2034
|
// dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
|
|
3101
2035
|
// with the main-session context and the whole thread.
|
|
3102
|
-
|
|
2036
|
+
// A native session remembers every earlier prompt, including old preferences, so a preferences
|
|
2037
|
+
// change (or deletion) on any backend starts a fresh session reseeded with the quoted thread.
|
|
2038
|
+
export function nativeBtwContinuation(thread: BtwThread, backend: BackendName, preferences = ""): string | undefined {
|
|
3103
2039
|
if (backend === "agy" && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
|
|
3104
2040
|
thread.conversationId = undefined;
|
|
3105
2041
|
}
|
|
2042
|
+
if (thread.conversationId && (thread.sessionPreferences ?? "") !== preferences) thread.conversationId = undefined;
|
|
3106
2043
|
return thread.conversationId;
|
|
3107
2044
|
}
|
|
3108
2045
|
|
|
2046
|
+
export function btwHeaderLabel(thread: Pick<BtwThread, "model" | "preferences">): string {
|
|
2047
|
+
return thread.model ? `${thread.model}${thread.preferences ? " · prefs" : ""}` : "";
|
|
2048
|
+
}
|
|
2049
|
+
|
|
3109
2050
|
export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
|
|
3110
2051
|
return turns
|
|
3111
2052
|
.map((turn) => {
|
|
@@ -3362,7 +2303,8 @@ async function openBtwModal(
|
|
|
3362
2303
|
controller?.abort();
|
|
3363
2304
|
});
|
|
3364
2305
|
|
|
3365
|
-
|
|
2306
|
+
// `replaced` is the turn /retry removed; it is put back if the turn never starts.
|
|
2307
|
+
const runTurn = async (question: string, replaced?: BtwTurn) => {
|
|
3366
2308
|
if (controller) return;
|
|
3367
2309
|
const turnController = new AbortController();
|
|
3368
2310
|
controller = turnController;
|
|
@@ -3370,13 +2312,23 @@ async function openBtwModal(
|
|
|
3370
2312
|
modal.clearComposer();
|
|
3371
2313
|
|
|
3372
2314
|
let settings: BroSettings;
|
|
2315
|
+
let preferences: string;
|
|
3373
2316
|
try {
|
|
3374
2317
|
settings = await readSettings();
|
|
3375
|
-
|
|
3376
|
-
if (bindBtwBackend(thread, backend)) modal.setNotice("Backend changed — started a fresh side thread.");
|
|
2318
|
+
preferences = await readPreferences();
|
|
3377
2319
|
} catch (error) {
|
|
3378
|
-
|
|
2320
|
+
if (replaced) thread.turns.push(replaced);
|
|
2321
|
+
if (controller === turnController) controller = undefined;
|
|
2322
|
+
if (!closed) { modal.setRunning(false); modal.setNotice(errorMessage(error)); }
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
// Closing (or reopening) during the reads above must not touch the shared thread.
|
|
2326
|
+
if (closed || turnController.signal.aborted) {
|
|
2327
|
+
if (replaced) thread.turns.push(replaced);
|
|
2328
|
+
if (controller === turnController) controller = undefined;
|
|
2329
|
+
return;
|
|
3379
2330
|
}
|
|
2331
|
+
if (bindBtwBackend(thread, capabilityBackend(settings, "btw"))) modal.setNotice("Backend changed — started a fresh side thread.");
|
|
3380
2332
|
if (thread.turns.length === 0) {
|
|
3381
2333
|
thread.conversationId = undefined;
|
|
3382
2334
|
const context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS, BTW_CONTEXT_MAX)?.text;
|
|
@@ -3384,7 +2336,7 @@ async function openBtwModal(
|
|
|
3384
2336
|
}
|
|
3385
2337
|
// Resume natively when possible; otherwise seed the fresh native session with the main-session
|
|
3386
2338
|
// context plus every earlier turn so nothing is silently lost.
|
|
3387
|
-
const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"));
|
|
2339
|
+
const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"), preferences);
|
|
3388
2340
|
const history = !conversationId && thread.turns.length ? formatBtwTranscript(thread.turns) : undefined;
|
|
3389
2341
|
const context = conversationId ? undefined : thread.context;
|
|
3390
2342
|
const full = thread.full;
|
|
@@ -3395,9 +2347,10 @@ async function openBtwModal(
|
|
|
3395
2347
|
try {
|
|
3396
2348
|
const selection = selectionForCapability(settings, "btw");
|
|
3397
2349
|
thread.model = selectionLabel(selection);
|
|
3398
|
-
|
|
2350
|
+
thread.preferences = Boolean(preferences);
|
|
2351
|
+
modal.setModel(btwHeaderLabel(thread));
|
|
3399
2352
|
const result = await runBtwTurn(
|
|
3400
|
-
buildBtwPrompt(context, question, { full, history }),
|
|
2353
|
+
buildBtwPrompt(context, question, { full, history, preferences }),
|
|
3401
2354
|
selection,
|
|
3402
2355
|
{ full, cwd: ctx.cwd, conversationId },
|
|
3403
2356
|
turnController.signal,
|
|
@@ -3411,6 +2364,7 @@ async function openBtwModal(
|
|
|
3411
2364
|
if (result.conversationId) {
|
|
3412
2365
|
thread.conversationId = result.conversationId;
|
|
3413
2366
|
thread.sessionFull = full;
|
|
2367
|
+
thread.sessionPreferences = preferences;
|
|
3414
2368
|
}
|
|
3415
2369
|
thread.turns[thread.turns.length - 1]!.answer = result.text;
|
|
3416
2370
|
} catch (error) {
|
|
@@ -3436,6 +2390,8 @@ async function openBtwModal(
|
|
|
3436
2390
|
thread.turns = [];
|
|
3437
2391
|
thread.conversationId = undefined;
|
|
3438
2392
|
thread.context = undefined;
|
|
2393
|
+
thread.sessionFull = undefined;
|
|
2394
|
+
thread.sessionPreferences = undefined;
|
|
3439
2395
|
modal.clearComposer();
|
|
3440
2396
|
modal.setNotice("");
|
|
3441
2397
|
modal.setText("");
|
|
@@ -3449,7 +2405,7 @@ async function openBtwModal(
|
|
|
3449
2405
|
return;
|
|
3450
2406
|
}
|
|
3451
2407
|
thread.turns.pop();
|
|
3452
|
-
void runTurn(last.question);
|
|
2408
|
+
void runTurn(last.question, last);
|
|
3453
2409
|
};
|
|
3454
2410
|
|
|
3455
2411
|
const copyOut = async (all: boolean) => {
|
|
@@ -3521,7 +2477,7 @@ async function openBtwModal(
|
|
|
3521
2477
|
}
|
|
3522
2478
|
|
|
3523
2479
|
modal.setFull(thread.full);
|
|
3524
|
-
modal.setModel(thread
|
|
2480
|
+
modal.setModel(btwHeaderLabel(thread));
|
|
3525
2481
|
modal.setText(transcript());
|
|
3526
2482
|
|
|
3527
2483
|
if (options.initialQuestion) void runTurn(options.initialQuestion);
|
|
@@ -3552,6 +2508,8 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3552
2508
|
}
|
|
3553
2509
|
};
|
|
3554
2510
|
|
|
2511
|
+
pi.on("session_shutdown", async () => { stopGuidedReviews(); });
|
|
2512
|
+
|
|
3555
2513
|
pi.on("session_start", async (_event, _ctx) => {
|
|
3556
2514
|
lastResult = undefined;
|
|
3557
2515
|
btwThread = undefined;
|
|
@@ -3576,11 +2534,14 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3576
2534
|
}),
|
|
3577
2535
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
3578
2536
|
const state = resolveAdvisorState(ctx.sessionManager.getBranch());
|
|
2537
|
+
const durableSteering = await readAdvisorSteering();
|
|
3579
2538
|
const settings = await readSettings();
|
|
3580
2539
|
const selection = selectionForCapability(settings, "advisor");
|
|
3581
2540
|
const backend = selection.backend ?? "agy";
|
|
3582
2541
|
const snapshot = buildAdvisorSnapshot(ctx, pi);
|
|
3583
|
-
const prompt = buildAdvisorPrompt(state.steering, snapshot.text, params
|
|
2542
|
+
const prompt = buildAdvisorPrompt({ durable: durableSteering, session: state.steering }, snapshot.text, params?.question);
|
|
2543
|
+
const hasDurable = Boolean(durableSteering.trim());
|
|
2544
|
+
const hasSession = Boolean(state.steering.trim());
|
|
3584
2545
|
let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0, backend };
|
|
3585
2546
|
try {
|
|
3586
2547
|
const run = await runAdvisorWithRetries(
|
|
@@ -3617,7 +2578,8 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3617
2578
|
effort,
|
|
3618
2579
|
durationMs: run.durationMs,
|
|
3619
2580
|
cwd: ctx.cwd,
|
|
3620
|
-
steeringIncluded:
|
|
2581
|
+
steeringIncluded: hasDurable || hasSession,
|
|
2582
|
+
steeringSources: { durable: hasDurable, session: hasSession },
|
|
3621
2583
|
snapshotChars: snapshot.text.length,
|
|
3622
2584
|
broTruncated: false,
|
|
3623
2585
|
omissions,
|
|
@@ -3627,7 +2589,14 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3627
2589
|
};
|
|
3628
2590
|
// The backend qualifier appears only off Agy, keeping the Agy header byte-identical.
|
|
3629
2591
|
const header = `Bro advisor · ${backend === "agy" ? "" : `backend: ${backend} · `}model: ${selection.model} · effort: ${effort} · ${run.attempts} attempt${run.attempts === 1 ? "" : "s"} · ${Math.ceil(run.durationMs / 1_000)}s`;
|
|
3630
|
-
const
|
|
2592
|
+
const steeringSummary = hasDurable && hasSession
|
|
2593
|
+
? "included (durable+session)"
|
|
2594
|
+
: hasDurable
|
|
2595
|
+
? "included (durable)"
|
|
2596
|
+
: hasSession
|
|
2597
|
+
? "included"
|
|
2598
|
+
: "none";
|
|
2599
|
+
const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${steeringSummary} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
|
|
3631
2600
|
return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
|
|
3632
2601
|
} finally {
|
|
3633
2602
|
ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, undefined);
|
|
@@ -3716,15 +2685,14 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3716
2685
|
if (!captured) {
|
|
3717
2686
|
return { text: "**Nothing to show yet**\n\nThis session has no conversation turns to draw. Run something first, then press **R**." };
|
|
3718
2687
|
}
|
|
3719
|
-
let
|
|
3720
|
-
let model: string;
|
|
2688
|
+
let result: Awaited<ReturnType<typeof runShowExplanation>>;
|
|
3721
2689
|
try {
|
|
3722
|
-
|
|
2690
|
+
result = await runShowExplanation(captured.text, steering, signal, await readSettings(), onProgress);
|
|
3723
2691
|
} catch (error) {
|
|
3724
2692
|
throw new Error(withDoctor(error));
|
|
3725
2693
|
}
|
|
3726
|
-
const html = extractShowHtml(text);
|
|
3727
|
-
return { source: captured,
|
|
2694
|
+
const html = extractShowHtml(result.text);
|
|
2695
|
+
return { source: captured, ...result, ...(html ? { htmlPath: await writeShowHtml(html) } : {}) };
|
|
3728
2696
|
};
|
|
3729
2697
|
try {
|
|
3730
2698
|
await showBroModal(ctx, {
|
|
@@ -3821,6 +2789,12 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3821
2789
|
return;
|
|
3822
2790
|
}
|
|
3823
2791
|
|
|
2792
|
+
if (action === "guided-review") {
|
|
2793
|
+
try { await openGuidedReview(ctx, value, await readPreferences()); }
|
|
2794
|
+
catch (error) { ctx.ui.notify(errorMessage(error), "error"); }
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
3824
2798
|
if (action === "btw") {
|
|
3825
2799
|
if (!hasBroCustomUi(ctx)) {
|
|
3826
2800
|
ctx.ui.notify("Use /bro btw in Pi's interactive UI.", "warning");
|
|
@@ -3858,7 +2832,7 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3858
2832
|
// an actionable notice pointing at the commands that replaced it, not a silent no-op.
|
|
3859
2833
|
if (parts.length > 1) {
|
|
3860
2834
|
ctx.ui.notify(
|
|
3861
|
-
"/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
|
|
2835
|
+
"/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.",
|
|
3862
2836
|
"warning",
|
|
3863
2837
|
);
|
|
3864
2838
|
return;
|
|
@@ -3866,8 +2840,8 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3866
2840
|
const active = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
|
|
3867
2841
|
ctx.ui.notify(
|
|
3868
2842
|
active
|
|
3869
|
-
?
|
|
3870
|
-
: "Bro advisor is unavailable in this runtime. Use /bro doctor for full diagnostics, /bro config for its model/effort, or /bro advisor-steer for
|
|
2843
|
+
? `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.`
|
|
2844
|
+
: "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.",
|
|
3871
2845
|
active ? "info" : "warning",
|
|
3872
2846
|
);
|
|
3873
2847
|
return;
|
|
@@ -3882,6 +2856,15 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3882
2856
|
return;
|
|
3883
2857
|
}
|
|
3884
2858
|
|
|
2859
|
+
if (action === "preferences") {
|
|
2860
|
+
if (parts.length !== 1) {
|
|
2861
|
+
ctx.ui.notify("Use /bro preferences.", "warning");
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
await showPreferencesModal(ctx);
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
3885
2868
|
if (action === "help") {
|
|
3886
2869
|
if (parts.length !== 1) {
|
|
3887
2870
|
ctx.ui.notify("Use /bro help.", "warning");
|
|
@@ -3894,7 +2877,14 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3894
2877
|
} catch (error) {
|
|
3895
2878
|
settingsError = errorMessage(error);
|
|
3896
2879
|
}
|
|
3897
|
-
|
|
2880
|
+
let preferences: string;
|
|
2881
|
+
try {
|
|
2882
|
+
const text = await readPreferences();
|
|
2883
|
+
preferences = text ? `on (${text.length.toLocaleString("en-US")} characters)` : "off";
|
|
2884
|
+
} catch (error) {
|
|
2885
|
+
preferences = `error: ${errorMessage(error)}`;
|
|
2886
|
+
}
|
|
2887
|
+
await showBroModal(ctx, { text: helpText(settings, settingsError, preferences), kind: "help", copyable: true });
|
|
3898
2888
|
return;
|
|
3899
2889
|
}
|
|
3900
2890
|
|