pi-better-model-select 0.1.3 → 0.2.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,60 @@
1
+ // Optional, isolated adapter: pi-subagents currently has no public discovery RPC.
2
+ // Never vendor a second agent-discovery implementation or execute an agent to list roles.
3
+ import { createRequire } from "node:module";
4
+ import { readFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
+
9
+ export interface AgentRole {
10
+ name: string;
11
+ model?: string;
12
+ thinking?: string | false;
13
+ runner?: unknown;
14
+ source?: string;
15
+ modelSource?: unknown;
16
+ }
17
+ export interface AgentDiscovery {
18
+ roles: AgentRole[];
19
+ projectRoot: string;
20
+ warning?: string;
21
+ }
22
+ /** Resolve the owning package, not the directory of its possibly bundled entry point. */
23
+ export function subagentsPackageRoot(entry: string): string {
24
+ let directory = dirname(entry);
25
+ for (;;) {
26
+ try {
27
+ const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
28
+ if (manifest.name === "pi-subagents") return directory;
29
+ } catch { /* keep looking above a bundle or other entry-point directory */ }
30
+ const parent = dirname(directory);
31
+ if (parent === directory) throw new Error("Cannot locate pi-subagents package root");
32
+ directory = parent;
33
+ }
34
+ }
35
+ export async function discoverRoles(cwd: string, pi: any, provider?: string, trusted = false): Promise<AgentDiscovery> {
36
+ try {
37
+ // Resolve only from this extension or the user's trusted Pi package directory.
38
+ const roots = [import.meta.url, pathToFileURL(join(getAgentDir(), "npm", "package.json")).href];
39
+ let entry: string | undefined;
40
+ for (const root of roots) {
41
+ try { entry = createRequire(root).resolve("pi-subagents"); break; } catch { /* optional package */ }
42
+ }
43
+ if (!entry) throw new Error("pi-subagents is not resolvable from BMS or the Pi npm package directory");
44
+ const base = subagentsPackageRoot(entry);
45
+ const api = await import(pathToFileURL(join(base, "src/agents/agents.js")).href);
46
+ if (typeof api.discoverAgents !== "function") throw new Error("Unsupported pi-subagents discovery API");
47
+ api.clearAgentDiscoveryCache?.();
48
+ const result = api.discoverAgents(cwd, trusted ? "both" : "user", provider, { projectTrusted: trusted });
49
+ let roles: AgentRole[] = result.agents;
50
+ try {
51
+ const runtime = await import(pathToFileURL(join(base, "src/agents/runtime-agent-registry.js")).href);
52
+ roles = runtime.mergeRuntimeAgents(pi, result, undefined, { cwd, scope: trusted ? "both" : "user", preferredModelProvider: provider, projectTrusted: trusted }).agents;
53
+ } catch { /* older versions have filesystem agents only */ }
54
+ // Native Pi and subagents may choose different roots. Use the subagents settings root for agents.
55
+ const projectRoot = api.findConfiguredProjectRoot?.(cwd) ?? cwd;
56
+ return { roles: [...roles].sort((a, b) => a.name.localeCompare(b.name)), projectRoot };
57
+ } catch (error) {
58
+ return { roles: [], projectRoot: cwd, warning: `Agent discovery unavailable: ${error}. Pi defaults remain available.` };
59
+ }
60
+ }
@@ -0,0 +1,15 @@
1
+ import { rankModels, type RankedModel } from "./picker.ts";
2
+ import type { ModelsConfig } from "./store.ts";
3
+
4
+ /** Assignment menus include authenticated registry models, not only custom models.json entries. */
5
+ export function rankModelsForDefaults(config: ModelsConfig, ctx: any): RankedModel[] {
6
+ const rows = rankModels(config);
7
+ const seen = new Set(rows.map(row => `${row.provider}/${row.modelId}`));
8
+ for (const model of ctx.modelRegistry.getAvailable?.() ?? []) {
9
+ const key = `${model.provider}/${model.id}`;
10
+ if (seen.has(key)) continue;
11
+ seen.add(key);
12
+ rows.push({ provider: model.provider, modelId: model.id, label: model.name ?? model.id, count: 0, lastUsed: 0, reasoning: model.reasoning, contextWindow: model.contextWindow });
13
+ }
14
+ return rows;
15
+ }
@@ -0,0 +1,108 @@
1
+ // Defaults use native Pi settings. BMS capability hints live separately from models.json.
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ export const EFFORTS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
8
+ export type Effort = typeof EFFORTS[number];
9
+ export type Scope = "user" | "project";
10
+ export type JsonObject = Record<string, any>;
11
+ export function isEffort(value: unknown): value is Effort {
12
+ return typeof value === "string" && (EFFORTS as readonly string[]).includes(value);
13
+ }
14
+ export function object(value: unknown): JsonObject {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a JSON object");
16
+ return value as JsonObject;
17
+ }
18
+ export function readSettings(path: string): JsonObject {
19
+ if (!existsSync(path)) return {};
20
+ return object(JSON.parse(readFileSync(path, "utf8")));
21
+ }
22
+ /** Fail closed on malformed files; serialize BMS writers and re-read immediately before patching. */
23
+ export function patchSettings(path: string, patch: (settings: JsonObject) => void): void {
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ const lock = `${path}.bms-lock`;
26
+ const fd = openSync(lock, "wx", 0o600);
27
+ const tmp = `${path}.bms-${randomUUID()}`;
28
+ try {
29
+ const settings = readSettings(path);
30
+ patch(settings);
31
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
32
+ renameSync(tmp, path);
33
+ } finally {
34
+ if (existsSync(tmp)) unlinkSync(tmp);
35
+ closeSync(fd);
36
+ unlinkSync(lock);
37
+ }
38
+ }
39
+ export function settingsPath(cwd: string, scope: Scope, agentDir = getAgentDir()): string {
40
+ return scope === "user" ? join(agentDir, "settings.json") : join(cwd, ".pi", "settings.json");
41
+ }
42
+ export interface Capability {
43
+ levels: Effort[];
44
+ source: "provider" | "manual" | "pi";
45
+ }
46
+ export function metadataPath(): string { return join(getAgentDir(), "bms-model-metadata.json"); }
47
+ export function normalizeEfforts(raw: unknown): Effort[] | undefined {
48
+ if (!Array.isArray(raw) || !raw.length || !raw.every(isEffort)) return undefined;
49
+ return EFFORTS.filter(level => raw.includes(level));
50
+ }
51
+ export function capabilities(key: string, model?: { reasoning?: boolean; thinkingLevelMap?: Record<string, unknown> }, path = metadataPath()): { levels: Effort[]; source: string } {
52
+ const stored = readSettings(path)[key];
53
+ const levels = normalizeEfforts(stored?.levels);
54
+ if (levels) return { levels, source: stored.source === "provider" ? "Provider metadata" : stored.source === "manual" ? "Manual override" : "Pi standard levels; provider support unverified" };
55
+ if (model?.reasoning === false) return { levels: ["off"], source: "Non-reasoning model" };
56
+ // Native maps explicitly mark unsupported Pi levels with null.
57
+ if (model?.thinkingLevelMap) {
58
+ const supported = EFFORTS.filter(level => model.thinkingLevelMap![level] !== null);
59
+ if (supported.length) return { levels: supported, source: "Pi model capability map" };
60
+ }
61
+ return { levels: [...EFFORTS], source: "Pi standard levels; provider support unverified" };
62
+ }
63
+ export function saveCapability(key: string, capability: Capability, path = metadataPath()): void {
64
+ if (!normalizeEfforts(capability.levels)) throw new Error("Choose at least one valid effort level");
65
+ patchSettings(path, data => { data[key] = capability; });
66
+ }
67
+ export function setPiDefault(data: JsonObject, provider: string, model: string, effort: Effort | undefined): void {
68
+ data.defaultProvider = provider;
69
+ data.defaultModel = model;
70
+ if (effort === undefined) delete data.defaultThinkingLevel;
71
+ else data.defaultThinkingLevel = effort;
72
+ // A per-model startup preference would otherwise beat the newly chosen default.
73
+ if (data.modelThinkingLevels !== undefined) {
74
+ const levels = object(data.modelThinkingLevels);
75
+ delete levels[`${provider}/${model}`];
76
+ }
77
+ }
78
+ export function setRoleField(data: JsonObject, role: string | undefined, field: "model" | "thinking", value: string | false | undefined): void {
79
+ if (role && ["__proto__", "constructor", "prototype"].includes(role)) throw new Error("Invalid agent name");
80
+ const sub = data.subagents === undefined ? data.subagents = {} : object(data.subagents);
81
+ if (!role) {
82
+ const key = field === "model" ? "defaultModel" : "defaultThinking";
83
+ if (value === undefined) delete sub[key];
84
+ else sub[key] = value;
85
+ return;
86
+ }
87
+ const overrides = sub.agentOverrides === undefined ? sub.agentOverrides = {} : object(sub.agentOverrides);
88
+ const entry = Object.hasOwn(overrides, role) ? object(overrides[role]) : overrides[role] = {};
89
+ if (value === undefined) delete entry[field];
90
+ else entry[field] = value;
91
+ if (!Object.keys(entry).length) delete overrides[role];
92
+ }
93
+ /** Settings-only projection for the menu, not a claim about a running child. */
94
+ export function roleField(user: JsonObject, project: JsonObject, provider: string | undefined, role: string | undefined, field: "model" | "thinking"): { value: unknown; source: string } {
95
+ for (const [scope, data] of [["Project", project], ["User", user]] as const) {
96
+ const sub = data.subagents;
97
+ if (role) {
98
+ const scoped = provider && sub?.agentOverridesByProvider?.[provider]?.[role];
99
+ if (scoped && Object.hasOwn(scoped, field)) return { value: scoped[field], source: `${scope}, provider override` };
100
+ const regular = sub?.agentOverrides?.[role];
101
+ if (regular && Object.hasOwn(regular, field)) return { value: regular[field], source: `${scope} role override` };
102
+ } else {
103
+ const key = field === "model" ? "defaultModel" : "defaultThinking";
104
+ if (sub && Object.hasOwn(sub, key)) return { value: sub[key], source: `${scope} fallback` };
105
+ }
106
+ }
107
+ return { value: undefined, source: role ? "Agent-defined / fallback" : "Parent session" };
108
+ }
@@ -0,0 +1,149 @@
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 { pickerDialog } from "./picker-dialog.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
+ return ctx.ui.select(`Effort for ${key}\n${capability.source}\nAutomatic removes this scope's preference. Pi/provider may clamp unsupported levels.`, ["automatic", ...levels]);
12
+ }
13
+ export function piPreference(user: JsonObject, project: JsonObject): { provider?: string; model?: string; effort?: Effort; source: string } {
14
+ const provider = project.defaultProvider ?? user.defaultProvider;
15
+ const model = project.defaultModel ?? user.defaultModel;
16
+ const key = `${provider}/${model}`;
17
+ const effort = project.modelThinkingLevels?.[key] ?? user.modelThinkingLevels?.[key] ?? project.defaultThinkingLevel ?? user.defaultThinkingLevel;
18
+ return { provider, model, effort, source: project.defaultModel !== undefined || project.defaultProvider !== undefined ? "Project" : "User / automatic" };
19
+ }
20
+ function trusted(pi: any, ctx: any): boolean {
21
+ return typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : typeof pi.isProjectTrusted === "function" && pi.isProjectTrusted();
22
+ }
23
+ function projectSettings(pi: any, ctx: any, cwd: string): JsonObject { return trusted(pi, ctx) ? readSettings(settingsPath(cwd, "project")) : {}; }
24
+
25
+ /** Switch first; never replace saved defaults when authentication/model selection fails. */
26
+ export async function setMainDefault(pi: any, ctx: any, provider: string, modelId: string, scope: Scope = "user"): Promise<void> {
27
+ if (scope === "project" && !trusted(pi, ctx)) throw new Error("Project is not trusted");
28
+ await ctx.modelRegistry.refresh();
29
+ const model = ctx.modelRegistry.find(provider, modelId);
30
+ if (!model) throw new Error(`Model ${provider}/${modelId} is not in the active registry`);
31
+ const user = readSettings(settingsPath(ctx.cwd, "user"));
32
+ const project = projectSettings(pi, ctx, ctx.cwd);
33
+ const choice = await chooseEffort(ctx, `${provider}/${modelId}`, model, piPreference(user, project).effort);
34
+ if (choice === undefined) return;
35
+ const effort = choice === "automatic" ? undefined : choice;
36
+ const preview = structuredClone(scope === "user" ? user : project);
37
+ setPiDefault(preview, provider, modelId, effort);
38
+ const effective = piPreference(scope === "user" ? preview : user, scope === "project" ? preview : project);
39
+ const shadowed = effective.provider !== provider || effective.model !== modelId || (effort !== undefined && effective.effort !== effort);
40
+ if (shadowed && !(await ctx.ui.confirm("Higher-priority settings", "Project or per-model settings override this preference at startup. Save and use your selection in this session anyway?"))) return;
41
+ if (!(await ctx.ui.confirm("Save and use Pi default?", `${provider}/${modelId}, effort ${choice}\nScope: ${scope}\n${settingsPath(ctx.cwd, scope)}`))) return;
42
+ // Parse before changing session state. A malformed file must never be overwritten.
43
+ readSettings(settingsPath(ctx.cwd, scope));
44
+ const previousModel = ctx.model;
45
+ const previousEffort = pi.getThinkingLevel();
46
+ if (!(await pi.setModel(model))) {
47
+ ctx.ui.notify("Could not switch model. Defaults were not changed.", "warning");
48
+ return;
49
+ }
50
+ const requested = effort ?? effective.effort ?? "medium";
51
+ try {
52
+ pi.setThinkingLevel(requested);
53
+ const applied = pi.getThinkingLevel();
54
+ if (effort !== undefined && applied !== effort && !(await ctx.ui.confirm("Pi adjusted the effort", `Requested ${effort}; Pi uses ${applied}. Save ${applied} instead?`))) {
55
+ if (previousModel) await pi.setModel(previousModel);
56
+ pi.setThinkingLevel(previousEffort);
57
+ return;
58
+ }
59
+ patchSettings(settingsPath(ctx.cwd, scope), data => setPiDefault(data, provider, modelId, effort === undefined ? undefined : applied));
60
+ ctx.ui.notify(`Using ${provider}/${modelId}, effort ${applied}. Saved ${scope} Pi default.${shadowed ? " Higher-priority startup settings still apply." : ""}`, "info");
61
+ } catch (error) {
62
+ if (previousModel) await pi.setModel(previousModel);
63
+ pi.setThinkingLevel(previousEffort);
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ function display(value: unknown): string {
69
+ if (value === undefined) return "agent-defined / fallback";
70
+ if (value === false) return "off";
71
+ return String(value);
72
+ }
73
+ async function assignModel(ctx: any, config: ModelsConfig, title: string): Promise<{ provider: string; modelId: string } | undefined> {
74
+ const rows = rankModelsForDefaults(config, ctx);
75
+ if (!rows.length) { ctx.ui.notify("No models available to assign.", "warning"); return; }
76
+ const action = await pickerDialog(ctx, rows, title, true);
77
+ return action?.kind === "load" ? action : undefined;
78
+ }
79
+ export async function defaultsFlow(pi: any, ctx: any, config: ModelsConfig): Promise<void> {
80
+ let scope: Scope = "user";
81
+ for (;;) {
82
+ const discovery = await discoverRoles(ctx.cwd, pi, ctx.model?.provider, trusted(pi, ctx));
83
+ const user = readSettings(settingsPath(ctx.cwd, "user"));
84
+ const project = projectSettings(pi, ctx, ctx.cwd);
85
+ const agentUser = readSettings(settingsPath(discovery.projectRoot, "user"));
86
+ const agentProject = projectSettings(pi, ctx, discovery.projectRoot);
87
+ const main = piPreference(user, project);
88
+ const roles = discovery.roles;
89
+ const modelFallback = roleField(agentUser, agentProject, ctx.model?.provider, undefined, "model");
90
+ const thinkingFallback = roleField(agentUser, agentProject, ctx.model?.provider, undefined, "thinking");
91
+ const options = [
92
+ `Scope: ${scope} (select to change)`,
93
+ `Pi session: ${main.provider ?? "auto"}/${main.model ?? "auto"} | ${main.effort ?? "automatic"} [${main.source}]`,
94
+ `Subagent fallback: ${display(modelFallback.value)} | ${display(thinkingFallback.value)}`,
95
+ ...roles.map(role => {
96
+ const model = roleField(agentUser, agentProject, ctx.model?.provider, role.name, "model");
97
+ const effort = roleField(agentUser, agentProject, ctx.model?.provider, role.name, "thinking");
98
+ return `${role.name}: ${display(model.value ?? role.model)} | ${display(effort.value ?? role.thinking)} [${model.source}; ${effort.source}]${role.runner ? " [external runner]" : ""}`;
99
+ }),
100
+ ...(discovery.warning ? ["Agent discovery details"] : []),
101
+ "Done",
102
+ ];
103
+ const selected = await ctx.ui.select(`BMS defaults\nSaved settings, not running-child state. Agent changes require /reload.\n${scope === "user" ? settingsPath(ctx.cwd, scope) : "Pi: " + settingsPath(ctx.cwd, scope) + "\nAgents: " + settingsPath(discovery.projectRoot, scope)}`, options);
104
+ if (!selected || selected === "Done") return;
105
+ const index = options.indexOf(selected);
106
+ if (index === 0) {
107
+ if (!trusted(pi, ctx)) { ctx.ui.notify("Project defaults require a trusted project.", "warning"); continue; }
108
+ scope = scope === "user" ? "project" : "user";
109
+ } else if (index === 1) {
110
+ const model = await assignModel(ctx, config, "Choose Pi default model");
111
+ if (model) await setMainDefault(pi, ctx, model.provider, model.modelId, scope);
112
+ } else if (selected === "Agent discovery details") {
113
+ ctx.ui.notify(discovery.warning!, "warning");
114
+ } else {
115
+ const role = index === 2 ? undefined : roles[index - 3];
116
+ await editRole(pi, ctx, config, scope, discovery.projectRoot, role, agentUser, agentProject);
117
+ }
118
+ }
119
+ }
120
+ async function editRole(pi: any, ctx: any, config: ModelsConfig, scope: Scope, root: string, role: AgentRole | undefined, user: JsonObject, project: JsonObject): Promise<void> {
121
+ const title = role?.name ?? "Subagent fallback";
122
+ const path = settingsPath(root, scope);
123
+ if (role?.runner) {
124
+ ctx.ui.notify("External runner defaults are not editable with the Pi registry picker. Use the runner's own configuration.", "warning");
125
+ return;
126
+ }
127
+ const action = await ctx.ui.select(`${title}\nScope: ${scope}. Provider-specific and project overrides may take precedence.\n${path}`, ["Choose model", "Inherit parent model", "Remove model override", "Choose effort", "Remove effort override", "Back"]);
128
+ if (!action || action === "Back") return;
129
+ let field: "model" | "thinking" = "model";
130
+ let value: string | false | undefined;
131
+ if (action === "Choose model") {
132
+ const selected = await assignModel(ctx, config, `Choose model for ${title}`);
133
+ if (!selected) return;
134
+ value = `${selected.provider}/${selected.modelId}`;
135
+ } else if (action === "Inherit parent model") value = "inherit";
136
+ else if (action === "Choose effort") {
137
+ field = "thinking";
138
+ const selected = roleField(user, project, ctx.model?.provider, role?.name, "model").value ?? role?.model;
139
+ const ref = typeof selected === "string" && selected !== "inherit" ? selected.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, "") : `${ctx.model?.provider}/${ctx.model?.id}`;
140
+ const split = ref.indexOf("/");
141
+ const model = split > 0 ? ctx.modelRegistry.find(ref.slice(0, split), ref.slice(split + 1)) : undefined;
142
+ const effort = await chooseEffort(ctx, ref, model);
143
+ if (effort === undefined) return;
144
+ value = effort === "automatic" ? undefined : effort;
145
+ } else if (action === "Remove effort override") field = "thinking";
146
+ if (!(await ctx.ui.confirm(`Save ${title}?`, `${field}: ${value ?? "remove override"}\n${path}\nReload Pi before relying on the new child defaults. Removing effort restores agent-defined/fallback behavior; it does not force parent inheritance.`))) return;
147
+ patchSettings(path, data => setRoleField(data, role?.name, field, value));
148
+ ctx.ui.notify(`Saved ${title} ${field}. Run /reload to refresh subagent defaults.`, "info");
149
+ }
@@ -0,0 +1,69 @@
1
+ import type { FlowCtx } from "./flows.ts";
2
+ import { capabilities, EFFORTS, isEffort, metadataPath, normalizeEfforts, patchSettings, readSettings, saveCapability, settingsPath, type Capability, type Effort } from "./defaults-store.ts";
3
+
4
+ export interface EffortDraft { capability: Capability; preferred?: Effort }
5
+ export async function editModelEfforts(ctx: FlowCtx, key: string, reasoning: boolean, reported?: Effort[]): Promise<EffortDraft | undefined> {
6
+ const existing = capabilities(key, { reasoning });
7
+ let levels = !reasoning ? ["off" as const] : reported ?? existing.levels;
8
+ let source: Capability["source"] = reported ? "provider" : existing.source === "Manual override" ? "manual" : "pi";
9
+ if (reasoning) {
10
+ const mode = await ctx.ui.select(`Available efforts for ${key}\n${reported ? "Provider metadata" : existing.source}`, ["Use listed levels", "Configure manually"]);
11
+ if (!mode) return;
12
+ if (mode === "Configure manually") {
13
+ const raw = await ctx.ui.input(`Comma-separated Pi efforts: ${EFFORTS.join(", ")}`, levels.join(", "));
14
+ if (raw === undefined) return;
15
+ const parsed = normalizeEfforts(raw.split(",").map(value => value.trim()));
16
+ if (!parsed) { ctx.ui.notify("Choose one or more valid Pi effort levels. Nothing changed.", "warning"); return; }
17
+ levels = parsed;
18
+ source = "manual";
19
+ }
20
+ }
21
+ const previous = readSettings(settingsPath(ctx.cwd ?? process.cwd(), "user")).modelThinkingLevels?.[key];
22
+ const options = ["automatic", ...levels];
23
+ if (isEffort(previous) && levels.includes(previous)) options.splice(options.indexOf(previous), 1), options.unshift(previous);
24
+ const preferred = await ctx.ui.select(`Preferred effort when Pi loads ${key}\nCurrent: ${previous ?? "automatic"}. Saved in user modelThinkingLevels, independently of agent role defaults.`, options);
25
+ if (preferred === undefined) return;
26
+ return { capability: { levels, source }, preferred: isEffort(preferred) ? preferred : undefined };
27
+ }
28
+ /** Do not silently invalidate saved defaults when editing model capability hints. */
29
+ export function effortConflicts(data: Record<string, any>, key: string, levels: readonly Effort[], preferred?: Effort): string[] {
30
+ const conflicts: string[] = [];
31
+ const check = (label: string, effort: unknown) => {
32
+ if (isEffort(effort) && !levels.includes(effort)) conflicts.push(`${label}: ${effort}`);
33
+ };
34
+ check("Per-model preference", preferred);
35
+ if (`${data.defaultProvider}/${data.defaultModel}` === key) check("Pi default", preferred ?? data.defaultThinkingLevel);
36
+ const sub = data.subagents;
37
+ if (sub?.defaultModel === key) check("Subagent fallback", sub.defaultThinking);
38
+ const scan = (entries: Record<string, any> | undefined, prefix: string) => {
39
+ for (const [name, entry] of Object.entries(entries ?? {})) {
40
+ if (entry.model === key) check(`${prefix}${name}`, entry.thinking);
41
+ }
42
+ };
43
+ scan(sub?.agentOverrides, "Agent ");
44
+ for (const [provider, entries] of Object.entries(sub?.agentOverridesByProvider ?? {})) scan(entries as any, `${provider} agent `);
45
+ return conflicts;
46
+ }
47
+ export function saveModelEfforts(ctx: FlowCtx & { cwd?: string }, key: string, draft: EffortDraft): boolean {
48
+ const userPath = settingsPath(ctx.cwd ?? process.cwd(), "user");
49
+ const projectPath = settingsPath(ctx.cwd ?? process.cwd(), "project");
50
+ const project = ctx.isProjectTrusted?.() ? readSettings(projectPath) : {};
51
+ const conflicts = [
52
+ ...effortConflicts(readSettings(userPath), key, draft.capability.levels, draft.preferred),
53
+ ...effortConflicts(project, key, draft.capability.levels, project.modelThinkingLevels?.[key] ?? draft.preferred).map(value => `Project ${value}`),
54
+ ];
55
+ if (conflicts.length) {
56
+ ctx.ui.notify(`Effort update blocked. Change these defaults in /bms-defaults first: ${conflicts.join("; ")}`, "warning");
57
+ return false;
58
+ }
59
+ // Validate the capability file before writing a preference in a different file.
60
+ readSettings(metadataPath());
61
+ patchSettings(userPath, data => {
62
+ const levels = data.modelThinkingLevels ??= {};
63
+ if (!levels || typeof levels !== "object" || Array.isArray(levels)) throw new Error("Invalid modelThinkingLevels settings");
64
+ if (draft.preferred === undefined) delete levels[key];
65
+ else levels[key] = draft.preferred;
66
+ });
67
+ saveCapability(key, draft.capability);
68
+ return true;
69
+ }