pi-better-model-select 0.1.3 → 0.1.4

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
@@ -2,8 +2,8 @@
2
2
 
3
3
  A searchable, usage-ranked model picker for [pi](https://pi.dev).
4
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
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
7
  typing to filter; press enter to switch the session to the highlighted model.
8
8
  Add, update and delete live on ctrl-chords so plain keystrokes always go to the
9
9
  search box.
@@ -38,9 +38,22 @@ pi install npm:pi-better-model-select
38
38
  | `ctrl+a` | Add a model |
39
39
  | `ctrl+u` | Update the highlighted model |
40
40
  | `ctrl+x` | Delete the highlighted model |
41
+ | `ctrl+o` | Open BMS options |
41
42
  | `ctrl+r` | Reset usage statistics |
42
43
  | `esc` | Close |
43
44
 
45
+ ## Options
46
+
47
+ Press `ctrl+o` in `/bms` to configure:
48
+
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.
54
+
55
+ BMS stores these choices in `<agent-dir>/bms-settings.json`.
56
+
44
57
  ## Ranking
45
58
 
46
59
  Every model loaded through `/bms` increments a counter in
@@ -66,9 +79,8 @@ cap, reasoning support and input modalities from, in order:
66
79
 
67
80
  Each step is best-effort. A plain OpenAI-compatible endpoint usually yields
68
81
  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
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
72
84
  write so a concurrent edit in another pi session is not clobbered.
73
85
 
74
86
  ## Paths
@@ -77,19 +89,20 @@ All files resolve through pi's `getAgentDir()`, so `PI_CODING_AGENT_DIR` and
77
89
  rebranded distributions work without special handling:
78
90
 
79
91
  - `<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.
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.
81
94
  - `<agent-dir>/bms-usage.json` stores selection counters.
82
95
 
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.
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.
87
101
 
88
102
  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.
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.
93
106
 
94
107
  Writes are atomic using a temporary file and rename, so an interrupted write
95
108
  cannot truncate either configuration file.
package/extensions/bms.ts CHANGED
@@ -11,8 +11,10 @@ import { ModelPicker, rankModels, type PickerAction } from "./lib/picker.ts";
11
11
  import {
12
12
  loadConfig,
13
13
  loadConfigFile,
14
+ loadSettings,
14
15
  pruneUsage,
15
16
  recordUsage,
17
+ saveSettings,
16
18
  saveUsage,
17
19
  type ModelEntry,
18
20
  type ModelsConfig,
@@ -47,11 +49,13 @@ function providerConfig(
47
49
 
48
50
  /** Apply the personal BMS overlay as extension providers. */
49
51
  function syncLocalConfig(pi: ExtensionAPI, ctx: any): ModelsConfig | null {
50
- const local = loadConfigFile(ctx, "models.local.json", true);
52
+ const settings = loadSettings();
53
+ const overlay =
54
+ settings.writeMode === "overlay" ? loadConfigFile(ctx, "models.bms.json", true) : { providers: {} };
51
55
  const effective = loadConfig(ctx);
52
- if (!local || !effective) return null;
56
+ if (!overlay || !effective) return null;
53
57
 
54
- const next = new Set(Object.keys(local.providers ?? {}));
58
+ const next = new Set(Object.keys(overlay.providers ?? {}));
55
59
  for (const providerName of registeredLocalProviders) {
56
60
  if (!next.has(providerName)) pi.unregisterProvider(providerName);
57
61
  }
@@ -62,7 +66,7 @@ function syncLocalConfig(pi: ExtensionAPI, ctx: any): ModelsConfig | null {
62
66
  try {
63
67
  pi.registerProvider(providerName, providerConfig(ctx, providerName, provider) as any);
64
68
  } catch (e) {
65
- ctx.ui.notify(`Cannot apply ${providerName} from models.local.json: ${e}`, "error");
69
+ ctx.ui.notify(`Cannot apply ${providerName} from models.bms.json: ${e}`, "error");
66
70
  return null;
67
71
  }
68
72
  }
@@ -73,6 +77,52 @@ function syncLocalConfig(pi: ExtensionAPI, ctx: any): ModelsConfig | null {
73
77
  }
74
78
 
75
79
  /** Re-read model configuration from disk and switch the session to the given model. */
80
+ async function optionsFlow(ctx: any, config: ModelsConfig): Promise<void> {
81
+ for (;;) {
82
+ const settings = loadSettings();
83
+ const writeLabel =
84
+ settings.writeMode === "overlay" ? "BMS overlay (models.bms.json)" : "Pi config (models.json)";
85
+ const hidden = settings.disabledProviders.length;
86
+ const pick = await ctx.ui.select("BMS options", [
87
+ `Write mode: ${writeLabel}`,
88
+ `Provider filters: ${hidden} hidden`,
89
+ "Done",
90
+ ]);
91
+ if (!pick || pick === "Done") return;
92
+
93
+ if (pick.startsWith("Write mode:")) {
94
+ const mode = await ctx.ui.select("Where should BMS write model changes?", [
95
+ "BMS overlay (models.bms.json)",
96
+ "Pi config (models.json)",
97
+ ]);
98
+ if (!mode) continue;
99
+ settings.writeMode = mode.startsWith("BMS overlay") ? "overlay" : "direct";
100
+ saveSettings(settings);
101
+ ctx.ui.notify(`BMS write mode: ${settings.writeMode}.`, "info");
102
+ continue;
103
+ }
104
+
105
+ const providers = Object.keys(config.providers ?? {}).sort();
106
+ for (;;) {
107
+ const current = loadSettings();
108
+ const options = providers.map((provider) =>
109
+ `${current.disabledProviders.includes(provider) ? "[ ]" : "[x]"} ${provider}`,
110
+ );
111
+ const choice = await ctx.ui.select(
112
+ "Providers shown in /bms (select to toggle, ESC when done)",
113
+ options,
114
+ );
115
+ if (!choice) break;
116
+ const provider = choice.slice(4);
117
+ const disabled = new Set(current.disabledProviders);
118
+ if (disabled.has(provider)) disabled.delete(provider);
119
+ else disabled.add(provider);
120
+ current.disabledProviders = [...disabled].sort();
121
+ saveSettings(current);
122
+ }
123
+ }
124
+ }
125
+
76
126
  async function loadModel(
77
127
  pi: ExtensionAPI,
78
128
  ctx: any,
@@ -129,8 +179,22 @@ export default function (pi: ExtensionAPI) {
129
179
  if (!config) return;
130
180
  pruneUsage(config);
131
181
 
132
- const rows = rankModels(config);
182
+ const settings = loadSettings();
183
+ const rows = rankModels(config, settings.disabledProviders);
133
184
  if (rows.length === 0) {
185
+ const total = Object.values(config.providers ?? {}).reduce(
186
+ (sum, provider) => sum + (provider.models?.length ?? 0),
187
+ 0,
188
+ );
189
+ if (total > 0) {
190
+ const open = await ctx.ui.confirm(
191
+ "No models visible",
192
+ "All providers are hidden by BMS filters. Open options?",
193
+ );
194
+ if (!open) return;
195
+ await optionsFlow(ctx, config);
196
+ continue;
197
+ }
134
198
  const add = await ctx.ui.confirm(
135
199
  "No models configured",
136
200
  "The effective model configuration has no models. Add one now?",
@@ -188,6 +252,9 @@ export default function (pi: ExtensionAPI) {
188
252
  : undefined,
189
253
  );
190
254
  break;
255
+ case "options":
256
+ await optionsFlow(ctx, config);
257
+ break;
191
258
  case "reset": {
192
259
  const ok = await ctx.ui.confirm(
193
260
  "Reset usage statistics?",
@@ -208,7 +275,7 @@ export default function (pi: ExtensionAPI) {
208
275
 
209
276
  // Standalone add, kept for muscle memory and non-interactive convenience.
210
277
  pi.registerCommand("bms-add", {
211
- description: "Add a model from a provider's endpoint to a selected config file",
278
+ description: "Add a model from a provider's endpoint using the configured write mode",
212
279
  handler: async (_args, ctx: any) => addFlow(ctx as FlowCtx),
213
280
  });
214
281
  }
@@ -1,6 +1,6 @@
1
1
  // Add / update / delete flows against the effective model configuration.
2
2
  //
3
- // Each mutation asks which file to write, then re-reads that file immediately
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
6
  import {
@@ -17,9 +17,11 @@ import {
17
17
  configContainsModel,
18
18
  loadConfig,
19
19
  loadConfigFile,
20
+ loadSettings,
20
21
  saveConfig,
21
22
  tombstoneModel,
22
23
  upsertModel,
24
+ writeTarget,
23
25
  type ConfigTarget,
24
26
  type ModelEntry,
25
27
  type ModelsConfig,
@@ -37,20 +39,8 @@ export interface FlowCtx {
37
39
 
38
40
  const INPUT_OPTIONS: string[][] = [["text"], ["text", "image"]];
39
41
 
40
- const TARGET_LABELS = {
41
- "models.local.json": "models.local.json (personal BMS overlay)",
42
- "models.json": "models.json (Pi configuration)",
43
- } satisfies Record<ConfigTarget, string>;
44
-
45
- async function selectWriteTarget(
46
- ctx: FlowCtx,
47
- message = "Write changes to which file?",
48
- ): Promise<ConfigTarget | undefined> {
49
- const options = [TARGET_LABELS["models.local.json"], TARGET_LABELS["models.json"]];
50
- const pick = await ctx.ui.select(message, options);
51
- if (pick === TARGET_LABELS["models.local.json"]) return "models.local.json";
52
- if (pick === TARGET_LABELS["models.json"]) return "models.json";
53
- return undefined;
42
+ function selectedWriteTarget(): ConfigTarget {
43
+ return writeTarget(loadSettings());
54
44
  }
55
45
 
56
46
  function providerOverlay(config: ModelsConfig, providerName: string): Record<string, unknown> {
@@ -187,11 +177,10 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
187
177
  if (input === undefined) return;
188
178
  crumbs.push(`Input: ${input.join("+")}`);
189
179
 
190
- const writeTarget = await selectWriteTarget(ctx);
191
- if (!writeTarget) return;
180
+ const targetFile = selectedWriteTarget();
192
181
  const ok = await ctx.ui.confirm(
193
182
  `${header()}Add this model to "${providerName}"?`,
194
- `Write the entry to ${writeTarget}?`,
183
+ `Write the entry to ${targetFile}?`,
195
184
  );
196
185
  if (!ok) {
197
186
  ctx.ui.notify("Cancelled. Nothing written.", "info");
@@ -199,7 +188,7 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
199
188
  }
200
189
 
201
190
  const freshEffective = loadConfig(ctx);
202
- const freshTarget = loadConfigFile(ctx, writeTarget, writeTarget === "models.local.json");
191
+ const freshTarget = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
203
192
  if (!freshEffective || !freshTarget) return;
204
193
  if (!freshEffective.providers?.[providerName]) {
205
194
  ctx.ui.notify(`Provider "${providerName}" no longer exists.`, "error");
@@ -218,8 +207,8 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
218
207
  contextWindow,
219
208
  maxTokens,
220
209
  });
221
- saveConfig(freshTarget, writeTarget);
222
- ctx.ui.notify(`Added "${modelId}" to ${providerName} in ${writeTarget}.`, "info");
210
+ saveConfig(freshTarget, targetFile);
211
+ ctx.ui.notify(`Added "${modelId}" to ${providerName} in ${targetFile}.`, "info");
223
212
  }
224
213
 
225
214
  // ----- update --------------------------------------------------------------
@@ -381,11 +370,10 @@ export async function updateFlow(
381
370
  return;
382
371
  }
383
372
 
384
- const writeTarget = await selectWriteTarget(ctx);
385
- if (!writeTarget) return;
373
+ const targetFile = selectedWriteTarget();
386
374
  const ok = await ctx.ui.confirm(
387
375
  `Update ${existing.id}?`,
388
- `Write ${[...changes.keys()].join(", ")} to "${providerName}" in ${writeTarget}?`,
376
+ `Write ${[...changes.keys()].join(", ")} to "${providerName}" in ${targetFile}?`,
389
377
  );
390
378
  if (!ok) {
391
379
  ctx.ui.notify("Cancelled. Nothing updated.", "info");
@@ -393,7 +381,7 @@ export async function updateFlow(
393
381
  }
394
382
 
395
383
  const freshEffective = loadConfig(ctx);
396
- const freshTarget = loadConfigFile(ctx, writeTarget, writeTarget === "models.local.json");
384
+ const freshTarget = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
397
385
  if (!freshEffective || !freshTarget) return;
398
386
  const modelEntry = completeModel(freshEffective, providerName, existing.id);
399
387
  if (!modelEntry) {
@@ -403,9 +391,9 @@ export async function updateFlow(
403
391
  Object.assign(modelEntry, Object.fromEntries(changes));
404
392
  ensureProviderSettings(freshTarget, freshEffective, providerName);
405
393
  upsertModel(freshTarget, providerName, modelEntry);
406
- saveConfig(freshTarget, writeTarget);
394
+ saveConfig(freshTarget, targetFile);
407
395
  ctx.ui.notify(
408
- `Updated "${existing.id}" in ${providerName} via ${writeTarget}: ${[...changes.entries()]
396
+ `Updated "${existing.id}" in ${providerName} via ${targetFile}: ${[...changes.entries()]
409
397
  .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join("+") : String(v)}`)
410
398
  .join(", ")}.`,
411
399
  "info",
@@ -444,60 +432,66 @@ export async function deleteFlow(
444
432
  ({ provider, id } = map.get(label)!);
445
433
  }
446
434
 
435
+ const settings = loadSettings();
436
+ const targetFile = writeTarget(settings);
447
437
  const base = loadConfigFile(ctx, "models.json");
448
- const local = loadConfigFile(ctx, "models.local.json", true);
449
- if (!base || !local) return;
450
-
451
- const candidates: ConfigTarget[] = [];
452
- if (configContainsModel(local, provider, id)) candidates.push("models.local.json");
453
- if (configContainsModel(base, provider, id)) candidates.push("models.json");
454
- if (candidates.length === 0) {
455
- ctx.ui.notify(`Model "${id}" is no longer present in either config file.`, "warning");
456
- return;
457
- }
438
+ if (!base) return;
439
+
440
+ if (settings.writeMode === "overlay") {
441
+ const overlay = loadConfigFile(ctx, "models.bms.json", true);
442
+ if (!overlay) return;
443
+ const inherited = configContainsModel(base, provider, id);
444
+ const local = configContainsModel(overlay, provider, id);
445
+ if (!inherited && !local) {
446
+ ctx.ui.notify(`Model "${id}" is no longer present in either config file.`, "warning");
447
+ return;
448
+ }
458
449
 
459
- if (candidates.length === 1 && candidates[0] === "models.json") {
460
450
  const ok = await ctx.ui.confirm(
461
- `Hide inherited model ${id}?`,
462
- "Keep models.json unchanged and add a BMS tombstone to models.local.json?",
451
+ `${inherited && !local ? "Hide inherited model" : "Delete"} ${id}?`,
452
+ inherited && !local
453
+ ? "Keep models.json unchanged and add a BMS tombstone to models.bms.json?"
454
+ : `Remove it from "${provider}" in models.bms.json?`,
463
455
  );
464
456
  if (!ok) {
465
457
  ctx.ui.notify("Cancelled. Nothing deleted.", "info");
466
458
  return;
467
459
  }
468
460
 
469
- const freshLocal = loadConfigFile(ctx, "models.local.json", true);
470
- if (!freshLocal) return;
471
- tombstoneModel(freshLocal, provider, id);
472
- saveConfig(freshLocal, "models.local.json");
473
- ctx.ui.notify(`Hidden inherited model "${id}" via models.local.json.`, "info");
461
+ if (inherited) tombstoneModel(overlay, provider, id);
462
+ else {
463
+ const target = overlay.providers?.[provider];
464
+ if (target?.models) target.models = target.models.filter((model) => model.id !== id);
465
+ }
466
+ saveConfig(overlay, "models.bms.json");
467
+ ctx.ui.notify(
468
+ inherited
469
+ ? `Hidden inherited model "${id}" via models.bms.json.`
470
+ : `Deleted "${id}" from ${provider} in models.bms.json.`,
471
+ "info",
472
+ );
474
473
  return;
475
474
  }
476
475
 
477
- let writeTarget: ConfigTarget;
478
- if (candidates.length === 1) {
479
- writeTarget = candidates[0]!;
480
- } else {
481
- const selected = await selectWriteTarget(ctx, "Remove the model from which file?");
482
- if (!selected) return;
483
- writeTarget = selected;
476
+ if (!configContainsModel(base, provider, id)) {
477
+ ctx.ui.notify(`Model "${id}" is no longer present in models.json.`, "warning");
478
+ return;
484
479
  }
485
-
486
480
  const ok = await ctx.ui.confirm(
487
481
  `Delete ${id}?`,
488
- `Remove it from "${provider}" in ${writeTarget}?`,
482
+ `Remove it from "${provider}" in ${targetFile}?`,
489
483
  );
490
484
  if (!ok) {
491
485
  ctx.ui.notify("Cancelled. Nothing deleted.", "info");
492
486
  return;
493
487
  }
494
488
 
495
- const fresh = loadConfigFile(ctx, writeTarget, writeTarget === "models.local.json");
489
+ const fresh = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
496
490
  if (!fresh) return;
497
491
  const target = fresh.providers?.[provider];
498
492
  if (target?.models) {
499
493
  target.models = target.models.filter((m) => m.id !== id);
500
- saveConfig(fresh, writeTarget);
501
- ctx.ui.notify(`Deleted "${id}" from ${provider} in ${writeTarget}.`, "info");
494
+ saveConfig(fresh, targetFile);
495
+ ctx.ui.notify(`Deleted "${id}" from ${provider} in ${targetFile}.`, "info");
502
496
  }
503
497
  }
@@ -22,6 +22,7 @@ export type PickerAction =
22
22
  | { kind: "add" }
23
23
  | { kind: "update"; provider?: string; modelId?: string }
24
24
  | { kind: "delete"; provider?: string; modelId?: string }
25
+ | { kind: "options" }
25
26
  | { kind: "reset" }
26
27
  | { kind: "close" };
27
28
 
@@ -39,10 +40,15 @@ export interface RankedModel {
39
40
  * Rank models by usage: most-selected first, ties broken by most-recent, then
40
41
  * alphabetically so the order is stable for never-used models.
41
42
  */
42
- export function rankModels(config: ModelsConfig): RankedModel[] {
43
+ export function rankModels(
44
+ config: ModelsConfig,
45
+ disabledProviders: readonly string[] = [],
46
+ ): RankedModel[] {
43
47
  const stats = loadUsage();
48
+ const disabled = new Set(disabledProviders);
44
49
  const rows: RankedModel[] = [];
45
50
  for (const [provider, entry] of Object.entries(config.providers ?? {})) {
51
+ if (disabled.has(provider)) continue;
46
52
  for (const m of entry.models ?? []) {
47
53
  const key = usageKey(provider, m.id);
48
54
  rows.push({
@@ -103,7 +109,7 @@ export function selectListTheme(theme: any): SelectListTheme {
103
109
  }
104
110
 
105
111
  const HINT =
106
- "↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+r reset stats • esc close";
112
+ "↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+o options • ctrl+r reset • esc close";
107
113
 
108
114
  export class ModelPicker extends Container implements Focusable {
109
115
  private search: Input;
@@ -229,6 +235,7 @@ export class ModelPicker extends Container implements Focusable {
229
235
  handleInput(data: string): void {
230
236
  if (matchesKey(data, "escape")) return this.done({ kind: "close" });
231
237
  if (matchesKey(data, "ctrl+a")) return this.done({ kind: "add" });
238
+ if (matchesKey(data, "ctrl+o")) return this.done({ kind: "options" });
232
239
  if (matchesKey(data, "ctrl+r")) return this.done({ kind: "reset" });
233
240
 
234
241
  if (matchesKey(data, "ctrl+u")) {
@@ -1,19 +1,34 @@
1
1
  // Model configuration access + usage statistics store.
2
2
  //
3
- // Pi reads models.json itself. Better Model Select also supports an optional
4
- // models.local.json overlay for personal edits. The extension merges and
5
- // registers that overlay at runtime, so the local file remains private to BMS.
3
+ // Pi reads models.json itself. Better Model Select can also use a BMS-owned
4
+ // models.bms.json overlay. The extension merges and registers that overlay at
5
+ // runtime, so Pi never needs to understand the extra file.
6
6
 
7
7
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
8
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
- export type ConfigTarget = "models.json" | "models.local.json";
11
+ export type ConfigTarget = "models.json" | "models.bms.json";
12
+ export type WriteMode = "overlay" | "direct";
12
13
 
13
14
  export const MODELS_PATH = (): string => join(getAgentDir(), "models.json");
14
- export const LOCAL_MODELS_PATH = (): string => join(getAgentDir(), "models.local.json");
15
+ export const BMS_MODELS_PATH = (): string => join(getAgentDir(), "models.bms.json");
16
+ export const LEGACY_LOCAL_MODELS_PATH = (): string => join(getAgentDir(), "models.local.json");
17
+ export const SETTINGS_PATH = (): string => join(getAgentDir(), "bms-settings.json");
15
18
  export const USAGE_PATH = (): string => join(getAgentDir(), "bms-usage.json");
16
19
 
20
+ export interface BmsSettings {
21
+ version: 1;
22
+ writeMode: WriteMode;
23
+ disabledProviders: string[];
24
+ }
25
+
26
+ const DEFAULT_SETTINGS: BmsSettings = {
27
+ version: 1,
28
+ writeMode: "overlay",
29
+ disabledProviders: [],
30
+ };
31
+
17
32
  export interface ModelEntry {
18
33
  id: string;
19
34
  /** BMS overlay tombstone. Hides an inherited model without editing models.json. */
@@ -41,7 +56,37 @@ export interface ModelsConfig {
41
56
  }
42
57
 
43
58
  export function configPath(target: ConfigTarget): string {
44
- return target === "models.local.json" ? LOCAL_MODELS_PATH() : MODELS_PATH();
59
+ return target === "models.bms.json" ? BMS_MODELS_PATH() : MODELS_PATH();
60
+ }
61
+
62
+ /** Move the old BMS overlay name to the explicit BMS-owned filename. */
63
+ export function migrateLegacyOverlay(): void {
64
+ if (!existsSync(BMS_MODELS_PATH()) && existsSync(LEGACY_LOCAL_MODELS_PATH())) {
65
+ renameSync(LEGACY_LOCAL_MODELS_PATH(), BMS_MODELS_PATH());
66
+ }
67
+ }
68
+
69
+ export function loadSettings(): BmsSettings {
70
+ try {
71
+ const raw = JSON.parse(readFileSync(SETTINGS_PATH(), "utf8")) as Partial<BmsSettings>;
72
+ return {
73
+ version: 1,
74
+ writeMode: raw.writeMode === "direct" ? "direct" : "overlay",
75
+ disabledProviders: Array.isArray(raw.disabledProviders)
76
+ ? raw.disabledProviders.filter((value): value is string => typeof value === "string")
77
+ : [],
78
+ };
79
+ } catch {
80
+ return structuredClone(DEFAULT_SETTINGS);
81
+ }
82
+ }
83
+
84
+ export function saveSettings(settings: BmsSettings): void {
85
+ writeJsonAtomic(SETTINGS_PATH(), settings);
86
+ }
87
+
88
+ export function writeTarget(settings = loadSettings()): ConfigTarget {
89
+ return settings.writeMode === "direct" ? "models.json" : "models.bms.json";
45
90
  }
46
91
 
47
92
  /** Write JSON atomically so a crash mid-write cannot truncate the target file. */
@@ -60,6 +105,7 @@ export function loadConfigFile(
60
105
  target: ConfigTarget,
61
106
  missingIsEmpty = false,
62
107
  ): ModelsConfig | null {
108
+ if (target === "models.bms.json") migrateLegacyOverlay();
63
109
  const path = configPath(target);
64
110
  if (missingIsEmpty && !existsSync(path)) return { providers: {} };
65
111
  try {
@@ -70,7 +116,7 @@ export function loadConfigFile(
70
116
  }
71
117
  }
72
118
 
73
- /** Merge models.local.json over models.json. Model entries merge by id. */
119
+ /** Merge models.bms.json over models.json. Model entries merge by id. */
74
120
  export function mergeConfigs(base: ModelsConfig, local: ModelsConfig): ModelsConfig {
75
121
  const merged = structuredClone(base);
76
122
  merged.providers = merged.providers ?? {};
@@ -102,9 +148,11 @@ export function loadConfig(ctx: {
102
148
  }): ModelsConfig | null {
103
149
  const base = loadConfigFile(ctx, "models.json");
104
150
  if (!base) return null;
105
- if (!existsSync(LOCAL_MODELS_PATH())) return base;
106
- const local = loadConfigFile(ctx, "models.local.json");
107
- return local ? mergeConfigs(base, local) : null;
151
+ if (loadSettings().writeMode === "direct") return base;
152
+ migrateLegacyOverlay();
153
+ if (!existsSync(BMS_MODELS_PATH())) return base;
154
+ const overlay = loadConfigFile(ctx, "models.bms.json");
155
+ return overlay ? mergeConfigs(base, overlay) : null;
108
156
  }
109
157
 
110
158
  export function saveConfig(config: ModelsConfig, target: ConfigTarget): void {
@@ -119,7 +167,7 @@ export function configContainsModel(
119
167
  return Boolean(config.providers?.[provider]?.models?.some((model) => model.id === modelId));
120
168
  }
121
169
 
122
- /** Hide an inherited model through the BMS local overlay. */
170
+ /** Hide an inherited model through the BMS overlay. */
123
171
  export function tombstoneModel(config: ModelsConfig, provider: string, modelId: string): void {
124
172
  upsertModel(config, provider, { id: modelId, bmsDeleted: true });
125
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-model-select",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
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.",
6
6
  "keywords": [