pi-better-model-select 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 msenebald
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # pi-better-model-select
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
6
+ `models.local.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.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pi install npm:pi-better-model-select
15
+ ```
16
+
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 |
30
+
31
+ ## Keys
32
+
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+r` | Reset usage statistics |
42
+ | `esc` | Close |
43
+
44
+ ## Ranking
45
+
46
+ Every model loaded through `/bms` increments a counter in
47
+ `<agent-dir>/bms-usage.json`. The list sorts by count, then by most recent use,
48
+ then alphabetically — so an unused config starts in plain alphabetical order and
49
+ drifts toward your habits.
50
+
51
+ Only `/bms` selections count. Switching via `/model`, `ctrl+p` cycling, or a
52
+ session restore does not, which keeps the ranking a record of deliberate choices
53
+ rather than of how often you restart pi.
54
+
55
+ Stats for models that disappear from the effective configuration are pruned
56
+ when the picker opens. `ctrl+r` clears everything.
57
+
58
+ ## Metadata discovery
59
+
60
+ When adding or updating, the extension tries to fill in context window, output
61
+ cap, reasoning support and input modalities from, in order:
62
+
63
+ 1. `GET {baseUrl}/model/info` — LiteLLM proxies
64
+ 2. `GET {baseUrl}/models/{id}` — OpenAI-style, plus proxy-specific fields
65
+ 3. The public LiteLLM model cost map on GitHub
66
+
67
+ Each step is best-effort. A plain OpenAI-compatible endpoint usually yields
68
+ nothing from steps 1 and 2, and the flow falls back to name-based heuristics
69
+ with editable defaults. Before a write, BMS asks you to choose
70
+ `models.local.json` or `models.json`. The local file is listed first and acts as
71
+ a personal BMS overlay. The selected file is re-read immediately before the
72
+ write so a concurrent edit in another pi session is not clobbered.
73
+
74
+ ## Paths
75
+
76
+ All files resolve through pi's `getAgentDir()`, so `PI_CODING_AGENT_DIR` and
77
+ rebranded distributions work without special handling:
78
+
79
+ - `<agent-dir>/models.json` is Pi's documented compatible-endpoint configuration.
80
+ - `<agent-dir>/models.local.json` is an optional personal overlay managed by BMS.
81
+ - `<agent-dir>/bms-usage.json` stores selection counters.
82
+
83
+ Pi does not currently document or load `models.local.json` itself. BMS merges it
84
+ over `models.json` by provider and model id, then registers the merged providers
85
+ through Pi's extension API at session start and whenever `/bms` opens. If the
86
+ same model exists in both files, fields from `models.local.json` win.
87
+
88
+ Deleting a model that exists only in `models.json` leaves that file untouched.
89
+ BMS writes `{ "id": "...", "bmsDeleted": true }` to the local overlay instead.
90
+ This BMS-owned tombstone keeps generated or externally managed model entries
91
+ hidden across restarts. Delete the tombstone from `models.local.json` to restore
92
+ the inherited model.
93
+
94
+ Writes are atomic using a temporary file and rename, so an interrupted write
95
+ cannot truncate either configuration file.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ npm install
101
+ npm run typecheck
102
+ node --experimental-strip-types --test extensions/lib/picker.test.ts
103
+ pi -e . -p "say OK" # load the extension without installing it
104
+ ```
105
+
106
+ ## Publishing
107
+
108
+ Set the new version in `package.json` and `package-lock.json`, then run:
109
+
110
+ ```bash
111
+ make publish
112
+ ```
113
+
114
+ The target runs the full package gate, checks npm authentication, opens npm's
115
+ browser login when needed, skips the upload if that exact version already
116
+ exists, publishes the public scoped package, and waits for npm to expose the
117
+ new version. npm can take several minutes to process a package. If verification
118
+ times out, rerun `make verify-publish`; do not publish the same version again.
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,214 @@
1
+ // pi-better-model-select — /bms
2
+ //
3
+ // A searchable model picker ranked by how often you actually pick each model,
4
+ // with add / update / delete wired to ctrl-chords so plain typing always goes
5
+ // to the search box.
6
+
7
+ import { DynamicBorder, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { Container, Text } from "@earendil-works/pi-tui";
9
+ import { addFlow, deleteFlow, updateFlow, type FlowCtx } from "./lib/flows.ts";
10
+ import { ModelPicker, rankModels, type PickerAction } from "./lib/picker.ts";
11
+ import {
12
+ loadConfig,
13
+ loadConfigFile,
14
+ pruneUsage,
15
+ recordUsage,
16
+ saveUsage,
17
+ type ModelEntry,
18
+ type ModelsConfig,
19
+ type ProviderEntry,
20
+ } from "./lib/store.ts";
21
+
22
+ const registeredLocalProviders = new Set<string>();
23
+
24
+ function providerConfig(
25
+ ctx: any,
26
+ providerName: string,
27
+ provider: ProviderEntry,
28
+ ): Record<string, unknown> {
29
+ const models = (provider.models ?? []).map((entry: ModelEntry) => {
30
+ const current = ctx.modelRegistry.find(providerName, entry.id);
31
+ return {
32
+ ...entry,
33
+ name: entry.name ?? current?.name ?? entry.id,
34
+ api: entry.api ?? provider.api ?? current?.api,
35
+ baseUrl: entry.baseUrl ?? provider.baseUrl ?? current?.baseUrl,
36
+ reasoning: entry.reasoning ?? current?.reasoning ?? false,
37
+ input: entry.input ?? current?.input ?? ["text"],
38
+ cost: entry.cost ?? current?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
39
+ contextWindow: entry.contextWindow ?? current?.contextWindow ?? 128000,
40
+ maxTokens: entry.maxTokens ?? current?.maxTokens ?? 16384,
41
+ };
42
+ });
43
+
44
+ const { models: _models, ...settings } = provider;
45
+ return { ...settings, models };
46
+ }
47
+
48
+ /** Apply the personal BMS overlay as extension providers. */
49
+ function syncLocalConfig(pi: ExtensionAPI, ctx: any): ModelsConfig | null {
50
+ const local = loadConfigFile(ctx, "models.local.json", true);
51
+ const effective = loadConfig(ctx);
52
+ if (!local || !effective) return null;
53
+
54
+ const next = new Set(Object.keys(local.providers ?? {}));
55
+ for (const providerName of registeredLocalProviders) {
56
+ if (!next.has(providerName)) pi.unregisterProvider(providerName);
57
+ }
58
+
59
+ for (const providerName of next) {
60
+ const provider = effective.providers?.[providerName];
61
+ if (!provider) continue;
62
+ try {
63
+ pi.registerProvider(providerName, providerConfig(ctx, providerName, provider) as any);
64
+ } catch (e) {
65
+ ctx.ui.notify(`Cannot apply ${providerName} from models.local.json: ${e}`, "error");
66
+ return null;
67
+ }
68
+ }
69
+
70
+ registeredLocalProviders.clear();
71
+ for (const providerName of next) registeredLocalProviders.add(providerName);
72
+ return effective;
73
+ }
74
+
75
+ /** Re-read model configuration from disk and switch the session to the given model. */
76
+ async function loadModel(
77
+ pi: ExtensionAPI,
78
+ ctx: any,
79
+ provider: string,
80
+ modelId: string,
81
+ ): Promise<void> {
82
+ try {
83
+ await ctx.modelRegistry.refresh();
84
+ } catch (e) {
85
+ ctx.ui.notify(`Could not reload model configuration: ${e}`, "error");
86
+ return;
87
+ }
88
+
89
+ const model = ctx.modelRegistry.find(provider, modelId);
90
+ if (!model) {
91
+ ctx.ui.notify(
92
+ `Model "${provider}/${modelId}" not found after reload — check the config.`,
93
+ "warning",
94
+ );
95
+ return;
96
+ }
97
+
98
+ // pi.setModel lives on the ExtensionAPI, not on the command context.
99
+ const ok = await pi.setModel(model);
100
+ if (ok) {
101
+ // Counted here rather than in model_select so /bms picks are ranked but
102
+ // session restores are not, which would otherwise inflate every startup.
103
+ recordUsage(provider, modelId);
104
+ ctx.ui.notify(`Loaded model: ${provider}/${modelId}`, "info");
105
+ } else {
106
+ ctx.ui.notify(
107
+ `Could not switch to ${provider}/${modelId} (no API key?). It is still available via /model.`,
108
+ "warning",
109
+ );
110
+ }
111
+ }
112
+
113
+ export default function (pi: ExtensionAPI) {
114
+ pi.on("session_start", async (_event, ctx: any) => {
115
+ syncLocalConfig(pi, ctx);
116
+ });
117
+
118
+ pi.registerCommand("bms", {
119
+ description: "Better model select — searchable, usage-ranked model picker",
120
+ handler: async (_args, ctx: any) => {
121
+ if (ctx.mode !== "tui") {
122
+ ctx.ui.notify("/bms needs an interactive terminal.", "warning");
123
+ return;
124
+ }
125
+
126
+ // Loop so the picker reappears after each add / update / delete.
127
+ for (;;) {
128
+ const config = syncLocalConfig(pi, ctx);
129
+ if (!config) return;
130
+ pruneUsage(config);
131
+
132
+ const rows = rankModels(config);
133
+ if (rows.length === 0) {
134
+ const add = await ctx.ui.confirm(
135
+ "No models configured",
136
+ "The effective model configuration has no models. Add one now?",
137
+ );
138
+ if (!add) return;
139
+ await addFlow(ctx as FlowCtx);
140
+ continue;
141
+ }
142
+
143
+ const action: PickerAction | undefined = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => {
144
+ const container = new Container();
145
+ const accent = (s: string) =>
146
+ typeof theme?.fg === "function" ? theme.fg("accent", s) : s;
147
+
148
+ container.addChild(new DynamicBorder(accent));
149
+ container.addChild(new Text(accent("Better Model Select"), 1, 0));
150
+
151
+ const picker = new ModelPicker(
152
+ rows,
153
+ theme,
154
+ () => tui.requestRender(),
155
+ (a: PickerAction) => done(a),
156
+ );
157
+ picker.focused = true;
158
+ container.addChild(picker);
159
+ container.addChild(new DynamicBorder(accent));
160
+
161
+ return {
162
+ render: (w: number) => container.render(w),
163
+ invalidate: () => container.invalidate(),
164
+ handleInput: (data: string) => picker.handleInput(data),
165
+ };
166
+ });
167
+
168
+ switch (action?.kind) {
169
+ case "load":
170
+ await loadModel(pi, ctx, action.provider, action.modelId);
171
+ return;
172
+ case "add":
173
+ await addFlow(ctx as FlowCtx);
174
+ break;
175
+ case "update":
176
+ await updateFlow(
177
+ ctx as FlowCtx,
178
+ action.provider && action.modelId
179
+ ? { provider: action.provider, modelId: action.modelId }
180
+ : undefined,
181
+ );
182
+ break;
183
+ case "delete":
184
+ await deleteFlow(
185
+ ctx as FlowCtx,
186
+ action.provider && action.modelId
187
+ ? { provider: action.provider, modelId: action.modelId }
188
+ : undefined,
189
+ );
190
+ break;
191
+ case "reset": {
192
+ const ok = await ctx.ui.confirm(
193
+ "Reset usage statistics?",
194
+ "Model ranking falls back to alphabetical order.",
195
+ );
196
+ if (ok) {
197
+ saveUsage({ version: 1, counts: {}, lastUsed: {} });
198
+ ctx.ui.notify("Usage statistics cleared.", "info");
199
+ }
200
+ break;
201
+ }
202
+ default:
203
+ return; // "close" or ESC
204
+ }
205
+ }
206
+ },
207
+ });
208
+
209
+ // Standalone add, kept for muscle memory and non-interactive convenience.
210
+ pi.registerCommand("bms-add", {
211
+ description: "Add a model from a provider's endpoint to a selected config file",
212
+ handler: async (_args, ctx: any) => addFlow(ctx as FlowCtx),
213
+ });
214
+ }