@juicesharp/rpiv-advisor 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -50
- package/advisor/execute.ts +87 -44
- package/advisor/pi-compat.test.ts +24 -0
- package/advisor/pi-compat.ts +26 -0
- package/advisor/register.ts +1 -0
- package/docs/configuration.md +142 -0
- package/docs/tool-reference.md +140 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,78 +1,127 @@
|
|
|
1
|
-
# rpiv-advisor
|
|
1
|
+
# @juicesharp/rpiv-advisor
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@juicesharp/rpiv-advisor)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
2
5
|
|
|
3
6
|
<div align="center">
|
|
4
7
|
<a href="https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-advisor">
|
|
5
8
|
<picture>
|
|
6
|
-
<img src="https://raw.githubusercontent.com/juicesharp/rpiv-mono/main/packages/rpiv-advisor/docs/cover.png" alt="rpiv-advisor cover" width="50%">
|
|
9
|
+
<img src="https://raw.githubusercontent.com/juicesharp/rpiv-mono/main/packages/rpiv-advisor/docs/cover.png" alt="rpiv-advisor cover: an executor model calling advisor() and a stronger reviewer answering with a plan, a correction, or a stop signal" width="50%">
|
|
7
10
|
</picture>
|
|
8
11
|
</a>
|
|
9
12
|
</div>
|
|
10
13
|
|
|
11
|
-
Let the model
|
|
12
|
-
|
|
13
|
-

|
|
14
|
-
|
|
15
|
-
## Features
|
|
16
|
-
|
|
17
|
-
- **Reviewer model selector** - `/advisor` opens a picker over any model in Pi's registry, plus a reasoning-effort picker for reasoning-capable models. Start typing to fuzzy-filter the list by model name or `provider/id`.
|
|
18
|
-
- **Persisted across sessions** - selection saved at `~/.config/rpiv-advisor/advisor.json` (chmod 0600).
|
|
19
|
-
- **Off by default** - the `advisor` tool is excluded until you pick a model; choose "No advisor" to disable.
|
|
20
|
-
- **Per-executor blocklist** - list executor models in `disabledForModels` (in `advisor.json`) to strip the `advisor` tool when those models drive the session. Entries can be plain strings (block at any effort) or `{ "model": "<provider:id>", "minEffort": "<level>" }` to block only when the executor's effort meets or exceeds the threshold. Available levels, lowest to highest: `minimal`, `low`, `medium`, `high`, `xhigh`.
|
|
21
|
-
- **Zero-parameter handoff** - calling `advisor` forwards the full serialized conversation branch; no manual prompt needed.
|
|
14
|
+
Let the model you're working with hand its whole conversation to a second, stronger model and get back a plan, a correction, or a stop signal — then keep going. `rpiv-advisor` adds the zero-parameter `advisor` tool and the `/advisor` picker to [Pi Agent](https://github.com/badlogic/pi-mono), so you can drive a session with a fast model and keep a stronger reviewer one call away.
|
|
22
15
|
|
|
23
16
|
## Install
|
|
24
17
|
|
|
25
|
-
```
|
|
18
|
+
```sh
|
|
26
19
|
pi install npm:@juicesharp/rpiv-advisor
|
|
27
20
|
```
|
|
28
21
|
|
|
29
|
-
|
|
22
|
+
Restart your Pi session.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
Nothing happens until you pick a reviewer. Run:
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
```
|
|
29
|
+
/advisor
|
|
30
|
+
```
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
You get a picker over every model Pi already has credentials for — start typing
|
|
33
|
+
to filter it by model name, provider, or `provider/id`. Pick one, and if it
|
|
34
|
+
supports reasoning you get a second picker for its effort level (`high` is
|
|
35
|
+
recommended). Pi confirms with `Advisor: <model>, <effort>`, and the `advisor`
|
|
36
|
+
tool goes live for this and every future session.
|
|
37
|
+
|
|
38
|
+
If the model you want isn't in the list, its provider isn't authenticated yet —
|
|
39
|
+
run Pi's `/login` for that provider, then re-run `/advisor`.
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
From there the executor model calls `advisor()` on its own when it needs stronger
|
|
44
|
+
judgment. To turn it back off, run `/advisor` and choose **No advisor**.
|
|
45
|
+
|
|
46
|
+
## What you get
|
|
47
|
+
|
|
48
|
+
- **A second opinion without leaving the session** — the executor calls
|
|
49
|
+
`advisor()` mid-turn, reads the reviewer's answer as the tool result, and
|
|
50
|
+
resumes. Nothing is injected into your transcript, and the default prompt
|
|
51
|
+
guidelines tell the executor to restate the advisor's key guidance in its
|
|
52
|
+
next visible reply, so you are not left with only a collapsed tool card.
|
|
53
|
+
- **Nothing to type or paste** — the tool takes zero parameters. The whole
|
|
54
|
+
conversation branch is serialised and forwarded automatically: the task, every
|
|
55
|
+
tool call made, every result seen. That whole branch is billed against the
|
|
56
|
+
reviewer model on every call, so escalations are not free.
|
|
57
|
+
- **The reviewer sees what survived compaction** — the branch is built from Pi's
|
|
58
|
+
resolved LLM context, so compaction and branch summaries are forwarded instead
|
|
59
|
+
of a stale raw replay.
|
|
60
|
+
- **Any model can be the reviewer** — every model you're authenticated for is in
|
|
61
|
+
the `/advisor` picker, found by fuzzy-typing. No provider is privileged.
|
|
62
|
+
- **Pick once, it stays picked** — model and effort persist to `advisor.json`
|
|
63
|
+
and are re-applied at every session start.
|
|
64
|
+
- **Skip it when you're already on a strong model** — list executor models in
|
|
65
|
+
`disabledForModels` to strip the tool for them, optionally only at or above a
|
|
66
|
+
reasoning-effort threshold. It strips and re-adds live as you switch model or
|
|
67
|
+
effort mid-session.
|
|
68
|
+
- **Off costs nothing** — with no model selected the tool is stripped from the
|
|
69
|
+
active set, so its prompt text never enters the system prompt at all.
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
Settings live in `~/.config/rpiv-advisor/advisor.json` (or
|
|
74
|
+
`$XDG_CONFIG_HOME/rpiv-advisor/advisor.json` when that variable is set to an
|
|
75
|
+
absolute path). `/advisor` creates the file and chmods it to `0600`; a failed
|
|
76
|
+
write leaves your previous selection untouched and tells you so.
|
|
77
|
+
|
|
78
|
+
| Key | What it does | Default |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| `modelKey` | The reviewer model, as `"provider/modelId"`. Written by `/advisor`. | absent — advisor off |
|
|
81
|
+
| `effort` | Reasoning effort for the reviewer: `minimal`, `low`, `medium`, `high`, `xhigh`. Written by `/advisor`. | absent — no reasoning sent |
|
|
82
|
+
| `disabledForModels` | Executor models the advisor is stripped for. Plain strings block at any effort; `{ "model": "…", "minEffort": "…" }` blocks only at or above that effort. | `[]` |
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"modelKey": "anthropic/claude-opus-4-5",
|
|
87
|
+
"effort": "high",
|
|
88
|
+
"disabledForModels": [
|
|
89
|
+
"anthropic/claude-opus-4-5",
|
|
90
|
+
{ "model": "openai/gpt-5.2", "minEffort": "high" }
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
37
94
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
disable.
|
|
95
|
+
`/advisor` only rewrites `modelKey` and `effort`, so hand-edited keys —
|
|
96
|
+
`disabledForModels` and the `guidance` overrides — survive every save.
|
|
41
97
|
|
|
42
|
-
|
|
43
|
-
conversation branch to the advisor model, which returns guidance (plan,
|
|
44
|
-
correction, or stop signal) that the executor consumes.
|
|
98
|
+
## Reference
|
|
45
99
|
|
|
46
|
-
|
|
100
|
+
- [Configuration](https://github.com/juicesharp/rpiv-mono/blob/main/packages/rpiv-advisor/docs/configuration.md) — config file resolution, every key, blocklist matching rules, guidance overrides, and the full notification catalogue.
|
|
101
|
+
- [`advisor` tool reference](https://github.com/juicesharp/rpiv-mono/blob/main/packages/rpiv-advisor/docs/tool-reference.md) — schema, result envelope, failure paths, what gets sent to the reviewer, lifecycle hooks, and picker keys.
|
|
47
102
|
|
|
48
|
-
|
|
103
|
+
## Requirements
|
|
49
104
|
|
|
50
|
-
|
|
105
|
+
- A [Pi Agent](https://github.com/badlogic/pi-mono) host — the extension loads
|
|
106
|
+
through Pi's extension manifest. No native dependencies.
|
|
107
|
+
- An authenticated provider for the **reviewer** model, resolved through Pi's
|
|
108
|
+
model registry.
|
|
109
|
+
- An interactive terminal for `/advisor`.
|
|
51
110
|
|
|
52
|
-
|
|
53
|
-
advisor() // zero parameters
|
|
54
|
-
```
|
|
111
|
+
## Troubleshooting
|
|
55
112
|
|
|
56
|
-
|
|
113
|
+
| Symptom | Cause | Fix |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| The `/advisor` picker offers only **No advisor** | No provider is authenticated in Pi | Run Pi's `/login` for a provider, then re-run `/advisor` |
|
|
116
|
+
| `Advisor (<model>) has no API key available.` comes back as the tool result | Credentials for the reviewer's provider no longer resolve | Re-authenticate that provider with `/login` |
|
|
117
|
+
| `/advisor requires interactive mode` | Running under `pi --print …` or RPC | Run Pi interactively |
|
|
57
118
|
|
|
58
|
-
|
|
119
|
+
## Related
|
|
59
120
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
details: {
|
|
64
|
-
advisorModel?: string, // "<provider>:<modelId>"
|
|
65
|
-
effort?: ThinkingLevel, // reasoning effort, when applicable
|
|
66
|
-
usage?: Usage, // token usage from the side-call
|
|
67
|
-
stopReason?: StopReason, // pi-ai stop reason
|
|
68
|
-
errorMessage?: string, // populated on auth/abort/error/empty paths
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
```
|
|
121
|
+
- [`@juicesharp/rpiv-pi`](https://www.npmjs.com/package/@juicesharp/rpiv-pi) —
|
|
122
|
+
the umbrella package; its `code-review` skill calls `advisor()` when this
|
|
123
|
+
package is installed.
|
|
72
124
|
|
|
73
125
|
## License
|
|
74
126
|
|
|
75
|
-
[
|
|
76
|
-
[](https://opensource.org/licenses/MIT)
|
|
77
|
-
|
|
78
|
-
MIT
|
|
127
|
+
MIT — see [LICENSE](https://github.com/juicesharp/rpiv-mono/blob/main/packages/rpiv-advisor/LICENSE).
|
package/advisor/execute.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* buildAdvisorResult so the envelope is built in exactly one place.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { Message, StopReason, ThinkingLevel, Usage } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { AssistantMessage, Message, StopReason, TextContent, ThinkingLevel, Usage } from "@earendil-works/pi-ai";
|
|
10
10
|
import {
|
|
11
11
|
type AgentToolResult,
|
|
12
12
|
type AgentToolUpdateCallback,
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
errNoApiKeyDetail,
|
|
32
32
|
msgConsulting,
|
|
33
33
|
} from "./messages.js";
|
|
34
|
-
import { loadCompleteSimple } from "./pi-compat.js";
|
|
34
|
+
import { getRuntimeCompleteSimple, loadCompleteSimple } from "./pi-compat.js";
|
|
35
35
|
import { ADVISOR_SYSTEM_PROMPT } from "./prompt.js";
|
|
36
36
|
import { getAdvisorEffort, getAdvisorModel } from "./state.js";
|
|
37
37
|
|
|
@@ -43,6 +43,18 @@ interface AdvisorDetails {
|
|
|
43
43
|
errorMessage?: string;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Extract the advisor's text content from a completeSimple response: concatenate
|
|
47
|
+
// every text part, trim. Thinking/toolCall parts are ignored. Returns "" when the
|
|
48
|
+
// model returned no text content — the empty-response class R6.4 retries once
|
|
49
|
+
// before surfacing. Pure so both attempts share one extraction path.
|
|
50
|
+
function advisorTextFromResponse(response: AssistantMessage): string {
|
|
51
|
+
return response.content
|
|
52
|
+
.filter((c): c is TextContent => c.type === "text")
|
|
53
|
+
.map((c) => c.text)
|
|
54
|
+
.join("\n")
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
// Single result-envelope builder — every executeAdvisor branch and the pre-call
|
|
47
59
|
// error paths funnel through here. `effort` is snapshotted once at executeAdvisor
|
|
48
60
|
// entry and threaded through every call so the returned details.effort always
|
|
@@ -117,52 +129,83 @@ export async function executeAdvisor(
|
|
|
117
129
|
});
|
|
118
130
|
|
|
119
131
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return buildAdvisorResult({
|
|
131
|
-
text: ERR_CALL_ABORTED,
|
|
132
|
-
effort,
|
|
133
|
-
advisorLabel,
|
|
134
|
-
usage: response.usage,
|
|
135
|
-
stopReason: response.stopReason,
|
|
136
|
-
errorMessage: response.errorMessage ?? ERR_ABORTED_DETAIL,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
132
|
+
// Prefer Pi's auth-aware runtime facade. Unlike the global compatibility
|
|
133
|
+
// function, it runs request preparation and applies credential-derived
|
|
134
|
+
// fields such as GitHub Copilot's OAuth-specific baseUrl. Do not pass the
|
|
135
|
+
// preflight key/headers to this path: explicit overrides would bypass that
|
|
136
|
+
// resolution and reintroduce the endpoint mismatch.
|
|
137
|
+
const runtimeCompleteSimple = getRuntimeCompleteSimple(ctx.modelRegistry);
|
|
138
|
+
const completeSimple = runtimeCompleteSimple ?? (await loadCompleteSimple());
|
|
139
|
+
const requestOptions = runtimeCompleteSimple
|
|
140
|
+
? { signal, reasoning: effort }
|
|
141
|
+
: { apiKey: auth.apiKey, headers: auth.headers, signal, reasoning: effort };
|
|
139
142
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
143
|
+
// Single dispatch point — both attempts reuse the SAME `messages` and
|
|
144
|
+
// `requestOptions`, so the retry cannot diverge from attempt 1. `tools: []`
|
|
145
|
+
// reaffirms the "never calls tools" contract even when `messages` contains
|
|
146
|
+
// prior toolCall/toolResult blocks (btw.ts:235).
|
|
147
|
+
const callAdvisor = (): Promise<AssistantMessage> =>
|
|
148
|
+
completeSimple(advisor, { systemPrompt: ADVISOR_SYSTEM_PROMPT, messages, tools: [] }, requestOptions);
|
|
149
|
+
|
|
150
|
+
// Build the terminal envelope for an aborted/error stopReason, or return
|
|
151
|
+
// undefined when the attempt produced a normal stop whose text (or lack of
|
|
152
|
+
// text) the caller must still resolve. Aborted/error short-circuit and are
|
|
153
|
+
// NEVER retried — they are not the empty-response class R6.4 targets.
|
|
154
|
+
const stopReasonEnvelope = (r: AssistantMessage): AgentToolResult<AdvisorDetails> | undefined => {
|
|
155
|
+
if (r.stopReason === "aborted") {
|
|
156
|
+
return buildAdvisorResult({
|
|
157
|
+
text: ERR_CALL_ABORTED,
|
|
158
|
+
effort,
|
|
159
|
+
advisorLabel,
|
|
160
|
+
usage: r.usage,
|
|
161
|
+
stopReason: r.stopReason,
|
|
162
|
+
errorMessage: r.errorMessage ?? ERR_ABORTED_DETAIL,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (r.stopReason === "error") {
|
|
166
|
+
return buildAdvisorResult({
|
|
167
|
+
text: errCallFailed(r.errorMessage),
|
|
168
|
+
effort,
|
|
169
|
+
advisorLabel,
|
|
170
|
+
usage: r.usage,
|
|
171
|
+
stopReason: r.stopReason,
|
|
172
|
+
errorMessage: r.errorMessage,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
};
|
|
150
177
|
|
|
151
|
-
|
|
152
|
-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
153
|
-
.map((c) => c.text)
|
|
154
|
-
.join("\n")
|
|
155
|
-
.trim();
|
|
178
|
+
let response = await callAdvisor();
|
|
156
179
|
|
|
180
|
+
// Aborted/error short-circuit on the first attempt — no retry.
|
|
181
|
+
const firstTerminal = stopReasonEnvelope(response);
|
|
182
|
+
if (firstTerminal) return firstTerminal;
|
|
183
|
+
|
|
184
|
+
let advisorText = advisorTextFromResponse(response);
|
|
185
|
+
|
|
186
|
+
// R6.4: a transient empty advisor response (normal stop, no text) gets
|
|
187
|
+
// exactly ONE retry with identical inputs before surfacing as a terminal
|
|
188
|
+
// error. Bounded to a single second call — never a `while`/loop — so a
|
|
189
|
+
// persistent-empty provider cannot hot-loop. The retry reuses the SAME
|
|
190
|
+
// pre-computed `messages`/`requestOptions` (no re-derivation that could
|
|
191
|
+
// diverge from attempt 1), then applies the same three-way route.
|
|
157
192
|
if (!advisorText) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
193
|
+
response = await callAdvisor();
|
|
194
|
+
|
|
195
|
+
const retryTerminal = stopReasonEnvelope(response);
|
|
196
|
+
if (retryTerminal) return retryTerminal;
|
|
197
|
+
|
|
198
|
+
advisorText = advisorTextFromResponse(response);
|
|
199
|
+
if (!advisorText) {
|
|
200
|
+
return buildAdvisorResult({
|
|
201
|
+
text: ERR_EMPTY_RESPONSE,
|
|
202
|
+
effort,
|
|
203
|
+
advisorLabel,
|
|
204
|
+
usage: response.usage,
|
|
205
|
+
stopReason: response.stopReason,
|
|
206
|
+
errorMessage: ERR_EMPTY_RESPONSE_DETAIL,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
166
209
|
}
|
|
167
210
|
|
|
168
211
|
return buildAdvisorResult({
|
|
@@ -9,6 +9,30 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { getRuntimeCompleteSimple } from "./pi-compat.js";
|
|
13
|
+
|
|
14
|
+
describe("getRuntimeCompleteSimple", () => {
|
|
15
|
+
it("returns a method bound to the host runtime", async () => {
|
|
16
|
+
const runtime = {
|
|
17
|
+
completeSimple: vi.fn(function (this: unknown) {
|
|
18
|
+
expect(this).toBe(runtime);
|
|
19
|
+
return Promise.resolve(undefined as never);
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
const completeSimple = getRuntimeCompleteSimple({ runtime });
|
|
23
|
+
|
|
24
|
+
expect(completeSimple).toBeTypeOf("function");
|
|
25
|
+
if (!completeSimple) throw new Error("expected runtime completion facade");
|
|
26
|
+
await completeSimple(undefined as never, undefined as never);
|
|
27
|
+
expect(runtime.completeSimple).toHaveBeenCalledOnce();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns undefined for missing or malformed runtime facades", () => {
|
|
31
|
+
expect(getRuntimeCompleteSimple(undefined)).toBeUndefined();
|
|
32
|
+
expect(getRuntimeCompleteSimple({ runtime: null })).toBeUndefined();
|
|
33
|
+
expect(getRuntimeCompleteSimple({ runtime: { completeSimple: "not-a-function" } })).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
12
36
|
|
|
13
37
|
describe("loadCompleteSimple", () => {
|
|
14
38
|
afterEach(() => {
|
package/advisor/pi-compat.ts
CHANGED
|
@@ -18,6 +18,32 @@
|
|
|
18
18
|
|
|
19
19
|
type CompleteSimpleFn = typeof import("@earendil-works/pi-ai/compat").completeSimple;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Resolve Pi's auth-aware completion facade when the host exposes one.
|
|
23
|
+
*
|
|
24
|
+
* Current Pi hosts keep the canonical `ModelRuntime` behind the extension
|
|
25
|
+
* `ModelRegistry`'s runtime-private `runtime` slot. This structural bridge is
|
|
26
|
+
* intentionally isolated here: calling that facade is what lets Pi apply
|
|
27
|
+
* credential-derived request fields such as GitHub Copilot's OAuth-specific
|
|
28
|
+
* `baseUrl`. Older hosts, and future hosts that move the slot, simply fall
|
|
29
|
+
* through to the legacy global completion path below.
|
|
30
|
+
*
|
|
31
|
+
* The returned method is bound because `ModelRuntime.completeSimple()` calls
|
|
32
|
+
* other runtime methods through `this`.
|
|
33
|
+
*/
|
|
34
|
+
export function getRuntimeCompleteSimple(modelRegistry: unknown): CompleteSimpleFn | undefined {
|
|
35
|
+
try {
|
|
36
|
+
if (modelRegistry === null || typeof modelRegistry !== "object") return undefined;
|
|
37
|
+
const runtime = (modelRegistry as { runtime?: unknown }).runtime;
|
|
38
|
+
if (runtime === null || typeof runtime !== "object") return undefined;
|
|
39
|
+
const completeSimple = (runtime as { completeSimple?: unknown }).completeSimple;
|
|
40
|
+
return typeof completeSimple === "function" ? (completeSimple.bind(runtime) as CompleteSimpleFn) : undefined;
|
|
41
|
+
} catch {
|
|
42
|
+
// A malformed/private host shape should retain the version-tolerant fallback.
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
/**
|
|
22
48
|
* Error codes meaning "the /compat entrypoint is not resolvable on this host":
|
|
23
49
|
* - `ERR_PACKAGE_PATH_NOT_EXPORTED` — Node's ESM resolver when the installed
|
package/advisor/register.ts
CHANGED
|
@@ -31,6 +31,7 @@ export const DEFAULT_PROMPT_GUIDELINES: string[] = [
|
|
|
31
31
|
"On tasks longer than a few steps, call `advisor` at least once before committing to an approach and once before declaring done. On short reactive tasks where the next action is dictated by tool output you just read, you don't need to keep calling — the advisor adds most of its value on the first call, before the approach crystallizes.",
|
|
32
32
|
"Give the advisor's advice serious weight. If you follow a step and it fails empirically, or you have primary-source evidence that contradicts a specific claim, adapt — a passing self-test is not evidence the advice is wrong, it's evidence your test doesn't check what the advice is checking.",
|
|
33
33
|
"If you've already retrieved data pointing one way and the advisor points another, don't silently switch — surface the conflict in one more `advisor` call (\"I found X, you suggest Y, which constraint breaks the tie?\"). A reconcile call is cheaper than committing to the wrong branch.",
|
|
34
|
+
"After each `advisor` result, put the advisor's key guidance into your next visible reply to the user before continuing — quote or paraphrase the plan, correction, or stop signal. The user often cannot see collapsed tool results; do not keep the advisor's words only in silent tool context.",
|
|
34
35
|
];
|
|
35
36
|
|
|
36
37
|
export function registerAdvisorTool(pi: ExtensionAPI): void {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# rpiv-advisor configuration
|
|
2
|
+
|
|
3
|
+
Complete reference for `advisor.json` — where it lives, every key it accepts, and how the per-executor blocklist is evaluated.
|
|
4
|
+
|
|
5
|
+
## Where the file lives
|
|
6
|
+
|
|
7
|
+
`advisor.json` resolves under the XDG config directory:
|
|
8
|
+
|
|
9
|
+
| `XDG_CONFIG_HOME` | Resolved path |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| unset, empty, or whitespace-only | `~/.config/rpiv-advisor/advisor.json` |
|
|
12
|
+
| absolute path | `$XDG_CONFIG_HOME/rpiv-advisor/advisor.json` |
|
|
13
|
+
| `~` or `~/…` | tilde expanded, then used as the config dir |
|
|
14
|
+
| relative path | ignored → `~/.config/rpiv-advisor/advisor.json` |
|
|
15
|
+
| `~user/…` | not expanded → `~/.config/rpiv-advisor/advisor.json` |
|
|
16
|
+
|
|
17
|
+
`XDG_CONFIG_HOME` is the only environment variable this package reads.
|
|
18
|
+
|
|
19
|
+
**Legacy read fallback.** If nothing exists at the XDG-resolved path, reads fall
|
|
20
|
+
back once to `~/.config/rpiv-advisor/advisor.json` (always `~/.config`, ignoring
|
|
21
|
+
`XDG_CONFIG_HOME`). If the XDG path *does* exist, it wins — even when it is
|
|
22
|
+
malformed. **Writes always go to the XDG-resolved path only**; there is no
|
|
23
|
+
migration or copy of the legacy file.
|
|
24
|
+
|
|
25
|
+
A missing or malformed file is treated as `{}` — malformed JSON logs a warning
|
|
26
|
+
and never crashes the extension.
|
|
27
|
+
|
|
28
|
+
## File permissions
|
|
29
|
+
|
|
30
|
+
`/advisor` writes the file with `JSON.stringify(config, null, 2)` plus a
|
|
31
|
+
trailing newline, creating parent directories as needed, then chmods it to
|
|
32
|
+
`0600` on a best-effort basis. A failed chmod does not fail the save; on Windows
|
|
33
|
+
the chmod is a no-op.
|
|
34
|
+
|
|
35
|
+
Saving happens **before** any in-memory state changes. If the write fails you
|
|
36
|
+
get `Failed to save advisor selection — selection not persisted` and both the
|
|
37
|
+
previous selection and the active tool list are left untouched.
|
|
38
|
+
|
|
39
|
+
## Keys
|
|
40
|
+
|
|
41
|
+
| Key | Type | Default | Written by |
|
|
42
|
+
| --- | --- | --- | --- |
|
|
43
|
+
| `modelKey` | `string` — `"provider/modelId"` | absent (advisor off) | `/advisor` |
|
|
44
|
+
| `effort` | `"minimal" \| "low" \| "medium" \| "high" \| "xhigh"` | absent (no `reasoning` sent) | `/advisor` effort picker |
|
|
45
|
+
| `disabledForModels` | `(string \| { model, minEffort? })[]` | `[]` | hand-edited |
|
|
46
|
+
| `guidance.promptSnippet` | `string` | built-in snippet | hand-edited |
|
|
47
|
+
| `guidance.promptGuidelines` | `string[]` | six built-in guidelines | hand-edited |
|
|
48
|
+
|
|
49
|
+
`/advisor` only ever writes `modelKey` and `effort`; `guidance` and
|
|
50
|
+
`disabledForModels` are preserved across saves, so hand-edits survive.
|
|
51
|
+
|
|
52
|
+
### `modelKey`
|
|
53
|
+
|
|
54
|
+
The canonical persisted form is slash-separated — `anthropic/claude-opus-4-5`.
|
|
55
|
+
Reads also accept the legacy colon form (`anthropic:claude-opus-4-5`); when both
|
|
56
|
+
forms are present the slash form wins. A colon-form key is rewritten to slash
|
|
57
|
+
form the next time you save through `/advisor`.
|
|
58
|
+
|
|
59
|
+
### `effort`
|
|
60
|
+
|
|
61
|
+
Offered only for models whose registry entry reports reasoning support. The
|
|
62
|
+
picker lists `off`, `minimal`, `low`, `medium`, `high`, and adds `xhigh` when
|
|
63
|
+
the picked model supports it. `high` is marked `(recommended)`. Choosing `off`
|
|
64
|
+
deletes the key, and no `reasoning` parameter is sent with the advisor call.
|
|
65
|
+
|
|
66
|
+
`EFFORT_ORDINAL`, lowest to highest, is `minimal`, `low`, `medium`, `high`,
|
|
67
|
+
`xhigh`. This ordering is what `minEffort` compares against.
|
|
68
|
+
|
|
69
|
+
### `disabledForModels`
|
|
70
|
+
|
|
71
|
+
A list of **executor** models for which the advisor tool should be stripped —
|
|
72
|
+
useful when you are already driving a top-tier model and do not want to pay for
|
|
73
|
+
a second opinion. Two entry forms:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"modelKey": "anthropic/claude-opus-4-5",
|
|
78
|
+
"effort": "high",
|
|
79
|
+
"disabledForModels": [
|
|
80
|
+
"anthropic/claude-opus-4-5",
|
|
81
|
+
{ "model": "openai/gpt-5.2", "minEffort": "high" }
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- **String entry** — blocks at any reasoning effort.
|
|
87
|
+
- **Object entry without `minEffort`** — blocks at any reasoning effort.
|
|
88
|
+
- **Object entry with `minEffort`** — blocks when the executor's current effort
|
|
89
|
+
is at or above the threshold in `EFFORT_ORDINAL`. Ties block.
|
|
90
|
+
- An executor effort of `off` or unset never matches a `minEffort` entry.
|
|
91
|
+
|
|
92
|
+
Entry keys are canonicalised to slash form before comparison, so a legacy
|
|
93
|
+
`"anthropic:claude-opus-4-5"` entry still blocks without a re-save.
|
|
94
|
+
|
|
95
|
+
**Validation.** A non-array value becomes `[]`. Empty strings are dropped.
|
|
96
|
+
Object entries need a non-empty string `model`; an unrecognised `minEffort`
|
|
97
|
+
drops the entry. `null`, numbers, booleans and `undefined` are dropped. The
|
|
98
|
+
order of surviving entries is preserved.
|
|
99
|
+
|
|
100
|
+
**Live re-evaluation.** The blocklist is re-applied on `session_start`, on every
|
|
101
|
+
turn, whenever you switch executor model, and whenever you change reasoning
|
|
102
|
+
effort — so the tool strips and re-adds mid-session as you move around. You see
|
|
103
|
+
`Advisor disabled for <provider/model>` when it strips and
|
|
104
|
+
`Advisor restored: <label>[, <effort>]` when it comes back.
|
|
105
|
+
|
|
106
|
+
### `guidance`
|
|
107
|
+
|
|
108
|
+
Overrides what the executor model is told about *when* to escalate, without
|
|
109
|
+
forking the package.
|
|
110
|
+
|
|
111
|
+
- `guidance.promptSnippet` — a non-empty string replacing the one-line snippet
|
|
112
|
+
that appears in the system prompt.
|
|
113
|
+
- `guidance.promptGuidelines` — a non-empty array of non-empty strings replacing
|
|
114
|
+
the six built-in guidelines.
|
|
115
|
+
|
|
116
|
+
Either field falls back to its built-in default when absent, empty, or the wrong
|
|
117
|
+
type. Both are read once at extension load, so restart your Pi session after
|
|
118
|
+
editing them.
|
|
119
|
+
|
|
120
|
+
The built-in guidelines tell the model to call `advisor` before substantive
|
|
121
|
+
work, again when it believes the task is complete (after making the deliverable
|
|
122
|
+
durable), and when it is stuck or considering a change of approach; to weight
|
|
123
|
+
the advice seriously unless empirically contradicted; and to reconcile
|
|
124
|
+
conflicting evidence with one more `advisor` call rather than silently switching.
|
|
125
|
+
|
|
126
|
+
## Notifications
|
|
127
|
+
|
|
128
|
+
| String | When |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `Advisor: <label>[, <effort>]` | you selected a model with `/advisor` |
|
|
131
|
+
| `Advisor: <label>[, <effort>] (inactive for current executor)` | selected, but blocked by `disabledForModels` |
|
|
132
|
+
| `Advisor restored: <label>[, <effort>]` | re-applied at session start, or unblocked mid-session |
|
|
133
|
+
| `Advisor restored: <label>[, <effort>] (inactive for current executor)` | restored while blocked |
|
|
134
|
+
| `Advisor disabled` | you chose **No advisor** |
|
|
135
|
+
| `Advisor disabled for <provider/model>` | you switched to a blocklisted executor model or effort |
|
|
136
|
+
| `Advisor selection not found: <choice>` | the model you picked was no longer in the available-model list when `/advisor` resolved the choice |
|
|
137
|
+
| `Previously configured advisor model <key> is no longer available` | the saved model left Pi's registry |
|
|
138
|
+
| `Failed to save advisor selection — selection not persisted` | the write to `advisor.json` failed |
|
|
139
|
+
| `/advisor requires interactive mode` | `/advisor` ran without a TTY |
|
|
140
|
+
|
|
141
|
+
The `Advisor restored: …` announcement fires at most once per process, so
|
|
142
|
+
programmatic session spawns (workflow stages, subagents) do not repeat it.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# `advisor` tool reference
|
|
2
|
+
|
|
3
|
+
The exact surface rpiv-advisor registers: the tool's schema, its result envelope, what gets sent to the reviewer, and the rules that decide when the tool is visible to the executor model.
|
|
4
|
+
|
|
5
|
+
## Signature
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
advisor() // zero parameters
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The parameter schema is an empty object. There is nothing for the executor model
|
|
12
|
+
to pass — the conversation branch is read live from the session manager at call
|
|
13
|
+
time and serialised automatically.
|
|
14
|
+
|
|
15
|
+
## What the reviewer receives
|
|
16
|
+
|
|
17
|
+
Each call assembles the request in this order:
|
|
18
|
+
|
|
19
|
+
1. **Tool inventory prefix** — one synthetic message listing every tool
|
|
20
|
+
registered in the executor's session (Pi's `getAllTools()`, i.e. the full
|
|
21
|
+
registry, not the active subset), so the reviewer knows what the executor can
|
|
22
|
+
do. It is cached and only rebuilt when the registered tool-name set changes;
|
|
23
|
+
the JSON is key-sorted so identical inventories serialise byte-identically
|
|
24
|
+
and stay prompt-cache friendly.
|
|
25
|
+
2. **The conversation branch** — built from Pi's resolved LLM context, not a raw
|
|
26
|
+
replay of history. Compaction summaries and branch summaries are forwarded as
|
|
27
|
+
the model actually sees them, so a compacted session sends the summary rather
|
|
28
|
+
than the pre-compaction detail.
|
|
29
|
+
3. **Tail massaging** — the in-flight `advisor()` tool call is stripped from the
|
|
30
|
+
tail, and a user-role message is guaranteed at the end (falling back to
|
|
31
|
+
`Please advise on the executor's situation above.`) so providers that reject
|
|
32
|
+
non-user tails accept the payload.
|
|
33
|
+
|
|
34
|
+
The reviewer is invoked with the advisor system prompt, `tools: []`, and the
|
|
35
|
+
configured reasoning effort. It never calls tools and never writes to your
|
|
36
|
+
transcript — its answer comes back only as the tool result the executor reads.
|
|
37
|
+
The default prompt guidelines direct the executor to restate the advisor's key
|
|
38
|
+
guidance in its next visible reply, so the guidance is not left only in a
|
|
39
|
+
collapsed tool card.
|
|
40
|
+
|
|
41
|
+
While the call is in flight the executor streams
|
|
42
|
+
`Consulting advisor (<label>[, <effort>])…`.
|
|
43
|
+
|
|
44
|
+
## Result envelope
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
{
|
|
48
|
+
content: [{ type: "text", text: string }], // reviewer's guidance, or an error message
|
|
49
|
+
details: {
|
|
50
|
+
advisorModel?: string, // "<provider>:<modelId>" — colon-joined
|
|
51
|
+
effort?: ThinkingLevel, // the reasoning level actually sent
|
|
52
|
+
usage?: Usage, // token usage from the side-call
|
|
53
|
+
stopReason?: StopReason, // pi-ai stop reason
|
|
54
|
+
errorMessage?: string, // populated on the no-model/auth/abort/error/empty paths
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`details.effort` is snapshotted once at entry, so it always matches the
|
|
60
|
+
`reasoning` value sent to the provider even if the selection changes mid-call.
|
|
61
|
+
|
|
62
|
+
Note that `details.advisorModel` uses the **colon** form (`provider:modelId`),
|
|
63
|
+
unlike the slash-form `modelKey` persisted in `advisor.json`.
|
|
64
|
+
|
|
65
|
+
## Failure paths
|
|
66
|
+
|
|
67
|
+
Every failure returns a normal tool result — the executor reads the text and
|
|
68
|
+
keeps going rather than crashing the turn.
|
|
69
|
+
|
|
70
|
+
| `content` text | `details.errorMessage` |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `No advisor model is configured. The user can enable one with the /advisor command.` | `no advisor model selected` |
|
|
73
|
+
| `Advisor (<label>) is misconfigured: <err>` | the registry's auth error |
|
|
74
|
+
| `Advisor (<label>) has no API key available.` | `no API key for <provider>` |
|
|
75
|
+
| `Advisor call was cancelled before it completed.` | the provider's error message, or `aborted` |
|
|
76
|
+
| `Advisor call failed: <err>` | the provider's error message |
|
|
77
|
+
| `Advisor returned no text content.` | `empty response` |
|
|
78
|
+
| `Advisor call threw: <msg>` | the thrown message |
|
|
79
|
+
|
|
80
|
+
## When the tool is active
|
|
81
|
+
|
|
82
|
+
The tool is always **registered** — but it is stripped from the *active* tool
|
|
83
|
+
set, meaning the executor model cannot see it and its `promptSnippet` /
|
|
84
|
+
`promptGuidelines` drop out of the system prompt, whenever any of:
|
|
85
|
+
|
|
86
|
+
1. No advisor model is selected.
|
|
87
|
+
2. `modelKey` is absent, unparseable, or names a model that is no longer in Pi's
|
|
88
|
+
registry at restore time. The stale in-memory selection is cleared too.
|
|
89
|
+
3. The current **executor** model matches a `disabledForModels` entry — see
|
|
90
|
+
[configuration.md](./configuration.md#disabledformodels).
|
|
91
|
+
|
|
92
|
+
This is what "off costs nothing" means: with no model configured, none of the
|
|
93
|
+
advisor's prompt text ever enters the system prompt.
|
|
94
|
+
|
|
95
|
+
## Lifecycle hooks
|
|
96
|
+
|
|
97
|
+
| Event | What happens |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `session_start` | Reload `advisor.json`, re-apply model / effort / blocklist, activate or strip, announce once per process. |
|
|
100
|
+
| `before_agent_start` | Per-turn reconcile: blocked when no model is selected or the executor is blocklisted. |
|
|
101
|
+
| `model_select` | Re-reconcile on executor model change. Skipped for `source === "restore"` to avoid a duplicate notification. |
|
|
102
|
+
| `thinking_level_select` | Re-reconcile on reasoning-effort change. |
|
|
103
|
+
|
|
104
|
+
The three mid-session hooks route through a shared strip-or-add hub
|
|
105
|
+
(`reconcileAdvisorTool`). `session_start` uses that hub for the strip path and
|
|
106
|
+
adds the tool directly on the restore path.
|
|
107
|
+
|
|
108
|
+
## `/advisor` picker keys
|
|
109
|
+
|
|
110
|
+
Both pickers (model, then reasoning level) show up to 10 rows and share the hint
|
|
111
|
+
`type to filter • ↑↓ navigate • enter select • esc cancel`.
|
|
112
|
+
|
|
113
|
+
| Key | Effect |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| any printable character | appends to the fuzzy filter and rebuilds the list |
|
|
116
|
+
| Backspace | deletes one character from the filter |
|
|
117
|
+
| ↑ / ↓ | navigate; ↑ from the first row wraps to the last |
|
|
118
|
+
| Enter | select |
|
|
119
|
+
| Esc | cancel — the command exits without changing anything |
|
|
120
|
+
|
|
121
|
+
The filter scores against both the visible label (`Name (provider)`) and the
|
|
122
|
+
underlying `provider/modelId` value, ranking contiguous runs and word-boundary
|
|
123
|
+
matches higher — so `op4` and `anthropic` both narrow the list.
|
|
124
|
+
|
|
125
|
+
`/advisor` requires an interactive TTY. Without one it notifies
|
|
126
|
+
`/advisor requires interactive mode` and returns.
|
|
127
|
+
|
|
128
|
+
## Host compatibility
|
|
129
|
+
|
|
130
|
+
The reviewer call uses pi-ai's `completeSimple`, which moved between
|
|
131
|
+
entrypoints across host versions: Pi ≥ 0.80.1 exports it from
|
|
132
|
+
`@earendil-works/pi-ai/compat`, and ≤ 0.79.x from the package root. Because
|
|
133
|
+
pi-ai resolves against the *host's* copy at runtime, the loader tries `/compat`
|
|
134
|
+
first and falls back to the root **only** on a module-resolution failure
|
|
135
|
+
(`ERR_PACKAGE_PATH_NOT_EXPORTED`, `ERR_MODULE_NOT_FOUND`, `MODULE_NOT_FOUND`,
|
|
136
|
+
walked through the `cause` chain). Any other `/compat` error is rethrown so the
|
|
137
|
+
real failure surfaces instead of being masked.
|
|
138
|
+
|
|
139
|
+
If neither entrypoint exposes it, the call throws
|
|
140
|
+
`pi-ai does not expose completeSimple on /compat or the package root — unsupported host pi-ai version`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juicesharp/rpiv-advisor",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Pi extension. A second opinion the model can request from a stronger reviewer model before it acts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"advisor-ui.ts",
|
|
33
33
|
"fuzzy.ts",
|
|
34
34
|
"prompts/",
|
|
35
|
+
"docs/",
|
|
36
|
+
"!docs/*.png",
|
|
37
|
+
"!docs/*.jpg",
|
|
38
|
+
"!docs/*.svg",
|
|
35
39
|
"README.md",
|
|
36
40
|
"LICENSE"
|
|
37
41
|
],
|
|
@@ -41,7 +45,7 @@
|
|
|
41
45
|
]
|
|
42
46
|
},
|
|
43
47
|
"dependencies": {
|
|
44
|
-
"@juicesharp/rpiv-config": "^2.
|
|
48
|
+
"@juicesharp/rpiv-config": "^2.2.0",
|
|
45
49
|
"typebox": "^1.1.24"
|
|
46
50
|
},
|
|
47
51
|
"peerDependencies": {
|