pi-better-model-select 0.1.4 → 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.
package/README.md CHANGED
@@ -1,12 +1,6 @@
1
1
  # pi-better-model-select
2
2
 
3
- A searchable, usage-ranked model picker for [pi](https://pi.dev).
4
-
5
- `/bms` opens the effective model list from `models.json` plus the optional BMS-owned
6
- `models.bms.json` overlay, sorted by how often you pick each model. Start
7
- typing to filter; press enter to switch the session to the highlighted model.
8
- Add, update and delete live on ctrl-chords so plain keystrokes always go to the
9
- search box.
3
+ A searchable model picker for [Pi](https://pi.dev). Switch models, edit model definitions, and save default models and thinking effort. Models you pick most often appear first.
10
4
 
11
5
  ## Install
12
6
 
@@ -14,121 +8,82 @@ search box.
14
8
  pi install npm:pi-better-model-select
15
9
  ```
16
10
 
17
- If you installed the package under its old name, migrate with:
18
-
19
- ```bash
20
- pi remove npm:@msenebald/better-model-select
21
- pi install npm:pi-better-model-select
22
- ```
23
-
24
- ## Commands
25
-
26
- | Command | What it does |
27
- | ---------- | --------------------------------------------------- |
28
- | `/bms` | Open the searchable, usage-ranked picker |
29
- | `/bms-add` | Jump straight to the add-a-model flow |
11
+ Run `/bms`, type to filter by name, model ID, or provider, then press Enter to switch models for the current session.
30
12
 
31
- ## Keys
13
+ ## Commands and shortcuts
32
14
 
33
- | Key | Action |
34
- | ---------- | --------------------------------------------------- |
35
- | `↑` `↓` | Move through the list |
36
- | *type* | Fuzzy filter by display name, model id or provider |
37
- | `enter` | Load the highlighted model |
38
- | `ctrl+a` | Add a model |
39
- | `ctrl+u` | Update the highlighted model |
40
- | `ctrl+x` | Delete the highlighted model |
41
- | `ctrl+o` | Open BMS options |
42
- | `ctrl+r` | Reset usage statistics |
43
- | `esc` | Close |
15
+ | Command | Action |
16
+ | --- | --- |
17
+ | `/bms` | Open the model picker |
18
+ | `/bms-add` | Add a model |
19
+ | `/bms-defaults` | Configure Pi and subagent defaults |
44
20
 
45
- ## Options
21
+ Inside `/bms`:
46
22
 
47
- Press `ctrl+o` in `/bms` to configure:
23
+ | Key | Action |
24
+ | --- | --- |
25
+ | ↑ / ↓ | Move through models |
26
+ | Enter | Use the selected model for this session |
27
+ | Ctrl+D | Choose effort, save as the Pi default, and use it now |
28
+ | Ctrl+G | Open the defaults menu |
29
+ | Ctrl+A / Ctrl+U / Ctrl+X | Add / update / delete a model |
30
+ | Ctrl+O | Set write mode and provider filters |
31
+ | Ctrl+R | Reset usage ranking |
32
+ | Esc | Close |
48
33
 
49
- - **Write mode.** Overlay mode keeps Pi's `models.json` unchanged and stores
50
- BMS edits in `models.bms.json`. Direct mode writes add, update and delete
51
- operations to `models.json`.
52
- - **Provider filters.** Enable or disable providers in the `/bms` model list.
53
- These filters do not change Pi's `/model` picker or either model file.
34
+ The picker marks the active model with `[current]` and the saved startup model with `[default]`. Only Enter selections count toward usage ranking.
54
35
 
55
- BMS stores these choices in `<agent-dir>/bms-settings.json`.
36
+ ## Default models and effort
56
37
 
57
- ## Ranking
38
+ New in BMS `0.2.0`: save a model and thinking effort for Pi, a subagent fallback, or an individual agent role.
58
39
 
59
- Every model loaded through `/bms` increments a counter in
60
- `<agent-dir>/bms-usage.json`. The list sorts by count, then by most recent use,
61
- then alphabetically — so an unused config starts in plain alphabetical order and
62
- drifts toward your habits.
40
+ - Use Ctrl+D in `/bms` for the main Pi default.
41
+ - Use `/bms-defaults` for all defaults. The menu starts in user scope; select the scope row to switch to a trusted project's settings.
42
+ - Agent model and effort overrides are independent. You can inherit the parent model while choosing a different effort.
63
43
 
64
- Only `/bms` selections count. Switching via `/model`, `ctrl+p` cycling, or a
65
- session restore does not, which keeps the ranking a record of deliberate choices
66
- rather than of how often you restart pi.
44
+ Removing an override restores the agent's own definition and fallback rules. Effort `off` is explicit, not inheritance. The menu shows saved settings and their sources, not running-child state.
67
45
 
68
- Stats for models that disappear from the effective configuration are pruned
69
- when the picker opens. `ctrl+r` clears everything.
46
+ Run `/reload` after changing agent defaults or per-model effort preferences. Existing children stay unchanged. Test Pi startup defaults in a fresh session, since resumed sessions restore their recorded model and effort.
70
47
 
71
- ## Metadata discovery
48
+ ### Optional pi-subagents integration
72
49
 
73
- When adding or updating, the extension tries to fill in context window, output
74
- cap, reasoning support and input modalities from, in order:
50
+ [pi-subagents](https://github.com/nicobailon/pi-subagents) is a separate extension. Install it only if you want subagents:
75
51
 
76
- 1. `GET {baseUrl}/model/info` — LiteLLM proxies
77
- 2. `GET {baseUrl}/models/{id}` — OpenAI-style, plus proxy-specific fields
78
- 3. The public LiteLLM model cost map on GitHub
52
+ ```bash
53
+ pi install npm:pi-subagents
54
+ ```
79
55
 
80
- Each step is best-effort. A plain OpenAI-compatible endpoint usually yields
81
- nothing from steps 1 and 2, and the flow falls back to name-based heuristics
82
- with editable defaults. Choose the default write mode from `ctrl+o` in `/bms`. Overlay mode writes to
83
- `models.bms.json`; direct mode edits Pi's `models.json`. The selected file is re-read immediately before the
84
- write so a concurrent edit in another pi session is not clobbered.
56
+ BMS discovers its agent roles and edits their saved model and effort defaults. It does not install `pi-subagents` automatically. External runner roles are read-only because their model settings belong to the runner.
85
57
 
86
- ## Paths
58
+ Discovery has now been checked with **pi-subagents `0.82.1`**, updated from the original `0.79.0` test. BMS handles the newer bundled entry-point layout. The adapter still uses internal modules, so future package changes may require an update. If discovery fails, **Agent discovery details** shows the error. Pi defaults work without this integration.
87
59
 
88
- All files resolve through pi's `getAgentDir()`, so `PI_CODING_AGENT_DIR` and
89
- rebranded distributions work without special handling:
60
+ ### Supported effort levels
90
61
 
91
- - `<agent-dir>/models.json` is Pi's documented compatible-endpoint configuration.
92
- - `<agent-dir>/models.bms.json` is the optional overlay owned and loaded by BMS.
93
- - `<agent-dir>/bms-settings.json` stores the write mode and provider filters.
94
- - `<agent-dir>/bms-usage.json` stores selection counters.
62
+ BMS uses provider-reported effort lists when available and Pi's capability restrictions where supplied. Otherwise it offers Pi's standard levels, marked as provider support unverified:
95
63
 
96
- Pi does not load `models.bms.json` itself. In overlay mode, BMS merges it over
97
- `models.json` by provider and model id, then registers the merged providers
98
- through Pi's extension API. If the same model exists in both files, fields from
99
- `models.bms.json` win. Existing `models.local.json` overlays from BMS 0.1.x are
100
- renamed to `models.bms.json` automatically.
64
+ ```text
65
+ off · minimal · low · medium · high · xhigh · max
66
+ ```
101
67
 
102
- Deleting a model that exists only in `models.json` leaves that file untouched.
103
- BMS writes `{ "id": "...", "bmsDeleted": true }` to the overlay instead.
104
- This tombstone keeps generated or externally managed model entries hidden across
105
- restarts. Delete it from `models.bms.json` to restore the inherited model.
68
+ Add/update also lets you edit supported levels and set a per-model preferred effort. These hints do not change Pi's provider adapters. If Pi clamps an effort, BMS asks before saving the applied level. No paid model probes are made.
106
69
 
107
- Writes are atomic using a temporary file and rename, so an interrupted write
108
- cannot truncate either configuration file.
70
+ ## Where changes are saved
109
71
 
110
- ## Development
72
+ Ctrl+O selects the model write mode:
111
73
 
112
- ```bash
113
- npm install
114
- npm run typecheck
115
- node --experimental-strip-types --test extensions/lib/picker.test.ts
116
- pi -e . -p "say OK" # load the extension without installing it
117
- ```
74
+ | Mode | Behavior |
75
+ | --- | --- |
76
+ | Overlay, the default | Save model edits in `models.bms.json`; leave `models.json` unchanged |
77
+ | Direct | Write model edits to Pi's `models.json` |
118
78
 
119
- ## Publishing
79
+ Defaults always go into native Pi `settings.json`, regardless of write mode. User files normally live in `~/.pi/agent`; project defaults use `.pi/settings.json`. BMS respects `PI_CODING_AGENT_DIR` and shows the selected settings paths.
120
80
 
121
- Set the new version in `package.json` and `package-lock.json`, then run:
81
+ Provider filters hide entries only in `/bms`, not Pi's `/model` picker. Default-assignment menus also include authenticated Pi registry models.
122
82
 
123
- ```bash
124
- make publish
125
- ```
83
+ ## Further documentation
126
84
 
127
- The target runs the full package gate, checks npm authentication, opens npm's
128
- browser login when needed, skips the upload if that exact version already
129
- exists, publishes the public scoped package, and waits for npm to expose the
130
- new version. npm can take several minutes to process a package. If verification
131
- times out, rerun `make verify-publish`; do not publish the same version again.
85
+ - [Configuration reference](https://g.han.zone/msenebald/pi-better-model-select/src/branch/main/docs/configuration.md): file paths, precedence, overlays, metadata, and integration limits.
86
+ - [Development and testing](https://g.han.zone/msenebald/pi-better-model-select/src/branch/main/docs/development.md): local loading, isolated tests, checks, and publishing.
132
87
 
133
88
  ## License
134
89
 
@@ -0,0 +1,105 @@
1
+ # Configuration reference
2
+
3
+ See the [README](../README.md) for installation and shortcuts.
4
+
5
+ ## Files
6
+
7
+ `<agent-dir>` is normally `~/.pi/agent`. BMS uses Pi's `getAgentDir()`, so it respects `PI_CODING_AGENT_DIR`.
8
+
9
+ | File | Contents |
10
+ | --- | --- |
11
+ | `<agent-dir>/models.json` | Pi model definitions |
12
+ | `<agent-dir>/models.bms.json` | BMS model overlay |
13
+ | `<agent-dir>/bms-settings.json` | Write mode and provider filters |
14
+ | `<agent-dir>/bms-usage.json` | Selection counts and recency |
15
+ | `<agent-dir>/bms-model-metadata.json` | Effort capability hints and their source |
16
+ | `<agent-dir>/settings.json` | User defaults and per-model effort preferences |
17
+ | `<project>/.pi/settings.json` | Project defaults |
18
+
19
+ ## Model write modes
20
+
21
+ Choose the write mode through Ctrl+O in `/bms`.
22
+
23
+ - Overlay mode, the default, leaves `models.json` unchanged and writes edits to `models.bms.json`.
24
+ - Direct mode writes add, update, and delete operations to `models.json`.
25
+
26
+ Pi does not load the overlay itself. BMS merges it over `models.json` by provider and model ID, then registers the merged providers through Pi's extension API. Overlay fields win for duplicate models. BMS automatically renames its old `models.local.json` overlay to `models.bms.json`.
27
+
28
+ Deleting an inherited model in overlay mode writes a tombstone:
29
+
30
+ ```json
31
+ { "id": "model-id", "bmsDeleted": true }
32
+ ```
33
+
34
+ Remove that entry from `models.bms.json` to restore the inherited model. Model files are re-read before writes and saved through a temporary file and rename. This does not provide a shared lock against other applications.
35
+
36
+ Provider filters affect only `/bms`. They do not change Pi's `/model` picker or model definitions. Default-assignment menus ignore the filters and also include authenticated Pi registry models.
37
+
38
+ ## Defaults and precedence
39
+
40
+ Default settings are independent of the model write mode.
41
+
42
+ | Setting | Native Pi settings field |
43
+ | --- | --- |
44
+ | Pi model | `defaultProvider`, `defaultModel` |
45
+ | Pi effort | `defaultThinkingLevel` |
46
+ | Per-model preferred effort | `modelThinkingLevels["provider/model"]` |
47
+ | Subagent fallback | `subagents.defaultModel`, `subagents.defaultThinking` |
48
+ | Role override | `subagents.agentOverrides.<name>.model` and `.thinking` |
49
+
50
+ The defaults menu starts in user scope. Project scope requires a trusted project. Pi defaults use the working directory's `.pi/settings.json`. Agent defaults use the project root resolved by `pi-subagents`, which may differ in nested repositories. The menu shows the paths.
51
+
52
+ Project settings and provider-specific role overrides may take precedence. BMS shows override sources and warns when a saved Pi default would be shadowed. Saving a Pi default removes the selected model's per-model effort preference in the same scope, so it cannot override the new default.
53
+
54
+ For agent roles:
55
+
56
+ - **Inherit parent model** writes `model: "inherit"`.
57
+ - **Remove model override** restores agent-definition and fallback rules.
58
+ - **Automatic** or **Remove effort override** removes that scope's effort preference. It does not force parent effort inheritance. `pi-subagents` has no separate persistent setting for that.
59
+ - **Off** is an explicit effort level, not inheritance.
60
+
61
+ Changing the Pi default also switches the main session. Subagent assignments do not switch it or increment usage counts. Cancelled or failed model switches leave saved defaults unchanged. If Pi clamps an effort, BMS asks before saving the applied level.
62
+
63
+ Run `/reload` after changing agent defaults or per-model effort preferences. Running children keep their existing configuration, and subagent model-scope policies still apply at launch. Resumed Pi sessions restore their recorded model and effort. Test startup defaults in a fresh session without CLI model or effort overrides.
64
+
65
+ Settings writes preserve unrelated fields, reject malformed JSON, and use atomic rename. A `.bms-lock` file serializes BMS settings writers only. After a crash, remove a stale lock only after checking that no BMS writer is active. Updates across model definitions, settings, and capability metadata are not a multi-file transaction.
66
+
67
+ ## Effort levels and metadata
68
+
69
+ BMS uses explicit provider effort lists when supplied, then native Pi capability restrictions where available. Otherwise it offers Pi's standard levels and labels provider support as unverified:
70
+
71
+ ```text
72
+ off, minimal, low, medium, high, xhigh, max
73
+ ```
74
+
75
+ The metadata adapter recognizes `supported_reasoning_efforts`, `supported_thinking_levels`, and `reasoning_efforts` containing Pi effort names. A generic reasoning flag or `supported_parameters: ["reasoning_effort"]` does not specify supported levels.
76
+
77
+ Add/update lets you edit the available levels and choose an optional per-model preferred effort. Non-reasoning models offer only `off`; correct the reasoning flag first if the endpoint metadata is wrong. BMS makes no paid model probes.
78
+
79
+ Capability hints guide menus. They do not change provider adapters or prove an endpoint honors a level. Pi may clamp effort or map it to a token budget. Provider errors remain visible rather than triggering a model fallback.
80
+
81
+ BMS blocks capability edits that conflict with directly pinned defaults in the inspected user/project settings. Update those defaults first. This check does not audit every inherited agent definition or settings file elsewhere on disk.
82
+
83
+ When adding or updating models, BMS tries these metadata sources in order:
84
+
85
+ 1. `GET {baseUrl}/model/info` for LiteLLM proxies.
86
+ 2. `GET {baseUrl}/models/{id}` for OpenAI-style and proxy-specific fields.
87
+ 3. The public LiteLLM model cost map on GitHub.
88
+
89
+ These requests are best-effort. Missing metadata falls back to name-based heuristics and editable defaults for context window, output limit, reasoning support, and input types.
90
+
91
+ ## Optional pi-subagents integration
92
+
93
+ [pi-subagents](https://github.com/nicobailon/pi-subagents) is a separate Pi extension. BMS configures its saved model and effort defaults; it does not install it or launch agents to discover roles.
94
+
95
+ Discovery was originally tested with `0.79.0` and has now been checked against `0.82.1`. BMS supports both the older root entry point and the newer `bundle/index.js` package layout. Discovery still uses internal discovery/runtime modules because the public agents API does not expose role discovery. Future incompatible versions may need an adapter update.
96
+
97
+ The package must be resolvable from BMS or `<agent-dir>/npm/node_modules`. Discovery includes built-in, package, user, project, and runtime roles when supported by the installed API. Untrusted project agents and settings are excluded. External runner roles are shown read-only; configure their models through the runner's own settings.
98
+
99
+ If discovery fails, the menu offers **Agent discovery details** with the error. Main Pi defaults remain usable.
100
+
101
+ ## Usage ranking
102
+
103
+ Only models loaded with Enter in `/bms` count toward ranking. BMS sorts by selection count, then recency, then alphabetically. Default assignments, `/model`, model cycling, and session restores do not count.
104
+
105
+ BMS prunes statistics for models missing from the effective configuration when the picker opens. Ctrl+R clears all usage statistics.
@@ -0,0 +1,104 @@
1
+ # Development and testing
2
+
3
+ Run these commands from the repository checkout:
4
+
5
+ ```bash
6
+ npm ci
7
+ npm run gate
8
+ npm run test:smoke # optional: installed Pi, Python 3 and a POSIX PTY required
9
+ pi --no-extensions -e ./extensions/bms.ts # temporary load; no install or publish
10
+ ```
11
+
12
+ ## Temporary local testing
13
+
14
+ Use a fresh terminal in this checkout. The command above disables installed
15
+ extensions to avoid loading two copies of BMS. It still uses your real Pi settings,
16
+ so default changes persist. For an isolated test, use a scratch agent directory
17
+ and project directory instead:
18
+
19
+ ```bash
20
+ REPO="$PWD"
21
+ SANDBOX="$(mktemp -d -t bms-local)"
22
+ mkdir -p "$SANDBOX/agent" "$SANDBOX/project/.pi"
23
+ printf '{}\n' > "$SANDBOX/project/.pi/settings.json"
24
+ # Optional: copy your model definitions. Review literal credentials in this file.
25
+ cp "${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/models.json" "$SANDBOX/agent/models.json"
26
+ cd "$SANDBOX/project"
27
+ PI_CODING_AGENT_DIR="$SANDBOX/agent" pi --no-extensions -e "$REPO/extensions/bms.ts"
28
+ ```
29
+
30
+ Provider API-key environment variables remain available. The isolated directory
31
+ has no login credentials; use `/login` there if needed. Do not commit credentials
32
+ or scratch settings. To test agent roles, load your installed pi-subagents entry
33
+ explicitly with another `-e` and make the package resolvable in the scratch agent's
34
+ `npm/node_modules`, or use your real agent directory with backups. For the standard
35
+ Pi npm install layout with pi-subagents 0.82.1, before launching:
36
+
37
+ ```bash
38
+ REAL_AGENT="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
39
+ ln -s "$REAL_AGENT/npm" "$SANDBOX/agent/npm"
40
+ PI_CODING_AGENT_DIR="$SANDBOX/agent" pi --no-extensions \
41
+ -e "$REPO/extensions/bms.ts" -e "$REAL_AGENT/npm/node_modules/pi-subagents/bundle/index.js"
42
+ ```
43
+
44
+ This symlinks installed package code, not your settings or auth files. Do not run
45
+ package installation/update commands in this sandbox, since they would modify the
46
+ shared package directory.
47
+
48
+ The automated `npm run test:smoke` uses an unreachable dummy endpoint and
49
+ throwaway settings. It checks command registration, the real TUI default-save
50
+ flow, the defaults shortcut, and restoration in a fresh Pi process. No model
51
+ prompts are sent. The regular test suite uses mocked provider metadata and UI.
52
+
53
+ Manual checks:
54
+
55
+ 1. `/bms`: filter and use Enter. Only the current session should change.
56
+ 2. Reopen `/bms`, press Ctrl+D, choose effort and confirm. Check both markers,
57
+ the footer effort, and the scratch `agent/settings.json`.
58
+ 3. Cancel the effort dialog and confirmation. Neither should change defaults.
59
+ 4. `/bms-defaults`: select a Pi model, then select the same model again to change
60
+ only its default effort. Test user and trusted-project scope.
61
+ 5. Configure a subagent fallback and a role model/effort. Confirm the parent
62
+ session stays unchanged. Test explicit model inheritance and removal of each
63
+ override, then `/reload` and inspect `/subagents-models reviewer`.
64
+ 6. Add/update a model. Check provider-reported effort lists, the unverified Pi
65
+ fallback, manual levels, and the preferred effort. Try removing a level that
66
+ a directly pinned default uses; BMS should block the conflicting edit.
67
+ 7. Exit and start a fresh Pi session with the same scratch paths. Confirm startup
68
+ model and effort. Do not use `--continue`, `--resume`, or CLI model/effort overrides.
69
+ 8. Test a narrow terminal and verify the list, wrapped hints, and search still work.
70
+
71
+ Quit Pi before deleting the sandbox with `rm -rf "$SANDBOX"`, then return to the
72
+ checkout with `cd "$REPO"`. The test commands do not publish or install BMS.
73
+
74
+ ## Publishing
75
+
76
+ Publish from `main` after the feature PR is reviewed and merged. Do not publish from the unmerged feature branch.
77
+
78
+ For the current `0.2.0` release:
79
+
80
+ ```bash
81
+ git switch main
82
+ git pull --ff-only origin main
83
+ npm ci
84
+ npm run gate
85
+ npm run test:smoke
86
+ npm pack --dry-run
87
+ ```
88
+
89
+ Check that the working tree is clean, both package manifests say `0.2.0`, and the package contains `README.md`, `docs/configuration.md`, and `docs/development.md`. Tests and the internal PR draft must not be included. The README links point to `main`, so merge the documentation before publishing.
90
+
91
+ Then publish to the public npm registry:
92
+
93
+ ```bash
94
+ make publish
95
+ ```
96
+
97
+ For later releases, update both manifests with `npm version <version> --no-git-tag-version`, and include that change in a reviewed PR before publishing.
98
+
99
+ The target runs the full package gate, checks npm authentication, opens npm's
100
+ browser login when needed, skips the upload if that exact version already
101
+ exists, publishes the public package, and waits for npm to expose the
102
+ new version. npm can take several minutes to process a package. If verification
103
+ times out, rerun `make verify-publish`; do not publish the same version again.
104
+
package/extensions/bms.ts CHANGED
@@ -4,10 +4,12 @@
4
4
  // with add / update / delete wired to ctrl-chords so plain typing always goes
5
5
  // to the search box.
6
6
 
7
- import { DynamicBorder, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
- import { Container, Text } from "@earendil-works/pi-tui";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { defaultsFlow, piPreference, setMainDefault } from "./lib/defaults.ts";
9
+ import { readSettings, settingsPath } from "./lib/defaults-store.ts";
10
+ import { pickerDialog } from "./lib/picker-dialog.ts";
9
11
  import { addFlow, deleteFlow, updateFlow, type FlowCtx } from "./lib/flows.ts";
10
- import { ModelPicker, rankModels, type PickerAction } from "./lib/picker.ts";
12
+ import { rankModels } from "./lib/picker.ts";
11
13
  import {
12
14
  loadConfig,
13
15
  loadConfigFile,
@@ -165,6 +167,23 @@ export default function (pi: ExtensionAPI) {
165
167
  syncLocalConfig(pi, ctx);
166
168
  });
167
169
 
170
+ const report = async (ctx: any, action: () => Promise<void>) => {
171
+ try { await action(); } catch (error) { ctx.ui.notify(`BMS: ${error}`, "error"); }
172
+ };
173
+ const addModel = async (ctx: any) => addFlow(ctx, async (provider, modelId) => {
174
+ if (syncLocalConfig(pi, ctx)) await report(ctx, () => setMainDefault(pi, ctx, provider, modelId));
175
+ });
176
+ pi.registerCommand("bms-defaults", {
177
+ description: "Configure Pi and subagent default models and effort levels",
178
+ handler: async (_args, ctx: any) => {
179
+ if (ctx.mode !== "tui") { ctx.ui.notify("/bms-defaults needs an interactive terminal.", "warning"); return; }
180
+ await report(ctx, async () => {
181
+ const config = syncLocalConfig(pi, ctx);
182
+ if (config) await defaultsFlow(pi, ctx, config);
183
+ });
184
+ },
185
+ });
186
+
168
187
  pi.registerCommand("bms", {
169
188
  description: "Better model select — searchable, usage-ranked model picker",
170
189
  handler: async (_args, ctx: any) => {
@@ -200,41 +219,31 @@ export default function (pi: ExtensionAPI) {
200
219
  "The effective model configuration has no models. Add one now?",
201
220
  );
202
221
  if (!add) return;
203
- await addFlow(ctx as FlowCtx);
222
+ await report(ctx, () => addModel(ctx));
204
223
  continue;
205
224
  }
206
225
 
207
- const action: PickerAction | undefined = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => {
208
- const container = new Container();
209
- const accent = (s: string) =>
210
- typeof theme?.fg === "function" ? theme.fg("accent", s) : s;
211
-
212
- container.addChild(new DynamicBorder(accent));
213
- container.addChild(new Text(accent("Better Model Select"), 1, 0));
214
-
215
- const picker = new ModelPicker(
216
- rows,
217
- theme,
218
- () => tui.requestRender(),
219
- (a: PickerAction) => done(a),
220
- );
221
- picker.focused = true;
222
- container.addChild(picker);
223
- container.addChild(new DynamicBorder(accent));
224
-
225
- return {
226
- render: (w: number) => container.render(w),
227
- invalidate: () => container.invalidate(),
228
- handleInput: (data: string) => picker.handleInput(data),
229
- };
230
- });
226
+ try {
227
+ const preference = piPreference(readSettings(settingsPath(ctx.cwd, "user")), ctx.isProjectTrusted?.() ? readSettings(settingsPath(ctx.cwd, "project")) : {});
228
+ for (const row of rows) {
229
+ row.current = ctx.model?.provider === row.provider && ctx.model?.id === row.modelId;
230
+ row.default = preference.provider === row.provider && preference.model === row.modelId;
231
+ }
232
+ } catch (error) { ctx.ui.notify(`Cannot read defaults: ${error}`, "warning"); }
233
+ const action = await pickerDialog(ctx, rows);
231
234
 
232
235
  switch (action?.kind) {
233
236
  case "load":
234
237
  await loadModel(pi, ctx, action.provider, action.modelId);
235
238
  return;
239
+ case "set-default":
240
+ await report(ctx, () => setMainDefault(pi, ctx, action.provider, action.modelId));
241
+ break;
242
+ case "defaults":
243
+ await report(ctx, () => defaultsFlow(pi, ctx, config));
244
+ break;
236
245
  case "add":
237
- await addFlow(ctx as FlowCtx);
246
+ await report(ctx, () => addModel(ctx));
238
247
  break;
239
248
  case "update":
240
249
  await updateFlow(
@@ -276,6 +285,6 @@ export default function (pi: ExtensionAPI) {
276
285
  // Standalone add, kept for muscle memory and non-interactive convenience.
277
286
  pi.registerCommand("bms-add", {
278
287
  description: "Add a model from a provider's endpoint using the configured write mode",
279
- handler: async (_args, ctx: any) => addFlow(ctx as FlowCtx),
288
+ handler: async (_args, ctx: any) => report(ctx, () => addModel(ctx)),
280
289
  });
281
290
  }
@@ -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
+ }
@@ -3,6 +3,7 @@
3
3
  // Each mutation follows the saved write mode, then re-reads the target file
4
4
  // before saving so another pi session's edit is not silently clobbered.
5
5
 
6
+ import { editModelEfforts, saveModelEfforts, type EffortDraft } from "./effort-editor.ts";
6
7
  import {
7
8
  DEFAULT_CONTEXT_WINDOW,
8
9
  DEFAULT_MAX_TOKENS,
@@ -29,6 +30,8 @@ import {
29
30
 
30
31
  // Minimal shape of the pi extension command context we rely on.
31
32
  export interface FlowCtx {
33
+ cwd?: string;
34
+ isProjectTrusted?: () => boolean;
32
35
  ui: {
33
36
  notify: (message: string, level: string) => void;
34
37
  select: (message: string, options: string[]) => Promise<string | undefined>;
@@ -92,7 +95,7 @@ async function selectInput(
92
95
 
93
96
  // ----- add -----------------------------------------------------------------
94
97
 
95
- export async function addFlow(ctx: FlowCtx): Promise<void> {
98
+ export async function addFlow(ctx: FlowCtx, onAdded?: (provider: string, modelId: string) => Promise<void>): Promise<void> {
96
99
  const config = loadConfig(ctx);
97
100
  if (!config) return;
98
101
  const providers = config.providers ?? {};
@@ -177,6 +180,9 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
177
180
  if (input === undefined) return;
178
181
  crumbs.push(`Input: ${input.join("+")}`);
179
182
 
183
+ const effortDraft = await editModelEfforts(ctx, `${providerName}/${modelId}`, reasoning, meta.effortLevels);
184
+ if (!effortDraft) return;
185
+
180
186
  const targetFile = selectedWriteTarget();
181
187
  const ok = await ctx.ui.confirm(
182
188
  `${header()}Add this model to "${providerName}"?`,
@@ -198,6 +204,7 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
198
204
  ctx.ui.notify(`Model "${modelId}" is already configured in ${providerName}.`, "warning");
199
205
  return;
200
206
  }
207
+ if (!saveModelEfforts(ctx, `${providerName}/${modelId}`, effortDraft)) return;
201
208
  ensureProviderSettings(freshTarget, freshEffective, providerName);
202
209
  upsertModel(freshTarget, providerName, {
203
210
  id: modelId,
@@ -209,6 +216,7 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
209
216
  });
210
217
  saveConfig(freshTarget, targetFile);
211
218
  ctx.ui.notify(`Added "${modelId}" to ${providerName} in ${targetFile}.`, "info");
219
+ if (onAdded && await ctx.ui.confirm("Set as Pi default?", "Configure this model and effort as the default for new sessions?")) await onAdded(providerName, modelId);
212
220
  }
213
221
 
214
222
  // ----- update --------------------------------------------------------------
@@ -276,7 +284,20 @@ export async function updateFlow(
276
284
  );
277
285
  }
278
286
 
287
+ let effortDraft: EffortDraft | undefined;
279
288
  const fields: Field[] = [
289
+ {
290
+ key: "bmsEfforts",
291
+ label: "Available / preferred efforts",
292
+ current: "edit capability levels and Pi per-model preference",
293
+ suggest: meta.effortLevels?.join(", "),
294
+ edit: async () => {
295
+ const draft = await editModelEfforts(ctx, `${providerName}/${existing.id}`, Boolean(changes.get("reasoning") ?? existing.reasoning), meta.effortLevels);
296
+ if (!draft) return;
297
+ effortDraft = draft;
298
+ return `${draft.capability.levels.join(", ")}; preferred ${draft.preferred ?? "automatic"}`;
299
+ },
300
+ },
280
301
  {
281
302
  key: "name",
282
303
  label: "Display name",
@@ -363,6 +384,10 @@ export async function updateFlow(
363
384
  const value = await field.edit(() => `Model: ${existing.id} [${providerName}]\n`);
364
385
  if (value === undefined) continue; // ESC in the field editor — back to list
365
386
  changes.set(field.key, value);
387
+ if (field.key === "reasoning") {
388
+ effortDraft = undefined;
389
+ changes.delete("bmsEfforts");
390
+ }
366
391
  }
367
392
 
368
393
  if (changes.size === 0) {
@@ -388,7 +413,12 @@ export async function updateFlow(
388
413
  ctx.ui.notify(`Model "${existing.id}" no longer exists in ${providerName}.`, "error");
389
414
  return;
390
415
  }
391
- Object.assign(modelEntry, Object.fromEntries(changes));
416
+ if (changes.has("reasoning") && !effortDraft) {
417
+ effortDraft = await editModelEfforts(ctx, `${providerName}/${existing.id}`, Boolean(changes.get("reasoning")), meta.effortLevels);
418
+ if (!effortDraft) return;
419
+ }
420
+ if (effortDraft && !saveModelEfforts(ctx, `${providerName}/${existing.id}`, effortDraft)) return;
421
+ Object.assign(modelEntry, Object.fromEntries([...changes].filter(([key]) => key !== "bmsEfforts")));
392
422
  ensureProviderSettings(freshTarget, freshEffective, providerName);
393
423
  upsertModel(freshTarget, providerName, modelEntry);
394
424
  saveConfig(freshTarget, targetFile);
@@ -5,11 +5,20 @@
5
5
  // endpoint yields nothing and the caller falls back to explicit defaults.
6
6
 
7
7
  import type { ModelsConfig } from "./store.ts";
8
+ import { normalizeEfforts, type Effort } from "./defaults-store.ts";
9
+
10
+ /** Only explicit enumerations count. supported_parameters alone is not an effort list. */
11
+ export function metadataEfforts(metadata: Record<string, any>): Effort[] | undefined {
12
+ return normalizeEfforts(metadata.supported_reasoning_efforts)
13
+ ?? normalizeEfforts(metadata.supported_thinking_levels)
14
+ ?? normalizeEfforts(metadata.reasoning_efforts);
15
+ }
8
16
 
9
17
  export const DEFAULT_CONTEXT_WINDOW = 128000;
10
18
  export const DEFAULT_MAX_TOKENS = 32768;
11
19
 
12
20
  export interface ModelMeta {
21
+ effortLevels?: Effort[];
13
22
  contextWindow?: number;
14
23
  maxTokens?: number;
15
24
  reasoning?: boolean;
@@ -55,6 +64,7 @@ export async function fetchModelMeta(
55
64
  key: string,
56
65
  modelId: string,
57
66
  ): Promise<ModelMeta> {
67
+ let effortLevels: Effort[] | undefined;
58
68
  let metaContext: number | undefined;
59
69
  let metaMaxTokens: number | undefined;
60
70
  let metaReasoning: boolean | undefined;
@@ -87,6 +97,8 @@ export async function fetchModelMeta(
87
97
  );
88
98
  if (match?.model_info) {
89
99
  const mi = match.model_info as Record<string, any>;
100
+ effortLevels = metadataEfforts(mi);
101
+ if (typeof mi.supports_reasoning === "boolean") metaReasoning = mi.supports_reasoning;
90
102
  metaContext = mi.max_input_tokens ?? mi.max_tokens;
91
103
  // Only trust an explicit output cap — LiteLLM's generic `max_tokens`
92
104
  // is often a default (e.g. 4096/128000), not the model's real cap.
@@ -106,8 +118,8 @@ export async function fetchModelMeta(
106
118
  // Not a LiteLLM proxy or endpoint unavailable — ignore.
107
119
  }
108
120
 
109
- // 2) OpenAI-style per-model GET
110
- if (metaContext === undefined) {
121
+ // 2) OpenAI-style per-model GET, also used when token metadata lacks effort information.
122
+ if (metaContext === undefined || effortLevels === undefined) {
111
123
  try {
112
124
  const modelRes = await fetch(`${base}/models/${encodeURIComponent(modelId)}`, {
113
125
  headers: authHeaders,
@@ -117,9 +129,10 @@ export async function fetchModelMeta(
117
129
  const b = (await modelRes.json()) as Record<string, any>;
118
130
  // OpenAI /v1/models/{id} does not return token limits, but some proxies
119
131
  // add custom fields like max_context_length or max_model_tokens.
120
- metaContext =
121
- b.max_context_length ?? b.max_input_tokens ?? b.context_window ?? b.max_tokens;
122
- metaMaxTokens = b.max_output_tokens ?? b.max_completion_tokens ?? metaContext;
132
+ effortLevels ??= metadataEfforts(b);
133
+ if (typeof b.reasoning === "boolean") metaReasoning ??= b.reasoning;
134
+ metaContext ??= b.max_context_length ?? b.max_input_tokens ?? b.context_window ?? b.max_tokens;
135
+ metaMaxTokens ??= b.max_output_tokens ?? b.max_completion_tokens;
123
136
  }
124
137
  } catch {
125
138
  // Endpoint not supported — ignore.
@@ -140,7 +153,7 @@ export async function fetchModelMeta(
140
153
  if (!entry) continue;
141
154
  metaContext = entry.max_input_tokens ?? entry.max_tokens;
142
155
  metaMaxTokens = entry.max_output_tokens ?? entry.max_tokens;
143
- metaReasoning =
156
+ metaReasoning ??=
144
157
  entry.mode === "reasoning" || entry.reasoning === true || heuristicReasoning;
145
158
  if (entry.input_cost_per_token > 0 || entry.output_cost_per_token > 0) {
146
159
  metaInput = entry.supports_vision ? ["text", "image"] : ["text"];
@@ -165,7 +178,8 @@ export async function fetchModelMeta(
165
178
  return {
166
179
  contextWindow: metaContext,
167
180
  maxTokens: metaMaxTokens,
168
- reasoning: metaReasoning,
181
+ reasoning: effortLevels?.some(level => level !== "off") ? true : metaReasoning,
182
+ effortLevels,
169
183
  input: metaInput,
170
184
  };
171
185
  }
@@ -0,0 +1,21 @@
1
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Text } from "@earendil-works/pi-tui";
3
+ import { ModelPicker, type PickerAction, type RankedModel } from "./picker.ts";
4
+
5
+ export async function pickerDialog(ctx: any, rows: RankedModel[], title = "Better Model Select", assignment = false): Promise<PickerAction | undefined> {
6
+ return ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => {
7
+ const container = new Container();
8
+ const accent = (text: string) => typeof theme?.fg === "function" ? theme.fg("accent", text) : text;
9
+ container.addChild(new DynamicBorder(accent));
10
+ container.addChild(new Text(accent(title), 1, 0));
11
+ const picker = new ModelPicker(rows, theme, () => tui.requestRender(), done, assignment);
12
+ picker.focused = true;
13
+ container.addChild(picker);
14
+ container.addChild(new DynamicBorder(accent));
15
+ return {
16
+ render: (width: number) => container.render(width),
17
+ invalidate: () => container.invalidate(),
18
+ handleInput: (data: string) => picker.handleInput(data),
19
+ };
20
+ });
21
+ }
@@ -19,6 +19,8 @@ import { loadUsage, usageKey, type ModelsConfig } from "./store.ts";
19
19
 
20
20
  export type PickerAction =
21
21
  | { kind: "load"; provider: string; modelId: string }
22
+ | { kind: "set-default"; provider: string; modelId: string }
23
+ | { kind: "defaults" }
22
24
  | { kind: "add" }
23
25
  | { kind: "update"; provider?: string; modelId?: string }
24
26
  | { kind: "delete"; provider?: string; modelId?: string }
@@ -34,6 +36,8 @@ export interface RankedModel {
34
36
  lastUsed: number;
35
37
  contextWindow?: number;
36
38
  reasoning: boolean;
39
+ current?: boolean;
40
+ default?: boolean;
37
41
  }
38
42
 
39
43
  /**
@@ -77,6 +81,8 @@ function formatContext(n?: number): string {
77
81
 
78
82
  export function toSelectItem(row: RankedModel): SelectItem {
79
83
  const bits: string[] = [row.modelId, `[${row.provider}]`];
84
+ if (row.current) bits.push("[current]");
85
+ if (row.default) bits.push("[default]");
80
86
  const ctxLabel = formatContext(row.contextWindow);
81
87
  if (ctxLabel) bits.push(`ctx ${ctxLabel}`);
82
88
  if (row.reasoning) bits.push("reasoning");
@@ -109,7 +115,7 @@ export function selectListTheme(theme: any): SelectListTheme {
109
115
  }
110
116
 
111
117
  const HINT =
112
- "↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+o options • ctrl+r reset • esc close";
118
+ "↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+d default • ctrl+g agent defaults • ctrl+o options • ctrl+r reset • esc close";
113
119
 
114
120
  export class ModelPicker extends Container implements Focusable {
115
121
  private search: Input;
@@ -123,6 +129,7 @@ export class ModelPicker extends Container implements Focusable {
123
129
  private theme: any;
124
130
  private onChange: () => void;
125
131
  private done: (action: PickerAction) => void;
132
+ private assignment: boolean;
126
133
 
127
134
  private _focused = false;
128
135
  get focused(): boolean {
@@ -139,11 +146,13 @@ export class ModelPicker extends Container implements Focusable {
139
146
  theme: any,
140
147
  onChange: () => void,
141
148
  done: (action: PickerAction) => void,
149
+ assignment = false,
142
150
  ) {
143
151
  super();
144
152
  this.theme = theme;
145
153
  this.onChange = onChange;
146
154
  this.done = done;
155
+ this.assignment = assignment;
147
156
  this.rows = rows;
148
157
  this.filtered = rows;
149
158
 
@@ -163,7 +172,7 @@ export class ModelPicker extends Container implements Focusable {
163
172
  this.list = this.buildList(rows);
164
173
 
165
174
  this.countText = new Text(fg("dim", this.countLabel()), 1, 0);
166
- this.hintText = new Text(fg("dim", HINT), 1, 0);
175
+ this.hintText = new Text(fg("dim", assignment ? "↑↓ move • enter assign • esc back" : HINT), 1, 0);
167
176
 
168
177
  this.addChild(this.search);
169
178
  this.addChild(this.list);
@@ -234,6 +243,13 @@ export class ModelPicker extends Container implements Focusable {
234
243
 
235
244
  handleInput(data: string): void {
236
245
  if (matchesKey(data, "escape")) return this.done({ kind: "close" });
246
+ if (!this.assignment && matchesKey(data, "ctrl+g")) return this.done({ kind: "defaults" });
247
+ if (!this.assignment && matchesKey(data, "ctrl+d")) {
248
+ const row = this.currentRow();
249
+ if (row) this.done({ kind: "set-default", provider: row.provider, modelId: row.modelId });
250
+ return;
251
+ }
252
+ if (this.assignment && ["ctrl+a", "ctrl+o", "ctrl+r", "ctrl+u", "ctrl+x", "ctrl+d", "ctrl+g"].some(key => matchesKey(data, key as any))) return;
237
253
  if (matchesKey(data, "ctrl+a")) return this.done({ kind: "add" });
238
254
  if (matchesKey(data, "ctrl+o")) return this.done({ kind: "options" });
239
255
  if (matchesKey(data, "ctrl+r")) return this.done({ kind: "reset" });
@@ -146,7 +146,7 @@ export function mergeConfigs(base: ModelsConfig, local: ModelsConfig): ModelsCon
146
146
  export function loadConfig(ctx: {
147
147
  ui: { notify: (m: string, l: string) => void };
148
148
  }): ModelsConfig | null {
149
- const base = loadConfigFile(ctx, "models.json");
149
+ const base = loadConfigFile(ctx, "models.json", true);
150
150
  if (!base) return null;
151
151
  if (loadSettings().writeMode === "direct") return base;
152
152
  migrateLegacyOverlay();
package/package.json CHANGED
@@ -1,14 +1,17 @@
1
1
  {
2
2
  "name": "pi-better-model-select",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "description": "Better model select for pi — /bms opens a searchable model picker sorted by how often you actually use each model, with inline add, update and delete.",
5
+ "description": "Searchable, usage-ranked model picker for Pi with model management, default model and thinking effort selection, and per-role subagent defaults.",
6
6
  "keywords": [
7
7
  "pi-package",
8
8
  "pi-extension",
9
9
  "bms",
10
10
  "model-select",
11
11
  "model-manager",
12
+ "model-defaults",
13
+ "thinking-effort",
14
+ "subagents",
12
15
  "model-picker",
13
16
  "search",
14
17
  "fuzzy",
@@ -22,6 +25,8 @@
22
25
  "extensions/",
23
26
  "!extensions/**/*.test.ts",
24
27
  "README.md",
28
+ "docs/configuration.md",
29
+ "docs/development.md",
25
30
  "LICENSE"
26
31
  ],
27
32
  "pi": {
@@ -31,6 +36,7 @@
31
36
  },
32
37
  "scripts": {
33
38
  "typecheck": "tsc --noEmit",
39
+ "test:smoke": "python3 scripts/smoke-local.py",
34
40
  "test": "node --experimental-strip-types --test extensions/lib/*.test.ts",
35
41
  "check:pack": "npm pack --dry-run --ignore-scripts >/dev/null",
36
42
  "gate": "npm run typecheck && npm test && npm run check:pack"