@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 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
- > Also included in [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core):
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
- Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
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.2.8",
4
- "description": "Pi extension — enhanced /models picker with BenchLM ranks",
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.0",
47
- "@xynogen/pix-pretty": "^1.11.2",
48
- "@xynogen/pix-runtime": "^0.10.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
- const i = entry.cost.input ?? 0;
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
- const maxRankWidth = Math.max(
309
- ...sourceRows.map((row) => (row.localRank ? String(row.localRank).length : 0)),
310
- 1,
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 items: SelectItem[] = sourceRows.map(({ m, dev, bench, localRank }) => {
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: `${m.provider}/${m.id}`,
382
+ value,
356
383
  label,
357
- description: dotJoin([mute(ctxRaw.padStart(4)), costSeg, benchSeg], mute),
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
- pickerData = buildPickerData(buildRows(available));
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,
@@ -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. `pi` binary via PATH → realpath → split at /dist/ to get the pkg root.
18
- * 2. Well-known global install locations (bun, npm).
19
- * 3. createRequire against the extension's own node_modules.
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. Resolve via the `pi` binary on PATH → realpath → strip at /dist/.
57
- const piReal = resolvePiBinary();
58
- if (piReal) {
59
- const idx = piReal.indexOf(`${sep}dist${sep}`);
60
- if (idx >= 0) pushRoot(piReal.slice(0, idx));
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. Well-known global install locations.
64
- const home = homedir();
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, "@earendil-works", "pi-coding-agent"));
106
+ pushRoot(join(root, ...PI_PACKAGE));
72
107
  }
73
108
 
74
- // 3. Fallback: createRequire from this file (co-installed extension).
109
+ // 5. Fallback: createRequire from this file (co-installed extension).
75
110
  try {
76
111
  const require = createRequire(import.meta.url);
77
- const entry = require.resolve("@earendil-works/pi-coding-agent");
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
+ }