@booplex/bpx-consult 0.1.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 +16 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/index.ts +112 -0
- package/package.json +54 -0
- package/prompts/advisor-system.txt +28 -0
- package/src/advisor.ts +137 -0
- package/src/cli-backend.ts +256 -0
- package/src/config.ts +422 -0
- package/src/consensus.ts +173 -0
- package/src/context-engine.ts +395 -0
- package/src/council.ts +429 -0
- package/src/debate.ts +292 -0
- package/src/messages.ts +49 -0
- package/src/personas.ts +163 -0
- package/src/solo.ts +205 -0
- package/src/timeout.ts +87 -0
- package/src/triggers.ts +190 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli-backend — external-CLI advisor calls via async pi.exec.
|
|
3
|
+
*
|
|
4
|
+
* An alternative to the inline `completeSimple` path: pipe the fitted context
|
|
5
|
+
* (as markdown) to an external CLI's stdin (codex / claude / opencode) and parse
|
|
6
|
+
* the reply. Replaces pi-external-advisor's `execSync` with async `pi.exec` —
|
|
7
|
+
* execSync blocks the event loop and would serialize a Promise.all council.
|
|
8
|
+
*
|
|
9
|
+
* The whole point of going async is that a CLI-backed council member can run
|
|
10
|
+
* in parallel with an inline member. A solo CLI call doesn't prove that; the
|
|
11
|
+
* mixed inline+cli council smoke test does.
|
|
12
|
+
*
|
|
13
|
+
* Defensive parsing is load-bearing: real CLIs print deprecation notices,
|
|
14
|
+
* progress warnings, and auth chatter to stdout/stderr before the payload.
|
|
15
|
+
* We don't crash on junk preamble — we scan for the JSON payload (codex/
|
|
16
|
+
* opencode JSONL) or fall back to the whole stdout (claude plain text).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { withTimeout } from "./timeout.js";
|
|
22
|
+
|
|
23
|
+
export type CliCommand = "codex" | "claude" | "opencode";
|
|
24
|
+
|
|
25
|
+
/** Pre-baked invocations. Read prompt from stdin (`-` or `-p`). */
|
|
26
|
+
const CLI_INVOCATIONS: Record<CliCommand, { command: string; args: string[] }> = {
|
|
27
|
+
codex: { command: "codex", args: ["exec", "--sandbox", "read-only", "--skip-git-repo-check", "-"] },
|
|
28
|
+
claude: { command: "claude", args: ["-p"] },
|
|
29
|
+
opencode: { command: "opencode", args: ["exec", "--sandbox", "read-only", "--skip-git-repo-check", "-"] },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface CliBackendConfig {
|
|
33
|
+
type: "cli";
|
|
34
|
+
command: CliCommand | string;
|
|
35
|
+
args?: string[];
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CliCallInput {
|
|
40
|
+
systemPrompt: string;
|
|
41
|
+
/** Fitted, window-safe messages (already through §C). */
|
|
42
|
+
messages: Message[];
|
|
43
|
+
backend: CliBackendConfig;
|
|
44
|
+
signal: AbortSignal | undefined;
|
|
45
|
+
/** Working directory for the subprocess (usually ctx.cwd). */
|
|
46
|
+
cwd?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CliCallResult {
|
|
50
|
+
text: string;
|
|
51
|
+
/** Whether the subprocess timed out (res.killed). */
|
|
52
|
+
timedOut: boolean;
|
|
53
|
+
/** Non-zero exit without timeout. */
|
|
54
|
+
exitCode: number | null;
|
|
55
|
+
errorMessage?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_CLI_TIMEOUT_MS = 120_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run one CLI advisor call. Never throws — every failure path returns a result
|
|
62
|
+
* with errorMessage set, so a council can collect it as a failed member without
|
|
63
|
+
* a try/catch at every call site.
|
|
64
|
+
*
|
|
65
|
+
* Uses node:child_process.spawn directly (async, non-blocking) rather than
|
|
66
|
+
* pi.exec — pi 0.80.x's ExecOptions doesn't expose stdin, and these CLIs read
|
|
67
|
+
* the prompt from stdin. spawn is the right primitive: it's non-blocking (unlike
|
|
68
|
+
* execSync, which is what makes pi-external-advisor serialize under a council),
|
|
69
|
+
* so a CLI council member runs truly parallel to an inline completeSimple member.
|
|
70
|
+
*/
|
|
71
|
+
export async function callCliAdvisor(input: CliCallInput): Promise<CliCallResult> {
|
|
72
|
+
const { systemPrompt, messages, backend, signal, cwd } = input;
|
|
73
|
+
const inv = resolveInvocation(backend);
|
|
74
|
+
const promptText = buildPromptText(systemPrompt, messages);
|
|
75
|
+
const timeoutMs = backend.timeoutMs && backend.timeoutMs > 0 ? backend.timeoutMs : DEFAULT_CLI_TIMEOUT_MS;
|
|
76
|
+
|
|
77
|
+
// Race the subprocess against a wall-clock timeout that fires its own abort
|
|
78
|
+
// controller (linked to the parent signal so user-abort still propagates).
|
|
79
|
+
const outcome = await withTimeout(timeoutMs, signal, (timeoutSignal) => runSpawn(inv, promptText, cwd, timeoutSignal));
|
|
80
|
+
|
|
81
|
+
if (outcome.timedOut) {
|
|
82
|
+
return { text: "", timedOut: true, exitCode: null, errorMessage: `CLI "${inv.command}" timed out after ${timeoutMs}ms` };
|
|
83
|
+
}
|
|
84
|
+
if (!outcome.ok) {
|
|
85
|
+
// Non-timeout throw — likely ENOENT (CLI not installed).
|
|
86
|
+
const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
|
|
87
|
+
return { text: "", timedOut: false, exitCode: null, errorMessage: `CLI "${inv.command}" failed to run: ${message}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { stdout, code } = outcome.value;
|
|
91
|
+
// FR5 branch order (from rpiv-args): non-zero exit here. (Timeout is handled
|
|
92
|
+
// above via withTimeout aborting the subprocess; a kill surfaces as a throw.)
|
|
93
|
+
if (code !== 0) {
|
|
94
|
+
const detail = truncate(outcome.value.stderr || stdout, 500);
|
|
95
|
+
return { text: "", timedOut: false, exitCode: code, errorMessage: `CLI "${inv.command}" exited ${code}${detail ? `: ${detail}` : ""}` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const text = parseCliOutput(stdout, backend.command as CliCommand);
|
|
99
|
+
if (!text.trim()) {
|
|
100
|
+
return { text: "", timedOut: false, exitCode: 0, errorMessage: `CLI "${inv.command}" returned no usable output` };
|
|
101
|
+
}
|
|
102
|
+
return { text: text.trim(), timedOut: false, exitCode: 0 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Spawn the CLI, write the prompt to stdin, collect stdout/stderr, resolve on exit. */
|
|
106
|
+
function runSpawn(
|
|
107
|
+
inv: { command: string; args: string[] },
|
|
108
|
+
promptText: string,
|
|
109
|
+
cwd: string | undefined,
|
|
110
|
+
signal: AbortSignal,
|
|
111
|
+
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
let child;
|
|
114
|
+
try {
|
|
115
|
+
child = spawn(inv.command, inv.args, { cwd, stdio: ["pipe", "pipe", "pipe"], signal });
|
|
116
|
+
} catch (e) {
|
|
117
|
+
reject(e);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let stdout = "";
|
|
122
|
+
let stderr = "";
|
|
123
|
+
child.stdout?.on("data", (d) => { stdout += d.toString(); });
|
|
124
|
+
child.stderr?.on("data", (d) => { stderr += d.toString(); });
|
|
125
|
+
|
|
126
|
+
child.on("error", reject); // ENOENT etc.
|
|
127
|
+
child.on("close", (code) => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
128
|
+
|
|
129
|
+
// Write the prompt to stdin and close it so the CLI knows input is complete.
|
|
130
|
+
child.stdin?.on("error", reject);
|
|
131
|
+
child.stdin?.end(promptText);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Invocation resolution
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function resolveInvocation(backend: CliBackendConfig): { command: string; args: string[] } {
|
|
140
|
+
// Custom command path: user specified a command + args verbatim.
|
|
141
|
+
if (backend.args && backend.args.length > 0) {
|
|
142
|
+
return { command: String(backend.command), args: backend.args };
|
|
143
|
+
}
|
|
144
|
+
const preset = CLI_INVOCATIONS[backend.command as CliCommand];
|
|
145
|
+
if (preset) return preset;
|
|
146
|
+
// Unknown command name with no preset and no args — treat the string itself
|
|
147
|
+
// as a bare command (user-defined CLI).
|
|
148
|
+
return { command: String(backend.command), args: [] };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Prompt assembly — markdown transcript piped to stdin
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
function buildPromptText(systemPrompt: string, messages: Message[]): string {
|
|
156
|
+
const lines: string[] = [systemPrompt, "", "---", ""];
|
|
157
|
+
for (const msg of messages) {
|
|
158
|
+
const role = msg.role === "user" ? "User" : msg.role === "assistant" ? "Assistant" : "Tool result";
|
|
159
|
+
const text = messageToText(msg);
|
|
160
|
+
if (!text.trim()) continue;
|
|
161
|
+
lines.push(`=== ${role} ===`, text, "");
|
|
162
|
+
}
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function messageToText(msg: Message): string {
|
|
167
|
+
if (msg.role === "user") {
|
|
168
|
+
return typeof msg.content === "string" ? msg.content : msg.content.filter((b): b is { type: "text"; text: string } => b.type === "text").map((b) => b.text).join("\n");
|
|
169
|
+
}
|
|
170
|
+
if (msg.role === "assistant") {
|
|
171
|
+
return msg.content
|
|
172
|
+
.map((b) => {
|
|
173
|
+
if (b.type === "text") return b.text;
|
|
174
|
+
if (b.type === "toolCall") return `[tool call: ${b.name}]`;
|
|
175
|
+
if (b.type === "thinking") return "";
|
|
176
|
+
return "";
|
|
177
|
+
})
|
|
178
|
+
.filter(Boolean)
|
|
179
|
+
.join("\n");
|
|
180
|
+
}
|
|
181
|
+
return msg.content.filter((b): b is { type: "text"; text: string } => b.type === "text").map((b) => b.text).join("\n");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Defensive output parsing
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Parse CLI stdout into advisor text.
|
|
190
|
+
*
|
|
191
|
+
* - JSONL producers (codex, opencode): scan lines for `{"type":"item.completed",...}`
|
|
192
|
+
* or any line that JSON-parses to an object with a `.text` / `.item.text` field.
|
|
193
|
+
* Ignore everything else (deprecation notices, progress chatter, auth warnings).
|
|
194
|
+
* - Plain-text producers (claude): return the trimmed stdout.
|
|
195
|
+
*
|
|
196
|
+
* The junk-preamble tolerance is the whole point: a real codex run prints a
|
|
197
|
+
* "Using model X" line and sometimes a warning before the payload. We must not
|
|
198
|
+
* crash or return that junk as the advisor's reply.
|
|
199
|
+
*/
|
|
200
|
+
export function parseCliOutput(stdout: string, command: CliCommand): string {
|
|
201
|
+
const trimmed = stdout.trim();
|
|
202
|
+
if (!trimmed) return "";
|
|
203
|
+
|
|
204
|
+
// JSONL producers: collect text from every parseable line that carries it.
|
|
205
|
+
if (command === "codex" || command === "opencode") {
|
|
206
|
+
const collected: string[] = [];
|
|
207
|
+
for (const line of trimmed.split("\n")) {
|
|
208
|
+
const payload = extractJsonlText(line.trim());
|
|
209
|
+
if (payload) collected.push(payload);
|
|
210
|
+
}
|
|
211
|
+
if (collected.length > 0) return collected.join("\n");
|
|
212
|
+
// Fall through to plain text if no JSONL payload was found — some codex
|
|
213
|
+
// builds print plain text despite the documented JSONL contract.
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Plain text: return as-is (already trimmed).
|
|
217
|
+
return trimmed;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Try to extract advisor text from one JSONL line. Returns undefined for lines
|
|
222
|
+
* that aren't JSON, or JSON without a recognizable text field.
|
|
223
|
+
*/
|
|
224
|
+
function extractJsonlText(line: string): string | undefined {
|
|
225
|
+
if (!line.startsWith("{")) return undefined;
|
|
226
|
+
let parsed: unknown;
|
|
227
|
+
try {
|
|
228
|
+
parsed = JSON.parse(line);
|
|
229
|
+
} catch {
|
|
230
|
+
return undefined; // junk preamble that happens to start with '{' — skip
|
|
231
|
+
}
|
|
232
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
233
|
+
|
|
234
|
+
// codex/opencode shape: { type: "item.completed", item: { text: "..." } }
|
|
235
|
+
const obj = parsed as Record<string, unknown>;
|
|
236
|
+
const item = obj.item;
|
|
237
|
+
if (item && typeof item === "object") {
|
|
238
|
+
const t = (item as Record<string, unknown>).text;
|
|
239
|
+
if (typeof t === "string" && t.trim()) return t;
|
|
240
|
+
}
|
|
241
|
+
// Generic shape: { text: "..." } at the top level.
|
|
242
|
+
if (typeof obj.text === "string" && obj.text.trim()) return obj.text;
|
|
243
|
+
// message.content array shape (some CLIs echo the prompt schema).
|
|
244
|
+
if (Array.isArray(obj.content)) {
|
|
245
|
+
const text = obj.content
|
|
246
|
+
.map((c) => (c && typeof c === "object" && typeof (c as Record<string, unknown>).text === "string" ? (c as Record<string, unknown>).text : null))
|
|
247
|
+
.filter((x): x is string => !!x)
|
|
248
|
+
.join("\n");
|
|
249
|
+
if (text.trim()) return text;
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function truncate(s: string, max: number): string {
|
|
255
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
256
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config — persisted bpx-consult config.
|
|
3
|
+
*
|
|
4
|
+
* Lives at the pi-native path `~/.pi/agent/bpx-consult.json` (not rpiv's
|
|
5
|
+
* `~/.config` convention) because bpx-consult is a pi extension first and
|
|
6
|
+
* should sit alongside pi's own state. Reuses @juicesharp/rpiv-config for
|
|
7
|
+
* the crash-resistant load/save + TypeBox-driven validate primitives rather
|
|
8
|
+
* than hand-rolling JSON I/O.
|
|
9
|
+
*
|
|
10
|
+
* Schema mirrors SPEC §X. personas and backends are intentionally left open
|
|
11
|
+
* (additionalProperties) so user-defined persona names and CLI commands
|
|
12
|
+
* survive validation instead of being stripped by Value.Clean.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
18
|
+
import { loadJsonConfig, saveJsonConfig, validateConfig } from "@juicesharp/rpiv-config";
|
|
19
|
+
import { type Static, type TObject, Type } from "typebox";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Primitives
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const ThinkingLevelSchema = Type.Union(
|
|
26
|
+
[
|
|
27
|
+
Type.Literal("minimal"),
|
|
28
|
+
Type.Literal("low"),
|
|
29
|
+
Type.Literal("medium"),
|
|
30
|
+
Type.Literal("high"),
|
|
31
|
+
Type.Literal("xhigh"),
|
|
32
|
+
],
|
|
33
|
+
{ description: "Reasoning effort. Mirrors @earendil-works/pi-ai ThinkingLevel." },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const FeedbackModeSchema = Type.Union(
|
|
37
|
+
[Type.Literal("show"), Type.Literal("pipe"), Type.Literal("steer")],
|
|
38
|
+
{ description: "How the advisor's response reaches the executor." },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const ConsultModeSchema = Type.Union(
|
|
42
|
+
[Type.Literal("solo"), Type.Literal("council"), Type.Literal("debate"), Type.Literal("gut-check")],
|
|
43
|
+
{ description: "Consultation mode selected when consult() is called." },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** A provider/model string plus an optional effort. Shared by every mode entry. */
|
|
47
|
+
const ModelEntrySchema = Type.Object(
|
|
48
|
+
{
|
|
49
|
+
model: Type.Optional(Type.String({ description: 'provider/model key, e.g. "anthropic/claude-sonnet-4-6"' })),
|
|
50
|
+
thinkingLevel: Type.Optional(ThinkingLevelSchema),
|
|
51
|
+
feedbackMode: Type.Optional(FeedbackModeSchema),
|
|
52
|
+
},
|
|
53
|
+
{ additionalProperties: true },
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Modes
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const SoloModeSchema = Type.Object(
|
|
61
|
+
{
|
|
62
|
+
model: Type.Optional(Type.String()),
|
|
63
|
+
thinkingLevel: Type.Optional(ThinkingLevelSchema),
|
|
64
|
+
feedbackMode: Type.Optional(FeedbackModeSchema),
|
|
65
|
+
// terse is honored when gut-check merges its config into solo. Caps the
|
|
66
|
+
// response so gut-check returns a short read, not an essay.
|
|
67
|
+
terse: Type.Optional(Type.Boolean()),
|
|
68
|
+
},
|
|
69
|
+
{ additionalProperties: true },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const GutCheckModeSchema = Type.Object(
|
|
73
|
+
{
|
|
74
|
+
model: Type.Optional(Type.String()),
|
|
75
|
+
thinkingLevel: Type.Optional(ThinkingLevelSchema),
|
|
76
|
+
terse: Type.Optional(Type.Boolean()),
|
|
77
|
+
feedbackMode: Type.Optional(FeedbackModeSchema),
|
|
78
|
+
},
|
|
79
|
+
{ additionalProperties: true },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const CouncilModeSchema = Type.Object(
|
|
83
|
+
{
|
|
84
|
+
members: Type.Optional(Type.Array(Type.String())),
|
|
85
|
+
synthesizer: Type.Optional(ModelEntrySchema),
|
|
86
|
+
parallel: Type.Optional(Type.Boolean()),
|
|
87
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 0, description: "Per-member wall-clock budget. 0 disables." })),
|
|
88
|
+
feedbackMode: Type.Optional(FeedbackModeSchema),
|
|
89
|
+
},
|
|
90
|
+
{ additionalProperties: true },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const DebateModeSchema = Type.Object(
|
|
94
|
+
{
|
|
95
|
+
advocate: Type.Optional(Type.String()),
|
|
96
|
+
critic: Type.Optional(Type.String()),
|
|
97
|
+
rounds: Type.Optional(Type.Integer({ minimum: 1, maximum: 4 })),
|
|
98
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 0, description: "Wall-clock budget for the whole debate (all rounds + synth). 0 disables." })),
|
|
99
|
+
feedbackMode: Type.Optional(FeedbackModeSchema),
|
|
100
|
+
},
|
|
101
|
+
{ additionalProperties: true },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const ModesSchema = Type.Object(
|
|
105
|
+
{
|
|
106
|
+
solo: Type.Optional(SoloModeSchema),
|
|
107
|
+
gutCheck: Type.Optional(GutCheckModeSchema),
|
|
108
|
+
council: Type.Optional(CouncilModeSchema),
|
|
109
|
+
debate: Type.Optional(DebateModeSchema),
|
|
110
|
+
},
|
|
111
|
+
{ additionalProperties: true },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Personas / backends (open — user-defined names must survive cleaning)
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
const PersonaSchema = Type.Object(
|
|
119
|
+
{
|
|
120
|
+
name: Type.Optional(Type.String()),
|
|
121
|
+
systemPrompt: Type.Optional(Type.String()),
|
|
122
|
+
stance: Type.Optional(Type.Union([Type.Literal("for"), Type.Literal("against"), Type.Literal("neutral")])),
|
|
123
|
+
defaultModel: Type.Optional(Type.String()),
|
|
124
|
+
thinkingLevel: Type.Optional(ThinkingLevelSchema),
|
|
125
|
+
},
|
|
126
|
+
{ additionalProperties: true },
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const BackendSchema = Type.Object(
|
|
130
|
+
{
|
|
131
|
+
type: Type.Optional(Type.Union([Type.Literal("inline"), Type.Literal("cli")])),
|
|
132
|
+
command: Type.Optional(Type.String()),
|
|
133
|
+
args: Type.Optional(Type.Array(Type.String())),
|
|
134
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
135
|
+
},
|
|
136
|
+
{ additionalProperties: true },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Triggers / context budget / disabled-for-models
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
const TriggersSchema = Type.Object(
|
|
144
|
+
{
|
|
145
|
+
onDone: Type.Optional(Type.Boolean()),
|
|
146
|
+
whenStuck: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
147
|
+
},
|
|
148
|
+
{ additionalProperties: true },
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const ContextBudgetSchema = Type.Object(
|
|
152
|
+
{
|
|
153
|
+
userChars: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
154
|
+
assistantChars: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
155
|
+
toolArgChars: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
156
|
+
toolResultChars: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
157
|
+
keepFirst: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
158
|
+
keepLast: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
159
|
+
responseReserveTokens: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
160
|
+
},
|
|
161
|
+
{ additionalProperties: true },
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const DisabledEntrySchema = Type.Union([
|
|
165
|
+
Type.String(),
|
|
166
|
+
Type.Object({ model: Type.String(), minEffort: Type.Optional(ThinkingLevelSchema) }, { additionalProperties: true }),
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Root schema
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
export const BpxConsultConfigSchema = Type.Object(
|
|
174
|
+
{
|
|
175
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
176
|
+
defaultMode: Type.Optional(ConsultModeSchema),
|
|
177
|
+
modes: Type.Optional(ModesSchema),
|
|
178
|
+
personas: Type.Optional(Type.Record(Type.String(), PersonaSchema)),
|
|
179
|
+
backends: Type.Optional(Type.Record(Type.String(), BackendSchema)),
|
|
180
|
+
triggers: Type.Optional(TriggersSchema),
|
|
181
|
+
feedbackMode: Type.Optional(FeedbackModeSchema),
|
|
182
|
+
contextBudget: Type.Optional(ContextBudgetSchema),
|
|
183
|
+
disabledForModels: Type.Optional(Type.Array(DisabledEntrySchema)),
|
|
184
|
+
},
|
|
185
|
+
{ additionalProperties: true },
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
export type BpxConsultConfig = Static<typeof BpxConsultConfigSchema>;
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Defaults
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Built-in defaults. validateConfig() merges Value.Create(schema) under the
|
|
196
|
+
* cleaned user value, so these are only used where the schema itself doesn't
|
|
197
|
+
* express a default. Anything that must be a specific value regardless of
|
|
198
|
+
* schema defaults lives here and is re-asserted after validation.
|
|
199
|
+
*/
|
|
200
|
+
export const DEFAULT_CONFIG: BpxConsultConfig = {
|
|
201
|
+
enabled: true,
|
|
202
|
+
defaultMode: "solo",
|
|
203
|
+
modes: {
|
|
204
|
+
solo: { model: "anthropic/claude-sonnet-4-6", thinkingLevel: "high" },
|
|
205
|
+
gutCheck: { model: "google/gemini-2.5-flash", thinkingLevel: "low", terse: true },
|
|
206
|
+
council: {
|
|
207
|
+
members: ["architect", "critic", "simplifier"],
|
|
208
|
+
synthesizer: { model: "anthropic/claude-sonnet-4-6", thinkingLevel: "high" },
|
|
209
|
+
parallel: true,
|
|
210
|
+
timeoutMs: 120000,
|
|
211
|
+
},
|
|
212
|
+
debate: { advocate: "architect", critic: "critic", rounds: 2, timeoutMs: 180000 },
|
|
213
|
+
},
|
|
214
|
+
// Per-persona default models. CRITICAL: members must NOT all share one model/tier,
|
|
215
|
+
// and should avoid sharing the executor's model — parallel calls to the same
|
|
216
|
+
// free-tier provider trip QPM rate limits and silently kill members (caught
|
|
217
|
+
// in live testing). Default roster spreads across Anthropic tiers so the
|
|
218
|
+
// out-of-box council survives a parallel fan-out. Users with only one
|
|
219
|
+
// provider authed should override this to that provider's distinct tiers.
|
|
220
|
+
personas: {
|
|
221
|
+
architect: { defaultModel: "anthropic/claude-opus-4-6", thinkingLevel: "high" }, // strong, for the design-for seat
|
|
222
|
+
critic: { defaultModel: "anthropic/claude-sonnet-4-6", thinkingLevel: "high" }, // different tier, forces genuine critique
|
|
223
|
+
simplifier: { defaultModel: "anthropic/claude-haiku-4-5", thinkingLevel: "medium" }, // cheap+fast, questions complexity
|
|
224
|
+
},
|
|
225
|
+
triggers: { onDone: false, whenStuck: 3 },
|
|
226
|
+
feedbackMode: "steer",
|
|
227
|
+
contextBudget: {
|
|
228
|
+
userChars: 2800,
|
|
229
|
+
assistantChars: 1800,
|
|
230
|
+
toolArgChars: 800,
|
|
231
|
+
toolResultChars: 2000,
|
|
232
|
+
keepFirst: 2,
|
|
233
|
+
keepLast: 12,
|
|
234
|
+
responseReserveTokens: 4096,
|
|
235
|
+
},
|
|
236
|
+
disabledForModels: [],
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Path
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolve the config path under pi's agent directory.
|
|
245
|
+
*
|
|
246
|
+
* We do NOT use rpiv-config.configPath() — that resolves under ~/.config,
|
|
247
|
+
* the rpiv family convention. bpx-consult lives in the pi ecosystem, so its
|
|
248
|
+
* state sits alongside pi's own (~/.pi/agent/) per SPEC §X. PI_CODING_AGENT_DIR
|
|
249
|
+
* is honoured if set, matching pi's own resolution (usage.md:293).
|
|
250
|
+
*/
|
|
251
|
+
export function bpxConfigPath(): string {
|
|
252
|
+
const base = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
253
|
+
return join(base, "bpx-consult.json");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Project-local config path: `<cwd>/.pi/bpx-consult.json`.
|
|
258
|
+
*
|
|
259
|
+
* SPEC §X precedence is env > project (.pi, trusted) > global > defaults.
|
|
260
|
+
* Project-local is only honoured when the project is trusted (pi's trust model
|
|
261
|
+
* — an untrusted repo must not be able to silently reconfigure the advisor).
|
|
262
|
+
* The caller passes trust state from ctx.isProjectTrusted().
|
|
263
|
+
*/
|
|
264
|
+
export function projectConfigPath(cwd: string): string {
|
|
265
|
+
return join(cwd, ".pi", "bpx-consult.json");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Load / save
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/** Options for loadConfig. Project-local config is only read when trusted. */
|
|
273
|
+
export interface LoadConfigOptions {
|
|
274
|
+
/** Current working directory (for project-local config discovery). */
|
|
275
|
+
cwd?: string;
|
|
276
|
+
/** Whether the project is trusted (ctx.isProjectTrusted()). Defaults true. */
|
|
277
|
+
projectTrusted?: boolean;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Load, clean, and validate the config against the schema.
|
|
282
|
+
*
|
|
283
|
+
* Precedence (SPEC §X): project (.pi, trusted) > global > defaults. Project
|
|
284
|
+
* config is deep-merged ON TOP of global, so a project can override e.g.
|
|
285
|
+
* personas.solo.model without re-stating the whole file. Both layers pass
|
|
286
|
+
* through validateConfig independently so a malformed project config can't
|
|
287
|
+
* corrupt a valid global one — the bad layer just collapses to {}.
|
|
288
|
+
*
|
|
289
|
+
* Fail-soft: missing files, malformed JSON, or validation failures all collapse
|
|
290
|
+
* to defaults rather than throwing — an unreadable config must never break the
|
|
291
|
+
* extension at startup.
|
|
292
|
+
*/
|
|
293
|
+
export function loadConfig(options: LoadConfigOptions = {}): BpxConsultConfig {
|
|
294
|
+
const globalRaw = loadJsonConfig<unknown>(bpxConfigPath());
|
|
295
|
+
let mergedRaw = globalRaw;
|
|
296
|
+
|
|
297
|
+
const trusted = options.projectTrusted ?? true;
|
|
298
|
+
if (trusted && options.cwd) {
|
|
299
|
+
const pPath = projectConfigPath(options.cwd);
|
|
300
|
+
const projectRaw = loadJsonConfig<unknown>(pPath);
|
|
301
|
+
mergedRaw = deepMerge(globalRaw, projectRaw);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const validated = validateConfig(BpxConsultConfigSchema as TObject, mergedRaw);
|
|
305
|
+
return mergeDefaults(validated);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Shallow-per-section deep merge for config objects. Project wins at the leaf;
|
|
310
|
+
* arrays replace (not concat) — council.members in project replaces global's.
|
|
311
|
+
* Unknown top-level keys are ignored (validateConfig strips them anyway).
|
|
312
|
+
*/
|
|
313
|
+
function deepMerge(global: unknown, project: unknown): unknown {
|
|
314
|
+
if (!isObject(global)) return isObject(project) ? project : {};
|
|
315
|
+
if (!isObject(project)) return global;
|
|
316
|
+
const out: Record<string, unknown> = { ...global };
|
|
317
|
+
for (const [k, pv] of Object.entries(project as Record<string, unknown>)) {
|
|
318
|
+
const gv = (global as Record<string, unknown>)[k];
|
|
319
|
+
if (isObject(gv) && isObject(pv)) {
|
|
320
|
+
out[k] = deepMerge(gv, pv);
|
|
321
|
+
} else {
|
|
322
|
+
out[k] = pv;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
329
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Persist config. Returns true on successful write (see saveJsonConfig contract). */
|
|
333
|
+
export function saveConfig(config: BpxConsultConfig): boolean {
|
|
334
|
+
return saveJsonConfig(bpxConfigPath(), config);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Deep-merge user config over built-in defaults, section by section.
|
|
339
|
+
*
|
|
340
|
+
* User values win at the leaf level; missing leaves fall back to DEFAULT_CONFIG.
|
|
341
|
+
* This is deliberately not a generic deep-merge — the shape is fixed and small,
|
|
342
|
+
* so an explicit per-section spread is easier to reason about than a recursive
|
|
343
|
+
* helper that has to special-case arrays (council.members must replace, not
|
|
344
|
+
* concat) and open records (personas/backends).
|
|
345
|
+
*/
|
|
346
|
+
function mergeDefaults(user: BpxConsultConfig): BpxConsultConfig {
|
|
347
|
+
return {
|
|
348
|
+
enabled: user.enabled ?? DEFAULT_CONFIG.enabled,
|
|
349
|
+
defaultMode: user.defaultMode ?? DEFAULT_CONFIG.defaultMode,
|
|
350
|
+
modes: {
|
|
351
|
+
solo: { ...DEFAULT_CONFIG.modes?.solo, ...user.modes?.solo },
|
|
352
|
+
gutCheck: { ...DEFAULT_CONFIG.modes?.gutCheck, ...user.modes?.gutCheck },
|
|
353
|
+
council: { ...DEFAULT_CONFIG.modes?.council, ...user.modes?.council },
|
|
354
|
+
debate: { ...DEFAULT_CONFIG.modes?.debate, ...user.modes?.debate },
|
|
355
|
+
},
|
|
356
|
+
personas: user.personas ?? {},
|
|
357
|
+
backends: user.backends ?? {},
|
|
358
|
+
triggers: { ...DEFAULT_CONFIG.triggers, ...user.triggers },
|
|
359
|
+
feedbackMode: user.feedbackMode ?? DEFAULT_CONFIG.feedbackMode,
|
|
360
|
+
contextBudget: { ...DEFAULT_CONFIG.contextBudget, ...user.contextBudget },
|
|
361
|
+
disabledForModels: user.disabledForModels ?? DEFAULT_CONFIG.disabledForModels,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Disabled-for-models — policy helper (mirrors rpiv-advisor's shape)
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
export type DisabledForModelsEntry = string | { model: string; minEffort?: ThinkingLevel };
|
|
370
|
+
|
|
371
|
+
const EFFORT_ORDINAL: readonly ThinkingLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Should consult be suppressed for this executor model?
|
|
375
|
+
*
|
|
376
|
+
* - A bare string entry disables unconditionally.
|
|
377
|
+
* - An object entry disables only below its minEffort (so a user can say
|
|
378
|
+
* "don't bother consulting when I'm already on opus at high").
|
|
379
|
+
*/
|
|
380
|
+
/**
|
|
381
|
+
* Resolve a backend for a model key. Looks up `config.backends[modelKey]`;
|
|
382
|
+
* returns undefined (→ inline default) when no CLI override is configured.
|
|
383
|
+
*
|
|
384
|
+
* The backend map is keyed by the same provider/model string used everywhere
|
|
385
|
+
* else, so a user can say "route codex/codex to the codex CLI" without touching
|
|
386
|
+
* the rest of the config. A backend entry with no `type` defaults to inline.
|
|
387
|
+
*/
|
|
388
|
+
export function resolveBackend(config: BpxConsultConfig, modelKey: string | undefined): { type: "cli"; command: string; args?: string[]; timeoutMs?: number } | { type: "inline" } | undefined {
|
|
389
|
+
if (!modelKey) return undefined;
|
|
390
|
+
const entry = config.backends?.[modelKey];
|
|
391
|
+
if (!entry) return undefined;
|
|
392
|
+
if (entry.type === "cli") {
|
|
393
|
+
return {
|
|
394
|
+
type: "cli",
|
|
395
|
+
command: typeof entry.command === "string" ? entry.command : "codex",
|
|
396
|
+
args: Array.isArray(entry.args) ? entry.args : undefined,
|
|
397
|
+
timeoutMs: typeof entry.timeoutMs === "number" ? entry.timeoutMs : undefined,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
return { type: "inline" };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function isDisabledForModel(
|
|
404
|
+
entries: DisabledForModelsEntry[] | undefined,
|
|
405
|
+
executorModelLabel: string,
|
|
406
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
407
|
+
): boolean {
|
|
408
|
+
if (!entries || entries.length === 0) return false;
|
|
409
|
+
const executorOrdinal = thinkingLevel ? EFFORT_ORDINAL.indexOf(thinkingLevel) : -1;
|
|
410
|
+
for (const entry of entries) {
|
|
411
|
+
if (typeof entry === "string") {
|
|
412
|
+
if (entry === executorModelLabel) return true;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (entry.model !== executorModelLabel) continue;
|
|
416
|
+
if (entry.minEffort === undefined) return true;
|
|
417
|
+
const threshold = EFFORT_ORDINAL.indexOf(entry.minEffort);
|
|
418
|
+
if (threshold === -1) return true; // unknown effort → treat as unconditional
|
|
419
|
+
if (executorOrdinal !== -1 && executorOrdinal < threshold) return true;
|
|
420
|
+
}
|
|
421
|
+
return false;
|
|
422
|
+
}
|