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