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 +51 -96
- package/docs/configuration.md +105 -0
- package/docs/development.md +104 -0
- package/extensions/bms.ts +39 -30
- package/extensions/lib/agents.ts +60 -0
- package/extensions/lib/defaults-models.ts +15 -0
- package/extensions/lib/defaults-store.ts +108 -0
- package/extensions/lib/defaults.ts +149 -0
- package/extensions/lib/effort-editor.ts +69 -0
- package/extensions/lib/flows.ts +32 -2
- package/extensions/lib/meta.ts +21 -7
- package/extensions/lib/picker-dialog.ts +21 -0
- package/extensions/lib/picker.ts +18 -2
- package/extensions/lib/store.ts +1 -1
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
# pi-better-model-select
|
|
2
2
|
|
|
3
|
-
A searchable
|
|
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
|
-
|
|
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
|
-
##
|
|
13
|
+
## Commands and shortcuts
|
|
32
14
|
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
|
|
|
36
|
-
|
|
|
37
|
-
| `
|
|
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
|
-
|
|
21
|
+
Inside `/bms`:
|
|
46
22
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
## Default models and effort
|
|
56
37
|
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
+
### Optional pi-subagents integration
|
|
72
49
|
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
52
|
+
```bash
|
|
53
|
+
pi install npm:pi-subagents
|
|
54
|
+
```
|
|
79
55
|
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
rebranded distributions work without special handling:
|
|
60
|
+
### Supported effort levels
|
|
90
61
|
|
|
91
|
-
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
cannot truncate either configuration file.
|
|
70
|
+
## Where changes are saved
|
|
109
71
|
|
|
110
|
-
|
|
72
|
+
Ctrl+O selects the model write mode:
|
|
111
73
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
make publish
|
|
125
|
-
```
|
|
83
|
+
## Further documentation
|
|
126
84
|
|
|
127
|
-
|
|
128
|
-
|
|
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 {
|
|
8
|
-
import {
|
|
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 {
|
|
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
|
|
222
|
+
await report(ctx, () => addModel(ctx));
|
|
204
223
|
continue;
|
|
205
224
|
}
|
|
206
225
|
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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) =>
|
|
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
|
+
}
|
package/extensions/lib/flows.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/extensions/lib/meta.ts
CHANGED
|
@@ -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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
}
|
package/extensions/lib/picker.ts
CHANGED
|
@@ -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" });
|
package/extensions/lib/store.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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"
|