pi-better-openai 0.1.18 → 0.1.21
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 +90 -1
- package/index.ts +308 -1058
- package/package.json +9 -4
- package/src/codex-auth.ts +136 -0
- package/src/config.ts +297 -2
- package/src/fast-controller.ts +109 -0
- package/src/format.ts +81 -20
- package/src/image.ts +182 -200
- package/src/paths.ts +17 -0
- package/src/pet-footer-controller.ts +627 -0
- package/src/pets.ts +212 -63
- package/src/usage-controller.ts +299 -0
- package/src/usage.ts +99 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-openai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "Personal pi extension that improves OpenAI with fast mode, usage stats, and footer polish.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fast",
|
|
@@ -41,15 +41,20 @@
|
|
|
41
41
|
"sharp": "^0.34.5"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@
|
|
44
|
+
"@earendil-works/pi-coding-agent": "0.80.6",
|
|
45
|
+
"@earendil-works/pi-tui": "0.80.6",
|
|
46
|
+
"@types/node": "^24.13.2",
|
|
45
47
|
"oxfmt": "^0.47.0",
|
|
46
48
|
"oxlint": "^1.62.0",
|
|
47
49
|
"typescript": "^6.0.3",
|
|
48
50
|
"vitest": "^4.1.5"
|
|
49
51
|
},
|
|
50
52
|
"peerDependencies": {
|
|
51
|
-
"@
|
|
52
|
-
"@
|
|
53
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
54
|
+
"@earendil-works/pi-tui": "*"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=22.19.0"
|
|
53
58
|
},
|
|
54
59
|
"pi": {
|
|
55
60
|
"extensions": [
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { piAgentDir } from "./paths.ts";
|
|
5
|
+
|
|
6
|
+
export const AUTH_FILE = join(piAgentDir(), "auth.json");
|
|
7
|
+
|
|
8
|
+
export type CodexCredentials = {
|
|
9
|
+
accessToken: string;
|
|
10
|
+
accountId: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type CodexCredentialsWithSource = CodexCredentials & {
|
|
14
|
+
source: "modelRegistry" | "authFile";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function waitForSignal<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
18
|
+
if (!signal) return operation;
|
|
19
|
+
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Operation was aborted."));
|
|
20
|
+
|
|
21
|
+
return new Promise<T>((resolve, reject) => {
|
|
22
|
+
const onAbort = () => {
|
|
23
|
+
cleanup();
|
|
24
|
+
reject(signal.reason ?? new Error("Operation was aborted."));
|
|
25
|
+
};
|
|
26
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
27
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
28
|
+
void operation.then(
|
|
29
|
+
(value) => {
|
|
30
|
+
cleanup();
|
|
31
|
+
resolve(value);
|
|
32
|
+
},
|
|
33
|
+
(error: unknown) => {
|
|
34
|
+
cleanup();
|
|
35
|
+
reject(error);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decodeBase64Url(value: string): string {
|
|
42
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
43
|
+
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
44
|
+
return Buffer.from(padded, "base64").toString("utf8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function extractAccountIdFromJwt(token: string): string | undefined {
|
|
52
|
+
try {
|
|
53
|
+
const [, payload] = token.split(".");
|
|
54
|
+
if (!payload) return undefined;
|
|
55
|
+
const parsed = JSON.parse(decodeBase64Url(payload)) as unknown;
|
|
56
|
+
if (!isRecord(parsed)) return undefined;
|
|
57
|
+
const auth = parsed["https://api.openai.com/auth"];
|
|
58
|
+
if (!isRecord(auth)) return undefined;
|
|
59
|
+
const accountId = auth.chatgpt_account_id;
|
|
60
|
+
return typeof accountId === "string" && accountId.trim() ? accountId.trim() : undefined;
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseCodexRegistryCredentials(
|
|
67
|
+
raw: string | undefined,
|
|
68
|
+
): CodexCredentials | undefined {
|
|
69
|
+
const value = raw?.trim();
|
|
70
|
+
if (!value) return undefined;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(value) as unknown;
|
|
73
|
+
if (isRecord(parsed)) {
|
|
74
|
+
const accessToken =
|
|
75
|
+
typeof parsed.access === "string"
|
|
76
|
+
? parsed.access
|
|
77
|
+
: typeof parsed.token === "string"
|
|
78
|
+
? parsed.token
|
|
79
|
+
: undefined;
|
|
80
|
+
const accountId =
|
|
81
|
+
typeof parsed.accountId === "string"
|
|
82
|
+
? parsed.accountId
|
|
83
|
+
: typeof parsed.account_id === "string"
|
|
84
|
+
? parsed.account_id
|
|
85
|
+
: undefined;
|
|
86
|
+
if (accessToken?.trim() && accountId?.trim())
|
|
87
|
+
return { accessToken: accessToken.trim(), accountId: accountId.trim() };
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Plain bearer token is expected for openai-codex in pi.
|
|
91
|
+
}
|
|
92
|
+
const accountId = extractAccountIdFromJwt(value);
|
|
93
|
+
return accountId ? { accessToken: value, accountId } : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readCodexAuth(): CodexCredentials | undefined {
|
|
97
|
+
try {
|
|
98
|
+
const auth = JSON.parse(readFileSync(AUTH_FILE, "utf8")) as Record<
|
|
99
|
+
string,
|
|
100
|
+
| {
|
|
101
|
+
type?: string;
|
|
102
|
+
access?: string | null;
|
|
103
|
+
accountId?: string | null;
|
|
104
|
+
account_id?: string | null;
|
|
105
|
+
expires?: number | null;
|
|
106
|
+
}
|
|
107
|
+
| undefined
|
|
108
|
+
>;
|
|
109
|
+
const entry = auth["openai-codex"];
|
|
110
|
+
if (entry?.type !== "oauth") return undefined;
|
|
111
|
+
if (typeof entry.expires === "number" && Date.now() >= entry.expires) return undefined;
|
|
112
|
+
const accessToken = entry.access?.trim();
|
|
113
|
+
const accountId = (entry.accountId ?? entry.account_id)?.trim();
|
|
114
|
+
return accessToken && accountId ? { accessToken, accountId } : undefined;
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function getCodexCredentials(
|
|
121
|
+
ctx?: Pick<ExtensionContext, "modelRegistry">,
|
|
122
|
+
signal?: AbortSignal,
|
|
123
|
+
): Promise<CodexCredentialsWithSource | undefined> {
|
|
124
|
+
if (signal?.aborted) throw signal.reason ?? new Error("Operation was aborted.");
|
|
125
|
+
const registryRequest = ctx?.modelRegistry?.getApiKeyForProvider("openai-codex");
|
|
126
|
+
const registryToken = registryRequest
|
|
127
|
+
? await waitForSignal(
|
|
128
|
+
registryRequest.catch(() => undefined),
|
|
129
|
+
signal,
|
|
130
|
+
)
|
|
131
|
+
: undefined;
|
|
132
|
+
const registryCredentials = parseCodexRegistryCredentials(registryToken);
|
|
133
|
+
if (registryCredentials) return { ...registryCredentials, source: "modelRegistry" };
|
|
134
|
+
const auth = readCodexAuth();
|
|
135
|
+
return auth ? { ...auth, source: "authFile" } : undefined;
|
|
136
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { CONFIG_BASENAME, logPrefix } from "./identity.ts";
|
|
5
|
+
import { piAgentDir } from "./paths.ts";
|
|
5
6
|
|
|
6
7
|
export const FOOTER_MODES = ["replace", "status", "off"] as const;
|
|
7
8
|
export const IMAGE_SAVE_MODES = ["none", "project", "global", "custom"] as const;
|
|
@@ -145,14 +146,274 @@ export const DEFAULT_CONFIG: ConfigFile = {
|
|
|
145
146
|
pets: DEFAULT_PET_CONFIG,
|
|
146
147
|
};
|
|
147
148
|
|
|
149
|
+
export type SettingsOptionSection = "root" | "usage" | "footer" | "image" | "pets";
|
|
150
|
+
|
|
151
|
+
export type SettingsValueContext = {
|
|
152
|
+
petEmptyValue?: string;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export type SettingsOptionDescriptor = {
|
|
156
|
+
id: string;
|
|
157
|
+
section: SettingsOptionSection;
|
|
158
|
+
key: string;
|
|
159
|
+
label: string;
|
|
160
|
+
description: string;
|
|
161
|
+
values?: readonly string[];
|
|
162
|
+
parse(rawValue: string, context?: SettingsValueContext): boolean | number | string;
|
|
163
|
+
currentValue(cfg: ResolvedConfig): string;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const booleanSetting = (rawValue: string): boolean => rawValue === "true";
|
|
167
|
+
const numberSetting = (rawValue: string): number => Number(rawValue);
|
|
168
|
+
const stringSetting = (rawValue: string): string => rawValue;
|
|
169
|
+
|
|
170
|
+
export const FAST_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
|
|
171
|
+
{
|
|
172
|
+
id: "persistState",
|
|
173
|
+
section: "root",
|
|
174
|
+
key: "persistState",
|
|
175
|
+
label: "Persist fast state",
|
|
176
|
+
currentValue: (cfg) => String(cfg.persistState),
|
|
177
|
+
values: ["true", "false"],
|
|
178
|
+
description: "Remember fast-mode state across sessions.",
|
|
179
|
+
parse: booleanSetting,
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
export const FOOTER_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
|
|
184
|
+
{
|
|
185
|
+
id: "footer.mode",
|
|
186
|
+
section: "footer",
|
|
187
|
+
key: "mode",
|
|
188
|
+
label: "Footer mode",
|
|
189
|
+
currentValue: (cfg) => cfg.footer.mode,
|
|
190
|
+
values: FOOTER_MODES,
|
|
191
|
+
description:
|
|
192
|
+
"replace = custom footer, status = pi footer plus status line, off = no Better OpenAI footer/status unless Footer pet is enabled.",
|
|
193
|
+
parse: stringSetting,
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
export const USAGE_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
|
|
198
|
+
{
|
|
199
|
+
id: "usage.enabled",
|
|
200
|
+
section: "usage",
|
|
201
|
+
key: "enabled",
|
|
202
|
+
label: "Usage display",
|
|
203
|
+
currentValue: (cfg) => String(cfg.usage.enabled),
|
|
204
|
+
values: ["true", "false"],
|
|
205
|
+
description: "Fetch and display OpenAI subscription usage windows.",
|
|
206
|
+
parse: booleanSetting,
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: "usage.refreshIntervalMs",
|
|
210
|
+
section: "usage",
|
|
211
|
+
key: "refreshIntervalMs",
|
|
212
|
+
label: "Usage refresh",
|
|
213
|
+
currentValue: (cfg) => String(cfg.usage.refreshIntervalMs),
|
|
214
|
+
values: ["15000", "30000", "60000", "120000", "300000", "600000"],
|
|
215
|
+
description: "Usage refresh interval in milliseconds.",
|
|
216
|
+
parse: numberSetting,
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: "usage.showOnlyOnSubscriptionModels",
|
|
220
|
+
section: "usage",
|
|
221
|
+
key: "showOnlyOnSubscriptionModels",
|
|
222
|
+
label: "Usage only on OAuth",
|
|
223
|
+
currentValue: (cfg) => String(cfg.usage.showOnlyOnSubscriptionModels),
|
|
224
|
+
values: ["true", "false"],
|
|
225
|
+
description: "Only show usage when the current OpenAI model uses subscription/OAuth auth.",
|
|
226
|
+
parse: booleanSetting,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "usage.showResetTimes",
|
|
230
|
+
section: "usage",
|
|
231
|
+
key: "showResetTimes",
|
|
232
|
+
label: "Usage reset times",
|
|
233
|
+
currentValue: (cfg) => String(cfg.usage.showResetTimes),
|
|
234
|
+
values: ["true", "false"],
|
|
235
|
+
description: "Include compact reset countdowns and local reset times.",
|
|
236
|
+
parse: booleanSetting,
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
export const IMAGE_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
|
|
241
|
+
{
|
|
242
|
+
id: "image.enabled",
|
|
243
|
+
section: "image",
|
|
244
|
+
key: "enabled",
|
|
245
|
+
label: "Image tool",
|
|
246
|
+
currentValue: (cfg) => String(cfg.image.enabled),
|
|
247
|
+
values: ["true", "false"],
|
|
248
|
+
description: "Allow the openai_image tool to make image requests.",
|
|
249
|
+
parse: booleanSetting,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: "image.defaultModel",
|
|
253
|
+
section: "image",
|
|
254
|
+
key: "defaultModel",
|
|
255
|
+
label: "Image model",
|
|
256
|
+
currentValue: (cfg) => cfg.image.defaultModel,
|
|
257
|
+
values: ["gpt-5.5", "gpt-5.4", "gpt-5.2", "gpt-5"],
|
|
258
|
+
description: "Mainline model used for image generation when current model is not openai-codex.",
|
|
259
|
+
parse: stringSetting,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: "image.defaultSave",
|
|
263
|
+
section: "image",
|
|
264
|
+
key: "defaultSave",
|
|
265
|
+
label: "Image save",
|
|
266
|
+
currentValue: (cfg) => cfg.image.defaultSave,
|
|
267
|
+
values: IMAGE_SAVE_MODES,
|
|
268
|
+
description: "Where generated images are saved by default.",
|
|
269
|
+
parse: stringSetting,
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
id: "image.outputFormat",
|
|
273
|
+
section: "image",
|
|
274
|
+
key: "outputFormat",
|
|
275
|
+
label: "Image format",
|
|
276
|
+
currentValue: (cfg) => cfg.image.outputFormat,
|
|
277
|
+
values: IMAGE_OUTPUT_FORMATS,
|
|
278
|
+
description: "Generated image file format.",
|
|
279
|
+
parse: stringSetting,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: "image.timeoutMs",
|
|
283
|
+
section: "image",
|
|
284
|
+
key: "timeoutMs",
|
|
285
|
+
label: "Image timeout",
|
|
286
|
+
currentValue: (cfg) => String(cfg.image.timeoutMs),
|
|
287
|
+
values: ["30000", "60000", "120000", "180000", "300000"],
|
|
288
|
+
description: "Image request timeout in milliseconds.",
|
|
289
|
+
parse: numberSetting,
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
export const PET_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
|
|
294
|
+
{
|
|
295
|
+
id: "pets.enabled",
|
|
296
|
+
section: "pets",
|
|
297
|
+
key: "enabled",
|
|
298
|
+
label: "Enabled",
|
|
299
|
+
currentValue: (cfg) => String(cfg.pets.enabled),
|
|
300
|
+
values: ["true", "false"],
|
|
301
|
+
description:
|
|
302
|
+
"Render a custom Codex pet from ${CODEX_HOME:-~/.codex}/pets in the Better OpenAI footer.",
|
|
303
|
+
parse: booleanSetting,
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
id: "pets.slug",
|
|
307
|
+
section: "pets",
|
|
308
|
+
key: "slug",
|
|
309
|
+
label: "Pet",
|
|
310
|
+
currentValue: (cfg) => cfg.pets.slug,
|
|
311
|
+
description: "Selected custom pet slug.",
|
|
312
|
+
parse: (rawValue, context) =>
|
|
313
|
+
rawValue === (context?.petEmptyValue ?? "not selected") ? "" : rawValue,
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
id: "pets.placement",
|
|
317
|
+
section: "pets",
|
|
318
|
+
key: "placement",
|
|
319
|
+
label: "Placement",
|
|
320
|
+
currentValue: (cfg) => cfg.pets.placement,
|
|
321
|
+
values: PET_PLACEMENTS,
|
|
322
|
+
description: "Footer layout: stacked, inline-left, inline-right, badge, or habitat divider.",
|
|
323
|
+
parse: stringSetting,
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
id: "pets.state",
|
|
327
|
+
section: "pets",
|
|
328
|
+
key: "state",
|
|
329
|
+
label: "Idle state",
|
|
330
|
+
currentValue: (cfg) => cfg.pets.state,
|
|
331
|
+
values: PET_STATES,
|
|
332
|
+
description: "Animation row to show when pi is idle.",
|
|
333
|
+
parse: stringSetting,
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: "pets.thinkingState",
|
|
337
|
+
section: "pets",
|
|
338
|
+
key: "thinkingState",
|
|
339
|
+
label: "Thinking state",
|
|
340
|
+
currentValue: (cfg) => cfg.pets.thinkingState,
|
|
341
|
+
values: PET_STATES,
|
|
342
|
+
description: "Animation row to show while the model is thinking or streaming.",
|
|
343
|
+
parse: stringSetting,
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
id: "pets.toolState",
|
|
347
|
+
section: "pets",
|
|
348
|
+
key: "toolState",
|
|
349
|
+
label: "Tool state",
|
|
350
|
+
currentValue: (cfg) => cfg.pets.toolState,
|
|
351
|
+
values: PET_STATES,
|
|
352
|
+
description: "Animation row to show during tool execution.",
|
|
353
|
+
parse: stringSetting,
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
id: "pets.failedToolState",
|
|
357
|
+
section: "pets",
|
|
358
|
+
key: "failedToolState",
|
|
359
|
+
label: "Failed tool state",
|
|
360
|
+
currentValue: (cfg) => cfg.pets.failedToolState,
|
|
361
|
+
values: PET_STATES,
|
|
362
|
+
description: "Animation row to flash after any tool call returns an error.",
|
|
363
|
+
parse: stringSetting,
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
id: "pets.idleEmotes",
|
|
367
|
+
section: "pets",
|
|
368
|
+
key: "idleEmotes",
|
|
369
|
+
label: "Random idle emotes",
|
|
370
|
+
currentValue: (cfg) => String(cfg.pets.idleEmotes),
|
|
371
|
+
values: ["true", "false"],
|
|
372
|
+
description: "Occasionally flash a wave or jump while pi is idle.",
|
|
373
|
+
parse: booleanSetting,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
id: "pets.idleEmoteIntervalMs",
|
|
377
|
+
section: "pets",
|
|
378
|
+
key: "idleEmoteIntervalMs",
|
|
379
|
+
label: "Idle emote interval",
|
|
380
|
+
currentValue: (cfg) => String(cfg.pets.idleEmoteIntervalMs),
|
|
381
|
+
values: ["5000", "15000", "30000", "60000", "120000", "300000"],
|
|
382
|
+
description: "Average delay between random idle pet emotes in milliseconds.",
|
|
383
|
+
parse: numberSetting,
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
id: "pets.sizeCells",
|
|
387
|
+
section: "pets",
|
|
388
|
+
key: "sizeCells",
|
|
389
|
+
label: "Size",
|
|
390
|
+
currentValue: (cfg) => String(cfg.pets.sizeCells),
|
|
391
|
+
values: ["4", "6", "8", "10", "12", "16"],
|
|
392
|
+
description: "Pet image width in terminal cells.",
|
|
393
|
+
parse: numberSetting,
|
|
394
|
+
},
|
|
395
|
+
];
|
|
396
|
+
|
|
397
|
+
export const SETTINGS_OPTION_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
|
|
398
|
+
...FAST_SETTING_DESCRIPTORS,
|
|
399
|
+
...FOOTER_SETTING_DESCRIPTORS,
|
|
400
|
+
...USAGE_SETTING_DESCRIPTORS,
|
|
401
|
+
...IMAGE_SETTING_DESCRIPTORS,
|
|
402
|
+
...PET_SETTING_DESCRIPTORS,
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
const SETTINGS_OPTION_BY_ID = new Map(
|
|
406
|
+
SETTINGS_OPTION_DESCRIPTORS.map((descriptor) => [descriptor.id, descriptor]),
|
|
407
|
+
);
|
|
408
|
+
|
|
148
409
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
149
410
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
150
411
|
}
|
|
151
412
|
|
|
152
|
-
export function configPaths(cwd: string, home = homedir()) {
|
|
413
|
+
export function configPaths(cwd: string, home = homedir(), env = process.env) {
|
|
153
414
|
return {
|
|
154
415
|
project: join(cwd, ".pi", "extensions", CONFIG_BASENAME),
|
|
155
|
-
global: join(
|
|
416
|
+
global: join(piAgentDir(env, home), "extensions", CONFIG_BASENAME),
|
|
156
417
|
};
|
|
157
418
|
}
|
|
158
419
|
|
|
@@ -276,6 +537,40 @@ export function readConfig(path: string): ConfigFile | undefined {
|
|
|
276
537
|
return config;
|
|
277
538
|
}
|
|
278
539
|
|
|
540
|
+
export type SettingPatchContext = SettingsValueContext & {
|
|
541
|
+
persistState?: boolean;
|
|
542
|
+
active?: boolean;
|
|
543
|
+
desiredActive?: boolean;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
export function applySettingToRawConfig(
|
|
547
|
+
current: Record<string, unknown>,
|
|
548
|
+
id: string,
|
|
549
|
+
rawValue: string,
|
|
550
|
+
context: SettingPatchContext = {},
|
|
551
|
+
): Record<string, unknown> {
|
|
552
|
+
const next: Record<string, unknown> = { ...current };
|
|
553
|
+
const bool = rawValue === "true";
|
|
554
|
+
if (id === "fast.enabled") {
|
|
555
|
+
if (context.persistState) {
|
|
556
|
+
next.active = context.active ?? bool;
|
|
557
|
+
next.desiredActive = context.desiredActive ?? bool;
|
|
558
|
+
}
|
|
559
|
+
} else {
|
|
560
|
+
const descriptor = SETTINGS_OPTION_BY_ID.get(id);
|
|
561
|
+
if (!descriptor) return next;
|
|
562
|
+
const parsedValue = descriptor.parse(rawValue, context);
|
|
563
|
+
if (descriptor.section === "root") next[descriptor.key] = parsedValue;
|
|
564
|
+
else {
|
|
565
|
+
const currentSection = next[descriptor.section];
|
|
566
|
+
const section = isRecord(currentSection) ? { ...currentSection } : {};
|
|
567
|
+
section[descriptor.key] = parsedValue;
|
|
568
|
+
next[descriptor.section] = section;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return next;
|
|
572
|
+
}
|
|
573
|
+
|
|
279
574
|
export function writeConfig(path: string, config: ConfigFile | Record<string, unknown>): void {
|
|
280
575
|
try {
|
|
281
576
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ResolvedConfig, SupportedModel } from "./config.ts";
|
|
3
|
+
import { isRecord } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
export function currentModelKey(ctx: ExtensionContext): string {
|
|
6
|
+
return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function supportsFast(ctx: ExtensionContext, supportedModels: SupportedModel[]): boolean {
|
|
10
|
+
const current = ctx.model;
|
|
11
|
+
if (!current) return false;
|
|
12
|
+
return supportedModels.some(
|
|
13
|
+
(model) => model.provider === current.provider && model.id === current.id,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function modelList(supportedModels: SupportedModel[]): string {
|
|
18
|
+
return supportedModels.length > 0
|
|
19
|
+
? supportedModels.map((model) => `${model.provider}/${model.id}`).join(", ")
|
|
20
|
+
: "none configured";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function fastStateText(
|
|
24
|
+
ctx: ExtensionContext,
|
|
25
|
+
desiredActive: boolean,
|
|
26
|
+
active: boolean,
|
|
27
|
+
supportedModels: SupportedModel[],
|
|
28
|
+
): string {
|
|
29
|
+
const model = currentModelKey(ctx);
|
|
30
|
+
if (active) return `Fast mode is on for ${model}.`;
|
|
31
|
+
if (desiredActive) {
|
|
32
|
+
return `Fast mode is requested, but inactive for unsupported model ${model}. Supported models: ${modelList(supportedModels)}.`;
|
|
33
|
+
}
|
|
34
|
+
return `Fast mode is off. Current model: ${model}.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class FastController {
|
|
38
|
+
desiredActive = false;
|
|
39
|
+
active = false;
|
|
40
|
+
private lastInjectedAt: number | undefined;
|
|
41
|
+
private lastInjectedModel: string | undefined;
|
|
42
|
+
private lastInjectedTier: string | undefined;
|
|
43
|
+
|
|
44
|
+
constructor(private readonly serviceTier: string) {}
|
|
45
|
+
|
|
46
|
+
applyDesiredState(ctx: ExtensionContext, cfg: ResolvedConfig): void {
|
|
47
|
+
this.active = this.desiredActive && supportsFast(ctx, cfg.supportedModels);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
initializeForSession(ctx: ExtensionContext, cfg: ResolvedConfig, flagActive: boolean): void {
|
|
51
|
+
this.desiredActive = cfg.persistState ? cfg.desiredActive : false;
|
|
52
|
+
if (flagActive) this.desiredActive = true;
|
|
53
|
+
this.applyDesiredState(ctx, cfg);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setDesired(ctx: ExtensionContext, cfg: ResolvedConfig, next: boolean): void {
|
|
57
|
+
this.desiredActive = next;
|
|
58
|
+
this.applyDesiredState(ctx, cfg);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
stateText(ctx: ExtensionContext, cfg: ResolvedConfig): string {
|
|
62
|
+
return fastStateText(ctx, this.desiredActive, this.active, cfg.supportedModels);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
unsupportedRequestMessage(ctx: ExtensionContext, cfg: ResolvedConfig): string {
|
|
66
|
+
return `Fast mode requested, but ${currentModelKey(ctx)} is unsupported. It will activate automatically when you switch to a supported model: ${modelList(cfg.supportedModels)}.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
inactiveForModelMessage(ctx: ExtensionContext): string {
|
|
70
|
+
return `Fast mode inactive for unsupported model ${currentModelKey(ctx)}.`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
settingsSummary(ctx: ExtensionContext, cfg: ResolvedConfig): string {
|
|
74
|
+
if (this.active) return "on";
|
|
75
|
+
if (this.desiredActive)
|
|
76
|
+
return supportsFast(ctx, cfg.supportedModels) ? "requested" : "requested inactive";
|
|
77
|
+
return "off";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
statusSegment(ctx: ExtensionContext, cfg: ResolvedConfig): string | undefined {
|
|
81
|
+
return this.active && supportsFast(ctx, cfg.supportedModels)
|
|
82
|
+
? `${ctx.model?.id ?? "model"} fast`
|
|
83
|
+
: undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
injectProviderPayload(
|
|
87
|
+
event: { payload?: unknown },
|
|
88
|
+
ctx: ExtensionContext,
|
|
89
|
+
cfg: ResolvedConfig,
|
|
90
|
+
): unknown {
|
|
91
|
+
if (!this.active || !supportsFast(ctx, cfg.supportedModels) || !isRecord(event.payload))
|
|
92
|
+
return undefined;
|
|
93
|
+
this.lastInjectedAt = Date.now();
|
|
94
|
+
this.lastInjectedModel = currentModelKey(ctx);
|
|
95
|
+
this.lastInjectedTier = this.serviceTier;
|
|
96
|
+
return { ...event.payload, service_tier: this.serviceTier };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
debugLines(ctx: ExtensionContext, cfg: ResolvedConfig): string[] {
|
|
100
|
+
return [
|
|
101
|
+
`Fast desired: ${this.desiredActive}`,
|
|
102
|
+
`Fast active: ${this.active}`,
|
|
103
|
+
`Current model: ${currentModelKey(ctx)}`,
|
|
104
|
+
`Supported model: ${supportsFast(ctx, cfg.supportedModels)}`,
|
|
105
|
+
`Configured service_tier: ${this.serviceTier}`,
|
|
106
|
+
`Last injected: ${this.lastInjectedAt ? `${new Date(this.lastInjectedAt).toLocaleTimeString()} (${this.lastInjectedModel}, ${this.lastInjectedTier})` : "never"}`,
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
}
|