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