@bacnh85/pi-advisor 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +16 -11
- package/extensions/commands/advisor.ts +9 -11
- package/extensions/index.ts +0 -5
- package/extensions/lib/config.ts +27 -3
- package/extensions/lib/isolated-model.ts +8 -3
- package/extensions/lib/model-picker.ts +14 -2
- package/extensions/lib/models-panel.ts +46 -19
- package/extensions/lib/watcher.ts +5 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-09-13)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Per-slot thinking levels.** A trailing `:level`
|
|
8
|
+
(`minimal|low|medium|high|xhigh|max`) on a `pi-advisor.models` entry pins
|
|
9
|
+
that candidate's thinking; it rides into the stream call as
|
|
10
|
+
`SimpleStreamOptions.reasoning`. Entries without a suffix (and `:off`)
|
|
11
|
+
keep the provider default, so each fallback in the chain can carry its own
|
|
12
|
+
level. Strict trailing match — openrouter `:free` ids stay intact.
|
|
13
|
+
`/advisor models` gains a thinking row per slot (blank = model default);
|
|
14
|
+
the chain canonicalizer preserves pinned levels on save.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Evidence sizing now strips a trailing `:level` before the registry lookup —
|
|
19
|
+
previously a pinned entry failed `parseModelRef` and the transcript budget
|
|
20
|
+
silently fell back to the 32k default (over-sizing for small-context
|
|
21
|
+
fallback models, whose context errors count toward the 3-strike pause).
|
|
22
|
+
|
|
23
|
+
## 0.2.4 (2026-09-12)
|
|
24
|
+
|
|
25
|
+
### Removed
|
|
26
|
+
|
|
27
|
+
- Dead `AdvisorState.getThinking` knob (sole impl returned `undefined`; the
|
|
28
|
+
value flowed nowhere meaningful).
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- `saveModels` / `migrateLegacyAdvisorModel` no longer leave a `*.tmp-*` file
|
|
33
|
+
behind when the final rename throws (best-effort unlink, error re-thrown).
|
|
34
|
+
|
|
35
|
+
### Documentation
|
|
36
|
+
|
|
37
|
+
- README: added `/advisor watch-off` to the command list.
|
|
38
|
+
|
|
3
39
|
## 0.2.3 (2026-09-11)
|
|
4
40
|
|
|
5
41
|
- **Fix 20–30s+ TUI freeze after every settled turn**: pi core awaits
|
package/README.md
CHANGED
|
@@ -14,10 +14,10 @@ consult tool. Inspired by the advisor subsystem in
|
|
|
14
14
|
turn during the post-steer calm-down window. The severity sets the note's
|
|
15
15
|
authority wording (“nit — consider” vs “concern — address this” vs
|
|
16
16
|
“blocker — fix before continuing”).
|
|
17
|
-
- Post-steer cooldown: after a note steers,
|
|
18
|
-
|
|
19
|
-
instead of waking the agent again — bounds ping-pong.
|
|
20
|
-
steer immediately.
|
|
17
|
+
- Post-steer cooldown: after a note steers, nit notes within the next
|
|
18
|
+
`immuneTurns` settled turns are deferred (LLM-visible next turn)
|
|
19
|
+
instead of waking the agent again — bounds ping-pong. Concerns and
|
|
20
|
+
blockers always steer immediately.
|
|
21
21
|
- **Emission guard** (noise control): content-free phrases ("lgtm", "done", …)
|
|
22
22
|
are dropped, identical notes are deduped (severity escalation still passes),
|
|
23
23
|
and at most one note is delivered per review cycle.
|
|
@@ -44,9 +44,10 @@ npm install -g @bacnh85/pi-advisor
|
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
/advisor <provider/model[, …]> # set the chain (one model or comma-separated fallbacks)
|
|
47
|
-
/advisor models # edit the
|
|
47
|
+
/advisor models # edit the chain + per-slot thinking rows (TUI panel; non-TUI prints it)
|
|
48
48
|
/advisor status # model chain, watch state, counters
|
|
49
49
|
/advisor on # enable watch for this session (also clears a pause)
|
|
50
|
+
/advisor watch-off # disable background watch for this session
|
|
50
51
|
/advisor off # clear the chain (disables tool + watch)
|
|
51
52
|
```
|
|
52
53
|
|
|
@@ -56,18 +57,22 @@ Settings live in `~/.pi/agent/settings.json` (global) and `.pi/settings.json`
|
|
|
56
57
|
```json
|
|
57
58
|
{
|
|
58
59
|
"pi-advisor": {
|
|
59
|
-
"models": ["zai-coding-cn/glm-5.3", "opencode-go/deepseek-v4-pro"],
|
|
60
|
+
"models": ["zai-coding-cn/glm-5.3:high", "opencode-go/deepseek-v4-pro"],
|
|
60
61
|
"watch": { "enabled": true, "minToolCalls": 3, "immuneTurns": 3 }
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
- `models` — ordered fallback chain, first entry is primary. Accepts an array
|
|
66
|
-
or a comma-separated string (`"a/b, c/d"`).
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
or a comma-separated string (`"a/b, c/d"`). A trailing `:level`
|
|
68
|
+
(`minimal|low|medium|high|xhigh|max`) on an entry pins that candidate's
|
|
69
|
+
thinking; entries without one use the model's provider default, so each
|
|
70
|
+
fallback can carry its own level (`:high` on a strong primary, none on a
|
|
71
|
+
flash fallback). Legacy single `model` string is still honored. If the
|
|
72
|
+
primary is rate-limited or unavailable at review/consult time, the next
|
|
73
|
+
candidate serves automatically; a whole-chain failure counts as one review
|
|
74
|
+
failure (the 3-strike pause still applies). The advisor never falls back to
|
|
75
|
+
the primary model — it must never review its own turns.
|
|
71
76
|
- `watch.enabled` (default `true`) — turn-end reviewing on session start (TUI only — print/rpc/json runs skip the watch; the on-demand advisor tool still works)
|
|
72
77
|
- `watch.minToolCalls` (default `3`, `0` = every turn) — skip trivial turns
|
|
73
78
|
- `watch.immuneTurns` (default `3`) — review window during which the same
|
|
@@ -2,7 +2,7 @@ import { buildSessionContext, type ExtensionAPI, type ExtensionContext } from "@
|
|
|
2
2
|
import { fuzzyFilter } from "@earendil-works/pi-tui";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { runIsolatedChain } from "../lib/isolated-model";
|
|
5
|
-
import { chooseModel, exactModel, firstAvailable, modelRef, modelSearchText } from "../lib/model-picker";
|
|
5
|
+
import { canonicalEntry, chooseModel, exactModel, firstAvailable, modelRef, modelSearchText } from "../lib/model-picker";
|
|
6
6
|
import { buildEvidence } from "../lib/watcher";
|
|
7
7
|
import type { WatcherRuntime } from "../lib/watcher";
|
|
8
8
|
|
|
@@ -19,7 +19,6 @@ export function parseChainArgument(raw: string): string[] {
|
|
|
19
19
|
export interface AdvisorState {
|
|
20
20
|
getModels(): string[];
|
|
21
21
|
setModels(models: string[]): Promise<void> | void;
|
|
22
|
-
getThinking(): string | undefined;
|
|
23
22
|
getRuntime(): WatcherRuntime | undefined;
|
|
24
23
|
isWatchEnabled(): boolean;
|
|
25
24
|
setWatchEnabled(value: boolean): void;
|
|
@@ -100,7 +99,6 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
|
|
|
100
99
|
const transcriptEvidence = buildEvidence(ctx, models, transcript.messages, SYSTEM);
|
|
101
100
|
const chain = models.join(" → ");
|
|
102
101
|
onUpdate?.({ content: [{ type: "text", text: `Consulting ${chain}…` }], details: { models } });
|
|
103
|
-
const reasoning = state.getThinking();
|
|
104
102
|
// Progressive display resets per attempt: a candidate that dies mid-stream
|
|
105
103
|
// must not leave its partial output above the next candidate's response.
|
|
106
104
|
let output = "";
|
|
@@ -116,7 +114,7 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
|
|
|
116
114
|
if (forAttempt !== attempt) { attempt = forAttempt; output = ""; }
|
|
117
115
|
output += delta;
|
|
118
116
|
onUpdate?.({ content: [{ type: "text", text: output }], details: { models } });
|
|
119
|
-
}, signal
|
|
117
|
+
}, signal);
|
|
120
118
|
return {
|
|
121
119
|
content: [{ type: "text", text: `Advice from ${result.model}:\n${result.text}` }],
|
|
122
120
|
details: { models, served: result.model },
|
|
@@ -155,6 +153,7 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
|
|
|
155
153
|
"Advisor models (ordered fallback, first = primary):",
|
|
156
154
|
...(models.length > 0 ? models.map((m, i) => ` #${i + 1} ${m}`) : [" (none — advisor inactive)"]),
|
|
157
155
|
"",
|
|
156
|
+
`Append :level (minimal…max) to an entry to pin thinking per slot.`,
|
|
158
157
|
`Edit ~/.pi/agent/settings.json → pi-advisor.models, or run /advisor models in a TUI.`,
|
|
159
158
|
];
|
|
160
159
|
pi.sendMessage({ customType: "pi-advisor", content: lines.join("\n"), display: true });
|
|
@@ -162,10 +161,10 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
|
|
|
162
161
|
}
|
|
163
162
|
const working = panel.buildModelsPanelCfg(models);
|
|
164
163
|
const actions: Record<string, { label: string; run: (prompt: (label: string, onDone: (value: string | undefined) => void) => void) => Promise<void> | void }> = {
|
|
165
|
-
addModel: { label: "+ Add model slot", run: () => { working.models.push(""); } },
|
|
164
|
+
addModel: { label: "+ Add model slot", run: () => { working.models.push({ ref: "", thinking: "" }); } },
|
|
166
165
|
removeLast: { label: "− Remove last slot", run: () => {
|
|
167
166
|
const popped = working.models.pop();
|
|
168
|
-
if (popped) ctx.ui.notify(`Removed slot #${working.models.length + 1} ("${popped}" discarded).`, "warning");
|
|
167
|
+
if (popped?.ref) ctx.ui.notify(`Removed slot #${working.models.length + 1} ("${popped.ref}" discarded).`, "warning");
|
|
169
168
|
} },
|
|
170
169
|
};
|
|
171
170
|
const panelOptions = { models: () => (registry?.getAvailable() ?? []).map((m) => modelRef(m)) };
|
|
@@ -176,6 +175,8 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
|
|
|
176
175
|
title: "Advisor models (ordered fallback)",
|
|
177
176
|
onSave: (saved) => {
|
|
178
177
|
if (!saved) return;
|
|
178
|
+
const invalid = panel.invalidThinkingSlots(working);
|
|
179
|
+
if (invalid.length > 0) ctx.ui.notify(`Dropped invalid thinking in slot(s): ${invalid.map((i) => `#${i + 1}`).join(", ")} (valid: minimal…max).`, "warning");
|
|
179
180
|
// set() persists, updates the runtime, syncs tool availability, and
|
|
180
181
|
// notifies; surface unexpected rejections instead of dropping them.
|
|
181
182
|
set(panel.cfgToModels(working), ctx).catch((error) => ctx.ui.notify(`Advisor update failed: ${String(error)}`, "error"));
|
|
@@ -231,13 +232,10 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
|
|
|
231
232
|
// chain runner and availability gate skip dead entries at call time).
|
|
232
233
|
// Dedupe here too: two raw spellings can resolve to the same provider/id.
|
|
233
234
|
const available = ctx.modelRegistry.getAvailable();
|
|
234
|
-
return await set([...new Set(chain.map((entry) =>
|
|
235
|
-
const match = exactModel(available, entry);
|
|
236
|
-
return match ? modelRef(match) : entry;
|
|
237
|
-
}))], ctx);
|
|
235
|
+
return await set([...new Set(chain.map((entry) => canonicalEntry(available, entry)))], ctx);
|
|
238
236
|
}
|
|
239
237
|
const match = chain.length === 1 ? exactModel(ctx.modelRegistry.getAvailable(), chain[0]) : undefined;
|
|
240
|
-
if (match) return await set([
|
|
238
|
+
if (match) return await set([canonicalEntry(ctx.modelRegistry.getAvailable(), chain[0])], ctx);
|
|
241
239
|
if (ctx.mode !== "tui") throw new Error("Usage: /advisor <provider/model[, …]|models|on|off|status>");
|
|
242
240
|
const choice = await chooseModel(ctx, firstAvailable(ctx, state.getModels()), args.trim() || undefined);
|
|
243
241
|
if (!choice) return;
|
package/extensions/index.ts
CHANGED
|
@@ -94,10 +94,6 @@ export default function piAdvisor(pi: ExtensionAPI): void {
|
|
|
94
94
|
if (legacy) {
|
|
95
95
|
models = [legacy];
|
|
96
96
|
ctx.ui.notify(`Advisor model migrated from pi-plan: ${legacy}`, "info");
|
|
97
|
-
// If we migrated on top of a pi-plan that had legacyMigrated:true already,
|
|
98
|
-
// the user config may still lack migrationVersion. Backfill it idempotently
|
|
99
|
-
// so no future manual patch is needed (code writes, agent never touches
|
|
100
|
-
// the real global config directly).
|
|
101
97
|
}
|
|
102
98
|
}
|
|
103
99
|
runtime = createRuntime(config, models);
|
|
@@ -151,7 +147,6 @@ export default function piAdvisor(pi: ExtensionAPI): void {
|
|
|
151
147
|
await saveModels(models);
|
|
152
148
|
if (runtime) runtime.models = models;
|
|
153
149
|
},
|
|
154
|
-
getThinking: () => undefined,
|
|
155
150
|
getRuntime: () => runtime,
|
|
156
151
|
isWatchEnabled: () => watchEnabled,
|
|
157
152
|
setWatchEnabled: (value) => {
|
package/extensions/lib/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
1
|
+
import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { CONFIG_DIR_NAME, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -94,7 +94,12 @@ export async function saveModels(models: string[]): Promise<void> {
|
|
|
94
94
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
95
95
|
await mkdir(path.dirname(file), { recursive: true });
|
|
96
96
|
await writeFile(tmp, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
97
|
-
|
|
97
|
+
try {
|
|
98
|
+
await rename(tmp, file);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
try { await unlink(tmp); } catch { /* best-effort cleanup */ }
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
export function parseModel(value: string): { provider: string; id: string } | undefined {
|
|
@@ -103,6 +108,20 @@ export function parseModel(value: string): { provider: string; id: string } | un
|
|
|
103
108
|
return { provider: value.slice(0, slash), id: value.slice(slash + 1) };
|
|
104
109
|
}
|
|
105
110
|
|
|
111
|
+
/** Thinking levels accepted as a trailing `:level` on a chain entry.
|
|
112
|
+
* `off` maps to "no explicit level" (SimpleStreamOptions.reasoning has no off). */
|
|
113
|
+
export const THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
114
|
+
|
|
115
|
+
/** Split a trailing `:thinking` suffix. Strict trailing match only — openrouter
|
|
116
|
+
* ids like `model:free` stay intact. */
|
|
117
|
+
export function splitThinkingSuffix(entry: string): { name: string; thinking?: string } {
|
|
118
|
+
const idx = entry.lastIndexOf(":");
|
|
119
|
+
if (idx <= 0 || idx >= entry.length - 1) return { name: entry };
|
|
120
|
+
const suffix = entry.slice(idx + 1);
|
|
121
|
+
if (!THINKING_LEVELS.includes(suffix)) return { name: entry };
|
|
122
|
+
return { name: entry.slice(0, idx), thinking: suffix };
|
|
123
|
+
}
|
|
124
|
+
|
|
106
125
|
/**
|
|
107
126
|
* One-shot legacy migration: if pi-advisor.model is unset, adopt the old
|
|
108
127
|
* pi-plan advisorModel preference so existing users keep their advisor.
|
|
@@ -130,7 +149,12 @@ export async function migrateLegacyAdvisorModel(): Promise<string | undefined> {
|
|
|
130
149
|
const tmp = `${settingsPath}.tmp-${process.pid}`;
|
|
131
150
|
await mkdir(path.dirname(settingsPath), { recursive: true });
|
|
132
151
|
await writeFile(tmp, JSON.stringify({ ...settings, [KEY]: { ...block, model: legacy, migrationVersion: MIGRATION_VERSION } }, null, 2) + "\n", "utf8");
|
|
133
|
-
|
|
152
|
+
try {
|
|
153
|
+
await rename(tmp, settingsPath);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
try { await unlink(tmp); } catch { /* best-effort cleanup */ }
|
|
156
|
+
throw e;
|
|
157
|
+
}
|
|
134
158
|
return legacy;
|
|
135
159
|
} catch { return undefined; }
|
|
136
160
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { parseModel } from "./config";
|
|
3
|
+
import { parseModel, splitThinkingSuffix } from "./config";
|
|
4
4
|
|
|
5
5
|
export interface IsolatedContext {
|
|
6
6
|
systemPrompt: string;
|
|
@@ -21,14 +21,19 @@ export async function runIsolated(
|
|
|
21
21
|
/** Progress hook — every stream event (incl. non-text deltas) resets the caller's idle deadline. */
|
|
22
22
|
onEvent?: () => void,
|
|
23
23
|
): Promise<string> {
|
|
24
|
-
|
|
24
|
+
// A trailing `:level` on the chain entry pins thinking for this candidate;
|
|
25
|
+
// `:off` and no suffix fall back to the chain-wide reasoning (or the
|
|
26
|
+
// provider default when that is unset too).
|
|
27
|
+
const { name, thinking } = modelId ? splitThinkingSuffix(modelId) : { name: modelId, thinking: undefined };
|
|
28
|
+
const effectiveReasoning = thinking && thinking !== "off" ? thinking : reasoning;
|
|
29
|
+
const parsed = name ? parseModel(name) : undefined;
|
|
25
30
|
if (modelId && !parsed) throw new Error(`Invalid model: ${modelId}`);
|
|
26
31
|
const model = parsed ? ctx.modelRegistry.find(parsed.provider, parsed.id) : ctx.model;
|
|
27
32
|
if (!model) throw new Error(`Model unavailable: ${modelId ?? "active"}`);
|
|
28
33
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
29
34
|
if (!auth.ok) throw new Error(auth.error);
|
|
30
35
|
const provider = ctx.modelRegistry.getRegisteredProviderConfig(model.provider);
|
|
31
|
-
const options: Record<string, unknown> = { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal, reasoning };
|
|
36
|
+
const options: Record<string, unknown> = { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal, reasoning: effectiveReasoning };
|
|
32
37
|
// ponytail: providers accept SimpleStreamOptions which expects ThinkingLevel for reasoning
|
|
33
38
|
const streamOptions = options as any;
|
|
34
39
|
const response = provider?.streamSimple
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ModelSelectorComponent, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { splitThinkingSuffix } from "./config";
|
|
2
3
|
|
|
3
4
|
export type Model = ReturnType<ExtensionContext["modelRegistry"]["getAvailable"]>[number];
|
|
4
5
|
|
|
@@ -6,9 +7,11 @@ export function modelRef(model: Pick<Model, "provider" | "id">): string {
|
|
|
6
7
|
return `${model.provider}/${model.id}`;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
/** Resolve a `provider/model` (or unambiguous bare id) reference against available models.
|
|
10
|
+
/** Resolve a `provider/model` (or unambiguous bare id) reference against available models.
|
|
11
|
+
* A trailing `:level` thinking suffix is ignored for matching. */
|
|
10
12
|
export function exactModel(models: Model[], reference: string): Model | undefined {
|
|
11
|
-
const
|
|
13
|
+
const { name } = splitThinkingSuffix(reference.trim());
|
|
14
|
+
const value = name.toLowerCase();
|
|
12
15
|
if (!value) return undefined;
|
|
13
16
|
const canonical = models.filter((model) => modelRef(model).toLowerCase() === value);
|
|
14
17
|
if (canonical.length === 1) return canonical[0];
|
|
@@ -21,6 +24,15 @@ export function modelAvailable(ctx: ExtensionContext, modelId: string | undefine
|
|
|
21
24
|
return !!modelId && !!exactModel(ctx.modelRegistry.getAvailable(), modelId);
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
/** Canonicalize a chain entry: resolve the ref (ignoring a trailing `:level`),
|
|
28
|
+
* re-attach the level so a pinned thinking level survives the save. */
|
|
29
|
+
export function canonicalEntry(models: Model[], entry: string): string {
|
|
30
|
+
const { name, thinking } = splitThinkingSuffix(entry.trim());
|
|
31
|
+
const match = exactModel(models, name);
|
|
32
|
+
if (!match) return entry;
|
|
33
|
+
return thinking ? `${modelRef(match)}:${thinking}` : modelRef(match);
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
/** First registry-available ref in the chain, or undefined when none resolve. */
|
|
25
37
|
export function firstAvailable(ctx: ExtensionContext, models: readonly string[]): string | undefined {
|
|
26
38
|
return models.find((model) => modelAvailable(ctx, model));
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `/advisor models` panel (kernel lives in @bacnh85/pi-config-panel).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `pi-advisor.models`
|
|
4
|
+
* Per slot: a model-ref row plus a thinking row (blank = provider default).
|
|
5
|
+
* A pinned level serializes into the chain entry as `provider/id:level`
|
|
6
|
+
* (strict trailing match; openrouter `:free` ids stay intact), so the saved
|
|
7
|
+
* `pi-advisor.models` shape stays a plain string array. Plus "Add model slot"
|
|
8
|
+
* / "Remove last" action rows. Saving writes to the GLOBAL
|
|
9
|
+
* `~/.pi/agent/settings.json` via saveModels (merge + atomic rename).
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { row } from "@bacnh85/pi-config-panel";
|
|
11
13
|
import type { PanelGroup, PanelAction } from "@bacnh85/pi-config-panel";
|
|
14
|
+
import { splitThinkingSuffix, THINKING_LEVELS } from "./config";
|
|
12
15
|
|
|
13
16
|
/** Completion sources for the panel's model rows (lazy — resolved per keypress). */
|
|
14
17
|
export interface ModelsPanelOptions {
|
|
@@ -17,37 +20,61 @@ export interface ModelsPanelOptions {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export interface ModelsPanelCfg {
|
|
20
|
-
/** Working copy: ordered
|
|
21
|
-
models: string[];
|
|
23
|
+
/** Working copy: ordered slots; blank ref = removed slot. */
|
|
24
|
+
models: { ref: string; thinking: string }[];
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
/** Seed a working config from the current effective chain. */
|
|
27
|
+
/** Seed a working config from the current effective chain (`ref:level` entries parsed). */
|
|
25
28
|
export function buildModelsPanelCfg(models: readonly string[]): ModelsPanelCfg {
|
|
26
|
-
return {
|
|
29
|
+
return {
|
|
30
|
+
models: models.map((entry) => {
|
|
31
|
+
const { name, thinking } = splitThinkingSuffix(String(entry ?? "").trim());
|
|
32
|
+
return { ref: name, thinking: thinking ?? "" };
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
/** Build panel groups:
|
|
37
|
+
/** Build panel groups: per-slot model + thinking rows + add/remove actions.
|
|
30
38
|
* `options` adds inline model completions (optional so unit tests and
|
|
31
39
|
* non-TUI callers stay unchanged). */
|
|
32
40
|
export function buildRows(cfg: ModelsPanelCfg, options?: ModelsPanelOptions, actions: Record<string, PanelAction> = {}): PanelGroup[] {
|
|
33
41
|
const modelItems = (): { value: string }[] =>
|
|
34
42
|
(options?.models() ?? []).sort().map((ref) => ({ value: ref }));
|
|
35
43
|
const withCompletions = options ? { completions: modelItems } : {};
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
const levelItems = () => THINKING_LEVELS.filter((l) => l !== "off").map((level) => ({ value: level }));
|
|
45
|
+
const rows = cfg.models.flatMap((slot, index) => {
|
|
46
|
+
const modelRow = row(`model.${index}`, `#${index + 1}${index === 0 ? " (primary)" : ""}`, "string", slot.ref, (v) => {
|
|
47
|
+
slot.ref = String(v ?? "").trim();
|
|
48
|
+
}, withCompletions);
|
|
49
|
+
const thinkingRow = row(`model.${index}.thinking`, `#${index + 1} thinking (blank = model default)`, "string", slot.thinking, (v) => {
|
|
50
|
+
slot.thinking = String(v ?? "").trim();
|
|
51
|
+
}, { completions: levelItems } as unknown as { mask?: boolean });
|
|
52
|
+
return [modelRow, thinkingRow];
|
|
53
|
+
});
|
|
41
54
|
const actionRows = Object.entries(actions).map(([key, action]) => ({ key, label: action.label, kind: "action" as const, value: "", set: action.run as unknown as (v: unknown) => void }));
|
|
42
|
-
return [{ key: "models", label: "Model chain (ordered fallback, first = primary)", rows: [...
|
|
55
|
+
return [{ key: "models", label: "Model chain (ordered fallback, first = primary)", rows: [...rows, ...actionRows] }];
|
|
43
56
|
}
|
|
44
57
|
|
|
45
|
-
/**
|
|
58
|
+
/** Slot indexes whose thinking value is non-blank but not a valid level
|
|
59
|
+
* (typo guard — cfgToModels drops them). Empty when all values are OK. */
|
|
60
|
+
export function invalidThinkingSlots(cfg: ModelsPanelCfg): number[] {
|
|
61
|
+
const out: number[] = [];
|
|
62
|
+
cfg.models.forEach((slot, index) => {
|
|
63
|
+
const t = slot.thinking.trim();
|
|
64
|
+
if (t && !THINKING_LEVELS.includes(t)) out.push(index);
|
|
65
|
+
});
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Convert a working config back to the saved chain: `ref:level` when a valid
|
|
70
|
+
* level is pinned; blank refs and invalid levels dropped. */
|
|
46
71
|
export function cfgToModels(cfg: ModelsPanelCfg): string[] {
|
|
47
72
|
const out: string[] = [];
|
|
48
|
-
for (const
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
73
|
+
for (const slot of cfg.models) {
|
|
74
|
+
const ref = slot.ref.trim();
|
|
75
|
+
if (!ref) continue;
|
|
76
|
+
const thinking = slot.thinking.trim();
|
|
77
|
+
out.push(thinking && THINKING_LEVELS.includes(thinking) && thinking !== "off" ? `${ref}:${thinking}` : ref);
|
|
51
78
|
}
|
|
52
79
|
return out;
|
|
53
80
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildSessionContext, convertToLlm, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { runIsolatedChain } from "./isolated-model";
|
|
3
3
|
import { createGuard, guardCheck, nextCycle, parseReviewOutput, type GuardState, type Severity } from "./emission-guard";
|
|
4
|
-
import type
|
|
4
|
+
import { splitThinkingSuffix, type AdvisorConfig } from "./config";
|
|
5
5
|
|
|
6
6
|
export const REVIEW_ENTRY = "pi-advisor";
|
|
7
7
|
export type { Severity };
|
|
@@ -103,9 +103,10 @@ export function buildEvidence(ctx: ExtensionContext, modelId: string | readonly
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function parseModelRef(value: string): { provider: string; id: string } | undefined {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
const { name } = splitThinkingSuffix(value);
|
|
107
|
+
const slash = name.indexOf("/");
|
|
108
|
+
if (slash <= 0 || slash === name.length - 1) return undefined;
|
|
109
|
+
return { provider: name.slice(0, slash), id: name.slice(slash + 1) };
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
function toolCallCount(entries: any[], sinceId: string | undefined): number {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-advisor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pi extension for an automatic advisor: a second model that reviews each settled turn and injects severity-routed notes, plus an on-demand consult tool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|