@xynogen/pix-models 0.2.8 → 0.3.1
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 +21 -0
- package/README.md +9 -8
- package/package.json +5 -5
- package/src/models.ts +91 -41
- package/src/patch-builtin.ts +49 -16
- package/src/project-prefs.ts +99 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xynogen
|
|
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
CHANGED
|
@@ -10,6 +10,7 @@ Registers a `/models` slash command — a richer TUI picker replacing Pi's built
|
|
|
10
10
|
- **Sorting** — by coding score (best first), then alphabetically for unscored models. Fuzzy search filters as you type.
|
|
11
11
|
- **Thinking level** — left/right cycles `off` → `minimal` → `low` → `medium` → `high` → `xhigh`, shown live in the header.
|
|
12
12
|
- **Select** — switches the active model for the session.
|
|
13
|
+
- **Per-project prefs** — every model / thinking change is saved with `defaultProvider`, `defaultModel`, `defaultThinkingLevel` in one file: `<project>/.pi/settings.json` for trusted projects (created on first change), otherwise `~/.pi/agent/settings.json`. Pi merges the project file over the user file, so other projects fall back to user defaults. Each save shows a short notice with the target file.
|
|
13
14
|
|
|
14
15
|
Model metadata comes from `~/.cache/pi/` via `pix-data`; the coding score/rank is computed locally from the modelgrep catalog (best = #1).
|
|
15
16
|
|
|
@@ -19,22 +20,22 @@ Model metadata comes from `~/.cache/pi/` via `pix-data`; the coding score/rank i
|
|
|
19
20
|
pi install npm:@xynogen/pix-models
|
|
20
21
|
```
|
|
21
22
|
|
|
22
|
-
>
|
|
23
|
-
>
|
|
24
|
-
> ```bash
|
|
25
|
-
> pi install npm:@xynogen/pix-core
|
|
26
|
-
> ```
|
|
23
|
+
> Bundled in [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core). Install it alone only if you do not use pix-core.
|
|
27
24
|
|
|
28
25
|
## Full distro
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
To install the complete pix suite (all packages + Pi itself):
|
|
27
|
+
This package is part of [Pix](https://github.com/xynogen/pix-mono). The installer sets up Pi and the full distro. See [Install](https://github.com/xynogen/pix-mono#install) for the notes for each OS.
|
|
33
28
|
|
|
34
29
|
```bash
|
|
30
|
+
# Linux / macOS
|
|
35
31
|
curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
|
|
36
32
|
```
|
|
37
33
|
|
|
34
|
+
```powershell
|
|
35
|
+
# Windows
|
|
36
|
+
irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
|
|
37
|
+
```
|
|
38
|
+
|
|
38
39
|
## License
|
|
39
40
|
|
|
40
41
|
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-models",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Pi extension \u2014 enhanced /models picker with BenchLM ranks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"exports": {
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@xynogen/pix-data": "^0.5.
|
|
47
|
-
"@xynogen/pix-pretty": "^1.
|
|
48
|
-
"@xynogen/pix-runtime": "^0.
|
|
46
|
+
"@xynogen/pix-data": "^0.5.3",
|
|
47
|
+
"@xynogen/pix-pretty": "^1.30.0",
|
|
48
|
+
"@xynogen/pix-runtime": "^0.13.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/models.ts
CHANGED
|
@@ -8,11 +8,7 @@
|
|
|
8
8
|
* Sorted by benchlm rank when available (best first), then alphabetical.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
type ExtensionAPI,
|
|
13
|
-
type ExtensionContext,
|
|
14
|
-
SettingsManager,
|
|
15
|
-
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
12
|
import {
|
|
17
13
|
fuzzyFilter,
|
|
18
14
|
Input,
|
|
@@ -22,7 +18,7 @@ import {
|
|
|
22
18
|
SelectList,
|
|
23
19
|
visibleWidth,
|
|
24
20
|
} from "@earendil-works/pi-tui";
|
|
25
|
-
import { benchScoreColor, lookupBenchmark, lookupModelsDev } from "@xynogen/pix-data";
|
|
21
|
+
import { benchScoreColor, formatCost, lookupBenchmark, lookupModelsDev } from "@xynogen/pix-data";
|
|
26
22
|
import { icon } from "@xynogen/pix-pretty/icon-catalog";
|
|
27
23
|
import {
|
|
28
24
|
frameModal,
|
|
@@ -35,6 +31,7 @@ import {
|
|
|
35
31
|
import { dotJoin } from "@xynogen/pix-pretty/utils";
|
|
36
32
|
import { SPINNER } from "@xynogen/pix-pretty/widget-format";
|
|
37
33
|
import { patchOutBuiltinModelCommand } from "./patch-builtin";
|
|
34
|
+
import { registerProjectPrefs } from "./project-prefs";
|
|
38
35
|
|
|
39
36
|
// ─── Pure logic (exported for tests) ─────────────────────────────────────────
|
|
40
37
|
|
|
@@ -61,12 +58,10 @@ export function fmtCtx(n: number): string {
|
|
|
61
58
|
return `${Math.round(n / 1_000)}k`;
|
|
62
59
|
}
|
|
63
60
|
|
|
61
|
+
/** Table cell: pix-data formatCost, or an em dash when the price is unknown. */
|
|
64
62
|
export function fmtCost(entry: { cost?: { input?: number; output?: number } } | undefined): string {
|
|
65
63
|
if (!entry?.cost) return "\u2014";
|
|
66
|
-
|
|
67
|
-
const o = entry.cost.output ?? 0;
|
|
68
|
-
if (i === 0 && o === 0) return "free";
|
|
69
|
-
return `${i.toFixed(2)}/${o.toFixed(2)}`;
|
|
64
|
+
return formatCost(entry.cost.input ?? 0, entry.cost.output ?? 0) ?? "\u2014";
|
|
70
65
|
}
|
|
71
66
|
|
|
72
67
|
export function benchStars(score: number | null | undefined): {
|
|
@@ -115,6 +110,16 @@ export function sortModels<T extends SortableModel>(models: T[]): T[] {
|
|
|
115
110
|
});
|
|
116
111
|
}
|
|
117
112
|
|
|
113
|
+
/** Short protocol tag for a classifier model's api. */
|
|
114
|
+
export function classifierProtocol(api: string): string {
|
|
115
|
+
if (api.endsWith("system-one")) return "System One";
|
|
116
|
+
if (api === "llama-cpp-classify") return "llama.cpp classify";
|
|
117
|
+
return api;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Picker value prefix for classifier rows. They cannot be chat models. */
|
|
121
|
+
export const CLASSIFIER_VALUE_PREFIX = "classifier:";
|
|
122
|
+
|
|
118
123
|
/** Lowercase and strip all non-alphanumerics: "glm-5.2" → "glm52". */
|
|
119
124
|
export function normalizeModelText(s: string): string {
|
|
120
125
|
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
@@ -240,9 +245,23 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
240
245
|
const registry = ctx.modelRegistry as unknown as {
|
|
241
246
|
refresh?: () => void;
|
|
242
247
|
getAvailable(): AvailableModels | Promise<AvailableModels>;
|
|
248
|
+
getAvailableOfType?: (type: "classifier") => Promise<
|
|
249
|
+
readonly {
|
|
250
|
+
provider: string;
|
|
251
|
+
id: string;
|
|
252
|
+
name?: string;
|
|
253
|
+
api: string;
|
|
254
|
+
contextWindow?: number;
|
|
255
|
+
}[]
|
|
256
|
+
>;
|
|
243
257
|
};
|
|
258
|
+
// Classifier models (System One etc.) answer typed questions, not chat.
|
|
259
|
+
// Show them in their own section so they are visible but never picked as the chat model.
|
|
260
|
+
const loadClassifiers = async () =>
|
|
261
|
+
(await registry.getAvailableOfType?.("classifier").catch(() => [])) ?? [];
|
|
244
262
|
registry.refresh?.();
|
|
245
263
|
let available = await registry.getAvailable();
|
|
264
|
+
let classifiers = await loadClassifiers();
|
|
246
265
|
if (available.length === 0) {
|
|
247
266
|
ctx.ui.notify("No models with configured auth.", "warning");
|
|
248
267
|
return;
|
|
@@ -304,15 +323,11 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
304
323
|
theme.fg("text", key) + theme.fg("muted", ` ${action}`);
|
|
305
324
|
const guideSep = theme.fg("muted", " · ");
|
|
306
325
|
|
|
307
|
-
const buildPickerData = (sourceRows: Row[]) => {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
);
|
|
312
|
-
const maxCostWidth = Math.max(
|
|
313
|
-
...sourceRows.map((row) => fmtCost(row.dev).length),
|
|
314
|
-
"free".length,
|
|
315
|
-
);
|
|
326
|
+
const buildPickerData = (sourceRows: Row[], classifierModels: typeof classifiers) => {
|
|
327
|
+
// Classifier rows continue the numbering after the last ranked chat row.
|
|
328
|
+
const firstClassifierRank = Math.max(0, ...sourceRows.map((row) => row.localRank ?? 0)) + 1;
|
|
329
|
+
const lastRank = firstClassifierRank - 1 + classifierModels.length;
|
|
330
|
+
const maxRankWidth = Math.max(String(lastRank).length, 1);
|
|
316
331
|
const rankByValue = new Map<string, number>();
|
|
317
332
|
const searchTextByValue = new Map<string, string>();
|
|
318
333
|
const normalizedByValue = new Map<string, string>();
|
|
@@ -324,13 +339,25 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
324
339
|
normalizedByValue.set(value, normalizeModelText(text));
|
|
325
340
|
}
|
|
326
341
|
|
|
327
|
-
const
|
|
342
|
+
const classifierRows = classifierModels.map((c) => ({
|
|
343
|
+
m: c,
|
|
344
|
+
dev: lookupModelsDev(c.provider, c.id),
|
|
345
|
+
bench: lookupBenchmark(c.id),
|
|
346
|
+
}));
|
|
347
|
+
const maxCostWidth = Math.max(
|
|
348
|
+
...[...sourceRows, ...classifierRows].map((row) => fmtCost(row.dev).length),
|
|
349
|
+
"free".length,
|
|
350
|
+
);
|
|
351
|
+
const renderRow = (
|
|
352
|
+
m: { provider: string; id: string; contextWindow?: number },
|
|
353
|
+
dev: Row["dev"],
|
|
354
|
+
bench: Row["bench"],
|
|
355
|
+
rankPrefix: string,
|
|
356
|
+
value: string,
|
|
357
|
+
kindMark?: string,
|
|
358
|
+
): SelectItem => {
|
|
328
359
|
const isCurrent = current && m.provider === current.provider && m.id === current.id;
|
|
329
360
|
const marker = isCurrent ? theme.fg(accent, "▶") : " ";
|
|
330
|
-
const rankPrefix = localRank
|
|
331
|
-
? mute("#") +
|
|
332
|
-
theme.fg(benchScoreColor(bench?.overallScore), String(localRank).padEnd(maxRankWidth))
|
|
333
|
-
: mute("#") + mute("—".padEnd(maxRankWidth, " "));
|
|
334
361
|
const nameColor = bench ? benchScoreColor(bench.overallScore) : accent;
|
|
335
362
|
const label = `${marker} ${rankPrefix} ${theme.fg(nameColor, m.id)}`;
|
|
336
363
|
const ctxRaw = fmtCtx(
|
|
@@ -352,10 +379,34 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
352
379
|
benchSeg = `⚡${theme.fg(scoreColor, String(score))} ${stars}`;
|
|
353
380
|
}
|
|
354
381
|
return {
|
|
355
|
-
value
|
|
382
|
+
value,
|
|
356
383
|
label,
|
|
357
|
-
description: dotJoin(
|
|
384
|
+
description: dotJoin(
|
|
385
|
+
[mute(ctxRaw.padStart(4)), costSeg, [benchSeg, kindMark].filter(Boolean).join(" ")],
|
|
386
|
+
mute,
|
|
387
|
+
),
|
|
358
388
|
};
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const items: SelectItem[] = sourceRows.map(({ m, dev, bench, localRank }) => {
|
|
392
|
+
const rankPrefix = localRank
|
|
393
|
+
? mute("#") +
|
|
394
|
+
theme.fg(benchScoreColor(bench?.overallScore), String(localRank).padEnd(maxRankWidth))
|
|
395
|
+
: mute("#") + mute("—".padEnd(maxRankWidth, " "));
|
|
396
|
+
return renderRow(m, dev, bench, rankPrefix, `${m.provider}/${m.id}`);
|
|
397
|
+
});
|
|
398
|
+
// Classifier rows: same columns and numbering. The symbol takes the
|
|
399
|
+
// empty score slot at the end of the row.
|
|
400
|
+
const classifierMark = theme.fg("warning", icon("model.classifier"));
|
|
401
|
+
classifierRows.forEach(({ m: c, dev, bench }, i) => {
|
|
402
|
+
const rank = firstClassifierRank + i;
|
|
403
|
+
const value = `${CLASSIFIER_VALUE_PREFIX}${c.provider}/${c.id}`;
|
|
404
|
+
const text = `${c.id} ${c.name ?? ""} classifier ${classifierProtocol(c.api)}`;
|
|
405
|
+
rankByValue.set(value, rank);
|
|
406
|
+
searchTextByValue.set(value, text);
|
|
407
|
+
normalizedByValue.set(value, normalizeModelText(text));
|
|
408
|
+
const rankPrefix = mute("#") + mute(String(rank).padEnd(maxRankWidth));
|
|
409
|
+
items.push(renderRow(c, dev, bench, rankPrefix, value, classifierMark));
|
|
359
410
|
});
|
|
360
411
|
return {
|
|
361
412
|
items,
|
|
@@ -366,7 +417,7 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
366
417
|
};
|
|
367
418
|
};
|
|
368
419
|
|
|
369
|
-
let pickerData = buildPickerData(buildRows(available));
|
|
420
|
+
let pickerData = buildPickerData(buildRows(available), classifiers);
|
|
370
421
|
const currentIdx = current
|
|
371
422
|
? pickerData.items.findIndex((item) => item.value === `${current.provider}/${current.id}`)
|
|
372
423
|
: 0;
|
|
@@ -431,7 +482,8 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
431
482
|
return;
|
|
432
483
|
}
|
|
433
484
|
available = refreshed;
|
|
434
|
-
|
|
485
|
+
classifiers = await loadClassifiers();
|
|
486
|
+
pickerData = buildPickerData(buildRows(available), classifiers);
|
|
435
487
|
listInternal.items = pickerData.items;
|
|
436
488
|
listInternal.maxVisible = Math.max(1, pickerData.items.length);
|
|
437
489
|
listInternal.layout = {
|
|
@@ -569,6 +621,15 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
569
621
|
|
|
570
622
|
if (!result) return;
|
|
571
623
|
|
|
624
|
+
if (result.startsWith(CLASSIFIER_VALUE_PREFIX)) {
|
|
625
|
+
const ref = result.slice(CLASSIFIER_VALUE_PREFIX.length);
|
|
626
|
+
ctx.ui.notify(
|
|
627
|
+
`${ref} is a classifier model, not a chat model. Call it with models.classify() in codemode or ctx.modelRegistry.classify().`,
|
|
628
|
+
"info",
|
|
629
|
+
);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
572
633
|
// Apply selection
|
|
573
634
|
const [provider, ...rest] = result.split("/");
|
|
574
635
|
const id = rest.join("/");
|
|
@@ -582,19 +643,7 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
582
643
|
ctx.ui.notify(`Failed to switch to ${picked.id}`, "error");
|
|
583
644
|
return;
|
|
584
645
|
}
|
|
585
|
-
|
|
586
|
-
const settings = SettingsManager.create(ctx.cwd);
|
|
587
|
-
settings.setDefaultModelAndProvider(picked.provider, picked.id);
|
|
588
|
-
await settings.flush();
|
|
589
|
-
const saveError = settings.drainErrors()[0];
|
|
590
|
-
if (saveError) {
|
|
591
|
-
ctx.ui.notify(
|
|
592
|
-
`Switched to ${picked.name ?? picked.id}, but failed to save the default: ${saveError.error.message}`,
|
|
593
|
-
"warning",
|
|
594
|
-
);
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
ctx.ui.notify(`Default model: ${picked.provider}/${picked.id}`, "info");
|
|
646
|
+
// Persistence happens in project-prefs.ts via the model_select event.
|
|
598
647
|
}
|
|
599
648
|
|
|
600
649
|
export default function modelPickerExtension(pi: ExtensionAPI) {
|
|
@@ -605,6 +654,7 @@ export default function modelPickerExtension(pi: ExtensionAPI) {
|
|
|
605
654
|
const handler = async (_args: unknown, ctx: ExtensionContext) => {
|
|
606
655
|
await showEnhancedPicker(pi, ctx);
|
|
607
656
|
};
|
|
657
|
+
registerProjectPrefs(pi);
|
|
608
658
|
pi.registerCommand("models", {
|
|
609
659
|
description: "Enhanced model picker — shows benchlm rank + score",
|
|
610
660
|
handler,
|
package/src/patch-builtin.ts
CHANGED
|
@@ -14,17 +14,46 @@
|
|
|
14
14
|
* only fall back to dist/core/ for older hosts that lack a bundle dir.
|
|
15
15
|
*
|
|
16
16
|
* Package-root resolution (in order):
|
|
17
|
-
* 1.
|
|
18
|
-
* 2.
|
|
19
|
-
*
|
|
17
|
+
* 1. The running CLI script (`process.argv[1]`) → split at /dist/.
|
|
18
|
+
* 2. Pi managed install: `<PI_MANAGED_INSTALL_ROOT | agentDir/install>/
|
|
19
|
+
* releases/<current-version>/node_modules/@earendil-works/pi-coding-agent`.
|
|
20
|
+
* 3. `pi` binary via PATH → realpath → split at /dist/ to get the pkg root.
|
|
21
|
+
* 4. Well-known global install locations (bun, npm).
|
|
22
|
+
* 5. createRequire against the extension's own node_modules.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
25
|
import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
23
26
|
import { createRequire } from "node:module";
|
|
24
|
-
import { homedir } from "node:os";
|
|
25
27
|
import { join, sep } from "node:path";
|
|
28
|
+
import { agentDir, homeDir } from "@xynogen/pix-runtime/paths";
|
|
26
29
|
import { findExecutableSync } from "@xynogen/pix-runtime/which";
|
|
27
30
|
|
|
31
|
+
const PI_PACKAGE = ["@earendil-works", "pi-coding-agent"] as const;
|
|
32
|
+
|
|
33
|
+
/** Package root before `/dist/` in a path, if any. */
|
|
34
|
+
function rootBeforeDist(path: string | undefined): string | undefined {
|
|
35
|
+
if (!path) return undefined;
|
|
36
|
+
const idx = path.indexOf(`${sep}dist${sep}`);
|
|
37
|
+
return idx >= 0 ? path.slice(0, idx) : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Package root of Pi's managed install (`releases-v1` layout), or undefined
|
|
42
|
+
* when the install dir or its `current-version` file is absent.
|
|
43
|
+
*/
|
|
44
|
+
export function managedInstallRoot(
|
|
45
|
+
installRoot: string = process.env.PI_MANAGED_INSTALL_ROOT || join(agentDir(), "install"),
|
|
46
|
+
): string | undefined {
|
|
47
|
+
try {
|
|
48
|
+
const version = readFileSync(join(installRoot, "current-version"), "utf8").trim();
|
|
49
|
+
if (!/^[0-9A-Za-z._+-]+$/.test(version) || version === "." || version === "..")
|
|
50
|
+
return undefined;
|
|
51
|
+
return join(installRoot, "releases", version, "node_modules", ...PI_PACKAGE);
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
/** Locate `pi` on PATH, returning its real (symlink-resolved) path. */
|
|
29
58
|
function resolvePiBinary(): string | undefined {
|
|
30
59
|
const found = findExecutableSync("pi");
|
|
@@ -53,30 +82,34 @@ function packageRoots(): string[] {
|
|
|
53
82
|
if (r && !roots.includes(r)) roots.push(r);
|
|
54
83
|
};
|
|
55
84
|
|
|
56
|
-
// 1.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
85
|
+
// 1. The CLI script this process is running (most specific).
|
|
86
|
+
try {
|
|
87
|
+
pushRoot(rootBeforeDist(process.argv[1] ? realpathSync(process.argv[1]) : undefined));
|
|
88
|
+
} catch {
|
|
89
|
+
pushRoot(rootBeforeDist(process.argv[1]));
|
|
61
90
|
}
|
|
62
91
|
|
|
63
|
-
// 2.
|
|
64
|
-
|
|
92
|
+
// 2. Pi managed install (pi on PATH is a launcher shim there).
|
|
93
|
+
pushRoot(managedInstallRoot());
|
|
94
|
+
|
|
95
|
+
// 3. Resolve via the `pi` binary on PATH → realpath → strip at /dist/.
|
|
96
|
+
pushRoot(rootBeforeDist(resolvePiBinary()));
|
|
97
|
+
|
|
98
|
+
// 4. Well-known global install locations.
|
|
99
|
+
const home = homeDir();
|
|
65
100
|
for (const root of [
|
|
66
101
|
join(home, ".bun", "install", "global", "node_modules"),
|
|
67
102
|
join(home, ".npm-global", "lib", "node_modules"),
|
|
68
103
|
"/usr/local/lib/node_modules",
|
|
69
104
|
"/usr/lib/node_modules",
|
|
70
105
|
]) {
|
|
71
|
-
pushRoot(join(root,
|
|
106
|
+
pushRoot(join(root, ...PI_PACKAGE));
|
|
72
107
|
}
|
|
73
108
|
|
|
74
|
-
//
|
|
109
|
+
// 5. Fallback: createRequire from this file (co-installed extension).
|
|
75
110
|
try {
|
|
76
111
|
const require = createRequire(import.meta.url);
|
|
77
|
-
|
|
78
|
-
const idx = entry.indexOf(`${sep}dist${sep}`);
|
|
79
|
-
if (idx >= 0) pushRoot(entry.slice(0, idx));
|
|
112
|
+
pushRoot(rootBeforeDist(require.resolve("@earendil-works/pi-coding-agent")));
|
|
80
113
|
} catch {
|
|
81
114
|
// local resolution failed — skip
|
|
82
115
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-prefs.ts — persist model/provider/thinking per project.
|
|
3
|
+
*
|
|
4
|
+
* All three prefs live together in Pi's own project settings file
|
|
5
|
+
* (`<cwd>/.pi/settings.json`). Pi deep-merges that file over the user-level
|
|
6
|
+
* `<agentDir>/settings.json` at startup, so projects without their own prefs
|
|
7
|
+
* fall back to the user defaults with no extra loader.
|
|
8
|
+
*
|
|
9
|
+
* Untrusted projects (Pi refuses to read their settings) and a cwd whose
|
|
10
|
+
* `.pi` IS the user config root (cwd === home) write the user-level file.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { showTransientMessage } from "@xynogen/pix-pretty/transient-error";
|
|
17
|
+
import { getErrorMessage } from "@xynogen/pix-pretty/utils";
|
|
18
|
+
import { writeFileAtomicSync } from "@xynogen/pix-runtime/atomic-write";
|
|
19
|
+
import { agentDir, projectDir } from "@xynogen/pix-runtime/paths";
|
|
20
|
+
|
|
21
|
+
export type ModelPrefs = {
|
|
22
|
+
defaultProvider?: string;
|
|
23
|
+
defaultModel?: string;
|
|
24
|
+
defaultThinkingLevel?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type PrefsTarget = { path: string; scope: "project" | "user" };
|
|
28
|
+
|
|
29
|
+
/** Pick the settings file that should own this cwd's prefs. */
|
|
30
|
+
export function prefsTarget(cwd: string, trusted: boolean, agent = agentDir()): PrefsTarget {
|
|
31
|
+
const userFile = join(agent, "settings.json");
|
|
32
|
+
const project = projectDir(resolve(cwd));
|
|
33
|
+
// cwd/.pi is the parent of the agent dir → "project" file would be ~/.pi/settings.json,
|
|
34
|
+
// which is confusing and not a real project. Use the user file instead.
|
|
35
|
+
if (!trusted || resolve(project) === resolve(dirname(agent))) {
|
|
36
|
+
return { path: userFile, scope: "user" };
|
|
37
|
+
}
|
|
38
|
+
return { path: join(project, "settings.json"), scope: "project" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Merge `prefs` into the JSON settings file at `path`. Returns true if it changed. */
|
|
42
|
+
export function writePrefs(path: string, prefs: ModelPrefs): boolean {
|
|
43
|
+
let current: Record<string, unknown> = {};
|
|
44
|
+
if (existsSync(path)) {
|
|
45
|
+
const raw = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
|
|
46
|
+
if (raw.trim()) {
|
|
47
|
+
const parsed: unknown = JSON.parse(raw);
|
|
48
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
49
|
+
current = parsed as Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
let changed = false;
|
|
54
|
+
for (const [key, value] of Object.entries(prefs)) {
|
|
55
|
+
if (value !== undefined && current[key] !== value) {
|
|
56
|
+
current[key] = value;
|
|
57
|
+
changed = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (changed) writeFileAtomicSync(path, `${JSON.stringify(current, null, 2)}\n`, 0o644);
|
|
61
|
+
return changed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Save prefs for ctx.cwd and surface where they went. */
|
|
65
|
+
export function savePrefs(
|
|
66
|
+
ctx: ExtensionContext,
|
|
67
|
+
prefs: ModelPrefs,
|
|
68
|
+
label: string,
|
|
69
|
+
): PrefsTarget | undefined {
|
|
70
|
+
const target = prefsTarget(ctx.cwd, ctx.isProjectTrusted());
|
|
71
|
+
try {
|
|
72
|
+
if (writePrefs(target.path, prefs) && ctx.hasUI) {
|
|
73
|
+
const where = target.scope === "project" ? ".pi/settings.json" : "user settings.json";
|
|
74
|
+
showTransientMessage(ctx.ui, `${label} saved to ${where}`, "info");
|
|
75
|
+
}
|
|
76
|
+
return target;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (ctx.hasUI) {
|
|
79
|
+
const msg = getErrorMessage(err);
|
|
80
|
+
showTransientMessage(ctx.ui, `Failed to save ${label}: ${msg}`, "warning");
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Persist every user-driven model / thinking change to the owning settings file. */
|
|
87
|
+
export function registerProjectPrefs(pi: ExtensionAPI): void {
|
|
88
|
+
pi.on("model_select", (event, ctx) => {
|
|
89
|
+
// "restore" = session resume; not a user choice.
|
|
90
|
+
if (event.source === "restore") return;
|
|
91
|
+
const { provider, id } = event.model;
|
|
92
|
+
savePrefs(ctx, { defaultProvider: provider, defaultModel: id }, `Model ${provider}/${id}`);
|
|
93
|
+
});
|
|
94
|
+
pi.on("thinking_level_select", (event, ctx) => {
|
|
95
|
+
// Non-reasoning models force "off"; don't let that clobber the saved preference.
|
|
96
|
+
if (!ctx.model?.reasoning) return;
|
|
97
|
+
savePrefs(ctx, { defaultThinkingLevel: event.level }, `Thinking ${event.level}`);
|
|
98
|
+
});
|
|
99
|
+
}
|