@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 CHANGED
@@ -1,78 +1,127 @@
1
- # rpiv-advisor
1
+ # @juicesharp/rpiv-advisor
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@juicesharp/rpiv-advisor.svg)](https://www.npmjs.com/package/@juicesharp/rpiv-advisor)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 ask a stronger model for a second opinion before it acts. `rpiv-advisor` adds the `advisor` tool and `/advisor` slash command to [Pi Agent](https://github.com/badlogic/pi-mono) - the working model can hand the full conversation to a reviewer (e.g. Opus) and resume with its plan, correction, or stop signal.
12
-
13
- ![Advisor model selector](https://raw.githubusercontent.com/juicesharp/rpiv-mono/main/packages/rpiv-advisor/docs/advisor.jpg)
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
- ```bash
18
+ ```sh
26
19
  pi install npm:@juicesharp/rpiv-advisor
27
20
  ```
28
21
 
29
- Then restart your Pi session.
22
+ Restart your Pi session.
23
+
24
+ ## Quick start
25
+
26
+ Nothing happens until you pick a reviewer. Run:
30
27
 
31
- ## Usage
28
+ ```
29
+ /advisor
30
+ ```
32
31
 
33
- Configure an advisor model with `/advisor` - the command opens a selector for
34
- any model registered with Pi's model registry, plus a reasoning-effort picker
35
- for reasoning-capable models. Selection persists across sessions at
36
- `~/.config/rpiv-advisor/advisor.json` (chmod 0600).
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
+ ![The /advisor picker: a bordered panel titled "Advisor Tool" above a scrollable list of the models Pi has credentials for, with the current selection highlighted](https://raw.githubusercontent.com/juicesharp/rpiv-mono/main/packages/rpiv-advisor/docs/advisor.jpg)
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
- The `advisor` tool is registered at load but excluded from active tools by
39
- default; selecting a model via `/advisor` enables it. Choose "No advisor" to
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
- `advisor` takes zero parameters - calling it forwards the full serialized
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
- ## Tool
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
- - **`advisor`** - escalate the current conversation branch to the configured reviewer model. Inactive until a model is selected via `/advisor`.
103
+ ## Requirements
49
104
 
50
- ### Schema
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
- ```ts
53
- advisor() // zero parameters
54
- ```
111
+ ## Troubleshooting
55
112
 
56
- The full conversation branch is auto-serialized from `ctx.sessionManager` - the LLM does not (and cannot) pass it explicitly.
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
- Returns:
119
+ ## Related
59
120
 
60
- ```ts
61
- {
62
- content: [{ type: "text", text: string }], // reviewer's guidance, or error message
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
- [![npm version](https://img.shields.io/npm/v/@juicesharp/rpiv-advisor.svg)](https://www.npmjs.com/package/@juicesharp/rpiv-advisor)
76
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
77
-
78
- MIT
127
+ MIT — see [LICENSE](https://github.com/juicesharp/rpiv-mono/blob/main/packages/rpiv-advisor/LICENSE).
@@ -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
- const completeSimple = await loadCompleteSimple();
121
- const response = await completeSimple(
122
- advisor,
123
- // `tools: []` reaffirms the "never calls tools" contract even when
124
- // `messages` contains prior toolCall/toolResult blocks (btw.ts:235).
125
- { systemPrompt: ADVISOR_SYSTEM_PROMPT, messages, tools: [] },
126
- { apiKey: auth.apiKey, headers: auth.headers, signal, reasoning: effort },
127
- );
128
-
129
- if (response.stopReason === "aborted") {
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
- if (response.stopReason === "error") {
141
- return buildAdvisorResult({
142
- text: errCallFailed(response.errorMessage),
143
- effort,
144
- advisorLabel,
145
- usage: response.usage,
146
- stopReason: response.stopReason,
147
- errorMessage: response.errorMessage,
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
- const advisorText = response.content
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
- return buildAdvisorResult({
159
- text: ERR_EMPTY_RESPONSE,
160
- effort,
161
- advisorLabel,
162
- usage: response.usage,
163
- stopReason: response.stopReason,
164
- errorMessage: ERR_EMPTY_RESPONSE_DETAIL,
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(() => {
@@ -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
@@ -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.0.0",
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.0.0",
48
+ "@juicesharp/rpiv-config": "^2.2.0",
45
49
  "typebox": "^1.1.24"
46
50
  },
47
51
  "peerDependencies": {