@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 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, non-blocker notes within the
18
- next `immuneTurns` settled turns are deferred (LLM-visible next turn)
19
- instead of waking the agent again — bounds ping-pong. Blockers always
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 full model chain (TUI panel; non-TUI prints it)
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"`). Legacy single `model` string is
67
- still honored. If the primary is rate-limited or unavailable at review/consult
68
- time, the next candidate serves automatically; a whole-chain failure counts
69
- as one review failure (the 3-strike pause still applies). The advisor never
70
- falls back to the primary model it must never review its own turns.
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, reasoning);
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([modelRef(match)], ctx);
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;
@@ -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) => {
@@ -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
- await rename(tmp, file);
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
- await rename(tmp, settingsPath);
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
- const parsed = modelId ? parseModel(modelId) : undefined;
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 value = reference.trim().toLowerCase();
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
- * One string row per model slot (ordered fallback chain, blank = remove
5
- * slot), plus "Add model slot" / "Remove last" action rows. Saving writes
6
- * the chain to the GLOBAL `~/.pi/agent/settings.json` under
7
- * `pi-advisor.models` (merge + atomic rename via saveModels).
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 chain; blank row = removed slot. */
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 { models: [...models] };
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: one row per slot + add/remove actions.
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 slotRows = cfg.models.map((value, index) =>
37
- row(`model.${index}`, `#${index + 1}${index === 0 ? " (primary)" : ""}`, "string", value, (v) => {
38
- cfg.models[index] = String(v ?? "").trim();
39
- }, withCompletions),
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: [...slotRows, ...actionRows] }];
55
+ return [{ key: "models", label: "Model chain (ordered fallback, first = primary)", rows: [...rows, ...actionRows] }];
43
56
  }
44
57
 
45
- /** Convert a working config back to the saved chain (blanks removed). */
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 entry of cfg.models) {
49
- const trimmed = String(entry ?? "").trim();
50
- if (trimmed) out.push(trimmed);
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 { AdvisorConfig } from "./config";
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 slash = value.indexOf("/");
107
- if (slash <= 0 || slash === value.length - 1) return undefined;
108
- return { provider: value.slice(0, slash), id: value.slice(slash + 1) };
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.2.3",
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",