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 +39 -79
- package/index.ts +1 -4
- package/model-thinking.ts +203 -266
- package/package.json +1 -1
- package/fixed-defaults.ts +0 -469
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,
|
|
5
|
-
|
|
6
|
-
| Feature
|
|
7
|
-
|
|
8
|
-
| `copy-with-model`
|
|
9
|
-
| `copy-trajectory`
|
|
10
|
-
| `name-with-ai`
|
|
11
|
-
| `zed`
|
|
12
|
-
| `prefer-tools`
|
|
13
|
-
| `model-thinking`
|
|
14
|
-
| `
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
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.
|
|
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
|
|
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
|
|
63
|
-
settings
|
|
64
|
-
|
|
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
|
-
"
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
]
|
|
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
|
|
20
|
+
interface PreviousSessionState {
|
|
33
21
|
provider: string;
|
|
34
22
|
id: string;
|
|
23
|
+
thinkingLevel: ThinkingLevel;
|
|
35
24
|
}
|
|
36
25
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
158
|
+
const before = pi.getThinkingLevel();
|
|
159
|
+
pi.setThinkingLevel(level);
|
|
160
|
+
const after = pi.getThinkingLevel();
|
|
115
161
|
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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]
|
|
155
|
-
|
|
200
|
+
`[model-thinking] failed to restore ${previous.provider}/${previous.id} after /new:`,
|
|
201
|
+
error,
|
|
156
202
|
);
|
|
157
|
-
|
|
203
|
+
} finally {
|
|
204
|
+
restoringPreviousSession = false;
|
|
158
205
|
}
|
|
159
|
-
return this.cachedResult;
|
|
160
|
-
}
|
|
161
206
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
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
|
-
}
|