@bacnh85/pi-advisor 0.2.4 → 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,25 @@
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
+
3
23
  ## 0.2.4 (2026-09-12)
4
24
 
5
25
  ### Removed
package/README.md CHANGED
@@ -44,7 +44,7 @@ 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
50
  /advisor watch-off # disable background watch for this session
@@ -57,18 +57,22 @@ Settings live in `~/.pi/agent/settings.json` (global) and `.pi/settings.json`
57
57
  ```json
58
58
  {
59
59
  "pi-advisor": {
60
- "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"],
61
61
  "watch": { "enabled": true, "minToolCalls": 3, "immuneTurns": 3 }
62
62
  }
63
63
  }
64
64
  ```
65
65
 
66
66
  - `models` — ordered fallback chain, first entry is primary. Accepts an array
67
- or a comma-separated string (`"a/b, c/d"`). Legacy single `model` string is
68
- still honored. If the primary is rate-limited or unavailable at review/consult
69
- time, the next candidate serves automatically; a whole-chain failure counts
70
- as one review failure (the 3-strike pause still applies). The advisor never
71
- 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.
72
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)
73
77
  - `watch.minToolCalls` (default `3`, `0` = every turn) — skip trivial turns
74
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
 
@@ -153,6 +153,7 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
153
153
  "Advisor models (ordered fallback, first = primary):",
154
154
  ...(models.length > 0 ? models.map((m, i) => ` #${i + 1} ${m}`) : [" (none — advisor inactive)"]),
155
155
  "",
156
+ `Append :level (minimal…max) to an entry to pin thinking per slot.`,
156
157
  `Edit ~/.pi/agent/settings.json → pi-advisor.models, or run /advisor models in a TUI.`,
157
158
  ];
158
159
  pi.sendMessage({ customType: "pi-advisor", content: lines.join("\n"), display: true });
@@ -160,10 +161,10 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
160
161
  }
161
162
  const working = panel.buildModelsPanelCfg(models);
162
163
  const actions: Record<string, { label: string; run: (prompt: (label: string, onDone: (value: string | undefined) => void) => void) => Promise<void> | void }> = {
163
- addModel: { label: "+ Add model slot", run: () => { working.models.push(""); } },
164
+ addModel: { label: "+ Add model slot", run: () => { working.models.push({ ref: "", thinking: "" }); } },
164
165
  removeLast: { label: "− Remove last slot", run: () => {
165
166
  const popped = working.models.pop();
166
- 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");
167
168
  } },
168
169
  };
169
170
  const panelOptions = { models: () => (registry?.getAvailable() ?? []).map((m) => modelRef(m)) };
@@ -174,6 +175,8 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
174
175
  title: "Advisor models (ordered fallback)",
175
176
  onSave: (saved) => {
176
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");
177
180
  // set() persists, updates the runtime, syncs tool availability, and
178
181
  // notifies; surface unexpected rejections instead of dropping them.
179
182
  set(panel.cfgToModels(working), ctx).catch((error) => ctx.ui.notify(`Advisor update failed: ${String(error)}`, "error"));
@@ -229,13 +232,10 @@ export function registerAdvisor(pi: ExtensionAPI, state: AdvisorState): void {
229
232
  // chain runner and availability gate skip dead entries at call time).
230
233
  // Dedupe here too: two raw spellings can resolve to the same provider/id.
231
234
  const available = ctx.modelRegistry.getAvailable();
232
- return await set([...new Set(chain.map((entry) => {
233
- const match = exactModel(available, entry);
234
- return match ? modelRef(match) : entry;
235
- }))], ctx);
235
+ return await set([...new Set(chain.map((entry) => canonicalEntry(available, entry)))], ctx);
236
236
  }
237
237
  const match = chain.length === 1 ? exactModel(ctx.modelRegistry.getAvailable(), chain[0]) : undefined;
238
- if (match) return await set([modelRef(match)], ctx);
238
+ if (match) return await set([canonicalEntry(ctx.modelRegistry.getAvailable(), chain[0])], ctx);
239
239
  if (ctx.mode !== "tui") throw new Error("Usage: /advisor <provider/model[, …]|models|on|off|status>");
240
240
  const choice = await chooseModel(ctx, firstAvailable(ctx, state.getModels()), args.trim() || undefined);
241
241
  if (!choice) return;
@@ -108,6 +108,20 @@ export function parseModel(value: string): { provider: string; id: string } | un
108
108
  return { provider: value.slice(0, slash), id: value.slice(slash + 1) };
109
109
  }
110
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
+
111
125
  /**
112
126
  * One-shot legacy migration: if pi-advisor.model is unset, adopt the old
113
127
  * pi-plan advisorModel preference so existing users keep their advisor.
@@ -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.4",
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",