bermudis-pi-goodies 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,104 +1,64 @@
1
1
  # bermudis-pi-goodies
2
2
 
3
3
  A bundle of small, frequently-used [Pi](https://github.com/earendil-works/pi)
4
- extensions. One entry point, ten independent features.
5
-
6
- | Feature | Command / hook | What it does |
7
- |---------|----------------|--------------|
8
- | `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
9
- | `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks. |
10
- | `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
11
- | `zed` | `/z` | Open Zed editor on the current working directory. |
12
- | `prefer-tools` | hook (no command) | Nudge toward modern CLIs: `rg` over `grep`, `fd` over `find`, `uv` over bare `python`/`pip`/`pytest`/`mypy`. |
13
- | `model-thinking` | hook + `/model-thinking` | Apply provider/model thinking defaults and explicitly save per-model defaults. |
14
- | `fixed-defaults` | hook + `/fixed-defaults` | Keep the global startup provider and model fixed while allowing in-session model changes; `/fixed-defaults set` pins the current model. |
15
- | `kilo` | provider | Access Kilo Gateway models via `/login kilo` or `KILO_API_KEY`. |
16
- | `provider-balance` | footer (no command) | Show remaining Kilo or OpenRouter credits, z.ai token-plan quota, or OpenAI Codex quota on the right side of the working-directory footer line. |
17
- | `tps` | hook (no command) | Notify tokens/sec and in/out/cache token usage at the end of each agent turn. |
4
+ extensions. One entry point, nine independent features.
5
+
6
+ | Feature | Command / hook | What it does |
7
+ | ------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
8
+ | `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
9
+ | `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks. |
10
+ | `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
11
+ | `zed` | `/z` | Open Zed editor on the current working directory. |
12
+ | `prefer-tools` | hook (no command) | Nudge toward modern CLIs: `rg` over `grep`, `fd` over `find`, `uv` over bare `python`/`pip`/`pytest`/`mypy`. |
13
+ | `model-thinking` | hook (no command) | Apply native scoped-model thinking levels consistently when selecting a model through the full picker. |
14
+ | `kilo` | provider | Access Kilo Gateway models via `/login kilo` or `KILO_API_KEY`. |
15
+ | `provider-balance` | footer (no command) | Show remaining Kilo or OpenRouter credits, z.ai token-plan quota, or OpenAI Codex quota on the right side of the working-directory footer line. |
16
+ | `tps` | hook (no command) | Notify tokens/sec and in/out/cache token usage at the end of each agent turn. |
18
17
 
19
18
  ## Install
20
19
 
21
20
  After publishing the package to npm:
22
21
 
23
22
  ```bash
24
- pi install npm:bermudis-pi-goodies@0.5.3
23
+ pi install npm:bermudis-pi-goodies@0.5.4
25
24
  ```
26
25
 
27
26
  Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
28
27
  feature is independent — disabling one is a one-line edit in `index.ts`.
29
28
  Kilo's provider and its balance footer are bundled here.
30
29
 
31
- ## Model-specific thinking
32
-
33
- This feature is deliberately opt-in: models not covered by the config retain
34
- Pi's native thinking-level behavior. Create `~/.pi/agent/model-thinking.json`
35
- with provider defaults, exact model defaults, or both:
36
-
37
- ```json
38
- {
39
- "providers": {
40
- "anthropic": "high",
41
- "openai-codex": "xhigh"
42
- },
43
- "models": {
44
- "anthropic/claude-haiku-4-5": "low"
45
- }
46
- }
47
- ```
48
-
49
- Exact `provider/model-id` entries take precedence over provider defaults. Use
50
- `/model-thinking set` to explicitly save the current model's current thinking
51
- level as an exact-model entry; it also bootstraps models not yet covered by the
52
- config and overwrites an existing entry. Manual thinking-level changes in Pi do
53
- not modify this file. All current Pi levels are accepted:
54
- `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`.
55
- `/model-thinking` shows the active resolution and config path;
56
- `/model-thinking reset` deletes the whole config. A malformed config is reported
57
- and ignored; `/model-thinking set` refuses to overwrite it until you repair the
58
- file or reset it.
59
-
60
- ## Fixed startup model
30
+ ## Model selection and thinking
61
31
 
62
- Pi normally saves the last model and thinking level selected in the global
63
- settings file. `fixed-defaults` pins only the cross-session provider and model;
64
- `model-thinking` is the sole owner of model-specific thinking levels.
65
-
66
- Create `~/.pi/agent/fixed-defaults.json` to pin a startup model:
32
+ Pi's native model settings are the single source of truth. Add models to
33
+ `enabledModels` in `~/.pi/agent/settings.json` in cycle order. Add an optional
34
+ thinking level after a colon:
67
35
 
68
36
  ```json
69
37
  {
70
- "provider": "openai-codex",
71
- "model": "gpt-5.6-luna"
38
+ "enabledModels": [
39
+ "opencode/hy3-free:high",
40
+ "zai/glm-5.2:high",
41
+ "openai-codex/gpt-5.6-terra:medium"
42
+ ]
72
43
  }
73
44
  ```
74
45
 
75
- Selecting a different model still changes the active session and its transcript;
76
- `fixed-defaults` restores the startup model after Pi persists a selection. Pi
77
- chooses the initial model before extensions receive `session_start`, so if you
78
- manually create or edit a pin for B while settings still name A, the current
79
- session remains on A and B starts with the next fresh session. Resuming an
80
- existing session restores that session's model instead.
81
-
82
- `/new` keeps the model and thinking level from the session you were just in,
83
- rather than switching to the pinned default and the settings thinking level.
84
- The level is re-applied after any per-model thinking policy, so a `/new`
85
- carries over a manual override. The pin still applies to a fresh `pi` launch.
86
-
87
- Older config files may contain `thinkingLevel`; that field is accepted for
88
- compatibility but ignored and should be managed in `model-thinking.json` instead.
89
- `fixed-defaults` logs a warning and shows the migration in its status when it
90
- finds the legacy field. The `/fixed-defaults set` command rewrites the file in
91
- the provider/model-only format.
92
-
93
- Manage the pin from Pi:
94
-
95
- - `/fixed-defaults set` — pin the currently active model as the startup model
96
- (written to the override file and applied to `settings.json` immediately).
97
- - `/fixed-defaults` — show the active model, pinned startup model, and override
98
- path.
99
- - `/fixed-defaults reset` — save the currently active model as Pi's last
100
- selection, then delete the override file and stop pinning. With no active
101
- model, it refuses to remove the pin.
46
+ Pi applies those levels while cycling with Ctrl+P and Ctrl+Shift+P. This
47
+ package fills the small consistency gaps: choosing a scoped model through the
48
+ full model picker and starting with a plain explicit `--model` apply its
49
+ configured level. Explicit `--thinking` and `--model ...:<level>` still win
50
+ (unless the `:<level>` ending is the model's registered id, in which case the
51
+ scoped level applies).
52
+ One gap is inherent to Pi: it emits no selection event when you pick the
53
+ model that is already active, so re-selecting it in the full picker leaves a
54
+ manual thinking level in place — switch models (or cycle) to snap back.
55
+ Resumed and forked sessions retain the level restored by Pi; `/new` carries the
56
+ previous session's model and active thinking level into the new session.
57
+
58
+ Use `/scoped-models` to search, enable, disable, and reorder the cycle list.
59
+ Be aware that pi 0.84.2 currently writes bare model IDs when that screen is
60
+ saved, so saving there removes any `:level` suffixes; restore them in
61
+ `settings.json` afterward.
102
62
 
103
63
  ## Provider and balance details
104
64
 
package/index.ts CHANGED
@@ -8,8 +8,7 @@
8
8
  * - name-with-ai /name-with-ai generate a session name via the model
9
9
  * - zed /z open Zed on cwd
10
10
  * - prefer-tools hook block legacy tools (use trash/rg/fd/uv)
11
- * - model-thinking hook + command remember thinking levels by provider/model
12
- * - fixed-defaults hook + command keep startup provider/model stable; /fixed-defaults set pins the current model
11
+ * - model-thinking hook apply native scoped-model thinking levels consistently
13
12
  * - kilo provider access Kilo Gateway models
14
13
  * - provider-balance footer show Kilo credits or Codex quota in the footer
15
14
  * - tps hook notify tokens/sec and usage at each agent turn end
@@ -17,7 +16,6 @@
17
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
17
  import copyTrajectory from "./copy-trajectory.ts";
19
18
  import copyWithModel from "./copy-with-model.ts";
20
- import fixedDefaults from "./fixed-defaults.ts";
21
19
  import kilo from "./kilo.ts";
22
20
  import modelThinking from "./model-thinking.ts";
23
21
  import providerBalance from "./provider-balance.ts";
@@ -33,7 +31,6 @@ export default function bermudisPiGoodies(pi: ExtensionAPI): void {
33
31
  zed(pi);
34
32
  preferTools(pi);
35
33
  modelThinking(pi);
36
- fixedDefaults(pi);
37
34
  providerBalance(pi);
38
35
  kilo(pi);
39
36
  tps(pi);
package/model-thinking.ts CHANGED
@@ -1,18 +1,13 @@
1
- import {
2
- getAgentDir,
3
- type ExtensionAPI,
4
- type ExtensionContext,
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
5
4
  } from "@earendil-works/pi-coding-agent";
6
- import { readFileSync, statSync } from "node:fs";
7
- import { join } from "node:path";
8
- import {
9
- describeError,
10
- unlinkIfPresent,
11
- writeJsonFileAtomic,
12
- } from "./json-file.ts";
13
5
 
14
- const CONFIG_FILENAME = "model-thinking.json";
15
- const ALL_LEVELS = [
6
+ type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
7
+ type Model = NonNullable<ExtensionContext["model"]>;
8
+ type ModelRef = Pick<Model, "provider" | "id">;
9
+
10
+ const THINKING_LEVELS = new Set([
16
11
  "off",
17
12
  "minimal",
18
13
  "low",
@@ -20,304 +15,246 @@ const ALL_LEVELS = [
20
15
  "high",
21
16
  "xhigh",
22
17
  "max",
23
- ] as const;
24
-
25
- type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
26
-
27
- interface ModelThinkingConfig {
28
- models?: Record<string, ThinkingLevel>;
29
- providers?: Record<string, ThinkingLevel>;
30
- }
18
+ ]);
31
19
 
32
- interface ModelRef {
20
+ interface PreviousSessionState {
33
21
  provider: string;
34
22
  id: string;
23
+ thinkingLevel: ThinkingLevel;
35
24
  }
36
25
 
37
- interface ModelThinkingOptions {
38
- /** Internal seam used by tests; normal callers use Pi's global agent dir. */
39
- configPath?: string;
40
- }
26
+ // The extension factory is recreated when Pi replaces a session. Keep this
27
+ // snapshot at module scope so it survives that recreation.
28
+ let previousSessionState: PreviousSessionState | null = null;
41
29
 
42
- function isThinkingLevel(value: unknown): value is ThinkingLevel {
43
- return (
44
- typeof value === "string" &&
45
- (ALL_LEVELS as readonly string[]).includes(value)
46
- );
30
+ function sameModel(left: ModelRef, right: ModelRef): boolean {
31
+ return left.provider === right.provider && left.id === right.id;
47
32
  }
48
33
 
49
- function normalizeRecord(
50
- value: unknown,
51
- field: "models" | "providers",
52
- ): Record<string, ThinkingLevel> | undefined {
53
- if (value === undefined) return undefined;
54
- if (!value || typeof value !== "object" || Array.isArray(value)) {
55
- throw new Error(`\`${field}\` must be an object`);
34
+ /**
35
+ * Pi resolves scoped thinking for its normal initial-model path, but an
36
+ * explicit plain `--model` takes a different path and falls back to the
37
+ * global level. There is no CLI-selection detail on session_start, so use
38
+ * argv only to distinguish that one case from Pi's already-resolved startup
39
+ * state. Explicit `--thinking` and `--model ...:<level>` remain authoritative.
40
+ *
41
+ * Mirrors Pi's parseArgs semantics for these two flags: the last --model
42
+ * wins, --thinking counts only when its value is a valid level (invalid
43
+ * values are dropped with a CLI warning), and a trailing flag without a
44
+ * value token sets nothing. Degenerate argv where another flag consumes a
45
+ * bare "--model"/"--thinking" as its value is not mirrored.
46
+ *
47
+ * Do not trust the presence of --model alone: an invalid CLI model leaves a
48
+ * restored session's model active. Before touching thinking, confirm that the
49
+ * active model could be Pi's resolution of the CLI pattern. This also mirrors
50
+ * Pi's important ordering for a trailing ":<level>": it fuzzy-matches the
51
+ * complete pattern before splitting a thinking suffix.
52
+ */
53
+ function cliPatternMatchesActiveModel(
54
+ pattern: string,
55
+ provider: string | undefined,
56
+ activeModel: Model,
57
+ ): boolean {
58
+ const normalizedPattern = pattern.trim().toLowerCase();
59
+ if (!normalizedPattern) return false;
60
+
61
+ const normalizedProvider = provider?.trim().toLowerCase();
62
+ if (
63
+ normalizedProvider !== undefined &&
64
+ activeModel.provider.toLowerCase() !== normalizedProvider
65
+ ) {
66
+ return false;
56
67
  }
57
68
 
58
- const result: Record<string, ThinkingLevel> = {};
59
- for (const [key, level] of Object.entries(value)) {
60
- if (!isThinkingLevel(level)) {
61
- throw new Error(
62
- `\`${field}.${key}\` must be one of: ${ALL_LEVELS.join(", ")}`,
63
- );
64
- }
65
- result[key] = level;
69
+ let modelPattern = normalizedPattern;
70
+ const activeProviderPrefix = `${activeModel.provider}/`.toLowerCase();
71
+ if (modelPattern.startsWith(activeProviderPrefix)) {
72
+ modelPattern = modelPattern.slice(activeProviderPrefix.length);
73
+ } else if (
74
+ normalizedProvider === undefined &&
75
+ normalizedPattern.includes("/")
76
+ ) {
77
+ // Without the registry we cannot safely reinterpret an arbitrary slash as
78
+ // a provider separator: model IDs themselves may contain slashes.
79
+ return (
80
+ normalizedPattern ===
81
+ `${activeModel.provider}/${activeModel.id}`.toLowerCase()
82
+ );
66
83
  }
67
- return Object.keys(result).length > 0 ? result : undefined;
84
+
85
+ const id = activeModel.id.toLowerCase();
86
+ const name = activeModel.name?.toLowerCase();
87
+ return (
88
+ modelPattern === id ||
89
+ id.includes(modelPattern) ||
90
+ name?.includes(modelPattern) === true
91
+ );
68
92
  }
69
93
 
70
- function parseConfig(value: unknown): ModelThinkingConfig {
71
- if (!value || typeof value !== "object" || Array.isArray(value)) {
72
- throw new Error("the top level must be an object");
94
+ function plainCliModelNeedsScopedLevel(
95
+ activeModel: Model | undefined,
96
+ ): boolean {
97
+ const args = process.argv.slice(2);
98
+ let model: string | undefined;
99
+ let provider: string | undefined;
100
+ let thinking: string | undefined;
101
+ for (let index = 0; index < args.length; index++) {
102
+ const arg = args[index];
103
+ if (arg === "--model" && index + 1 < args.length) {
104
+ model = args[++index];
105
+ } else if (arg === "--provider" && index + 1 < args.length) {
106
+ provider = args[++index];
107
+ } else if (arg === "--thinking" && index + 1 < args.length) {
108
+ const level = args[++index];
109
+ if (THINKING_LEVELS.has(level)) thinking = level;
110
+ }
73
111
  }
112
+ if (thinking !== undefined) return false;
113
+ if (model === undefined || activeModel === undefined) return false;
74
114
 
75
- const input = value as Record<string, unknown>;
76
- const unknownKeys = Object.keys(input).filter(
77
- (key) => key !== "models" && key !== "providers",
78
- );
79
- if (unknownKeys.length > 0) {
80
- throw new Error(`unknown field(s): ${unknownKeys.join(", ")}`);
81
- }
115
+ // Pi first attempts the complete pattern. This confirms both that --model
116
+ // actually selected the active model (rather than falling back to a
117
+ // restored session) and that a ":<level>" suffix belongs to its ID.
118
+ if (cliPatternMatchesActiveModel(model, provider, activeModel)) return true;
82
119
 
83
- return {
84
- models: normalizeRecord(input.models, "models"),
85
- providers: normalizeRecord(input.providers, "providers"),
86
- };
120
+ // Either the CLI model did not resolve, or Pi split a trailing valid
121
+ // ":<level>" and applied explicit thinking. Both cases must be left alone.
122
+ return false;
87
123
  }
88
124
 
89
- function fileStamp(path: string): string | undefined {
90
- try {
91
- const stat = statSync(path);
92
- return `${stat.mtimeMs}:${stat.size}`;
93
- } catch (error) {
94
- const code = (error as NodeJS.ErrnoException).code;
95
- if (code === "ENOENT") return undefined;
96
- throw error;
97
- }
98
- }
125
+ /**
126
+ * Pi's scoped-models configuration is the source of truth for both the cycle
127
+ * list and per-model thinking levels. The native cycle path already applies
128
+ * those levels; this hook fills the two gaps in the native behavior:
129
+ *
130
+ * - selecting a model through the full picker should apply its scoped level;
131
+ *
132
+ * Pi emits model_select only when the model actually changes, so picking the
133
+ * already-active model in the full picker fires no event and its scoped
134
+ * level cannot be re-applied on that path; a manual level survives until a
135
+ * different model is selected.
136
+ *
137
+ * Pi applies the scoped level during normal startup selection. A plain
138
+ * explicit `--model` is the exception; the startup hook covers that case
139
+ * without overriding explicit CLI thinking choices.
140
+ *
141
+ * Resume and fork retain the model and level restored by Pi. /new is not a
142
+ * session restore in Pi: it starts from the saved default/scoped model, so we
143
+ * capture and restore the previous session explicitly.
144
+ */
145
+ export default function modelThinking(pi: ExtensionAPI): void {
146
+ let restoringPreviousSession = false;
99
147
 
100
- interface ConfigLoadResult {
101
- config: ModelThinkingConfig;
102
- /** Non-null when the file exists but could not be read or validated. */
103
- error: string | null;
104
- }
148
+ function applyScopedLevel(ctx: ExtensionContext, silent: boolean): void {
149
+ const model = ctx.model;
150
+ if (!model) return;
105
151
 
106
- class ConfigStore {
107
- readonly path: string;
108
- private initialized = false;
109
- private cachedStamp: string | undefined;
110
- private cachedResult: ConfigLoadResult = { config: {}, error: null };
152
+ const scoped = ctx.scopedModels.find((entry) =>
153
+ sameModel(entry.model, model),
154
+ );
155
+ const level = scoped?.thinkingLevel as ThinkingLevel | undefined;
156
+ if (level === undefined) return;
111
157
 
112
- constructor(path: string) {
113
- this.path = path;
114
- }
158
+ const before = pi.getThinkingLevel();
159
+ pi.setThinkingLevel(level);
160
+ const after = pi.getThinkingLevel();
115
161
 
116
- load(): ConfigLoadResult {
117
- let stamp: string | undefined;
118
- try {
119
- stamp = fileStamp(this.path);
120
- } catch (error) {
121
- const message = describeError(error);
122
- console.error(`[model-thinking] failed to stat ${this.path}:`, message);
123
- return { config: {}, error: message };
162
+ if (after !== before && !silent) {
163
+ ctx.ui.notify(`Thinking: ${before} → ${after}`, "info");
124
164
  }
165
+ }
125
166
 
126
- // Keep retrying a file that previously failed to load. A repair can leave
127
- // its size and mtime unchanged, so an error result must not be cached by
128
- // the same stamp as a successful read.
129
- if (
130
- this.initialized &&
131
- stamp === this.cachedStamp &&
132
- this.cachedResult.error === null
133
- ) {
134
- return this.cachedResult;
167
+ async function restorePreviousSession(
168
+ ctx: ExtensionContext,
169
+ previous: PreviousSessionState,
170
+ ): Promise<void> {
171
+ // Custom models supplied via --model may be synthesized for the current
172
+ // session and therefore not be present in the registry. If /new already
173
+ // selected that same model, the model itself is successfully restored;
174
+ // only the historical thinking level still needs to be applied.
175
+ if (ctx.model && sameModel(ctx.model, previous)) {
176
+ pi.setThinkingLevel(previous.thinkingLevel);
177
+ return;
135
178
  }
136
179
 
137
- this.initialized = true;
138
- this.cachedStamp = stamp;
139
- if (stamp === undefined) {
140
- this.cachedResult = { config: {}, error: null };
141
- return this.cachedResult;
180
+ const model = ctx.modelRegistry.find(previous.provider, previous.id);
181
+ if (!model) {
182
+ console.error(
183
+ `[model-thinking] could not restore ${previous.provider}/${previous.id} after /new: model is unavailable`,
184
+ );
185
+ return;
142
186
  }
143
187
 
188
+ let applied = false;
144
189
  try {
145
- this.cachedResult = {
146
- config: parseConfig(
147
- JSON.parse(readFileSync(this.path, "utf8")) as unknown,
148
- ),
149
- error: null,
150
- };
190
+ restoringPreviousSession = true;
191
+ applied = await pi.setModel(model);
192
+ if (!applied) {
193
+ console.error(
194
+ `[model-thinking] could not restore ${previous.provider}/${previous.id} after /new: provider authentication is unavailable`,
195
+ );
196
+ return;
197
+ }
151
198
  } catch (error) {
152
- const message = describeError(error);
153
199
  console.error(
154
- `[model-thinking] invalid config at ${this.path}:`,
155
- message,
200
+ `[model-thinking] failed to restore ${previous.provider}/${previous.id} after /new:`,
201
+ error,
156
202
  );
157
- this.cachedResult = { config: {}, error: message };
203
+ } finally {
204
+ restoringPreviousSession = false;
158
205
  }
159
- return this.cachedResult;
160
- }
161
206
 
162
- save(config: ModelThinkingConfig): void {
163
- writeJsonFileAtomic(this.path, config);
164
- this.cachedResult = { config, error: null };
165
- this.cachedStamp = fileStamp(this.path);
166
- this.initialized = true;
167
- }
168
-
169
- reset(): boolean {
170
- const removed = unlinkIfPresent(this.path);
171
- this.initialized = true;
172
- this.cachedStamp = undefined;
173
- this.cachedResult = { config: {}, error: null };
174
- return removed;
175
- }
176
- }
177
-
178
- function modelKey(model: ModelRef): string {
179
- return `${model.provider}/${model.id}`;
180
- }
181
-
182
- function resolveThinkingLevel(
183
- config: ModelThinkingConfig,
184
- model: ModelRef | undefined,
185
- ): ThinkingLevel | undefined {
186
- if (!model) return undefined;
187
- return config.models?.[modelKey(model)] ?? config.providers?.[model.provider];
188
- }
189
-
190
- /**
191
- * Opt-in per-model thinking policy. A provider or exact model must first be
192
- * present in model-thinking.json; unmanaged models retain Pi's native behavior.
193
- */
194
- export default function modelThinking(
195
- pi: ExtensionAPI,
196
- options: ModelThinkingOptions = {},
197
- ): void {
198
- const store = new ConfigStore(
199
- options.configPath ?? join(getAgentDir(), CONFIG_FILENAME),
200
- );
201
- function apply(ctx: ExtensionContext, silent: boolean): void {
202
- const model = ctx.model;
203
- const loaded = store.load();
204
- // Never treat a broken file as an empty policy: doing so would hide the
205
- // error and make a later `/model-thinking set` overwrite it.
206
- if (loaded.error || !model) return;
207
-
208
- const level = resolveThinkingLevel(loaded.config, model);
209
- if (level === undefined) return;
210
-
211
- const before = pi.getThinkingLevel();
212
- pi.setThinkingLevel(level);
213
- const after = pi.getThinkingLevel();
214
-
215
- if (after !== before && !silent) {
216
- ctx.ui.notify(`Thinking: ${before} → ${after}`, "info");
207
+ if (applied) {
208
+ // setModel emits model_select, where the scoped policy may apply. The
209
+ // captured session level must have the final word for /new.
210
+ pi.setThinkingLevel(previous.thinkingLevel);
217
211
  }
218
212
  }
219
213
 
220
214
  pi.on("model_select", (event, ctx) => {
221
- // When Pi restores a model from session history (e.g. resume/fork), keep
222
- // the thinking level that was active in that session instead of
223
- // overwriting it with the per-model default.
215
+ // A restored session owns its historical thinking level.
224
216
  if (event.source === "restore") return;
225
- apply(ctx, false);
217
+ // Pi suppresses this event when the selected model equals the active
218
+ // one (see _emitModelSelect), which is why re-selecting the current
219
+ // model in the full picker does not re-apply its scoped level.
220
+ applyScopedLevel(ctx, restoringPreviousSession);
226
221
  });
227
222
 
228
223
  pi.on("session_start", (event, ctx) => {
229
- if (!ctx.model) return;
230
- // Resumed or forked sessions already carry their last thinking level
231
- // (restored by Pi from the session's branch). Don't clobber it with
232
- // the per-model default; the default only applies to fresh sessions.
233
- if (event.reason === "resume" || event.reason === "fork") return;
234
- apply(ctx, true);
235
- });
236
-
237
- pi.registerCommand("model-thinking", {
238
- description: "Show, save, or reset model-specific thinking levels",
239
- handler: async (args, ctx) => {
240
- const command = args.trim();
224
+ if (event.reason === "new") {
225
+ const previous = previousSessionState;
226
+ previousSessionState = null;
227
+ if (previous) return restorePreviousSession(ctx, previous);
228
+ return;
229
+ }
241
230
 
242
- if (command === "set") {
243
- const model = ctx.model;
244
- if (!model) {
245
- ctx.ui.notify(
246
- "Cannot save thinking level without an active model.",
247
- "warning",
248
- );
249
- return;
250
- }
231
+ // Pi restores these sessions' model and thinking level from the session.
232
+ // Startup is already resolved by Pi except for a plain explicit --model,
233
+ // which bypasses the enabledModels startup selection. Reload preserves
234
+ // the current session, so applying the scoped default there would clobber
235
+ // a manual change.
236
+ if (
237
+ event.reason === "reload" ||
238
+ event.reason === "resume" ||
239
+ event.reason === "fork"
240
+ ) {
241
+ return;
242
+ }
243
+ if (event.reason === "startup" && !plainCliModelNeedsScopedLevel(ctx.model))
244
+ return;
245
+ applyScopedLevel(ctx, true);
246
+ });
251
247
 
252
- const key = modelKey(model);
253
- const level = pi.getThinkingLevel();
254
- try {
255
- const loaded = store.load();
256
- if (loaded.error) {
257
- ctx.ui.notify(
258
- `Cannot save model-thinking config: ${loaded.error}. Repair ${store.path} or run /model-thinking reset first.`,
259
- "error",
260
- );
261
- return;
262
- }
248
+ pi.on("session_before_switch", (event, ctx) => {
249
+ if (event.reason !== "new") return;
263
250
 
264
- store.save({
265
- ...loaded.config,
266
- models: { ...loaded.config.models, [key]: level },
267
- });
268
- ctx.ui.notify(`Saved ${key}: ${level}`, "info");
269
- } catch (error) {
270
- console.error(
271
- "[model-thinking] failed to save current model:",
272
- error,
273
- );
274
- ctx.ui.notify("Failed to save model-thinking config.", "error");
275
- }
276
- return;
277
- }
278
-
279
- if (command === "reset") {
280
- try {
281
- const removed = store.reset();
282
- ctx.ui.notify(
283
- removed
284
- ? "Model-thinking config cleared."
285
- : "No model-thinking config file to clear.",
286
- "info",
287
- );
288
- } catch (error) {
289
- console.error("[model-thinking] failed to clear config:", error);
290
- ctx.ui.notify("Failed to clear model-thinking config.", "error");
251
+ const model = ctx.model;
252
+ previousSessionState = model
253
+ ? {
254
+ provider: model.provider,
255
+ id: model.id,
256
+ thinkingLevel: pi.getThinkingLevel(),
291
257
  }
292
- return;
293
- }
294
-
295
- if (command !== "") {
296
- ctx.ui.notify("Usage: /model-thinking [set|reset]", "warning");
297
- return;
298
- }
299
-
300
- const model = ctx.model;
301
- const loaded = store.load();
302
- const resolved = loaded.error
303
- ? undefined
304
- : resolveThinkingLevel(loaded.config, model);
305
- const lines = [
306
- `model: ${model ? modelKey(model) : "none"}`,
307
- `managed: ${resolved === undefined ? "no" : "yes"}`,
308
- `file: ${store.path}`,
309
- `saved: ${resolved ?? "none — pi handles this model natively"}`,
310
- `current: ${pi.getThinkingLevel()}`,
311
- ...(loaded.error
312
- ? [`⚠ config invalid — no policy applied: ${loaded.error}`]
313
- : []),
314
- "",
315
- "run `/model-thinking set` to save this model and level; `/model-thinking reset` to clear all configured levels",
316
- ];
317
- const message = lines.join("\n");
318
-
319
- if (ctx.hasUI) ctx.ui.notify(message, resolved ? "info" : "warning");
320
- else console.log(message);
321
- },
258
+ : null;
322
259
  });
323
260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "private": false,
5
5
  "description": "A bundle of small, frequently-used Pi extensions.",
6
6
  "keywords": ["pi-package"],
package/fixed-defaults.ts DELETED
@@ -1,469 +0,0 @@
1
- import {
2
- getAgentDir,
3
- SettingsManager,
4
- type ExtensionAPI,
5
- type ExtensionContext,
6
- } from "@earendil-works/pi-coding-agent";
7
- import { readFileSync } from "node:fs";
8
- import { join } from "node:path";
9
- import {
10
- describeError,
11
- unlinkIfPresent,
12
- writeJsonFileAtomic,
13
- } from "./json-file.ts";
14
-
15
- const CONFIG_FILENAME = "fixed-defaults.json";
16
-
17
- /**
18
- * Model and thinking level active in the session being replaced by `/new`,
19
- * captured during `session_before_switch`. The factory is re-invoked per
20
- * session, so this must live at module scope to survive the switch to the new
21
- * extension instance.
22
- */
23
- let previousModelForNewSession: PreviousSessionModel | null = null;
24
-
25
- /** Model + thinking level snapshot taken when `/new` replaces a session. */
26
- interface PreviousSessionModel {
27
- provider: string;
28
- id: string;
29
- thinkingLevel: ThinkingLevel;
30
- }
31
-
32
- type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
33
- /**
34
- * Values read from the override file. `provider` and `model` are a coupled
35
- * pair — a model id is meaningless without its provider, so both must be
36
- * present if either is.
37
- *
38
- * `thinkingLevel` was accepted by older versions. It is deliberately ignored
39
- * for compatibility; model-thinking.ts is now the sole owner of thinking
40
- * policy.
41
- */
42
- interface FixedDefaultsOverride {
43
- provider?: string;
44
- model?: string;
45
- }
46
-
47
- interface FixedDefaultsOptions {
48
- /** Internal seam used by tests; normal callers use Pi's global agent dir. */
49
- agentDir?: string;
50
- /** Internal seam used by tests; defaults to <agentDir>/fixed-defaults.json. */
51
- configPath?: string;
52
- }
53
-
54
- function parseOverride(value: unknown): FixedDefaultsOverride {
55
- if (!value || typeof value !== "object" || Array.isArray(value)) {
56
- throw new Error("the top level must be an object");
57
- }
58
-
59
- const input = value as Record<string, unknown>;
60
- const unknownKeys = Object.keys(input).filter(
61
- (key) => key !== "provider" && key !== "model" && key !== "thinkingLevel",
62
- );
63
- if (unknownKeys.length > 0) {
64
- throw new Error(`unknown field(s): ${unknownKeys.join(", ")}`);
65
- }
66
-
67
- const override: FixedDefaultsOverride = {};
68
- if (input.provider !== undefined) {
69
- if (typeof input.provider !== "string" || input.provider.length === 0) {
70
- throw new Error("`provider` must be a non-empty string");
71
- }
72
- override.provider = input.provider;
73
- }
74
- if (input.model !== undefined) {
75
- if (typeof input.model !== "string" || input.model.length === 0) {
76
- throw new Error("`model` must be a non-empty string");
77
- }
78
- override.model = input.model;
79
- }
80
-
81
- // provider and model are a coupled pair — a model id is meaningless without
82
- // its provider. Require both if either is present so a hand-edited partial
83
- // file can't pin a broken default. The legacy thinkingLevel field is
84
- // intentionally ignored; model-thinking.ts owns thinking policy.
85
- if (
86
- (override.provider !== undefined && override.model === undefined) ||
87
- (override.model !== undefined && override.provider === undefined)
88
- ) {
89
- throw new Error(
90
- "`provider` and `model` must be specified together — a model id is meaningless without its provider",
91
- );
92
- }
93
-
94
- return override;
95
- }
96
-
97
- function hasLegacyThinkingLevel(value: unknown): boolean {
98
- return (
99
- value !== null &&
100
- typeof value === "object" &&
101
- !Array.isArray(value) &&
102
- Object.prototype.hasOwnProperty.call(value, "thinkingLevel")
103
- );
104
- }
105
-
106
- /**
107
- * Read/write the override file. Reads are uncached (the file is tiny and the
108
- * events are rare), so hand edits are picked up without a reload. Writes are
109
- * atomic via temp file + rename.
110
- */
111
- class DefaultsStore {
112
- readonly path: string;
113
- private warnedLegacyThinkingLevel = false;
114
-
115
- constructor(path: string) {
116
- this.path = path;
117
- }
118
-
119
- /** Load the override; an absent file is a valid, inactive configuration. */
120
- load(): LoadResult {
121
- let raw: string;
122
- try {
123
- raw = readFileSync(this.path, "utf8");
124
- } catch (error) {
125
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
126
- return {
127
- present: false,
128
- override: {},
129
- error: null,
130
- legacyThinkingLevel: false,
131
- };
132
- }
133
- throw error;
134
- }
135
-
136
- try {
137
- const value = JSON.parse(raw) as unknown;
138
- const legacyThinkingLevel = hasLegacyThinkingLevel(value);
139
- if (legacyThinkingLevel && !this.warnedLegacyThinkingLevel) {
140
- console.warn(
141
- `[fixed-defaults] legacy thinkingLevel found in ${this.path}; it is ignored. Add the policy to model-thinking.json instead.`,
142
- );
143
- this.warnedLegacyThinkingLevel = true;
144
- }
145
- return {
146
- present: true,
147
- override: parseOverride(value),
148
- error: null,
149
- legacyThinkingLevel,
150
- };
151
- } catch (error) {
152
- const message = describeError(error);
153
- console.error(
154
- `[fixed-defaults] invalid config at ${this.path}:`,
155
- message,
156
- );
157
- return {
158
- present: true,
159
- override: {},
160
- error: message,
161
- legacyThinkingLevel: false,
162
- };
163
- }
164
- }
165
-
166
- save(override: FixedDefaultsOverride): void {
167
- writeJsonFileAtomic(this.path, override);
168
- this.warnedLegacyThinkingLevel = false;
169
- }
170
-
171
- reset(): void {
172
- // Deletion is intentionally idempotent: if another process removes the
173
- // pin after it was loaded, reset has still reached the requested end state.
174
- unlinkIfPresent(this.path);
175
- this.warnedLegacyThinkingLevel = false;
176
- }
177
- }
178
-
179
- /** Result of reading the override file: the parsed values plus any error. */
180
- interface LoadResult {
181
- /** Whether the override file existed when it was read. */
182
- present: boolean;
183
- override: FixedDefaultsOverride;
184
- /** Non-null when the file existed but failed to parse or validate. */
185
- error: string | null;
186
- /** True when an old, ignored thinkingLevel field should be migrated. */
187
- legacyThinkingLevel: boolean;
188
- }
189
-
190
- /**
191
- * Keep Pi's cross-session provider/model defaults stable while still allowing
192
- * model changes in the current session. Pi intentionally saves the last
193
- * selected model, so this runs after model-selection notifications and restores
194
- * the configured startup model in the global settings file.
195
- *
196
- * The pin lives in `<agentDir>/fixed-defaults.json` and is the sole source of
197
- * truth — there are no built-in defaults, so with no override file the
198
- * extension is dormant and Pi's native last-selection behavior is preserved.
199
- * `provider` and `model` are a coupled pair. Thinking policy belongs to
200
- * model-thinking.ts. `/fixed-defaults set` pins the currently active model;
201
- * `/fixed-defaults reset` stops pinning; `/fixed-defaults` shows the active pin.
202
- */
203
- export default function fixedDefaults(
204
- pi: ExtensionAPI,
205
- options: FixedDefaultsOptions = {},
206
- ): void {
207
- const agentDir = options.agentDir ?? getAgentDir();
208
- const store = new DefaultsStore(
209
- options.configPath ?? join(agentDir, CONFIG_FILENAME),
210
- );
211
- let pending = Promise.resolve();
212
-
213
- async function persistModel(
214
- ctx: ExtensionContext,
215
- provider: string,
216
- modelId: string,
217
- ): Promise<void> {
218
- const settings = SettingsManager.create(ctx.cwd, agentDir, {
219
- projectTrusted: ctx.isProjectTrusted(),
220
- });
221
- settings.setDefaultModelAndProvider(provider, modelId);
222
- await settings.flush();
223
-
224
- const errors = settings.drainErrors();
225
- if (errors.length > 0) {
226
- const details = errors
227
- .map(({ scope, error }) => `${scope}: ${describeError(error)}`)
228
- .join("; ");
229
- throw new Error(`failed to persist fixed defaults (${details})`);
230
- }
231
- }
232
-
233
- async function restorePinnedModel(ctx: ExtensionContext): Promise<void> {
234
- const { override, error } = store.load();
235
- // A broken or absent override means no pin: leave settings untouched so
236
- // Pi's native last-selection behavior is preserved rather than guessed at.
237
- if (error) return;
238
- const hasModelPin =
239
- override.provider !== undefined && override.model !== undefined;
240
- if (!hasModelPin) return;
241
-
242
- await persistModel(ctx, override.provider!, override.model!);
243
- }
244
-
245
- /**
246
- * Restore the model and thinking level that were active before `/new`.
247
- * Unlike the pin, this only applies to the session being created, so the
248
- * next fresh `pi` still starts from the pin.
249
- */
250
- async function restorePreviousModel(
251
- ctx: ExtensionContext,
252
- previous: PreviousSessionModel | null,
253
- ): Promise<void> {
254
- if (!previous) return;
255
- const model = ctx.modelRegistry.find(previous.provider, previous.id);
256
- if (!model) return;
257
- // setModel writes the restored model to settings.json, then its model_select
258
- // notification re-applies the pin afterwards (see restorePinnedModel), so
259
- // the active session keeps the previous model while the pin survives.
260
- // That same notification can re-apply a per-model thinking policy
261
- // (model-thinking.ts), so the captured level is only restored after
262
- // setModel resolves — its emit is awaited, giving this the final word.
263
- // setThinkingLevel clamps to the restored model's capabilities.
264
- const applied = await pi.setModel(model);
265
- if (!applied) return;
266
- pi.setThinkingLevel(previous.thinkingLevel);
267
- }
268
-
269
- function enqueue<T>(
270
- operation: () => Promise<T>,
271
- failureMessage: string,
272
- ): Promise<T> {
273
- const queued = pending.then(operation);
274
- // Log once at the queue boundary, then recover only the internal tail so a
275
- // failed write does not prevent later operations. The returned promise
276
- // still rejects, allowing commands to notify the user.
277
- pending = queued.then(
278
- () => undefined,
279
- (error: unknown) => {
280
- console.error(failureMessage, error);
281
- },
282
- );
283
- return queued;
284
- }
285
-
286
- function schedule(
287
- ctx: ExtensionContext,
288
- failureMessage = "[fixed-defaults] failed to restore defaults:",
289
- ): Promise<void> {
290
- return enqueue(() => restorePinnedModel(ctx), failureMessage);
291
- }
292
-
293
- pi.on("session_start", (event, ctx) => {
294
- // `/new` has already selected a model (the pinned default) by the time this
295
- // fires, so restore the model and thinking level that were active before
296
- // the switch here. The resulting model_select notification re-applies the
297
- // pin in settings.json.
298
- if (event.reason === "new") {
299
- const previous = previousModelForNewSession;
300
- previousModelForNewSession = null;
301
- if (!previous) return;
302
- // Run outside the shared queue: pi.setModel emits model_select, whose
303
- // handler enqueues the pin restore. Enqueuing this operation too would
304
- // deadlock that handler by making it wait on this operation to finish.
305
- return restorePreviousModel(ctx, previous).catch((error: unknown) => {
306
- console.error(
307
- "[fixed-defaults] failed to restore the previous session model after /new:",
308
- error,
309
- );
310
- });
311
- }
312
- return schedule(ctx);
313
- });
314
- pi.on("model_select", (_event, ctx) => schedule(ctx));
315
-
316
- pi.on("session_before_switch", (event, ctx) => {
317
- if (event.reason === "new") {
318
- const model = ctx.model;
319
- previousModelForNewSession = model
320
- ? {
321
- provider: model.provider,
322
- id: model.id,
323
- thinkingLevel: pi.getThinkingLevel(),
324
- }
325
- : null;
326
- }
327
- });
328
-
329
- pi.registerCommand("fixed-defaults", {
330
- description: "Show, set, or reset the pinned startup model",
331
- handler: async (args, ctx) => {
332
- const command = args.trim();
333
-
334
- if (command === "set") {
335
- const model = ctx.model;
336
- if (!model) {
337
- ctx.ui.notify(
338
- "Cannot pin defaults without an active model.",
339
- "warning",
340
- );
341
- return;
342
- }
343
-
344
- const override: FixedDefaultsOverride = {
345
- provider: model.provider,
346
- model: model.id,
347
- };
348
- try {
349
- store.save(override);
350
- } catch (error) {
351
- console.error("[fixed-defaults] failed to save override:", error);
352
- ctx.ui.notify("Failed to save the new fixed defaults.", "error");
353
- return;
354
- }
355
-
356
- // The active model is already Pi's current selection. Do not rewrite
357
- // settings here: that write is unnecessary and must not take ownership
358
- // of Pi's native defaultThinkingLevel persistence.
359
- ctx.ui.notify(
360
- `Pinned ${model.provider}/${model.id} as the startup model`,
361
- "info",
362
- );
363
- return;
364
- }
365
-
366
- if (command === "reset") {
367
- type ResetResult =
368
- | { status: "missing" }
369
- | { status: "removed-inactive" }
370
- | { status: "no-active-model" }
371
- | {
372
- status: "removed";
373
- model: NonNullable<ExtensionContext["model"]>;
374
- };
375
-
376
- try {
377
- const result = await enqueue<ResetResult>(async () => {
378
- // Load and validate before touching settings. Invalid, legacy-only,
379
- // and partial files are inactive, so remove them without replacing
380
- // Pi's last selection with the currently active model.
381
- const loaded = store.load();
382
- if (!loaded.present) return { status: "missing" };
383
-
384
- const hasModelPin =
385
- loaded.error === null &&
386
- loaded.override.provider !== undefined &&
387
- loaded.override.model !== undefined;
388
- if (!hasModelPin) {
389
- store.reset();
390
- return { status: "removed-inactive" };
391
- }
392
-
393
- const model = ctx.model;
394
- if (!model) return { status: "no-active-model" };
395
-
396
- // Save the active model before removing the valid pin. Otherwise
397
- // the old pinned model remains in settings.json and wins at the
398
- // next fresh start.
399
- await persistModel(ctx, model.provider, model.id);
400
- store.reset();
401
- return { status: "removed", model };
402
- }, "[fixed-defaults] failed to reset override:");
403
-
404
- if (result.status === "missing") {
405
- ctx.ui.notify("No fixed-defaults override file to reset.", "info");
406
- } else if (result.status === "removed-inactive") {
407
- ctx.ui.notify(
408
- "Inactive or invalid fixed-defaults override removed; settings were left unchanged.",
409
- "info",
410
- );
411
- } else if (result.status === "no-active-model") {
412
- ctx.ui.notify(
413
- "Cannot reset fixed defaults without an active model; the pin was left in place.",
414
- "warning",
415
- );
416
- } else {
417
- ctx.ui.notify(
418
- `Fixed-defaults pin removed. Pi will use ${result.model.provider}/${result.model.id} as its last selection.`,
419
- "info",
420
- );
421
- }
422
- } catch {
423
- ctx.ui.notify(
424
- "Failed to reset fixed defaults. Pin removal could not be confirmed; settings may already reflect the active model.",
425
- "error",
426
- );
427
- }
428
- return;
429
- }
430
-
431
- if (command !== "") {
432
- ctx.ui.notify("Usage: /fixed-defaults [set|reset]", "warning");
433
- return;
434
- }
435
-
436
- const { override, error, legacyThinkingLevel } = store.load();
437
- const model = ctx.model;
438
- const lines = [
439
- `model: ${model ? `${model.provider}/${model.id}` : "none"}`,
440
- "",
441
- ];
442
- if (error) {
443
- lines.push(`⚠ override file invalid — no pin active: ${error}`);
444
- } else if (Object.keys(override).length === 0) {
445
- lines.push("No pin active. Pi will use your last selection.");
446
- } else {
447
- if (override.provider !== undefined && override.model !== undefined) {
448
- lines.push(`pinned provider: ${override.provider}`);
449
- lines.push(`pinned model: ${override.model}`);
450
- }
451
- }
452
- if (legacyThinkingLevel) {
453
- lines.push(
454
- "",
455
- "⚠ legacy thinkingLevel is present but ignored; manage it with /model-thinking",
456
- );
457
- }
458
- lines.push("", `override file: ${store.path}`);
459
- lines.push(
460
- "",
461
- "run `/fixed-defaults set` to pin the current model; `/fixed-defaults reset` to stop pinning",
462
- );
463
- const message = lines.join("\n");
464
-
465
- if (ctx.hasUI) ctx.ui.notify(message, "info");
466
- else console.log(message);
467
- },
468
- });
469
- }