pi-better-model-select 0.1.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.
@@ -0,0 +1,237 @@
1
+ import { discoverRoles, type AgentRole } from "./agents.ts";
2
+ import { capabilities, isEffort, patchSettings, readSettings, roleField, setPiDefault, setRoleField, settingsPath, type Effort, type JsonObject, type Scope } from "./defaults-store.ts";
3
+ import { tableSelect, editForm, confirmAction } from "./tui.ts";
4
+ import { rankModelsForDefaults } from "./defaults-models.ts";
5
+ import type { ModelsConfig } from "./store.ts";
6
+
7
+ export async function chooseEffort(ctx: any, key: string, model?: any, initial?: unknown): Promise<Effort | "automatic" | undefined> {
8
+ const capability = capabilities(key, model);
9
+ const levels = [...capability.levels];
10
+ if (isEffort(initial) && levels.includes(initial)) levels.splice(levels.indexOf(initial), 1), levels.unshift(initial);
11
+ const title = `Effort for ${key}\n${capability.source}\nAutomatic removes this scope's preference. Pi/provider may clamp unsupported levels.`;
12
+ // Keep the public helper usable by RPC and legacy headless select mocks.
13
+ if (typeof ctx.ui.custom !== "function") return ctx.ui.select(title, ["automatic", ...levels]);
14
+ return await tableSelect(ctx, { title, initialValue: typeof initial === "string" ? initial : "automatic", rows: ["automatic", ...levels].map(value => ({ value, label: value })) }) as Effort | "automatic" | undefined;
15
+ }
16
+ export function piPreference(user: JsonObject, project: JsonObject): { provider?: string; model?: string; effort?: Effort; source: string } {
17
+ const provider = project.defaultProvider ?? user.defaultProvider;
18
+ const model = project.defaultModel ?? user.defaultModel;
19
+ const key = `${provider}/${model}`;
20
+ const effort = project.modelThinkingLevels?.[key] ?? user.modelThinkingLevels?.[key] ?? project.defaultThinkingLevel ?? user.defaultThinkingLevel;
21
+ return { provider, model, effort, source: project.defaultModel !== undefined || project.defaultProvider !== undefined ? "Project" : "User / automatic" };
22
+ }
23
+ function trusted(pi: any, ctx: any): boolean {
24
+ return typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : typeof pi.isProjectTrusted === "function" && pi.isProjectTrusted();
25
+ }
26
+ function projectSettings(pi: any, ctx: any, cwd: string): JsonObject { return trusted(pi, ctx) ? readSettings(settingsPath(cwd, "project")) : {}; }
27
+ function confirmDefault(ctx: any, title: string, message: string, confirmLabel: string): Promise<boolean> {
28
+ return typeof ctx.ui.custom === "function"
29
+ ? confirmAction(ctx, { title, lines: message.split("\n"), confirmLabel })
30
+ : ctx.ui.confirm(title, message);
31
+ }
32
+
33
+ /** Switch first; never replace saved defaults when authentication/model selection fails. */
34
+ export async function setMainDefault(pi: any, ctx: any, provider: string, modelId: string, scope: Scope = "user"): Promise<void> {
35
+ await applyMainDefault(pi, ctx, provider, modelId, scope);
36
+ }
37
+ async function applyMainDefault(pi: any, ctx: any, provider: string, modelId: string, scope: Scope, selectedEffort?: Effort | "automatic"): Promise<boolean> {
38
+ if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
39
+ await ctx.modelRegistry.refresh();
40
+ const model = ctx.modelRegistry.find(provider, modelId);
41
+ if (!model) throw new Error(`Model ${provider}/${modelId} is not in the active registry`);
42
+ const user = readSettings(settingsPath(ctx.cwd, "user"));
43
+ const project = projectSettings(pi, ctx, ctx.cwd);
44
+ const choice = selectedEffort ?? await chooseEffort(ctx, `${provider}/${modelId}`, model, piPreference(user, project).effort);
45
+ if (choice === undefined) return false;
46
+ const effort = choice === "automatic" ? undefined : choice;
47
+ const preview = structuredClone(scope === "user" ? user : project);
48
+ setPiDefault(preview, provider, modelId, effort);
49
+ const effective = piPreference(scope === "user" ? preview : user, scope === "project" ? preview : project);
50
+ const shadowed = effective.provider !== provider || effective.model !== modelId || (effort !== undefined && effective.effort !== effort);
51
+ if (shadowed && !(await confirmDefault(ctx, "Higher-priority settings", "Project or per-model settings override this preference at startup. Save and use your selection in this session anyway?", "Save and use anyway"))) return false;
52
+ if (selectedEffort === undefined && !(await confirmDefault(ctx, "Save and use Pi default?", `${provider}/${modelId}, effort ${choice}\nScope: ${scope}\n${settingsPath(ctx.cwd, scope)}`, "Save and use"))) return false;
53
+ // Parse before changing session state. A malformed file must never be overwritten.
54
+ readSettings(settingsPath(ctx.cwd, scope));
55
+ const previousModel = ctx.model;
56
+ const previousEffort = pi.getThinkingLevel();
57
+ if (!(await pi.setModel(model))) {
58
+ ctx.ui.notify("Could not switch model. Defaults were not changed.", "warning");
59
+ return false;
60
+ }
61
+ const requested = effort ?? effective.effort ?? "medium";
62
+ try {
63
+ pi.setThinkingLevel(requested);
64
+ const applied = pi.getThinkingLevel();
65
+ if (effort !== undefined && applied !== effort && !(await confirmDefault(ctx, "Pi adjusted the effort", `Requested ${effort}; Pi uses ${applied}. Save ${applied} instead?`, `Save ${applied}`))) {
66
+ if (previousModel) await pi.setModel(previousModel);
67
+ pi.setThinkingLevel(previousEffort);
68
+ return false;
69
+ }
70
+ patchSettings(settingsPath(ctx.cwd, scope), data => setPiDefault(data, provider, modelId, effort === undefined ? undefined : applied));
71
+ ctx.ui.notify(`Using ${provider}/${modelId}, effort ${applied}. Saved ${scope} Pi default.${shadowed ? " Higher-priority startup settings still apply." : ""}`, "info");
72
+ return true;
73
+ } catch (error) {
74
+ if (previousModel) await pi.setModel(previousModel);
75
+ pi.setThinkingLevel(previousEffort);
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ function display(value: unknown): string {
81
+ if (value === undefined) return "agent-defined / fallback";
82
+ if (value === false) return "off";
83
+ return String(value);
84
+ }
85
+ async function assignModel(ctx: any, config: ModelsConfig, title: string): Promise<{ provider: string; modelId: string } | undefined> {
86
+ const rows = rankModelsForDefaults(config, ctx);
87
+ if (!rows.length) { ctx.ui.notify("No models available to assign.", "warning"); return; }
88
+ const value = await tableSelect(ctx, { title, search: true, columns: [{ key: "model", label: "MODEL" }, { key: "provider", label: "PROVIDER" }], rows: rows.map((row, index) => ({ value: String(index), label: row.label, cells: { model: row.modelId, provider: row.provider } })) });
89
+ const row = value === undefined ? undefined : rows[Number(value)];
90
+ return row ? { provider: row.provider, modelId: row.modelId } : undefined;
91
+ }
92
+ async function editPiDefault(pi: any, ctx: any, config: ModelsConfig, scope: Scope, user: JsonObject, project: JsonObject): Promise<void> {
93
+ const current = piPreference(user, project);
94
+ let provider = current.provider, modelId = current.model;
95
+ let effort: Effort | "automatic" = current.effort ?? "automatic";
96
+ let reset = false;
97
+ const path = settingsPath(ctx.cwd, scope);
98
+ await editForm(ctx, {
99
+ title: "Pi session default",
100
+ hint: `Scope: ${scope}\n${path}\nSave switches the session after validating the model. Higher-priority settings still apply at startup.`,
101
+ fields: [
102
+ { key: "model", label: "Model", value: `${provider ?? "auto"}/${modelId ?? "auto"}`, edit: async () => {
103
+ const action = await tableSelect(ctx, { title: "Pi default model", rows: [{ value: "choose", label: "Choose model" }, { value: "reset", label: "Remove scope defaults", description: "Remove this scope's default model and global effort. Keep other per-model preferences." }] });
104
+ if (action === undefined) return;
105
+ if (action === "reset") {
106
+ if (!(await confirmAction(ctx, { title: "Reset Pi defaults?", lines: [`Scope: ${scope}`, path, "Remove defaultProvider, defaultModel and defaultThinkingLevel only. Session stays unchanged."], confirmLabel: "Reset scope defaults" }))) return;
107
+ reset = true; return "remove scope defaults";
108
+ }
109
+ const selected = await assignModel(ctx, config, "Choose Pi default model");
110
+ if (!selected) return;
111
+ provider = selected.provider; modelId = selected.modelId; reset = false;
112
+ return `${provider}/${modelId}`;
113
+ } },
114
+ { key: "effort", label: "Effort", value: effort, edit: async () => {
115
+ if (!provider || !modelId || reset) { ctx.ui.notify("Choose a model first.", "warning"); return; }
116
+ const selected = await chooseEffort(ctx, `${provider}/${modelId}`, ctx.modelRegistry.find(provider, modelId), effort);
117
+ if (selected === undefined) return;
118
+ effort = selected; return selected;
119
+ } },
120
+ ],
121
+ onSave: async () => {
122
+ if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
123
+ if (reset) {
124
+ patchSettings(path, data => { delete data.defaultProvider; delete data.defaultModel; delete data.defaultThinkingLevel; });
125
+ return true;
126
+ }
127
+ if (!provider || !modelId) { ctx.ui.notify("Choose a model first.", "warning"); return false; }
128
+ return applyMainDefault(pi, ctx, provider, modelId, scope, effort);
129
+ },
130
+ });
131
+ }
132
+ export async function defaultsFlow(pi: any, ctx: any, config: ModelsConfig): Promise<void> {
133
+ let scope: Scope = "user";
134
+ const state = {};
135
+ for (;;) {
136
+ const discovery = await discoverRoles(ctx.cwd, pi, ctx.model?.provider, trusted(pi, ctx));
137
+ const user = readSettings(settingsPath(ctx.cwd, "user"));
138
+ const project = projectSettings(pi, ctx, ctx.cwd);
139
+ const agentUser = readSettings(settingsPath(discovery.projectRoot, "user"));
140
+ const agentProject = projectSettings(pi, ctx, discovery.projectRoot);
141
+ const main = piPreference(user, project);
142
+ const roles = discovery.roles;
143
+ const modelFallback = roleField(agentUser, agentProject, ctx.model?.provider, undefined, "model");
144
+ const thinkingFallback = roleField(agentUser, agentProject, ctx.model?.provider, undefined, "thinking");
145
+ const selected = await tableSelect(ctx, {
146
+ title: "BMS defaults",
147
+ search: false,
148
+ state,
149
+ hint: `Scope: ${scope}. Saved settings, not running children. Agent changes require /reload.\nPi: ${settingsPath(ctx.cwd, scope)}\nAgents: ${settingsPath(discovery.projectRoot, scope)}`,
150
+ columns: [{ key: "target", label: "TARGET" }, { key: "model", label: "MODEL" }, { key: "effort", label: "EFFORT" }, { key: "source", label: "SOURCE" }],
151
+ rows: [
152
+ { value: "0", label: `Scope: ${scope}`, cells: { target: "Scope", model: scope, effort: "", source: "Select to change" } },
153
+ { value: "1", label: "Pi session", cells: { target: "Pi session", model: `${main.provider ?? "auto"}/${main.model ?? "auto"}`, effort: main.effort ?? "automatic", source: main.source } },
154
+ { value: "2", label: "Subagent fallback", cells: { target: "Subagent fallback", model: display(modelFallback.value), effort: display(thinkingFallback.value), source: `${modelFallback.source}; ${thinkingFallback.source}` } },
155
+ ...roles.map((role, index) => {
156
+ const model = roleField(agentUser, agentProject, ctx.model?.provider, role.name, "model");
157
+ const effort = roleField(agentUser, agentProject, ctx.model?.provider, role.name, "thinking");
158
+ return { value: String(index + 3), label: role.name, cells: { target: role.name, model: display(model.value ?? role.model), effort: display(effort.value ?? role.thinking), source: role.runner ? "External runner" : `${model.source}; ${effort.source}` } };
159
+ }),
160
+ ...(discovery.warning ? [{ value: "details", label: "Agent discovery details" }] : []),
161
+ { value: "done", label: "Done" },
162
+ ],
163
+ });
164
+ if (selected === undefined || selected === "done") return;
165
+ const index = Number(selected);
166
+ if (index === 0) {
167
+ if (!trusted(pi, ctx)) { ctx.ui.notify("Project defaults require a trusted project.", "warning"); continue; }
168
+ scope = scope === "user" ? "project" : "user";
169
+ } else if (index === 1) {
170
+ await editPiDefault(pi, ctx, config, scope, user, project);
171
+ } else if (selected === "details") {
172
+ ctx.ui.notify(discovery.warning!, "warning");
173
+ } else {
174
+ const role = index === 2 ? undefined : roles[index - 3];
175
+ await editRole(pi, ctx, config, scope, discovery.projectRoot, role, agentUser, agentProject);
176
+ }
177
+ }
178
+ }
179
+ async function editRole(pi: any, ctx: any, config: ModelsConfig, scope: Scope, root: string, role: AgentRole | undefined, user: JsonObject, project: JsonObject): Promise<void> {
180
+ const title = role?.name ?? "Subagent fallback";
181
+ const path = settingsPath(root, scope);
182
+ if (role?.runner) {
183
+ ctx.ui.notify("External runner defaults are not editable with the Pi registry picker. Use the runner's own configuration.", "warning");
184
+ return;
185
+ }
186
+ const local = scope === "user" ? user : project;
187
+ const entry = role ? local.subagents?.agentOverrides?.[role.name] : undefined;
188
+ let model: string | undefined = role ? entry?.model : local.subagents?.defaultModel;
189
+ let effort: string | false | undefined = role ? entry?.thinking : local.subagents?.defaultThinking;
190
+ let modelChanged = false, effortChanged = false;
191
+ await editForm(ctx, {
192
+ title: `${title} defaults`,
193
+ hint: `Scope: ${scope}\n${path}\nProvider-specific and project overrides still take precedence. Removing effort restores agent-defined/fallback behavior, not parent inheritance.`,
194
+ fields: [
195
+ { key: "model", label: "Model", value: model ?? "remove override", edit: async () => {
196
+ const action = await tableSelect(ctx, { title: `Model for ${title}`, rows: [
197
+ { value: "choose", label: "Choose model" },
198
+ { value: "inherit", label: "Inherit parent model", description: "Explicitly use the parent session model." },
199
+ { value: "remove", label: "Remove model override", description: `Remove only the ${scope} ${title} model override.` },
200
+ ] });
201
+ if (action === undefined) return;
202
+ if (action === "choose") {
203
+ const selected = await assignModel(ctx, config, `Choose model for ${title}`);
204
+ if (!selected) return;
205
+ model = `${selected.provider}/${selected.modelId}`;
206
+ } else if (action === "inherit") model = "inherit";
207
+ else {
208
+ if (!(await confirmAction(ctx, { title: "Remove model override?", lines: [`Target: ${title}`, `Scope: ${scope}`, path], confirmLabel: "Remove override" }))) return;
209
+ model = undefined;
210
+ }
211
+ modelChanged = true;
212
+ return model ?? "remove override";
213
+ } },
214
+ { key: "effort", label: "Effort", value: display(effort), edit: async () => {
215
+ const effective = modelChanged ? model : roleField(user, project, ctx.model?.provider, role?.name, "model").value ?? role?.model;
216
+ const ref = typeof effective === "string" && effective !== "inherit" ? effective.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, "") : `${ctx.model?.provider}/${ctx.model?.id}`;
217
+ const split = ref.indexOf("/");
218
+ const selected = await chooseEffort(ctx, ref, split > 0 ? ctx.modelRegistry.find(ref.slice(0, split), ref.slice(split + 1)) : undefined, effort);
219
+ if (selected === undefined) return;
220
+ if (selected === "automatic" && !(await confirmAction(ctx, { title: "Remove effort override?", lines: [`Target: ${title}`, `Scope: ${scope}`, path, "Restores agent-defined/fallback behavior."], confirmLabel: "Remove override" }))) return;
221
+ effort = selected === "automatic" ? undefined : selected;
222
+ effortChanged = true;
223
+ return effort ?? "remove override";
224
+ } },
225
+ ],
226
+ onSave: async () => {
227
+ if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
228
+ if (!modelChanged && !effortChanged) return true;
229
+ patchSettings(path, data => {
230
+ if (modelChanged) setRoleField(data, role?.name, "model", model);
231
+ if (effortChanged) setRoleField(data, role?.name, "thinking", effort);
232
+ });
233
+ ctx.ui.notify(`Saved ${title}. Run /reload to refresh subagent defaults.`, "info");
234
+ return true;
235
+ },
236
+ });
237
+ }
@@ -0,0 +1,84 @@
1
+ import type { FlowCtx } from "./flows.ts";
2
+ import { editForm, tableSelect } from "./tui.ts";
3
+ import { capabilities, EFFORTS, isEffort, metadataPath, normalizeEfforts, patchSettings, readSettings, saveCapability, settingsPath, type Capability, type Effort } from "./defaults-store.ts";
4
+
5
+ export interface EffortDraft { capability: Capability; preferred?: Effort }
6
+ export function modelEffortDraft(ctx: FlowCtx, key: string, reasoning: boolean, reported?: Effort[]): EffortDraft {
7
+ const existing = capabilities(key, { reasoning });
8
+ const levels = !reasoning ? ["off" as const] : reported ?? existing.levels;
9
+ const source: Capability["source"] = reported ? "provider" : existing.source === "Manual override" ? "manual" : "pi";
10
+ const previous = readSettings(settingsPath(ctx.cwd ?? process.cwd(), "user")).modelThinkingLevels?.[key];
11
+ return { capability: { levels: [...levels], source }, preferred: isEffort(previous) ? previous : undefined };
12
+ }
13
+ export async function editModelEfforts(ctx: FlowCtx, key: string, reasoning: boolean, reported?: Effort[], initial?: EffortDraft): Promise<EffortDraft | undefined> {
14
+ const draft = structuredClone(initial ?? modelEffortDraft(ctx, key, reasoning, reported));
15
+ if (!reasoning) draft.capability = { levels: ["off"], source: "pi" };
16
+ const saved = await editForm(ctx, {
17
+ title: `Efforts for ${key}`,
18
+ hint: "Apply returns a draft to the model form. Only the outer Save writes files.",
19
+ fields: [
20
+ { key: "levels", label: "Available levels", value: draft.capability.levels.join(", "), description: `Source: ${draft.capability.source}. Provider support may be unverified.`, edit: async () => {
21
+ if (!reasoning) { ctx.ui.notify("Non-reasoning models only support off.", "info"); return; }
22
+ const raw = await ctx.ui.input(`Comma-separated Pi efforts: ${EFFORTS.join(", ")}`, draft.capability.levels.join(", "));
23
+ if (raw === undefined) return;
24
+ const levels = normalizeEfforts(raw.split(",").map(value => value.trim()));
25
+ if (!levels) { ctx.ui.notify("Choose one or more valid Pi effort levels.", "warning"); return; }
26
+ draft.capability = { levels, source: "manual" };
27
+ return levels.join(", ");
28
+ } },
29
+ { key: "preferred", label: "Preferred effort", value: draft.preferred ?? "automatic", description: "Automatic removes the user per-model preference, not agent role defaults.", edit: async () => {
30
+ const selected = await tableSelect(ctx, { title: `Preferred effort for ${key}`, initialValue: draft.preferred ?? "automatic", rows: ["automatic", ...draft.capability.levels].map(value => ({ value, label: value })) });
31
+ if (selected === undefined) return;
32
+ draft.preferred = isEffort(selected) ? selected : undefined;
33
+ return selected;
34
+ } },
35
+ ],
36
+ onSave: async () => {
37
+ if (draft.preferred && !draft.capability.levels.includes(draft.preferred)) { ctx.ui.notify("Preferred effort must be available. Choose another effort or automatic.", "warning"); return false; }
38
+ return true;
39
+ },
40
+ });
41
+ return saved ? draft : undefined;
42
+ }
43
+ /** Do not silently invalidate saved defaults when editing model capability hints. */
44
+ export function effortConflicts(data: Record<string, any>, key: string, levels: readonly Effort[], preferred?: Effort): string[] {
45
+ const conflicts: string[] = [];
46
+ const check = (label: string, effort: unknown) => {
47
+ if (isEffort(effort) && !levels.includes(effort)) conflicts.push(`${label}: ${effort}`);
48
+ };
49
+ check("Per-model preference", preferred);
50
+ if (`${data.defaultProvider}/${data.defaultModel}` === key) check("Pi default", preferred ?? data.defaultThinkingLevel);
51
+ const sub = data.subagents;
52
+ if (sub?.defaultModel === key) check("Subagent fallback", sub.defaultThinking);
53
+ const scan = (entries: Record<string, any> | undefined, prefix: string) => {
54
+ for (const [name, entry] of Object.entries(entries ?? {})) {
55
+ if (entry.model === key) check(`${prefix}${name}`, entry.thinking);
56
+ }
57
+ };
58
+ scan(sub?.agentOverrides, "Agent ");
59
+ for (const [provider, entries] of Object.entries(sub?.agentOverridesByProvider ?? {})) scan(entries as any, `${provider} agent `);
60
+ return conflicts;
61
+ }
62
+ export function saveModelEfforts(ctx: FlowCtx & { cwd?: string }, key: string, draft: EffortDraft): boolean {
63
+ const userPath = settingsPath(ctx.cwd ?? process.cwd(), "user");
64
+ const projectPath = settingsPath(ctx.cwd ?? process.cwd(), "project");
65
+ const project = ctx.isProjectTrusted?.() ? readSettings(projectPath) : {};
66
+ const conflicts = [
67
+ ...effortConflicts(readSettings(userPath), key, draft.capability.levels, draft.preferred),
68
+ ...effortConflicts(project, key, draft.capability.levels, project.modelThinkingLevels?.[key] ?? draft.preferred).map(value => `Project ${value}`),
69
+ ];
70
+ if (conflicts.length) {
71
+ ctx.ui.notify(`Effort update blocked. Change these defaults in /bms-defaults first: ${conflicts.join("; ")}`, "warning");
72
+ return false;
73
+ }
74
+ // Validate the capability file before writing a preference in a different file.
75
+ readSettings(metadataPath());
76
+ patchSettings(userPath, data => {
77
+ const levels = data.modelThinkingLevels ??= {};
78
+ if (!levels || typeof levels !== "object" || Array.isArray(levels)) throw new Error("Invalid modelThinkingLevels settings");
79
+ if (draft.preferred === undefined) delete levels[key];
80
+ else levels[key] = draft.preferred;
81
+ });
82
+ saveCapability(key, draft.capability);
83
+ return true;
84
+ }