@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to @booplex/bpx-consult are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- v1: solo, council, debate, and gut-check consult modes.
|
|
12
|
+
- Context engine that fits the conversation to the advisor model's actual window (the §P fix).
|
|
13
|
+
- Triggers: onDone and whenStuck (loop + error detection), solo-only by design.
|
|
14
|
+
- CLI backend (codex/claude/opencode) via non-blocking subprocess.
|
|
15
|
+
- Wall-clock timeouts on council, debate, and CLI paths.
|
|
16
|
+
- Project-local config (`.pi/bpx-consult.json`, trusted projects only).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gabi (Booplex)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# bpx-consult — a council of AI advisors for pi
|
|
2
|
+
|
|
3
|
+
I kept losing my advisor mid-session. The tool I was using would die with "context window exceeded" right when I needed it most. Long debugging session, deep in a problem, and the second opinion I called for just errored out. The bug was straightforward: it forwarded the whole compacted session to the advisor model without checking whether that model's window could actually hold it. Point a smaller advisor at a session the executor had compacted to 128k and the call overflowed every time.
|
|
4
|
+
|
|
5
|
+
So I built my own. Started as "fix the window bug," became "while I'm here, let me get a real council instead of one advisor." `bpx-consult` is the result: one advisor when you want speed, several debating when the call actually matters, and the window-fit guarantee the whole thing exists to provide.
|
|
6
|
+
|
|
7
|
+
Four modes, all wired: **solo** (one model, fast), **council** (several models in parallel with stances and a synthesizer), **debate** (advocate vs critic, sequential rounds, closing verdict), and **gut-check** (one cheap model, terse read). Plus triggers that fire a consult automatically when you're stuck or when a turn finishes.
|
|
8
|
+
|
|
9
|
+
Works in [pi](https://pi.dev) (the coding agent, v0.80+).
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:@booplex/bpx-consult
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then restart your pi session. The `consult` tool and `/consult` command register automatically.
|
|
20
|
+
|
|
21
|
+
<details>
|
|
22
|
+
<summary>Install from source</summary>
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/gabelul/bpx-mono
|
|
26
|
+
cd bpx-mono
|
|
27
|
+
pi install ./packages/bpx-consult
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
</details>
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## The window bug, and the fix
|
|
35
|
+
|
|
36
|
+
The reason this extension exists. Every consult path runs the conversation through a context engine before it reaches the advisor model:
|
|
37
|
+
|
|
38
|
+
1. **Strip** the in-flight `consult()` call. Providers reject orphan tool calls.
|
|
39
|
+
2. **Cap** each message (user text, assistant text, tool args, tool results) with explicit `[truncated]` markers. Never silent drops.
|
|
40
|
+
3. **Fit to a sliding window.** Keep the first few messages (task framing) and the last several (freshest evidence). Drop oldest-first with an `[omitted]` marker.
|
|
41
|
+
4. **Reserve** tokens for the advisor's reply, derived live from *that* advisor's context window minus a response reserve.
|
|
42
|
+
|
|
43
|
+
The budget is read per-call from the advisor model's actual window via the registry, never a global constant. Point a 32k flash-tier advisor at a 128k session and it fits. Point an 8k CLI advisor at the same session and it still fits. Council fits every member to the *smallest* window in the roster so the weakest member can't overflow.
|
|
44
|
+
|
|
45
|
+
Every mode goes through this.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## The four modes
|
|
50
|
+
|
|
51
|
+
| mode | what it does | when to reach for it |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| **solo** | One advisor model, one response. | Default. Fast, cheap, the second opinion you reach for most days. |
|
|
54
|
+
| **council** | Several models in parallel, each with a stance (for/against/neutral) and a persona. A synthesizer merges their verdicts with a confidence score. | Real decisions. Architecture, "should I even do this," tricky bugs where one voice isn't enough. |
|
|
55
|
+
| **debate** | Advocate proposes, critic attacks, advocate rebuts. Sequential rounds (1–4), then a synthesizer issues a verdict. | Controversial calls where you want the strongest case on both sides before you commit. |
|
|
56
|
+
| **gut-check** | One cheap fast model, terse output. | The "does this smell off?" sanity check before you do something you're 90% sure about. |
|
|
57
|
+
|
|
58
|
+
Call `consult()` with no args and solo runs. Pass `mode: "council"` (or `debate`, `gut-check`) to pick another. Or type `/consult` to open the status read-out and edit the config file.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Council, in more detail
|
|
63
|
+
|
|
64
|
+
This is the reason I went past a bugfix. The default roster seats three personas: **architect** (advocates for the design), **critic** (attacks it), **simplifier** (questions the complexity). Each runs on a distinct model tier so parallel calls don't trip provider rate limits, and each gets a stance-injected system prompt.
|
|
65
|
+
|
|
66
|
+
The stance framing biases what a persona hunts for, never the verdict. A `for` persona can still land on "don't do this" if the evidence says so. The guardrail is baked into the prompt because the alternative is theater: a critic that rubber-stamps, an advocate that caves.
|
|
67
|
+
|
|
68
|
+
When members genuinely disagree, the synthesizer is told to **surface the split**, not paper over it. A false consensus is worse than an honest "the architect argued X, the critic demolished it, here's my call." Every council result carries a confidence score (`0.4·success + 0.35·agreement + 0.25·stance-alignment`). It's a rough signal, not a verdict. The "agreement" term measures whether members landed on the same stance regardless of persona, which is roster-shaped: a default for/against/neutral trio will score lower on agreement than three neutrals. Treat it as a dial, not a grade.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Triggers
|
|
73
|
+
|
|
74
|
+
Consults don't have to be manual. Two auto-triggers, both off by default:
|
|
75
|
+
|
|
76
|
+
- **whenStuck:N** fires after N consecutive tool errors *or* N identical tool calls (loop detection via an un-truncated `toolName:input` fingerprint). Default N = 3.
|
|
77
|
+
- **onDone** fires when the agent finishes a turn, then reviews the work.
|
|
78
|
+
|
|
79
|
+
Auto-triggers always run **solo**, regardless of your default mode. An auto-fire is a safety net, not a deliberate consultation. A council burning 3+ model calls every time you hit a loop would be a surprise-quota footgun. If you want a council, call it explicitly.
|
|
80
|
+
|
|
81
|
+
Triggers never fire in untrusted projects.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## How advice reaches the executor
|
|
86
|
+
|
|
87
|
+
A consult result can come back three ways, set with `feedbackMode` in the config. The default, **steer**, injects it as a steering message mid-run so you get the advice without leaving the flow and the executor sees it and continues. **pipe** injects it as a user message, so the executor treats it as your input. **show** is UI-only: you read it, the executor never sees it.
|
|
88
|
+
|
|
89
|
+
Auto-triggers always steer so they don't interrupt. Manual consults honor whatever you've configured.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Backends
|
|
94
|
+
|
|
95
|
+
Solo can route to an external CLI instead of pi's inline provider. Set `backends.<model>.type: "cli"` in the config. Supported CLIs: `codex`, `claude`, `opencode`. Each reads the fitted context from stdin. The subprocess is non-blocking, so it doesn't serialize under the hood.
|
|
96
|
+
|
|
97
|
+
In v1, the CLI backend is solo-only. Council members don't route to CLI yet. That's a [v1.1 goal](#whats-not-in-v1).
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Resilience
|
|
102
|
+
|
|
103
|
+
Two things that would silently bite, both handled.
|
|
104
|
+
|
|
105
|
+
Each council member runs under its own `AbortController` linked to the parent signal, so one member timing out or erroring drops only that member. The rest proceed and the synthesizer works with whoever replied. A flaky member never crashes the council.
|
|
106
|
+
|
|
107
|
+
Wall-clock timeouts cover the hang case across all modes. Council (`council.timeoutMs`, default 120s), debate (`debate.timeoutMs`, default 180s), and CLI (`timeoutMs` per backend) all have explicit budgets. A provider that accepts-then-hangs settles as a clean failure instead of hanging the executor turn.
|
|
108
|
+
|
|
109
|
+
What v1 does *not* have: per-member circuit-breaker with exponential backoff. Isolation plus timeouts is the resilience story today. Smarter retry is on the v1.1 list.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Config
|
|
114
|
+
|
|
115
|
+
`~/.pi/agent/bpx-consult.json` (global) or `.pi/bpx-consult.json` (project-local, trusted projects only). Project overrides global at the leaf level.
|
|
116
|
+
|
|
117
|
+
```jsonc
|
|
118
|
+
{
|
|
119
|
+
"defaultMode": "solo",
|
|
120
|
+
"modes": {
|
|
121
|
+
"solo": { "model": "anthropic/claude-sonnet-4-6", "thinkingLevel": "high" },
|
|
122
|
+
"gutCheck": { "model": "google/gemini-2.5-flash", "thinkingLevel": "low", "terse": true },
|
|
123
|
+
"council": { "members": ["architect", "critic", "simplifier"], "synthesizer": { "model": "anthropic/claude-sonnet-4-6" }, "parallel": true, "timeoutMs": 120000 },
|
|
124
|
+
"debate": { "advocate": "architect", "critic": "critic", "rounds": 2, "timeoutMs": 180000 }
|
|
125
|
+
},
|
|
126
|
+
"personas": {
|
|
127
|
+
"architect": { "defaultModel": "anthropic/claude-opus-4-6" },
|
|
128
|
+
"critic": { "defaultModel": "anthropic/claude-sonnet-4-6" },
|
|
129
|
+
"simplifier": { "defaultModel": "anthropic/claude-haiku-4-5" }
|
|
130
|
+
},
|
|
131
|
+
"backends": {
|
|
132
|
+
"openai/codex": { "type": "cli", "command": "codex", "timeoutMs": 60000 }
|
|
133
|
+
},
|
|
134
|
+
"triggers": { "onDone": false, "whenStuck": 3 },
|
|
135
|
+
"contextBudget": { "responseReserveTokens": 4096 }
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The defaults are pinned to specific model versions, which means they'll drift as Anthropic ships new ones. The registry supports tier aliases in some places; where it does, prefer an alias. Otherwise expect to update these periodically, or override `personas.*.defaultModel` with whatever you actually have authed.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## What's not in v1
|
|
144
|
+
|
|
145
|
+
Honest about the scope so the README doesn't drift from the code:
|
|
146
|
+
|
|
147
|
+
- **Council members don't route to CLI** (solo only). A mixed inline+CLI council, one `completeSimple` member plus one CLI member running in parallel, is the headline v1.1 goal. It's what the whole async-subprocess decision is there to enable.
|
|
148
|
+
- **No per-member circuit-breaker / exponential backoff.** Resilience is isolation (`Promise.allSettled`) plus wall-clock timeouts. Smarter retry lands in v1.1.
|
|
149
|
+
- **No MCP delegation backend.** A council seat calling another MCP's consensus tool. v2.
|
|
150
|
+
- **No memory compression / branched-session handoff.** v2.
|
|
151
|
+
|
|
152
|
+
The [SPEC](./SPEC.md) has the full design, including the v1.1 and v2 sections.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Prerequisites
|
|
157
|
+
|
|
158
|
+
- pi 0.80+ (uses the `@earendil-works/pi-ai/compat` `completeSimple` entry, event handlers, `sendUserMessage`)
|
|
159
|
+
- At least one provider authed via `/login`. The default roster uses Anthropic; override `personas.*.defaultModel` to match what you have.
|
|
160
|
+
- For the CLI backend: `codex`, `claude`, or `opencode` installed and on PATH.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Related
|
|
165
|
+
|
|
166
|
+
Other tools for agents that care about quality:
|
|
167
|
+
|
|
168
|
+
- **[slopbuster](https://github.com/gabelul/slopbuster)** — AI text humanizer. 100+ patterns, two-pass audit, three-tier scoring. Makes AI-generated prose, code comments, and academic writing sound human.
|
|
169
|
+
- **[pixelslop](https://github.com/gabelul/pixelslop)** — Design quality scanner. Opens real pages in Playwright, measures actual pixels, catches visual AI slop.
|
|
170
|
+
- **[stitch-kit](https://github.com/gabelul/stitch-kit)** — Design superpowers for AI coding agents. 35 skills for ideation, generation, iteration, and production conversion via Google Stitch MCP.
|
|
171
|
+
- **[claude-code-skill-activator](https://github.com/gabelul/claude-code-skill-activator)** — Skill auto-detection for Claude Code. AI extracts keywords once, then fast offline matching suggests skills as you type.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
Built by Gabi @ [Booplex.com](https://booplex.com) — because the advisor that dies when you need it most isn't an advisor, it's a liability. MIT license.
|
package/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bpx-consult — Pi extension entry point.
|
|
3
|
+
*
|
|
4
|
+
* Registers the `consult()` tool and the `/consult` command. When the executor
|
|
5
|
+
* calls consult() with no args, solo runs (one advisor model, context-fitted).
|
|
6
|
+
* mode: "council" | "debate" | "gut-check" select the other modes.
|
|
7
|
+
*
|
|
8
|
+
* Config persists at ~/.pi/agent/bpx-consult.json.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Type } from "typebox";
|
|
13
|
+
import { isDisabledForModel, loadConfig } from "./src/config.js";
|
|
14
|
+
import { executeSolo } from "./src/solo.js";
|
|
15
|
+
import { executeCouncil } from "./src/council.js";
|
|
16
|
+
import { executeDebate } from "./src/debate.js";
|
|
17
|
+
import { registerTriggers } from "./src/triggers.js";
|
|
18
|
+
import {
|
|
19
|
+
CONSULT_DESCRIPTION,
|
|
20
|
+
CONSULT_TOOL_NAME,
|
|
21
|
+
DEFAULT_PROMPT_GUIDELINES,
|
|
22
|
+
DEFAULT_PROMPT_SNIPPET,
|
|
23
|
+
TOOL_LABEL,
|
|
24
|
+
} from "./src/messages.js";
|
|
25
|
+
|
|
26
|
+
const ConsultParams = Type.Object({
|
|
27
|
+
mode: Type.Optional(
|
|
28
|
+
Type.Union([Type.Literal("solo"), Type.Literal("council"), Type.Literal("debate"), Type.Literal("gut-check")]),
|
|
29
|
+
),
|
|
30
|
+
persona: Type.Optional(Type.String({ description: "Persona name (council mode), e.g. architect, critic." })),
|
|
31
|
+
question: Type.Optional(Type.String({ description: "Optional specific question to focus the advisor." })),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export default function bpxConsult(pi: ExtensionAPI): void {
|
|
35
|
+
registerConsultTool(pi);
|
|
36
|
+
registerConsultCommand(pi);
|
|
37
|
+
registerTriggers(pi);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function registerConsultTool(pi: ExtensionAPI): void {
|
|
41
|
+
pi.registerTool({
|
|
42
|
+
name: CONSULT_TOOL_NAME,
|
|
43
|
+
label: TOOL_LABEL,
|
|
44
|
+
description: CONSULT_DESCRIPTION,
|
|
45
|
+
promptSnippet: DEFAULT_PROMPT_SNIPPET,
|
|
46
|
+
promptGuidelines: DEFAULT_PROMPT_GUIDELINES,
|
|
47
|
+
parameters: ConsultParams,
|
|
48
|
+
|
|
49
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
50
|
+
const config = loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
|
|
51
|
+
|
|
52
|
+
// Disabled for this executor model? Bail with a short explanation so
|
|
53
|
+
// the executor knows consult is intentionally off, not broken.
|
|
54
|
+
if (ctx.model) {
|
|
55
|
+
const executorLabel = `${ctx.model.provider}/${ctx.model.id}`;
|
|
56
|
+
const tl = pi.getThinkingLevel();
|
|
57
|
+
// getThinkingLevel() returns ModelThinkingLevel (includes "off"); narrow it.
|
|
58
|
+
const thinkingLevel = tl === "off" ? undefined : tl;
|
|
59
|
+
if (isDisabledForModel(config.disabledForModels as never, executorLabel, thinkingLevel)) {
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: "text", text: `consult is disabled for ${executorLabel}.` }],
|
|
62
|
+
details: { mode: "disabled", advisorModel: "(disabled)" },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const mode = params.mode ?? config.defaultMode ?? "solo";
|
|
68
|
+
|
|
69
|
+
if (mode === "council") {
|
|
70
|
+
return executeCouncil({ ctx, config, signal, onUpdate, question: params.question });
|
|
71
|
+
}
|
|
72
|
+
if (mode === "debate") {
|
|
73
|
+
return executeDebate({ ctx, config, signal, onUpdate, question: params.question });
|
|
74
|
+
}
|
|
75
|
+
if (mode === "gut-check") {
|
|
76
|
+
// Gut-check = solo run against the cheap model in modes.gutCheck.
|
|
77
|
+
// Override modes.solo so executeSolo uses the gutCheck model + effort.
|
|
78
|
+
const gutCheck = config.modes?.gutCheck;
|
|
79
|
+
if (gutCheck?.model) {
|
|
80
|
+
const gutConfig = { ...config, modes: { ...config.modes, solo: { ...config.modes?.solo, ...gutCheck } } };
|
|
81
|
+
return executeSolo({ ctx, config: gutConfig, signal, onUpdate, question: params.question });
|
|
82
|
+
}
|
|
83
|
+
return executeSolo({ ctx, config, signal, onUpdate, question: params.question });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return executeSolo({ ctx, config, signal, onUpdate, question: params.question });
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function registerConsultCommand(pi: ExtensionAPI): void {
|
|
92
|
+
pi.registerCommand("consult", {
|
|
93
|
+
description: "Configure bpx-consult: status, model, mode, or toggle on/off.",
|
|
94
|
+
async handler(_args, ctx) {
|
|
95
|
+
// Minimal v1: status read-out. The full fuzzy model-picker (lifted from
|
|
96
|
+
// rpiv-advisor/advisor-ui.ts + fuzzy.ts) lands with the picker step.
|
|
97
|
+
const config = loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
|
|
98
|
+
const solo = config.modes?.solo;
|
|
99
|
+
const lines = [
|
|
100
|
+
`bpx-consult status`,
|
|
101
|
+
` enabled : ${config.enabled ?? true}`,
|
|
102
|
+
` defaultMode: ${config.defaultMode}`,
|
|
103
|
+
` solo model : ${solo?.model ?? "(none)"}`,
|
|
104
|
+
` effort : ${solo?.thinkingLevel ?? "(default)"}`,
|
|
105
|
+
` triggers : onDone=${config.triggers?.onDone ?? false}, whenStuck=${config.triggers?.whenStuck ?? 3}`,
|
|
106
|
+
``,
|
|
107
|
+
`Edit ~/.pi/agent/bpx-consult.json to change settings.`,
|
|
108
|
+
];
|
|
109
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@booplex/bpx-consult",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension. A council of AI advisors — consult one model or run a full multi-model consensus before you commit to a direction.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"bpx",
|
|
9
|
+
"advisor",
|
|
10
|
+
"council",
|
|
11
|
+
"consensus",
|
|
12
|
+
"multi-model"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "gabelul",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/gabelul/bpx-mono.git",
|
|
20
|
+
"directory": "packages/bpx-consult"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/gabelul/bpx-mono/tree/main/packages/bpx-consult#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/gabelul/bpx-mono/issues"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "vitest run"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"index.ts",
|
|
34
|
+
"src/",
|
|
35
|
+
"prompts/",
|
|
36
|
+
"README.md",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./index.ts"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@juicesharp/rpiv-config": "^1.20.0",
|
|
47
|
+
"typebox": "^1.1.24"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@earendil-works/pi-ai": "*",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
52
|
+
"@earendil-works/pi-tui": "*"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
You are an advisor model in a coding-agent consult-strategy pattern. You are
|
|
2
|
+
consulted mid-task by the executor model for a second opinion before it commits
|
|
3
|
+
to a direction.
|
|
4
|
+
|
|
5
|
+
You see the executor's conversation transcript, including every tool call it
|
|
6
|
+
made and every result it saw. Your job is to give direct, actionable guidance.
|
|
7
|
+
|
|
8
|
+
Return ONE of:
|
|
9
|
+
- A PLAN — the next concrete steps, in order, when the executor is exploring or
|
|
10
|
+
deciding how to approach the work.
|
|
11
|
+
- A CORRECTION — when the executor is about to do the wrong thing, has the wrong
|
|
12
|
+
model of the problem, or is building on a false assumption. Name the error and
|
|
13
|
+
the fix.
|
|
14
|
+
- A STOP signal — when the work is already done and sound, or when the executor
|
|
15
|
+
should not proceed at all.
|
|
16
|
+
|
|
17
|
+
Rules:
|
|
18
|
+
- You NEVER call tools. You advise; the executor acts.
|
|
19
|
+
- You do not produce user-facing output. Your reply goes back to the executor as
|
|
20
|
+
a tool result.
|
|
21
|
+
- Be concrete. Cite the specific file, line, command, or assumption you are
|
|
22
|
+
reacting to. No generic best-practice boilerplate.
|
|
23
|
+
- If you do not have enough information to advise, say exactly what is missing.
|
|
24
|
+
- When you disagree with the executor's direction, say so plainly and explain
|
|
25
|
+
why. Do not manufacture agreement. A confident "this is wrong, here is why"
|
|
26
|
+
is more useful than a polite hedge.
|
|
27
|
+
- Bias toward the simplest approach that solves the real problem. Question
|
|
28
|
+
complexity that is not justified.
|
package/src/advisor.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advisor — the shared model-resolution + side-call core.
|
|
3
|
+
*
|
|
4
|
+
* Every mode (solo, council member, debate side, synthesizer) eventually needs
|
|
5
|
+
* the same thing: given a provider/model string + thinking level, resolve it to
|
|
6
|
+
* a Model object, fetch auth, run completeSimple against a fitted context, and
|
|
7
|
+
* return the text. That lives here so the modes stay small.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Api, Message, Model, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
11
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
12
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { parseModelKey } from "@juicesharp/rpiv-config";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Resolution
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface ResolvedAdvisor {
|
|
20
|
+
model: Model<Api>;
|
|
21
|
+
label: string; // "provider/modelId" for display
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a "provider/model" string to a Model via the registry.
|
|
26
|
+
*
|
|
27
|
+
* Returns undefined if the string is malformed or the model isn't registered.
|
|
28
|
+
* Callers surface a configured error rather than throwing — a missing advisor
|
|
29
|
+
* model is a config problem, not a crash.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveAdvisor(ctx: ExtensionContext, modelKey: string | undefined): ResolvedAdvisor | undefined {
|
|
32
|
+
if (!modelKey) return undefined;
|
|
33
|
+
const parsed = parseModelKey(modelKey);
|
|
34
|
+
if (!parsed) return undefined;
|
|
35
|
+
const model = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
|
|
36
|
+
if (!model) return undefined;
|
|
37
|
+
return { model, label: `${parsed.provider}/${parsed.modelId}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fetch API key + headers for a model. Wraps the registry call in the same
|
|
42
|
+
* ok/error shape rpiv-advisor uses so error text is consistent.
|
|
43
|
+
*/
|
|
44
|
+
export async function getAuth(ctx: ExtensionContext, model: Model<Api>): Promise<
|
|
45
|
+
| { ok: true; apiKey: string; headers: Record<string, string> }
|
|
46
|
+
| { ok: false; error: string }
|
|
47
|
+
> {
|
|
48
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
49
|
+
if (!auth.ok) return { ok: false, error: auth.error };
|
|
50
|
+
if (!auth.apiKey) return { ok: false, error: "no api key resolved" };
|
|
51
|
+
return { ok: true, apiKey: auth.apiKey, headers: auth.headers ?? {} };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// The side-call
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export interface ConsultCallInput {
|
|
59
|
+
ctx: ExtensionContext;
|
|
60
|
+
advisor: ResolvedAdvisor;
|
|
61
|
+
systemPrompt: string;
|
|
62
|
+
messages: Message[];
|
|
63
|
+
thinkingLevel?: ThinkingLevel;
|
|
64
|
+
signal: AbortSignal | undefined;
|
|
65
|
+
/** Session id for prompt-cache routing on repeated consultations. Optional. */
|
|
66
|
+
sessionId?: string;
|
|
67
|
+
/** Cap on the advisor's response tokens. Enforces responseReserveTokens on the output side. Optional. */
|
|
68
|
+
maxTokens?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ConsultCallResult {
|
|
72
|
+
text: string;
|
|
73
|
+
usage: { input: number; output: number; total: number } | undefined;
|
|
74
|
+
stopReason: string;
|
|
75
|
+
errorMessage?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run a single advisor side-call. Used by solo directly; council calls it once
|
|
80
|
+
* per member in parallel.
|
|
81
|
+
*
|
|
82
|
+
* `tools: []` reaffirms the "advisor never calls tools" contract even when the
|
|
83
|
+
* forwarded messages contain prior toolCall/toolResult blocks — same guard as
|
|
84
|
+
* rpiv-advisor (btw.ts:235). The advisor reads and advises; it does not act.
|
|
85
|
+
*/
|
|
86
|
+
export async function callAdvisor(input: ConsultCallInput): Promise<ConsultCallResult> {
|
|
87
|
+
const { ctx, advisor, systemPrompt, messages, thinkingLevel, signal } = input;
|
|
88
|
+
const auth = await getAuth(ctx, advisor.model);
|
|
89
|
+
|
|
90
|
+
if (!auth.ok) {
|
|
91
|
+
return { text: "", usage: undefined, stopReason: "error", errorMessage: auth.error };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const response = await completeSimple(
|
|
95
|
+
advisor.model,
|
|
96
|
+
{ systemPrompt, messages, tools: [] },
|
|
97
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal, reasoning: thinkingLevel, sessionId: input.sessionId, maxTokens: input.maxTokens },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const text = response.content
|
|
101
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
102
|
+
.map((c) => c.text)
|
|
103
|
+
.join("\n")
|
|
104
|
+
.trim();
|
|
105
|
+
|
|
106
|
+
// Reasoning models sometimes reply with thinking blocks but no text. Fall
|
|
107
|
+
// back to the thinking content rather than reporting an empty response — the
|
|
108
|
+
// advisor did speak, just in its reasoning channel. Mirrors pi-advisor.
|
|
109
|
+
if (!text) {
|
|
110
|
+
const thinking = response.content
|
|
111
|
+
.filter((c): c is { type: "thinking"; thinking: string } => c.type === "thinking" && typeof (c as { thinking?: string }).thinking === "string")
|
|
112
|
+
.map((c) => (c as { thinking: string }).thinking)
|
|
113
|
+
.join("\n")
|
|
114
|
+
.trim();
|
|
115
|
+
if (thinking) {
|
|
116
|
+
return {
|
|
117
|
+
text: `(reasoning)\n${thinking}`,
|
|
118
|
+
usage: response.usage ? { input: response.usage.input, output: response.usage.output, total: response.usage.totalTokens } : undefined,
|
|
119
|
+
stopReason: response.stopReason,
|
|
120
|
+
errorMessage: response.errorMessage,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
text,
|
|
127
|
+
usage: response.usage
|
|
128
|
+
? {
|
|
129
|
+
input: response.usage.input,
|
|
130
|
+
output: response.usage.output,
|
|
131
|
+
total: response.usage.totalTokens,
|
|
132
|
+
}
|
|
133
|
+
: undefined,
|
|
134
|
+
stopReason: response.stopReason,
|
|
135
|
+
errorMessage: response.errorMessage,
|
|
136
|
+
};
|
|
137
|
+
}
|