@juicesharp/rpiv-advisor 1.15.0 → 1.16.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 +1 -1
- package/advisor/command.ts +146 -0
- package/advisor/config.ts +60 -0
- package/advisor/context.ts +40 -0
- package/advisor/execute.ts +178 -0
- package/advisor/handlers.ts +91 -0
- package/advisor/index.ts +39 -0
- package/advisor/inventory.ts +80 -0
- package/advisor/messages.ts +59 -0
- package/advisor/policy.ts +36 -0
- package/advisor/prompt.ts +14 -0
- package/advisor/register.ts +50 -0
- package/advisor/restore.ts +74 -0
- package/advisor/state.ts +25 -0
- package/advisor-ui.ts +65 -34
- package/fuzzy.ts +73 -0
- package/index.ts +3 -6
- package/package.json +4 -3
- package/advisor.ts +0 -746
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Let the model ask a stronger model for a second opinion before it acts. `rpiv-ad
|
|
|
14
14
|
|
|
15
15
|
## Features
|
|
16
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.
|
|
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
18
|
- **Persisted across sessions** - selection saved at `~/.config/rpiv-advisor/advisor.json` (chmod 0600).
|
|
19
19
|
- **Off by default** - the `advisor` tool is excluded until you pick a model; choose "No advisor" to disable.
|
|
20
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`.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command — the /advisor slash command. Reads top-down: interactive guard →
|
|
3
|
+
* model picker (buildModelItems) → no-advisor branch (applyDisable) → model
|
|
4
|
+
* lookup → effort picker (buildEffortItems) → enable (applyEnable). The apply
|
|
5
|
+
* helpers persist before mutating in-memory state (review I2).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
9
|
+
import { getSupportedThinkingLevels, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
12
|
+
import { showAdvisorPicker, showEffortPicker } from "../advisor-ui.js";
|
|
13
|
+
import { modelKey, saveAdvisorConfig } from "./config.js";
|
|
14
|
+
import { reconcileAdvisorTool } from "./handlers.js";
|
|
15
|
+
import {
|
|
16
|
+
ADVISOR_TOOL_NAME,
|
|
17
|
+
BASE_EFFORT_LEVELS,
|
|
18
|
+
CHECKMARK,
|
|
19
|
+
DEFAULT_EFFORT,
|
|
20
|
+
errSelectionNotFound,
|
|
21
|
+
MSG_ADVISOR_DISABLED,
|
|
22
|
+
MSG_PERSIST_FAILED,
|
|
23
|
+
MSG_REQUIRES_INTERACTIVE,
|
|
24
|
+
msgAdvisorEnabled,
|
|
25
|
+
msgAdvisorEnabledInactive,
|
|
26
|
+
NO_ADVISOR_VALUE,
|
|
27
|
+
OFF_VALUE,
|
|
28
|
+
RECOMMENDED_EFFORT_SUFFIX,
|
|
29
|
+
XHIGH_EFFORT_LEVEL,
|
|
30
|
+
} from "./messages.js";
|
|
31
|
+
import { isExecutorBlocked } from "./policy.js";
|
|
32
|
+
import { getAdvisorEffort, getAdvisorModel, setAdvisorEffort, setAdvisorModel } from "./state.js";
|
|
33
|
+
|
|
34
|
+
function buildModelItems(availableModels: Model<Api>[], currentKey: string | undefined): SelectItem[] {
|
|
35
|
+
const items: SelectItem[] = availableModels.map((m) => {
|
|
36
|
+
const key = modelKey(m);
|
|
37
|
+
const check = key === currentKey ? CHECKMARK : "";
|
|
38
|
+
return { value: key, label: `${m.name} (${m.provider})${check}` };
|
|
39
|
+
});
|
|
40
|
+
items.push({
|
|
41
|
+
value: NO_ADVISOR_VALUE,
|
|
42
|
+
label: currentKey === undefined ? `No advisor${CHECKMARK}` : "No advisor",
|
|
43
|
+
});
|
|
44
|
+
return items;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildEffortItems(picked: Model<Api>): SelectItem[] {
|
|
48
|
+
const levels = getSupportedThinkingLevels(picked).includes("xhigh")
|
|
49
|
+
? [...BASE_EFFORT_LEVELS, XHIGH_EFFORT_LEVEL]
|
|
50
|
+
: BASE_EFFORT_LEVELS;
|
|
51
|
+
return [
|
|
52
|
+
{ value: OFF_VALUE, label: "off" },
|
|
53
|
+
...levels.map((level) => ({
|
|
54
|
+
value: level,
|
|
55
|
+
label: level === DEFAULT_EFFORT ? `${level}${RECOMMENDED_EFFORT_SUFFIX}` : level,
|
|
56
|
+
})),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Disable path — persist BEFORE mutating in-memory state so a save failure
|
|
61
|
+
// can't strand "model=undefined + tool still registered" (review I2). The strip
|
|
62
|
+
// is unconditional-on-presence (no advisor at all), so it stays inline rather
|
|
63
|
+
// than routing through reconcileAdvisorTool's blocked-conditional path.
|
|
64
|
+
function applyDisable(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
65
|
+
if (!saveAdvisorConfig(undefined, undefined)) {
|
|
66
|
+
ctx.ui.notify(MSG_PERSIST_FAILED, "error");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
setAdvisorModel(undefined);
|
|
70
|
+
setAdvisorEffort(undefined);
|
|
71
|
+
const active = pi.getActiveTools();
|
|
72
|
+
if (active.includes(ADVISOR_TOOL_NAME)) {
|
|
73
|
+
pi.setActiveTools(active.filter((n) => n !== ADVISOR_TOOL_NAME));
|
|
74
|
+
}
|
|
75
|
+
ctx.ui.notify(MSG_ADVISOR_DISABLED, "info");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Enable path — persist first (review I2), set in-memory state, activate via
|
|
79
|
+
// reconcileAdvisorTool (which re-reads the active-tool list post-effort-picker-
|
|
80
|
+
// await), and notify. Silent reconcile — the enable/inactive notify is the
|
|
81
|
+
// single trailing notify call here.
|
|
82
|
+
function applyEnable(
|
|
83
|
+
pi: ExtensionAPI,
|
|
84
|
+
ctx: ExtensionContext,
|
|
85
|
+
picked: Model<Api>,
|
|
86
|
+
effort: ThinkingLevel | undefined,
|
|
87
|
+
): void {
|
|
88
|
+
if (!saveAdvisorConfig(modelKey(picked), effort)) {
|
|
89
|
+
ctx.ui.notify(MSG_PERSIST_FAILED, "error");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setAdvisorEffort(effort);
|
|
93
|
+
setAdvisorModel(picked);
|
|
94
|
+
|
|
95
|
+
const blocked = isExecutorBlocked(ctx, pi.getThinkingLevel());
|
|
96
|
+
reconcileAdvisorTool(pi, ctx, { blocked });
|
|
97
|
+
ctx.ui.notify(
|
|
98
|
+
blocked ? msgAdvisorEnabledInactive(modelKey(picked), effort) : msgAdvisorEnabled(modelKey(picked), effort),
|
|
99
|
+
"info",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function registerAdvisorCommand(pi: ExtensionAPI): void {
|
|
104
|
+
pi.registerCommand("advisor", {
|
|
105
|
+
description: "Configure the advisor model for the advisor-strategy pattern",
|
|
106
|
+
handler: async (_args, ctx) => {
|
|
107
|
+
if (!ctx.hasUI) {
|
|
108
|
+
ctx.ui.notify(MSG_REQUIRES_INTERACTIVE, "error");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const availableModels = ctx.modelRegistry.getAvailable();
|
|
113
|
+
const current = getAdvisorModel();
|
|
114
|
+
const currentKey = current ? modelKey(current) : undefined;
|
|
115
|
+
|
|
116
|
+
const choice = await showAdvisorPicker(ctx, buildModelItems(availableModels, currentKey));
|
|
117
|
+
if (!choice) return;
|
|
118
|
+
|
|
119
|
+
if (choice === NO_ADVISOR_VALUE) {
|
|
120
|
+
applyDisable(pi, ctx);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const picked = availableModels.find((m) => modelKey(m) === choice);
|
|
125
|
+
if (!picked) {
|
|
126
|
+
ctx.ui.notify(errSelectionNotFound(choice), "error");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Effort picker — only for reasoning-capable models
|
|
131
|
+
let effortChoice: ThinkingLevel | undefined;
|
|
132
|
+
if (picked.reasoning) {
|
|
133
|
+
const effortResult = await showEffortPicker(
|
|
134
|
+
ctx,
|
|
135
|
+
buildEffortItems(picked),
|
|
136
|
+
getAdvisorEffort(),
|
|
137
|
+
DEFAULT_EFFORT,
|
|
138
|
+
);
|
|
139
|
+
if (!effortResult) return;
|
|
140
|
+
effortChoice = effortResult === OFF_VALUE ? undefined : (effortResult as ThinkingLevel);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
applyEnable(pi, ctx, picked, effortChoice);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config — persisted advisor config (~/.config/rpiv-advisor/advisor.json) and
|
|
3
|
+
* the provider:id key codec. Owns the AdvisorConfig shape, load/validate/save,
|
|
4
|
+
* and the modelKey (join) / parseModelKey (split) inverse pair (L4-04).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
8
|
+
import type { GuidanceFields } from "@juicesharp/rpiv-config";
|
|
9
|
+
import { configPath, loadJsonConfig, saveJsonConfig } from "@juicesharp/rpiv-config";
|
|
10
|
+
import { EFFORT_ORDINAL } from "./messages.js";
|
|
11
|
+
|
|
12
|
+
const ADVISOR_CONFIG_PATH = configPath("rpiv-advisor", "advisor.json");
|
|
13
|
+
|
|
14
|
+
export type DisabledForModelsEntry = string | { model: string; minEffort?: ThinkingLevel };
|
|
15
|
+
|
|
16
|
+
interface AdvisorConfig {
|
|
17
|
+
modelKey?: string;
|
|
18
|
+
effort?: ThinkingLevel;
|
|
19
|
+
guidance?: GuidanceFields;
|
|
20
|
+
disabledForModels?: DisabledForModelsEntry[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadAdvisorConfig(): AdvisorConfig {
|
|
24
|
+
return loadJsonConfig<AdvisorConfig>(ADVISOR_CONFIG_PATH);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateDisabledForModels(value: unknown): DisabledForModelsEntry[] {
|
|
28
|
+
if (!Array.isArray(value)) return [];
|
|
29
|
+
return value.filter((entry): entry is DisabledForModelsEntry => {
|
|
30
|
+
if (typeof entry === "string") return entry.length > 0;
|
|
31
|
+
if (typeof entry !== "object" || entry === null) return false;
|
|
32
|
+
const obj = entry as Record<string, unknown>;
|
|
33
|
+
if (typeof obj.model !== "string" || obj.model.length === 0) return false;
|
|
34
|
+
if (obj.minEffort !== undefined && !EFFORT_ORDINAL.includes(obj.minEffort as ThinkingLevel)) return false;
|
|
35
|
+
return true;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function saveAdvisorConfig(key: string | undefined, effort: ThinkingLevel | undefined): boolean {
|
|
40
|
+
const existing = loadAdvisorConfig();
|
|
41
|
+
const config: AdvisorConfig = { ...existing };
|
|
42
|
+
// Delete (rather than omit) to clear fields that may exist in the spread
|
|
43
|
+
// from a prior read. JSON.parse always produces configurable properties,
|
|
44
|
+
// so delete is safe in strict mode.
|
|
45
|
+
if (key) config.modelKey = key;
|
|
46
|
+
else delete config.modelKey;
|
|
47
|
+
if (effort) config.effort = effort;
|
|
48
|
+
else delete config.effort;
|
|
49
|
+
return saveJsonConfig(ADVISOR_CONFIG_PATH, config);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseModelKey(key: string): { provider: string; modelId: string } | undefined {
|
|
53
|
+
const idx = key.indexOf(":");
|
|
54
|
+
if (idx < 1) return undefined;
|
|
55
|
+
return { provider: key.slice(0, idx), modelId: key.slice(idx + 1) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function modelKey(m: { provider: string; id: string }): string {
|
|
59
|
+
return `${m.provider}:${m.id}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context — branch-message massaging for the advisor side-call. Strips the
|
|
3
|
+
* executor's in-flight advisor() toolCall from the tail (orphan toolCalls are
|
|
4
|
+
* rejected by providers) and guarantees a user-role tail (some providers reject
|
|
5
|
+
* an assistant-prefill tail).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
9
|
+
import { ADVISOR_TOOL_NAME, MSG_ADVISOR_NUDGE } from "./messages.js";
|
|
10
|
+
|
|
11
|
+
// Strip the executor's in-flight advisor() toolCall from the tail assistant
|
|
12
|
+
// message. That call is what invoked *us* — there is no matching toolResult
|
|
13
|
+
// yet, and providers (Anthropic, GLM/zai, OpenAI) reject payloads with orphan
|
|
14
|
+
// toolCalls. Name-targeted to leave any other trailing toolCalls visible.
|
|
15
|
+
export function stripInflightAdvisorCall(messages: Message[]): Message[] {
|
|
16
|
+
if (messages.length === 0) return messages;
|
|
17
|
+
const last = messages[messages.length - 1];
|
|
18
|
+
if (last.role !== "assistant") return messages;
|
|
19
|
+
const filtered = last.content.filter((c) => !(c.type === "toolCall" && c.name === ADVISOR_TOOL_NAME));
|
|
20
|
+
if (filtered.length === last.content.length) return messages;
|
|
21
|
+
if (filtered.length === 0) return messages.slice(0, -1);
|
|
22
|
+
return [...messages.slice(0, -1), { ...last, content: filtered }];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Some providers (recent Anthropic Claude models) reject payloads ending on an
|
|
26
|
+
// assistant turn ("This model does not support assistant message prefill. The
|
|
27
|
+
// conversation must end with a user message."). After stripInflightAdvisorCall
|
|
28
|
+
// the tail can be assistant (e.g. the executor wrote thinking text before
|
|
29
|
+
// calling advisor). Append a minimal user-role nudge to guarantee user-tail.
|
|
30
|
+
export function ensureUserTailForAdvisor(messages: Message[]): Message[] {
|
|
31
|
+
if (messages.length === 0) return messages;
|
|
32
|
+
const last = messages[messages.length - 1];
|
|
33
|
+
if (last.role !== "assistant") return messages;
|
|
34
|
+
const nudge: Message = {
|
|
35
|
+
role: "user",
|
|
36
|
+
content: [{ type: "text", text: MSG_ADVISOR_NUDGE }],
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
};
|
|
39
|
+
return [...messages, nudge];
|
|
40
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* execute — the advisor side-call. Curates the executor's branch (inventory
|
|
3
|
+
* prefix + tail massaging), invokes the advisor model via completeSimple with
|
|
4
|
+
* no tools, and returns a structured tool result. Every result branch (success
|
|
5
|
+
* / abort / error / empty) and the pre-call error paths funnel through
|
|
6
|
+
* buildAdvisorResult so the envelope is built in exactly one place.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { StopReason, Usage } from "@earendil-works/pi-ai";
|
|
10
|
+
import { completeSimple, type Message, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
11
|
+
import {
|
|
12
|
+
type AgentToolResult,
|
|
13
|
+
type AgentToolUpdateCallback,
|
|
14
|
+
buildSessionContext,
|
|
15
|
+
convertToLlm,
|
|
16
|
+
type ExtensionAPI,
|
|
17
|
+
type ExtensionContext,
|
|
18
|
+
} from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { ensureUserTailForAdvisor, stripInflightAdvisorCall } from "./context.js";
|
|
20
|
+
import { getInventoryMessage } from "./inventory.js";
|
|
21
|
+
import {
|
|
22
|
+
ERR_ABORTED_DETAIL,
|
|
23
|
+
ERR_CALL_ABORTED,
|
|
24
|
+
ERR_EMPTY_RESPONSE,
|
|
25
|
+
ERR_EMPTY_RESPONSE_DETAIL,
|
|
26
|
+
ERR_NO_MODEL,
|
|
27
|
+
ERR_NO_MODEL_SELECTED,
|
|
28
|
+
errCallFailed,
|
|
29
|
+
errCallThrew,
|
|
30
|
+
errMisconfigured,
|
|
31
|
+
errNoApiKey,
|
|
32
|
+
errNoApiKeyDetail,
|
|
33
|
+
msgConsulting,
|
|
34
|
+
} from "./messages.js";
|
|
35
|
+
import { ADVISOR_SYSTEM_PROMPT } from "./prompt.js";
|
|
36
|
+
import { getAdvisorEffort, getAdvisorModel } from "./state.js";
|
|
37
|
+
|
|
38
|
+
interface AdvisorDetails {
|
|
39
|
+
advisorModel?: string;
|
|
40
|
+
effort?: ThinkingLevel;
|
|
41
|
+
usage?: Usage;
|
|
42
|
+
stopReason?: StopReason;
|
|
43
|
+
errorMessage?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Single result-envelope builder — every executeAdvisor branch and the pre-call
|
|
47
|
+
// error paths funnel through here. `effort` is snapshotted once at executeAdvisor
|
|
48
|
+
// entry and threaded through every call so the returned details.effort always
|
|
49
|
+
// matches the value sent as `reasoning` to completeSimple, even if module-level
|
|
50
|
+
// state is mutated during the await window.
|
|
51
|
+
function buildAdvisorResult(opts: {
|
|
52
|
+
text: string;
|
|
53
|
+
effort: ThinkingLevel | undefined;
|
|
54
|
+
advisorLabel?: string;
|
|
55
|
+
usage?: Usage;
|
|
56
|
+
stopReason?: StopReason;
|
|
57
|
+
errorMessage?: string;
|
|
58
|
+
}): AgentToolResult<AdvisorDetails> {
|
|
59
|
+
const details: AdvisorDetails = { effort: opts.effort };
|
|
60
|
+
if (opts.advisorLabel !== undefined) details.advisorModel = opts.advisorLabel;
|
|
61
|
+
if (opts.usage !== undefined) details.usage = opts.usage;
|
|
62
|
+
if (opts.stopReason !== undefined) details.stopReason = opts.stopReason;
|
|
63
|
+
if (opts.errorMessage !== undefined) details.errorMessage = opts.errorMessage;
|
|
64
|
+
return { content: [{ type: "text", text: opts.text }], details };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildErrorResult(
|
|
68
|
+
advisorLabel: string | undefined,
|
|
69
|
+
effort: ThinkingLevel | undefined,
|
|
70
|
+
userText: string,
|
|
71
|
+
errorMessage: string,
|
|
72
|
+
): AgentToolResult<AdvisorDetails> {
|
|
73
|
+
return buildAdvisorResult({ text: userText, effort, advisorLabel, errorMessage });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function executeAdvisor(
|
|
77
|
+
ctx: ExtensionContext,
|
|
78
|
+
pi: ExtensionAPI,
|
|
79
|
+
signal: AbortSignal | undefined,
|
|
80
|
+
onUpdate: AgentToolUpdateCallback<AdvisorDetails> | undefined,
|
|
81
|
+
): Promise<AgentToolResult<AdvisorDetails>> {
|
|
82
|
+
// Snapshot effort once at entry — every result envelope and the API call
|
|
83
|
+
// itself use this same value so a concurrent setAdvisorEffort() during the
|
|
84
|
+
// await window cannot desync details.effort from the `reasoning` actually sent.
|
|
85
|
+
const effort = getAdvisorEffort();
|
|
86
|
+
const advisor = getAdvisorModel();
|
|
87
|
+
if (!advisor) {
|
|
88
|
+
return buildErrorResult(undefined, effort, ERR_NO_MODEL, ERR_NO_MODEL_SELECTED);
|
|
89
|
+
}
|
|
90
|
+
const advisorLabel = `${advisor.provider}:${advisor.id}`;
|
|
91
|
+
|
|
92
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(advisor);
|
|
93
|
+
if (!auth.ok) {
|
|
94
|
+
return buildErrorResult(advisorLabel, effort, errMisconfigured(advisorLabel, auth.error), auth.error);
|
|
95
|
+
}
|
|
96
|
+
if (!auth.apiKey) {
|
|
97
|
+
return buildErrorResult(advisorLabel, effort, errNoApiKey(advisorLabel), errNoApiKeyDetail(advisor.provider));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Live-read every call — advisor runs mid-turn so any message_end snapshot
|
|
101
|
+
// is always one turn stale. buildSessionContext() preserves Pi's resolved
|
|
102
|
+
// LLM context, including compaction summaries and branch summaries, instead
|
|
103
|
+
// of replaying raw pre-compaction branch messages. convertToLlm is
|
|
104
|
+
// pass-through for user/assistant/toolResult (messages.js:111-114), so
|
|
105
|
+
// element refs are stable across calls via the session store.
|
|
106
|
+
const { messages: sessionMessages } = buildSessionContext(
|
|
107
|
+
ctx.sessionManager.getEntries(),
|
|
108
|
+
ctx.sessionManager.getLeafId(),
|
|
109
|
+
);
|
|
110
|
+
const branchMessages = ensureUserTailForAdvisor(stripInflightAdvisorCall(convertToLlm(sessionMessages)));
|
|
111
|
+
const inventoryMessage = getInventoryMessage(pi.getAllTools());
|
|
112
|
+
const messages: Message[] = inventoryMessage ? [inventoryMessage, ...branchMessages] : branchMessages;
|
|
113
|
+
|
|
114
|
+
onUpdate?.({
|
|
115
|
+
content: [{ type: "text", text: msgConsulting(advisorLabel, effort) }],
|
|
116
|
+
details: { advisorModel: advisorLabel, effort },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const response = await completeSimple(
|
|
121
|
+
advisor,
|
|
122
|
+
// `tools: []` reaffirms the "never calls tools" contract even when
|
|
123
|
+
// `messages` contains prior toolCall/toolResult blocks (btw.ts:235).
|
|
124
|
+
{ systemPrompt: ADVISOR_SYSTEM_PROMPT, messages, tools: [] },
|
|
125
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal, reasoning: effort },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (response.stopReason === "aborted") {
|
|
129
|
+
return buildAdvisorResult({
|
|
130
|
+
text: ERR_CALL_ABORTED,
|
|
131
|
+
effort,
|
|
132
|
+
advisorLabel,
|
|
133
|
+
usage: response.usage,
|
|
134
|
+
stopReason: response.stopReason,
|
|
135
|
+
errorMessage: response.errorMessage ?? ERR_ABORTED_DETAIL,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (response.stopReason === "error") {
|
|
140
|
+
return buildAdvisorResult({
|
|
141
|
+
text: errCallFailed(response.errorMessage),
|
|
142
|
+
effort,
|
|
143
|
+
advisorLabel,
|
|
144
|
+
usage: response.usage,
|
|
145
|
+
stopReason: response.stopReason,
|
|
146
|
+
errorMessage: response.errorMessage,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const advisorText = response.content
|
|
151
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
152
|
+
.map((c) => c.text)
|
|
153
|
+
.join("\n")
|
|
154
|
+
.trim();
|
|
155
|
+
|
|
156
|
+
if (!advisorText) {
|
|
157
|
+
return buildAdvisorResult({
|
|
158
|
+
text: ERR_EMPTY_RESPONSE,
|
|
159
|
+
effort,
|
|
160
|
+
advisorLabel,
|
|
161
|
+
usage: response.usage,
|
|
162
|
+
stopReason: response.stopReason,
|
|
163
|
+
errorMessage: ERR_EMPTY_RESPONSE_DETAIL,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return buildAdvisorResult({
|
|
168
|
+
text: advisorText,
|
|
169
|
+
effort,
|
|
170
|
+
advisorLabel,
|
|
171
|
+
usage: response.usage,
|
|
172
|
+
stopReason: response.stopReason,
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
176
|
+
return buildErrorResult(advisorLabel, effort, errCallThrew(message), message);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handlers — mid-session lifecycle handlers. A shared reconcileAdvisorTool
|
|
3
|
+
* strips or re-adds the advisor tool to match the blocked state (with an
|
|
4
|
+
* optional notify), and the three register*Handler functions wire it to
|
|
5
|
+
* before_agent_start, model_select, and thinking_level_select.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { modelKey } from "./config.js";
|
|
10
|
+
import { ADVISOR_TOOL_NAME, MSG_ADVISOR_DISABLED, msgAdvisorRestored } from "./messages.js";
|
|
11
|
+
import { isExecutorBlocked, isModelBlocked } from "./policy.js";
|
|
12
|
+
import { getAdvisorEffort, getAdvisorModel } from "./state.js";
|
|
13
|
+
|
|
14
|
+
interface ReconcileNotify {
|
|
15
|
+
/** Shown when the tool is stripped (executor became blocked). */
|
|
16
|
+
disabled: string;
|
|
17
|
+
/** Shown when the tool is re-added (executor became unblocked). */
|
|
18
|
+
restored: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Strip-or-add the advisor tool to match the blocked state, with an optional
|
|
22
|
+
// notify on each transition. Reads the active-tool list itself. Shared by the
|
|
23
|
+
// three lifecycle handlers and the /advisor command's activate step.
|
|
24
|
+
export function reconcileAdvisorTool(
|
|
25
|
+
pi: ExtensionAPI,
|
|
26
|
+
ctx: ExtensionContext,
|
|
27
|
+
opts: { blocked: boolean; notify?: ReconcileNotify },
|
|
28
|
+
): void {
|
|
29
|
+
const active = pi.getActiveTools();
|
|
30
|
+
const hasTool = active.includes(ADVISOR_TOOL_NAME);
|
|
31
|
+
if (opts.blocked && hasTool) {
|
|
32
|
+
pi.setActiveTools(active.filter((n) => n !== ADVISOR_TOOL_NAME));
|
|
33
|
+
if (opts.notify && ctx.hasUI) ctx.ui.notify(opts.notify.disabled, "info");
|
|
34
|
+
} else if (!opts.blocked && !hasTool) {
|
|
35
|
+
pi.setActiveTools([...active, ADVISOR_TOOL_NAME]);
|
|
36
|
+
if (opts.notify && ctx.hasUI) ctx.ui.notify(opts.notify.restored, "info");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerAdvisorBeforeAgentStart(pi: ExtensionAPI): void {
|
|
41
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
42
|
+
const advisor = getAdvisorModel();
|
|
43
|
+
if (!advisor) {
|
|
44
|
+
const active = pi.getActiveTools();
|
|
45
|
+
if (active.includes(ADVISOR_TOOL_NAME)) {
|
|
46
|
+
pi.setActiveTools(active.filter((n) => n !== ADVISOR_TOOL_NAME));
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
reconcileAdvisorTool(pi, ctx, { blocked: isExecutorBlocked(ctx, pi.getThinkingLevel()) });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function registerModelSelectHandler(pi: ExtensionAPI): void {
|
|
55
|
+
pi.on("model_select", async (event, ctx) => {
|
|
56
|
+
// session_start restore path is owned by restoreAdvisorState — it already
|
|
57
|
+
// activates the tool and notifies. Skipping "restore" here prevents a
|
|
58
|
+
// duplicate notification on initial model load.
|
|
59
|
+
if (event.source === "restore") return;
|
|
60
|
+
|
|
61
|
+
const advisor = getAdvisorModel();
|
|
62
|
+
if (!advisor) return;
|
|
63
|
+
|
|
64
|
+
reconcileAdvisorTool(pi, ctx, {
|
|
65
|
+
blocked: isModelBlocked(event.model, pi.getThinkingLevel()),
|
|
66
|
+
notify: {
|
|
67
|
+
disabled: `Advisor disabled for ${modelKey(event.model)}`,
|
|
68
|
+
restored: msgAdvisorRestored(modelKey(advisor), getAdvisorEffort()),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function registerThinkingLevelSelectHandler(pi: ExtensionAPI): void {
|
|
75
|
+
pi.on("thinking_level_select", async (event, ctx) => {
|
|
76
|
+
const advisor = getAdvisorModel();
|
|
77
|
+
if (!advisor) return;
|
|
78
|
+
|
|
79
|
+
// `blocked === true` implies a defined model (isModelBlocked returns false
|
|
80
|
+
// for undefined), so the MSG_ADVISOR_DISABLED fallback is unreachable — it
|
|
81
|
+
// only keeps the disabled string total without a non-null assertion on the model.
|
|
82
|
+
const model = ctx?.model;
|
|
83
|
+
reconcileAdvisorTool(pi, ctx, {
|
|
84
|
+
blocked: isModelBlocked(model, event.level),
|
|
85
|
+
notify: {
|
|
86
|
+
disabled: model ? `Advisor disabled for ${modelKey(model)}` : MSG_ADVISOR_DISABLED,
|
|
87
|
+
restored: msgAdvisorRestored(modelKey(advisor), getAdvisorEffort()),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
package/advisor/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advisor — Advisor-strategy pattern: a zero-param `advisor` tool + `/advisor`
|
|
3
|
+
* command that forward the serialized conversation branch to a separately-
|
|
4
|
+
* configured reviewer model. Advisor has no tools, never emits user-facing
|
|
5
|
+
* output, and returns guidance the executor resumes with.
|
|
6
|
+
*
|
|
7
|
+
* The implementation is one concern per file under this directory; this barrel
|
|
8
|
+
* re-exports the package's public surface (consumed by ../index.ts, the repo-
|
|
9
|
+
* root test/setup.ts, and the advisor.*.test.ts suite via "./advisor/index.js").
|
|
10
|
+
*
|
|
11
|
+
* Module map:
|
|
12
|
+
* messages — tool identity, sentinels, effort vocabulary, all strings
|
|
13
|
+
* config — persisted config + provider:id key codec
|
|
14
|
+
* state — in-memory model/effort selection
|
|
15
|
+
* policy — disabledForModels blocklist + blocked predicates
|
|
16
|
+
* inventory — globalThis tool-inventory cache + serializer
|
|
17
|
+
* context — branch-message massaging
|
|
18
|
+
* prompt — system-prompt loader
|
|
19
|
+
* execute — the advisor side-call
|
|
20
|
+
* register — advisor tool registration
|
|
21
|
+
* handlers — mid-session lifecycle handlers
|
|
22
|
+
* restore — session_start restoration
|
|
23
|
+
* command — /advisor slash command
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export { registerAdvisorCommand } from "./command.js";
|
|
27
|
+
export { loadAdvisorConfig, saveAdvisorConfig } from "./config.js";
|
|
28
|
+
export { ensureUserTailForAdvisor, stripInflightAdvisorCall } from "./context.js";
|
|
29
|
+
export {
|
|
30
|
+
registerAdvisorBeforeAgentStart,
|
|
31
|
+
registerModelSelectHandler,
|
|
32
|
+
registerThinkingLevelSelectHandler,
|
|
33
|
+
} from "./handlers.js";
|
|
34
|
+
export { getInventoryMessage, stableStringify } from "./inventory.js";
|
|
35
|
+
export { ADVISOR_TOOL_NAME } from "./messages.js";
|
|
36
|
+
export { setDisabledForModels } from "./policy.js";
|
|
37
|
+
export { DEFAULT_PROMPT_GUIDELINES, DEFAULT_PROMPT_SNIPPET, registerAdvisorTool } from "./register.js";
|
|
38
|
+
export { __resetAdvisorAnnounced, registerAdvisorSessionStart, restoreAdvisorState } from "./restore.js";
|
|
39
|
+
export { getAdvisorEffort, getAdvisorModel, setAdvisorEffort, setAdvisorModel } from "./state.js";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inventory — stable tool-inventory Message for advisor prompt-cache parity.
|
|
3
|
+
*
|
|
4
|
+
* globalThis-keyed (Symbol.for("rpiv-advisor")) so the cache survives module
|
|
5
|
+
* re-import on /new, /fork, /resume (mirrors rpiv-btw/btw.ts). Single-slot — the
|
|
6
|
+
* Pi tool registry is process-scoped, so per-session keying would be redundant;
|
|
7
|
+
* the cache invalidates only when the set of registered tool names changes. Also
|
|
8
|
+
* exposes the key-sorted JSON serializer (stableStringify) the block is built from.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
12
|
+
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
const ADVISOR_STATE_KEY = Symbol.for("rpiv-advisor");
|
|
15
|
+
|
|
16
|
+
interface AdvisorState {
|
|
17
|
+
inventorySignature?: string;
|
|
18
|
+
inventoryMessage?: Message;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getAdvisorRuntimeState(): AdvisorState {
|
|
22
|
+
const g = globalThis as unknown as { [k: symbol]: AdvisorState | undefined };
|
|
23
|
+
let state = g[ADVISOR_STATE_KEY];
|
|
24
|
+
if (!state) {
|
|
25
|
+
state = {};
|
|
26
|
+
g[ADVISOR_STATE_KEY] = state;
|
|
27
|
+
}
|
|
28
|
+
return state;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Recursive key-sorted JSON serializer — matches JSON.stringify semantics
|
|
32
|
+
// (drops `undefined` in objects, emits `null` for `undefined` in arrays) but
|
|
33
|
+
// guarantees stable key ordering across V8 insertion-order variation. Required
|
|
34
|
+
// because nested TypeBox schemas may be authored in any order, and prompt
|
|
35
|
+
// caching is byte-sensitive.
|
|
36
|
+
export function stableStringify(value: unknown): string {
|
|
37
|
+
if (value === null || typeof value !== "object") {
|
|
38
|
+
return JSON.stringify(value);
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
return `[${value.map((v) => (v === undefined ? "null" : stableStringify(v))).join(",")}]`;
|
|
42
|
+
}
|
|
43
|
+
const obj = value as Record<string, unknown>;
|
|
44
|
+
const entries: string[] = [];
|
|
45
|
+
for (const k of Object.keys(obj).sort()) {
|
|
46
|
+
const v = obj[k];
|
|
47
|
+
if (v === undefined) continue;
|
|
48
|
+
entries.push(`${JSON.stringify(k)}:${stableStringify(v)}`);
|
|
49
|
+
}
|
|
50
|
+
return `{${entries.join(",")}}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildInventoryBlock(tools: ToolInfo[]): string {
|
|
54
|
+
// Omit `sourceInfo` — its `path` field is install-location-dependent and
|
|
55
|
+
// would bust cache parity across machines/reinstalls.
|
|
56
|
+
return tools
|
|
57
|
+
.map((t) => `### ${t.name}\n${t.description}\n\nParameters: ${stableStringify(t.parameters)}`)
|
|
58
|
+
.join("\n\n---\n\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Returns `undefined` when the registry is empty (no extensions loaded) so
|
|
62
|
+
// callers can skip prepending an empty block that would still cost a cache unit.
|
|
63
|
+
export function getInventoryMessage(tools: ToolInfo[]): Message | undefined {
|
|
64
|
+
if (tools.length === 0) return undefined;
|
|
65
|
+
const sorted = [...tools].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
66
|
+
const signature = sorted.map((t) => t.name).join("|");
|
|
67
|
+
const state = getAdvisorRuntimeState();
|
|
68
|
+
if (state.inventorySignature === signature && state.inventoryMessage) {
|
|
69
|
+
return state.inventoryMessage;
|
|
70
|
+
}
|
|
71
|
+
const text = `## Available Executor Tools\n\n${buildInventoryBlock(sorted)}`;
|
|
72
|
+
const message: Message = {
|
|
73
|
+
role: "user",
|
|
74
|
+
content: [{ type: "text", text }],
|
|
75
|
+
timestamp: Date.now(),
|
|
76
|
+
};
|
|
77
|
+
state.inventorySignature = signature;
|
|
78
|
+
state.inventoryMessage = message;
|
|
79
|
+
return message;
|
|
80
|
+
}
|