@xynogen/pix-models 0.2.7 → 0.3.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/LICENSE +21 -0
- package/README.md +9 -8
- package/package.json +4 -4
- package/src/models.ts +94 -25
- 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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-models",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pi extension — enhanced /models picker with BenchLM ranks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -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.29.0",
|
|
48
|
+
"@xynogen/pix-runtime": "^0.12.2"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/models.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
SelectList,
|
|
19
19
|
visibleWidth,
|
|
20
20
|
} from "@earendil-works/pi-tui";
|
|
21
|
-
import { benchScoreColor, lookupBenchmark, lookupModelsDev } from "@xynogen/pix-data";
|
|
21
|
+
import { benchScoreColor, formatCost, lookupBenchmark, lookupModelsDev } from "@xynogen/pix-data";
|
|
22
22
|
import { icon } from "@xynogen/pix-pretty/icon-catalog";
|
|
23
23
|
import {
|
|
24
24
|
frameModal,
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { dotJoin } from "@xynogen/pix-pretty/utils";
|
|
32
32
|
import { SPINNER } from "@xynogen/pix-pretty/widget-format";
|
|
33
33
|
import { patchOutBuiltinModelCommand } from "./patch-builtin";
|
|
34
|
+
import { registerProjectPrefs } from "./project-prefs";
|
|
34
35
|
|
|
35
36
|
// ─── Pure logic (exported for tests) ─────────────────────────────────────────
|
|
36
37
|
|
|
@@ -57,12 +58,10 @@ export function fmtCtx(n: number): string {
|
|
|
57
58
|
return `${Math.round(n / 1_000)}k`;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** Table cell: pix-data formatCost, or an em dash when the price is unknown. */
|
|
60
62
|
export function fmtCost(entry: { cost?: { input?: number; output?: number } } | undefined): string {
|
|
61
63
|
if (!entry?.cost) return "\u2014";
|
|
62
|
-
|
|
63
|
-
const o = entry.cost.output ?? 0;
|
|
64
|
-
if (i === 0 && o === 0) return "free";
|
|
65
|
-
return `${i.toFixed(2)}/${o.toFixed(2)}`;
|
|
64
|
+
return formatCost(entry.cost.input ?? 0, entry.cost.output ?? 0) ?? "\u2014";
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
export function benchStars(score: number | null | undefined): {
|
|
@@ -111,6 +110,16 @@ export function sortModels<T extends SortableModel>(models: T[]): T[] {
|
|
|
111
110
|
});
|
|
112
111
|
}
|
|
113
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
|
+
|
|
114
123
|
/** Lowercase and strip all non-alphanumerics: "glm-5.2" → "glm52". */
|
|
115
124
|
export function normalizeModelText(s: string): string {
|
|
116
125
|
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
@@ -236,9 +245,23 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
236
245
|
const registry = ctx.modelRegistry as unknown as {
|
|
237
246
|
refresh?: () => void;
|
|
238
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
|
+
>;
|
|
239
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(() => [])) ?? [];
|
|
240
262
|
registry.refresh?.();
|
|
241
263
|
let available = await registry.getAvailable();
|
|
264
|
+
let classifiers = await loadClassifiers();
|
|
242
265
|
if (available.length === 0) {
|
|
243
266
|
ctx.ui.notify("No models with configured auth.", "warning");
|
|
244
267
|
return;
|
|
@@ -300,15 +323,11 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
300
323
|
theme.fg("text", key) + theme.fg("muted", ` ${action}`);
|
|
301
324
|
const guideSep = theme.fg("muted", " · ");
|
|
302
325
|
|
|
303
|
-
const buildPickerData = (sourceRows: Row[]) => {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
);
|
|
308
|
-
const maxCostWidth = Math.max(
|
|
309
|
-
...sourceRows.map((row) => fmtCost(row.dev).length),
|
|
310
|
-
"free".length,
|
|
311
|
-
);
|
|
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);
|
|
312
331
|
const rankByValue = new Map<string, number>();
|
|
313
332
|
const searchTextByValue = new Map<string, string>();
|
|
314
333
|
const normalizedByValue = new Map<string, string>();
|
|
@@ -320,13 +339,25 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
320
339
|
normalizedByValue.set(value, normalizeModelText(text));
|
|
321
340
|
}
|
|
322
341
|
|
|
323
|
-
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 => {
|
|
324
359
|
const isCurrent = current && m.provider === current.provider && m.id === current.id;
|
|
325
360
|
const marker = isCurrent ? theme.fg(accent, "▶") : " ";
|
|
326
|
-
const rankPrefix = localRank
|
|
327
|
-
? mute("#") +
|
|
328
|
-
theme.fg(benchScoreColor(bench?.overallScore), String(localRank).padEnd(maxRankWidth))
|
|
329
|
-
: mute("#") + mute("—".padEnd(maxRankWidth, " "));
|
|
330
361
|
const nameColor = bench ? benchScoreColor(bench.overallScore) : accent;
|
|
331
362
|
const label = `${marker} ${rankPrefix} ${theme.fg(nameColor, m.id)}`;
|
|
332
363
|
const ctxRaw = fmtCtx(
|
|
@@ -348,10 +379,34 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
348
379
|
benchSeg = `⚡${theme.fg(scoreColor, String(score))} ${stars}`;
|
|
349
380
|
}
|
|
350
381
|
return {
|
|
351
|
-
value
|
|
382
|
+
value,
|
|
352
383
|
label,
|
|
353
|
-
description: dotJoin(
|
|
384
|
+
description: dotJoin(
|
|
385
|
+
[mute(ctxRaw.padStart(4)), costSeg, [benchSeg, kindMark].filter(Boolean).join(" ")],
|
|
386
|
+
mute,
|
|
387
|
+
),
|
|
354
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));
|
|
355
410
|
});
|
|
356
411
|
return {
|
|
357
412
|
items,
|
|
@@ -362,7 +417,7 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
362
417
|
};
|
|
363
418
|
};
|
|
364
419
|
|
|
365
|
-
let pickerData = buildPickerData(buildRows(available));
|
|
420
|
+
let pickerData = buildPickerData(buildRows(available), classifiers);
|
|
366
421
|
const currentIdx = current
|
|
367
422
|
? pickerData.items.findIndex((item) => item.value === `${current.provider}/${current.id}`)
|
|
368
423
|
: 0;
|
|
@@ -427,7 +482,8 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
427
482
|
return;
|
|
428
483
|
}
|
|
429
484
|
available = refreshed;
|
|
430
|
-
|
|
485
|
+
classifiers = await loadClassifiers();
|
|
486
|
+
pickerData = buildPickerData(buildRows(available), classifiers);
|
|
431
487
|
listInternal.items = pickerData.items;
|
|
432
488
|
listInternal.maxVisible = Math.max(1, pickerData.items.length);
|
|
433
489
|
listInternal.layout = {
|
|
@@ -565,6 +621,15 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
565
621
|
|
|
566
622
|
if (!result) return;
|
|
567
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
|
+
|
|
568
633
|
// Apply selection
|
|
569
634
|
const [provider, ...rest] = result.split("/");
|
|
570
635
|
const id = rest.join("/");
|
|
@@ -574,8 +639,11 @@ async function showEnhancedPicker(pi: ExtensionAPI, ctx: ExtensionContext): Prom
|
|
|
574
639
|
return;
|
|
575
640
|
}
|
|
576
641
|
const ok = await pi.setModel(picked);
|
|
577
|
-
if (ok)
|
|
578
|
-
|
|
642
|
+
if (!ok) {
|
|
643
|
+
ctx.ui.notify(`Failed to switch to ${picked.id}`, "error");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// Persistence happens in project-prefs.ts via the model_select event.
|
|
579
647
|
}
|
|
580
648
|
|
|
581
649
|
export default function modelPickerExtension(pi: ExtensionAPI) {
|
|
@@ -586,6 +654,7 @@ export default function modelPickerExtension(pi: ExtensionAPI) {
|
|
|
586
654
|
const handler = async (_args: unknown, ctx: ExtensionContext) => {
|
|
587
655
|
await showEnhancedPicker(pi, ctx);
|
|
588
656
|
};
|
|
657
|
+
registerProjectPrefs(pi);
|
|
589
658
|
pi.registerCommand("models", {
|
|
590
659
|
description: "Enhanced model picker — shows benchlm rank + score",
|
|
591
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
|
+
}
|