pi-better-model-select 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -84
- package/docs/configuration.md +105 -0
- package/docs/development.md +104 -0
- package/extensions/bms.ts +112 -36
- 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 +83 -59
- package/extensions/lib/meta.ts +21 -7
- package/extensions/lib/picker-dialog.ts +21 -0
- package/extensions/lib/picker.ts +26 -3
- package/extensions/lib/store.ts +60 -12
- package/package.json +8 -2
package/extensions/lib/flows.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Add / update / delete flows against the effective model configuration.
|
|
2
2
|
//
|
|
3
|
-
// Each mutation
|
|
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,
|
|
@@ -17,9 +18,11 @@ import {
|
|
|
17
18
|
configContainsModel,
|
|
18
19
|
loadConfig,
|
|
19
20
|
loadConfigFile,
|
|
21
|
+
loadSettings,
|
|
20
22
|
saveConfig,
|
|
21
23
|
tombstoneModel,
|
|
22
24
|
upsertModel,
|
|
25
|
+
writeTarget,
|
|
23
26
|
type ConfigTarget,
|
|
24
27
|
type ModelEntry,
|
|
25
28
|
type ModelsConfig,
|
|
@@ -27,6 +30,8 @@ import {
|
|
|
27
30
|
|
|
28
31
|
// Minimal shape of the pi extension command context we rely on.
|
|
29
32
|
export interface FlowCtx {
|
|
33
|
+
cwd?: string;
|
|
34
|
+
isProjectTrusted?: () => boolean;
|
|
30
35
|
ui: {
|
|
31
36
|
notify: (message: string, level: string) => void;
|
|
32
37
|
select: (message: string, options: string[]) => Promise<string | undefined>;
|
|
@@ -37,20 +42,8 @@ export interface FlowCtx {
|
|
|
37
42
|
|
|
38
43
|
const INPUT_OPTIONS: string[][] = [["text"], ["text", "image"]];
|
|
39
44
|
|
|
40
|
-
|
|
41
|
-
|
|
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;
|
|
45
|
+
function selectedWriteTarget(): ConfigTarget {
|
|
46
|
+
return writeTarget(loadSettings());
|
|
54
47
|
}
|
|
55
48
|
|
|
56
49
|
function providerOverlay(config: ModelsConfig, providerName: string): Record<string, unknown> {
|
|
@@ -102,7 +95,7 @@ async function selectInput(
|
|
|
102
95
|
|
|
103
96
|
// ----- add -----------------------------------------------------------------
|
|
104
97
|
|
|
105
|
-
export async function addFlow(ctx: FlowCtx): Promise<void> {
|
|
98
|
+
export async function addFlow(ctx: FlowCtx, onAdded?: (provider: string, modelId: string) => Promise<void>): Promise<void> {
|
|
106
99
|
const config = loadConfig(ctx);
|
|
107
100
|
if (!config) return;
|
|
108
101
|
const providers = config.providers ?? {};
|
|
@@ -187,11 +180,13 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
|
|
|
187
180
|
if (input === undefined) return;
|
|
188
181
|
crumbs.push(`Input: ${input.join("+")}`);
|
|
189
182
|
|
|
190
|
-
const
|
|
191
|
-
if (!
|
|
183
|
+
const effortDraft = await editModelEfforts(ctx, `${providerName}/${modelId}`, reasoning, meta.effortLevels);
|
|
184
|
+
if (!effortDraft) return;
|
|
185
|
+
|
|
186
|
+
const targetFile = selectedWriteTarget();
|
|
192
187
|
const ok = await ctx.ui.confirm(
|
|
193
188
|
`${header()}Add this model to "${providerName}"?`,
|
|
194
|
-
`Write the entry to ${
|
|
189
|
+
`Write the entry to ${targetFile}?`,
|
|
195
190
|
);
|
|
196
191
|
if (!ok) {
|
|
197
192
|
ctx.ui.notify("Cancelled. Nothing written.", "info");
|
|
@@ -199,7 +194,7 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
|
|
|
199
194
|
}
|
|
200
195
|
|
|
201
196
|
const freshEffective = loadConfig(ctx);
|
|
202
|
-
const freshTarget = loadConfigFile(ctx,
|
|
197
|
+
const freshTarget = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
|
|
203
198
|
if (!freshEffective || !freshTarget) return;
|
|
204
199
|
if (!freshEffective.providers?.[providerName]) {
|
|
205
200
|
ctx.ui.notify(`Provider "${providerName}" no longer exists.`, "error");
|
|
@@ -209,6 +204,7 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
|
|
|
209
204
|
ctx.ui.notify(`Model "${modelId}" is already configured in ${providerName}.`, "warning");
|
|
210
205
|
return;
|
|
211
206
|
}
|
|
207
|
+
if (!saveModelEfforts(ctx, `${providerName}/${modelId}`, effortDraft)) return;
|
|
212
208
|
ensureProviderSettings(freshTarget, freshEffective, providerName);
|
|
213
209
|
upsertModel(freshTarget, providerName, {
|
|
214
210
|
id: modelId,
|
|
@@ -218,8 +214,9 @@ export async function addFlow(ctx: FlowCtx): Promise<void> {
|
|
|
218
214
|
contextWindow,
|
|
219
215
|
maxTokens,
|
|
220
216
|
});
|
|
221
|
-
saveConfig(freshTarget,
|
|
222
|
-
ctx.ui.notify(`Added "${modelId}" to ${providerName} in ${
|
|
217
|
+
saveConfig(freshTarget, targetFile);
|
|
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);
|
|
223
220
|
}
|
|
224
221
|
|
|
225
222
|
// ----- update --------------------------------------------------------------
|
|
@@ -287,7 +284,20 @@ export async function updateFlow(
|
|
|
287
284
|
);
|
|
288
285
|
}
|
|
289
286
|
|
|
287
|
+
let effortDraft: EffortDraft | undefined;
|
|
290
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
|
+
},
|
|
291
301
|
{
|
|
292
302
|
key: "name",
|
|
293
303
|
label: "Display name",
|
|
@@ -374,6 +384,10 @@ export async function updateFlow(
|
|
|
374
384
|
const value = await field.edit(() => `Model: ${existing.id} [${providerName}]\n`);
|
|
375
385
|
if (value === undefined) continue; // ESC in the field editor — back to list
|
|
376
386
|
changes.set(field.key, value);
|
|
387
|
+
if (field.key === "reasoning") {
|
|
388
|
+
effortDraft = undefined;
|
|
389
|
+
changes.delete("bmsEfforts");
|
|
390
|
+
}
|
|
377
391
|
}
|
|
378
392
|
|
|
379
393
|
if (changes.size === 0) {
|
|
@@ -381,11 +395,10 @@ export async function updateFlow(
|
|
|
381
395
|
return;
|
|
382
396
|
}
|
|
383
397
|
|
|
384
|
-
const
|
|
385
|
-
if (!writeTarget) return;
|
|
398
|
+
const targetFile = selectedWriteTarget();
|
|
386
399
|
const ok = await ctx.ui.confirm(
|
|
387
400
|
`Update ${existing.id}?`,
|
|
388
|
-
`Write ${[...changes.keys()].join(", ")} to "${providerName}" in ${
|
|
401
|
+
`Write ${[...changes.keys()].join(", ")} to "${providerName}" in ${targetFile}?`,
|
|
389
402
|
);
|
|
390
403
|
if (!ok) {
|
|
391
404
|
ctx.ui.notify("Cancelled. Nothing updated.", "info");
|
|
@@ -393,19 +406,24 @@ export async function updateFlow(
|
|
|
393
406
|
}
|
|
394
407
|
|
|
395
408
|
const freshEffective = loadConfig(ctx);
|
|
396
|
-
const freshTarget = loadConfigFile(ctx,
|
|
409
|
+
const freshTarget = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
|
|
397
410
|
if (!freshEffective || !freshTarget) return;
|
|
398
411
|
const modelEntry = completeModel(freshEffective, providerName, existing.id);
|
|
399
412
|
if (!modelEntry) {
|
|
400
413
|
ctx.ui.notify(`Model "${existing.id}" no longer exists in ${providerName}.`, "error");
|
|
401
414
|
return;
|
|
402
415
|
}
|
|
403
|
-
|
|
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")));
|
|
404
422
|
ensureProviderSettings(freshTarget, freshEffective, providerName);
|
|
405
423
|
upsertModel(freshTarget, providerName, modelEntry);
|
|
406
|
-
saveConfig(freshTarget,
|
|
424
|
+
saveConfig(freshTarget, targetFile);
|
|
407
425
|
ctx.ui.notify(
|
|
408
|
-
`Updated "${existing.id}" in ${providerName} via ${
|
|
426
|
+
`Updated "${existing.id}" in ${providerName} via ${targetFile}: ${[...changes.entries()]
|
|
409
427
|
.map(([k, v]) => `${k}=${Array.isArray(v) ? v.join("+") : String(v)}`)
|
|
410
428
|
.join(", ")}.`,
|
|
411
429
|
"info",
|
|
@@ -444,60 +462,66 @@ export async function deleteFlow(
|
|
|
444
462
|
({ provider, id } = map.get(label)!);
|
|
445
463
|
}
|
|
446
464
|
|
|
465
|
+
const settings = loadSettings();
|
|
466
|
+
const targetFile = writeTarget(settings);
|
|
447
467
|
const base = loadConfigFile(ctx, "models.json");
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
468
|
+
if (!base) return;
|
|
469
|
+
|
|
470
|
+
if (settings.writeMode === "overlay") {
|
|
471
|
+
const overlay = loadConfigFile(ctx, "models.bms.json", true);
|
|
472
|
+
if (!overlay) return;
|
|
473
|
+
const inherited = configContainsModel(base, provider, id);
|
|
474
|
+
const local = configContainsModel(overlay, provider, id);
|
|
475
|
+
if (!inherited && !local) {
|
|
476
|
+
ctx.ui.notify(`Model "${id}" is no longer present in either config file.`, "warning");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
458
479
|
|
|
459
|
-
if (candidates.length === 1 && candidates[0] === "models.json") {
|
|
460
480
|
const ok = await ctx.ui.confirm(
|
|
461
|
-
|
|
462
|
-
|
|
481
|
+
`${inherited && !local ? "Hide inherited model" : "Delete"} ${id}?`,
|
|
482
|
+
inherited && !local
|
|
483
|
+
? "Keep models.json unchanged and add a BMS tombstone to models.bms.json?"
|
|
484
|
+
: `Remove it from "${provider}" in models.bms.json?`,
|
|
463
485
|
);
|
|
464
486
|
if (!ok) {
|
|
465
487
|
ctx.ui.notify("Cancelled. Nothing deleted.", "info");
|
|
466
488
|
return;
|
|
467
489
|
}
|
|
468
490
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
491
|
+
if (inherited) tombstoneModel(overlay, provider, id);
|
|
492
|
+
else {
|
|
493
|
+
const target = overlay.providers?.[provider];
|
|
494
|
+
if (target?.models) target.models = target.models.filter((model) => model.id !== id);
|
|
495
|
+
}
|
|
496
|
+
saveConfig(overlay, "models.bms.json");
|
|
497
|
+
ctx.ui.notify(
|
|
498
|
+
inherited
|
|
499
|
+
? `Hidden inherited model "${id}" via models.bms.json.`
|
|
500
|
+
: `Deleted "${id}" from ${provider} in models.bms.json.`,
|
|
501
|
+
"info",
|
|
502
|
+
);
|
|
474
503
|
return;
|
|
475
504
|
}
|
|
476
505
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
} else {
|
|
481
|
-
const selected = await selectWriteTarget(ctx, "Remove the model from which file?");
|
|
482
|
-
if (!selected) return;
|
|
483
|
-
writeTarget = selected;
|
|
506
|
+
if (!configContainsModel(base, provider, id)) {
|
|
507
|
+
ctx.ui.notify(`Model "${id}" is no longer present in models.json.`, "warning");
|
|
508
|
+
return;
|
|
484
509
|
}
|
|
485
|
-
|
|
486
510
|
const ok = await ctx.ui.confirm(
|
|
487
511
|
`Delete ${id}?`,
|
|
488
|
-
`Remove it from "${provider}" in ${
|
|
512
|
+
`Remove it from "${provider}" in ${targetFile}?`,
|
|
489
513
|
);
|
|
490
514
|
if (!ok) {
|
|
491
515
|
ctx.ui.notify("Cancelled. Nothing deleted.", "info");
|
|
492
516
|
return;
|
|
493
517
|
}
|
|
494
518
|
|
|
495
|
-
const fresh = loadConfigFile(ctx,
|
|
519
|
+
const fresh = loadConfigFile(ctx, targetFile, targetFile === "models.bms.json");
|
|
496
520
|
if (!fresh) return;
|
|
497
521
|
const target = fresh.providers?.[provider];
|
|
498
522
|
if (target?.models) {
|
|
499
523
|
target.models = target.models.filter((m) => m.id !== id);
|
|
500
|
-
saveConfig(fresh,
|
|
501
|
-
ctx.ui.notify(`Deleted "${id}" from ${provider} in ${
|
|
524
|
+
saveConfig(fresh, targetFile);
|
|
525
|
+
ctx.ui.notify(`Deleted "${id}" from ${provider} in ${targetFile}.`, "info");
|
|
502
526
|
}
|
|
503
527
|
}
|
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,9 +19,12 @@ 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 }
|
|
27
|
+
| { kind: "options" }
|
|
25
28
|
| { kind: "reset" }
|
|
26
29
|
| { kind: "close" };
|
|
27
30
|
|
|
@@ -33,16 +36,23 @@ export interface RankedModel {
|
|
|
33
36
|
lastUsed: number;
|
|
34
37
|
contextWindow?: number;
|
|
35
38
|
reasoning: boolean;
|
|
39
|
+
current?: boolean;
|
|
40
|
+
default?: boolean;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
44
|
* Rank models by usage: most-selected first, ties broken by most-recent, then
|
|
40
45
|
* alphabetically so the order is stable for never-used models.
|
|
41
46
|
*/
|
|
42
|
-
export function rankModels(
|
|
47
|
+
export function rankModels(
|
|
48
|
+
config: ModelsConfig,
|
|
49
|
+
disabledProviders: readonly string[] = [],
|
|
50
|
+
): RankedModel[] {
|
|
43
51
|
const stats = loadUsage();
|
|
52
|
+
const disabled = new Set(disabledProviders);
|
|
44
53
|
const rows: RankedModel[] = [];
|
|
45
54
|
for (const [provider, entry] of Object.entries(config.providers ?? {})) {
|
|
55
|
+
if (disabled.has(provider)) continue;
|
|
46
56
|
for (const m of entry.models ?? []) {
|
|
47
57
|
const key = usageKey(provider, m.id);
|
|
48
58
|
rows.push({
|
|
@@ -71,6 +81,8 @@ function formatContext(n?: number): string {
|
|
|
71
81
|
|
|
72
82
|
export function toSelectItem(row: RankedModel): SelectItem {
|
|
73
83
|
const bits: string[] = [row.modelId, `[${row.provider}]`];
|
|
84
|
+
if (row.current) bits.push("[current]");
|
|
85
|
+
if (row.default) bits.push("[default]");
|
|
74
86
|
const ctxLabel = formatContext(row.contextWindow);
|
|
75
87
|
if (ctxLabel) bits.push(`ctx ${ctxLabel}`);
|
|
76
88
|
if (row.reasoning) bits.push("reasoning");
|
|
@@ -103,7 +115,7 @@ export function selectListTheme(theme: any): SelectListTheme {
|
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
const HINT =
|
|
106
|
-
"↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+r reset
|
|
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";
|
|
107
119
|
|
|
108
120
|
export class ModelPicker extends Container implements Focusable {
|
|
109
121
|
private search: Input;
|
|
@@ -117,6 +129,7 @@ export class ModelPicker extends Container implements Focusable {
|
|
|
117
129
|
private theme: any;
|
|
118
130
|
private onChange: () => void;
|
|
119
131
|
private done: (action: PickerAction) => void;
|
|
132
|
+
private assignment: boolean;
|
|
120
133
|
|
|
121
134
|
private _focused = false;
|
|
122
135
|
get focused(): boolean {
|
|
@@ -133,11 +146,13 @@ export class ModelPicker extends Container implements Focusable {
|
|
|
133
146
|
theme: any,
|
|
134
147
|
onChange: () => void,
|
|
135
148
|
done: (action: PickerAction) => void,
|
|
149
|
+
assignment = false,
|
|
136
150
|
) {
|
|
137
151
|
super();
|
|
138
152
|
this.theme = theme;
|
|
139
153
|
this.onChange = onChange;
|
|
140
154
|
this.done = done;
|
|
155
|
+
this.assignment = assignment;
|
|
141
156
|
this.rows = rows;
|
|
142
157
|
this.filtered = rows;
|
|
143
158
|
|
|
@@ -157,7 +172,7 @@ export class ModelPicker extends Container implements Focusable {
|
|
|
157
172
|
this.list = this.buildList(rows);
|
|
158
173
|
|
|
159
174
|
this.countText = new Text(fg("dim", this.countLabel()), 1, 0);
|
|
160
|
-
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);
|
|
161
176
|
|
|
162
177
|
this.addChild(this.search);
|
|
163
178
|
this.addChild(this.list);
|
|
@@ -228,7 +243,15 @@ export class ModelPicker extends Container implements Focusable {
|
|
|
228
243
|
|
|
229
244
|
handleInput(data: string): void {
|
|
230
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;
|
|
231
253
|
if (matchesKey(data, "ctrl+a")) return this.done({ kind: "add" });
|
|
254
|
+
if (matchesKey(data, "ctrl+o")) return this.done({ kind: "options" });
|
|
232
255
|
if (matchesKey(data, "ctrl+r")) return this.done({ kind: "reset" });
|
|
233
256
|
|
|
234
257
|
if (matchesKey(data, "ctrl+u")) {
|
package/extensions/lib/store.ts
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
// Model configuration access + usage statistics store.
|
|
2
2
|
//
|
|
3
|
-
// Pi reads models.json itself. Better Model Select also
|
|
4
|
-
// models.
|
|
5
|
-
//
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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 ?? {};
|
|
@@ -100,11 +146,13 @@ export function mergeConfigs(base: ModelsConfig, local: ModelsConfig): ModelsCon
|
|
|
100
146
|
export function loadConfig(ctx: {
|
|
101
147
|
ui: { notify: (m: string, l: string) => void };
|
|
102
148
|
}): ModelsConfig | null {
|
|
103
|
-
const base = loadConfigFile(ctx, "models.json");
|
|
149
|
+
const base = loadConfigFile(ctx, "models.json", true);
|
|
104
150
|
if (!base) return null;
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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,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"
|