pi-set-model 0.1.4 → 0.1.6
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/CHANGELOG.md +17 -0
- package/LICENSE +280 -277
- package/README.md +45 -45
- package/package.json +41 -38
- package/set-model.ts +276 -255
package/set-model.ts
CHANGED
|
@@ -1,255 +1,276 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
type
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
preference.
|
|
45
|
-
|
|
46
|
-
preference.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
await
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
let
|
|
70
|
-
let
|
|
71
|
-
let
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
1
|
+
// Copyright © 2026 kapper.net - KAPPER NETWORK-COMMUNICATIONS GmbH
|
|
2
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
3
|
+
|
|
4
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { clampThinkingLevel } from "@earendil-works/pi-ai";
|
|
7
|
+
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
type ThinkingLevel = Parameters<ExtensionAPI["setThinkingLevel"]>[0];
|
|
11
|
+
|
|
12
|
+
export interface ProjectPreference {
|
|
13
|
+
provider: string;
|
|
14
|
+
model: string;
|
|
15
|
+
thinkingLevel: ThinkingLevel;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PREFERENCES_FILE_NAME = "set-model.json";
|
|
19
|
+
export const ACTIONS = [
|
|
20
|
+
{ value: "view", label: "view", description: "Show this folder's saved model and thinking level" },
|
|
21
|
+
{ value: "set", label: "set", description: "Save the active model and thinking level for this folder" },
|
|
22
|
+
{ value: "clear", label: "clear", description: "Remove this folder's saved model and thinking level" },
|
|
23
|
+
] as const satisfies readonly AutocompleteItem[];
|
|
24
|
+
|
|
25
|
+
export function isSetModelCommandPrefix(value: string): boolean {
|
|
26
|
+
if (!value.startsWith("/setm") && !value.startsWith("/set-")) return false;
|
|
27
|
+
return "/setmodel".startsWith(value.replace("-", ""));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getActionCompletions(prefix: string): AutocompleteItem[] | null {
|
|
31
|
+
const normalized = prefix.trimStart().toLowerCase();
|
|
32
|
+
if (/\s/.test(normalized)) return null;
|
|
33
|
+
const matches = ACTIONS.filter((action) => action.value.startsWith(normalized));
|
|
34
|
+
return matches.length > 0 ? [...matches] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function loadPreference(path: string): Promise<ProjectPreference | undefined> {
|
|
38
|
+
try {
|
|
39
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
40
|
+
if (!parsed || typeof parsed !== "object") throw new Error("expected an object");
|
|
41
|
+
|
|
42
|
+
const preference = parsed as Partial<ProjectPreference>;
|
|
43
|
+
if (
|
|
44
|
+
typeof preference.provider !== "string" ||
|
|
45
|
+
preference.provider.trim() === "" ||
|
|
46
|
+
typeof preference.model !== "string" ||
|
|
47
|
+
preference.model.trim() === "" ||
|
|
48
|
+
typeof preference.thinkingLevel !== "string" ||
|
|
49
|
+
preference.thinkingLevel.trim() === ""
|
|
50
|
+
) {
|
|
51
|
+
throw new Error("expected non-empty provider, model, and thinkingLevel strings");
|
|
52
|
+
}
|
|
53
|
+
return preference as ProjectPreference;
|
|
54
|
+
} catch (error: unknown) {
|
|
55
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
56
|
+
console.error(`pi-set-model: could not read ${path}: ${String(error)}`);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function savePreference(path: string, preference: ProjectPreference): Promise<void> {
|
|
62
|
+
await mkdir(dirname(path), { recursive: true });
|
|
63
|
+
const temporaryPath = `${path}.tmp-${process.pid}`;
|
|
64
|
+
await writeFile(temporaryPath, `${JSON.stringify(preference, null, 2)}\n`, "utf8");
|
|
65
|
+
await rename(temporaryPath, path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default function setModelExtension(pi: ExtensionAPI) {
|
|
69
|
+
let preference: ProjectPreference | undefined;
|
|
70
|
+
let preferencePath = "";
|
|
71
|
+
let projectTrusted = false;
|
|
72
|
+
let modelBeforeProjectPreference: NonNullable<ExtensionContext["model"]> | undefined;
|
|
73
|
+
let thinkingBeforeProjectPreference: ThinkingLevel | undefined;
|
|
74
|
+
let shuttingDown = false;
|
|
75
|
+
let writeQueue = Promise.resolve();
|
|
76
|
+
|
|
77
|
+
function queueSave(nextPreference: ProjectPreference): void {
|
|
78
|
+
if (!projectTrusted || !preferencePath) return;
|
|
79
|
+
preference = nextPreference;
|
|
80
|
+
writeQueue = writeQueue
|
|
81
|
+
.then(() => savePreference(preferencePath, nextPreference))
|
|
82
|
+
.catch((error: unknown) => console.error(`pi-set-model: could not save preference: ${String(error)}`));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
86
|
+
shuttingDown = false;
|
|
87
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
88
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
89
|
+
const line = lines[cursorLine] ?? "";
|
|
90
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
91
|
+
if (cursorCol === line.length && isSetModelCommandPrefix(beforeCursor)) {
|
|
92
|
+
if (beforeCursor === "/set-model") {
|
|
93
|
+
return {
|
|
94
|
+
prefix: "",
|
|
95
|
+
items: ACTIONS.map((action) => ({ ...action })),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
prefix: beforeCursor,
|
|
100
|
+
items: [{ value: "set-model", label: "set-model", description: "Project model preference" }],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (beforeCursor === "/set-model ") {
|
|
104
|
+
return {
|
|
105
|
+
prefix: "",
|
|
106
|
+
items: ACTIONS.map((action) => ({ ...action })),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
113
|
+
const line = lines[cursorLine] ?? "";
|
|
114
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
115
|
+
if (cursorCol === line.length && isSetModelCommandPrefix(beforeCursor)) {
|
|
116
|
+
if (beforeCursor !== "/set-model") {
|
|
117
|
+
return {
|
|
118
|
+
lines: [...lines.slice(0, cursorLine), "/set-model", ...lines.slice(cursorLine + 1)],
|
|
119
|
+
cursorLine,
|
|
120
|
+
cursorCol: "/set-model".length,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const nextLine = `/set-model ${item.value}`;
|
|
124
|
+
return {
|
|
125
|
+
lines: [...lines.slice(0, cursorLine), nextLine, ...lines.slice(cursorLine + 1)],
|
|
126
|
+
cursorLine,
|
|
127
|
+
cursorCol: nextLine.length,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (beforeCursor === "/set-model " && cursorCol === line.length) {
|
|
131
|
+
const nextLine = `/set-model ${item.value}`;
|
|
132
|
+
return {
|
|
133
|
+
lines: [...lines.slice(0, cursorLine), nextLine, ...lines.slice(cursorLine + 1)],
|
|
134
|
+
cursorLine,
|
|
135
|
+
cursorCol: nextLine.length,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
142
|
+
const line = lines[cursorLine] ?? "";
|
|
143
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
144
|
+
if (
|
|
145
|
+
(isSetModelCommandPrefix(beforeCursor) || beforeCursor === "/set-model ") &&
|
|
146
|
+
cursorCol === line.length
|
|
147
|
+
) return false;
|
|
148
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
preferencePath = join(ctx.cwd, CONFIG_DIR_NAME, PREFERENCES_FILE_NAME);
|
|
153
|
+
projectTrusted = ctx.isProjectTrusted();
|
|
154
|
+
preference = projectTrusted ? await loadPreference(preferencePath) : undefined;
|
|
155
|
+
if (!preference) return;
|
|
156
|
+
|
|
157
|
+
const model = ctx.modelRegistry.find(preference.provider, preference.model);
|
|
158
|
+
if (!model) {
|
|
159
|
+
ctx.ui.notify(`Project model unavailable: ${preference.provider}/${preference.model}`, "warning");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const previousModel = ctx.model;
|
|
164
|
+
const previousThinkingLevel = pi.getThinkingLevel();
|
|
165
|
+
const modelChanged =
|
|
166
|
+
!previousModel || previousModel.provider !== model.provider || previousModel.id !== model.id;
|
|
167
|
+
const selected = await pi.setModel(model);
|
|
168
|
+
if (!selected) {
|
|
169
|
+
ctx.ui.notify(`No API key for project model: ${preference.provider}/${preference.model}`, "warning");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const restoredThinkingLevel = clampThinkingLevel(model, preference.thinkingLevel);
|
|
173
|
+
pi.setThinkingLevel(restoredThinkingLevel);
|
|
174
|
+
modelBeforeProjectPreference = previousModel;
|
|
175
|
+
thinkingBeforeProjectPreference = previousThinkingLevel;
|
|
176
|
+
|
|
177
|
+
if (modelChanged || restoredThinkingLevel !== preference.thinkingLevel) {
|
|
178
|
+
ctx.ui.notify(
|
|
179
|
+
ctx.ui.theme.fg(
|
|
180
|
+
"accent",
|
|
181
|
+
`Project model restored: ${preference.provider}/${preference.model} · thinking: ${pi.getThinkingLevel()}` +
|
|
182
|
+
(restoredThinkingLevel !== preference.thinkingLevel
|
|
183
|
+
? ` (saved level ${preference.thinkingLevel} is unsupported)`
|
|
184
|
+
: ""),
|
|
185
|
+
),
|
|
186
|
+
"info",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
pi.on("thinking_level_select", async (event, ctx) => {
|
|
192
|
+
if (
|
|
193
|
+
shuttingDown ||
|
|
194
|
+
!projectTrusted ||
|
|
195
|
+
!preference ||
|
|
196
|
+
!ctx.model ||
|
|
197
|
+
ctx.model.provider !== preference.provider ||
|
|
198
|
+
ctx.model.id !== preference.model ||
|
|
199
|
+
event.level === preference.thinkingLevel
|
|
200
|
+
) return;
|
|
201
|
+
|
|
202
|
+
queueSave({ ...preference, thinkingLevel: event.level });
|
|
203
|
+
await writeQueue;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
pi.on("session_shutdown", async () => {
|
|
207
|
+
shuttingDown = true;
|
|
208
|
+
if (!modelBeforeProjectPreference) return;
|
|
209
|
+
await pi.setModel(modelBeforeProjectPreference);
|
|
210
|
+
if (thinkingBeforeProjectPreference !== undefined) {
|
|
211
|
+
pi.setThinkingLevel(thinkingBeforeProjectPreference);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
pi.registerCommand("set-model", {
|
|
216
|
+
description: "Save, view, or clear this folder's remembered model and thinking level",
|
|
217
|
+
getArgumentCompletions: getActionCompletions,
|
|
218
|
+
handler: async (args, ctx) => {
|
|
219
|
+
const action = args.trim().toLowerCase() || "view";
|
|
220
|
+
if (action === "view") {
|
|
221
|
+
ctx.ui.notify(
|
|
222
|
+
ctx.ui.theme.fg(
|
|
223
|
+
"accent",
|
|
224
|
+
preference
|
|
225
|
+
? `Project model: ${preference.provider}/${preference.model} · thinking: ${preference.thinkingLevel}`
|
|
226
|
+
: "No model preference saved for this folder.",
|
|
227
|
+
),
|
|
228
|
+
"info",
|
|
229
|
+
);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (action === "set") {
|
|
234
|
+
if (!projectTrusted) {
|
|
235
|
+
ctx.ui.notify("Trust this project before saving its model preference.", "warning");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!ctx.model) {
|
|
239
|
+
ctx.ui.notify("No active model to save.", "warning");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const nextPreference: ProjectPreference = {
|
|
244
|
+
provider: ctx.model.provider,
|
|
245
|
+
model: ctx.model.id,
|
|
246
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
247
|
+
};
|
|
248
|
+
queueSave(nextPreference);
|
|
249
|
+
await writeQueue;
|
|
250
|
+
ctx.ui.notify(
|
|
251
|
+
`Saved project model: ${nextPreference.provider}/${nextPreference.model} · thinking: ${nextPreference.thinkingLevel}`,
|
|
252
|
+
"info",
|
|
253
|
+
);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (action === "clear") {
|
|
258
|
+
preference = undefined;
|
|
259
|
+
if (projectTrusted && preferencePath) {
|
|
260
|
+
writeQueue = writeQueue.then(async () => {
|
|
261
|
+
try {
|
|
262
|
+
await unlink(preferencePath);
|
|
263
|
+
} catch (error: unknown) {
|
|
264
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
await writeQueue;
|
|
268
|
+
}
|
|
269
|
+
ctx.ui.notify("Cleared this folder's model preference.", "info");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
ctx.ui.notify("Usage: /set-model [view|set|clear]", "error");
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|