advisor-pi 1.1.0 → 1.2.0
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 +25 -0
- package/dist/config-ui.d.ts +12 -0
- package/dist/config-ui.d.ts.map +1 -0
- package/dist/config-ui.js +26 -0
- package/dist/config-ui.js.map +1 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +188 -38
- package/dist/index.js.map +1 -1
- package/dist/preferences.d.ts +40 -0
- package/dist/preferences.d.ts.map +1 -0
- package/dist/preferences.js +198 -0
- package/dist/preferences.js.map +1 -0
- package/package.json +1 -1
- package/src/config-ui.ts +40 -0
- package/src/index.ts +206 -36
- package/src/preferences.ts +228 -0
package/src/index.ts
CHANGED
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
type ExtensionContext,
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Type, type Static } from "typebox";
|
|
10
|
+
import { loadPreferences, savePreferencesPatch, type AdvisorPreferencesPatch } from "./preferences.js";
|
|
11
|
+
import { buildMenuRows, type MenuKey, rowForLabel } from "./config-ui.js";
|
|
12
|
+
|
|
13
|
+
export { loadPreferences, preferencesPath, savePreferences, savePreferencesPatch } from "./preferences.js";
|
|
14
|
+
export type { AdvisorPreferences, AdvisorPreferencesConfig, AdvisorPreferencesPatch } from "./preferences.js";
|
|
10
15
|
|
|
11
16
|
export const STATE_ENTRY = "advisor-pi-state";
|
|
12
17
|
const TOOL_NAME = "advisor";
|
|
@@ -41,7 +46,9 @@ const advisorToolSchema = Type.Object({
|
|
|
41
46
|
type AdvisorToolInput = Static<typeof advisorToolSchema>;
|
|
42
47
|
type AdvisorThinkingLevel = ThinkingLevel | "max";
|
|
43
48
|
|
|
44
|
-
type
|
|
49
|
+
type ModeAwareContext = ExtensionContext & { mode?: string };
|
|
50
|
+
|
|
51
|
+
export type AdvisorConfig = {
|
|
45
52
|
enabled: boolean;
|
|
46
53
|
provider: string;
|
|
47
54
|
modelId: string;
|
|
@@ -81,8 +88,16 @@ export type ModelRegistryLike = {
|
|
|
81
88
|
getAvailable?: () => Array<{ provider?: unknown; id?: unknown }>;
|
|
82
89
|
};
|
|
83
90
|
|
|
91
|
+
export type NormalizeConfigOptions = {
|
|
92
|
+
migrateLegacyDefault?: boolean;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
function isTuiContext(ctx: ExtensionContext): boolean {
|
|
96
|
+
return (ctx as ModeAwareContext).mode === "tui" && ctx.hasUI;
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
85
|
-
let config = defaultConfig();
|
|
100
|
+
let config = normalizeConfig(loadPreferences(), defaultConfig(), undefined, { migrateLegacyDefault: false });
|
|
86
101
|
let useCount = 0;
|
|
87
102
|
|
|
88
103
|
pi.registerFlag("advisor-model", {
|
|
@@ -108,7 +123,6 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
108
123
|
pi.registerFlag("advisor-enabled", {
|
|
109
124
|
description: "Enable the advisor tool on startup",
|
|
110
125
|
type: "boolean",
|
|
111
|
-
default: true,
|
|
112
126
|
});
|
|
113
127
|
|
|
114
128
|
pi.registerTool<typeof advisorToolSchema, AdvisorToolDetails>({
|
|
@@ -126,8 +140,8 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
126
140
|
parameters: advisorToolSchema,
|
|
127
141
|
executionMode: "sequential",
|
|
128
142
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
129
|
-
|
|
130
|
-
|
|
143
|
+
// Branch state is replayed at session_start/session_tree; refreshing here
|
|
144
|
+
// would reapply startup flags over explicit in-session command edits.
|
|
131
145
|
if (!config.enabled) {
|
|
132
146
|
return advisorDisabledResult(config, useCount, params);
|
|
133
147
|
}
|
|
@@ -231,11 +245,14 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
231
245
|
pi.registerCommand("advisor-pi", {
|
|
232
246
|
description: "Configure advisor-pi: status, enable, disable, model, thinking, max-uses, max-transcript-chars, cache, reset",
|
|
233
247
|
handler: async (args, ctx) => {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
248
|
+
const trimmed = args.trim();
|
|
249
|
+
// Bare /advisor-pi is interactive only in the terminal TUI. Other modes
|
|
250
|
+
// keep the existing status response for scripts and print output.
|
|
251
|
+
if (!trimmed && isTuiContext(ctx)) {
|
|
252
|
+
await openConfigPanel(ctx);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await applyCommand(trimmed, ctx);
|
|
239
256
|
},
|
|
240
257
|
});
|
|
241
258
|
|
|
@@ -259,10 +276,11 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
259
276
|
});
|
|
260
277
|
|
|
261
278
|
function refreshStateFromBranch(ctx: ExtensionContext): void {
|
|
262
|
-
config = defaultConfig();
|
|
279
|
+
config = normalizeConfig(loadPreferences(), defaultConfig(), ctx.modelRegistry, { migrateLegacyDefault: false });
|
|
280
|
+
|
|
263
281
|
useCount = 0;
|
|
264
|
-
|
|
265
|
-
|
|
282
|
+
restoreStateFromSession(ctx, ctx.modelRegistry);
|
|
283
|
+
applyStartupFlags(pi, ctx.modelRegistry);
|
|
266
284
|
ensureAdvisorModelResolves(ctx);
|
|
267
285
|
syncActiveTool(pi);
|
|
268
286
|
updateStatus(ctx);
|
|
@@ -290,10 +308,10 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
290
308
|
}
|
|
291
309
|
|
|
292
310
|
function applyStartupFlags(api: ExtensionAPI, registry?: ModelRegistryLike): void {
|
|
293
|
-
const enabledFlag = api
|
|
311
|
+
const enabledFlag = flagValue(api, "advisor-enabled");
|
|
294
312
|
if (typeof enabledFlag === "boolean") config.enabled = enabledFlag;
|
|
295
313
|
|
|
296
|
-
const modelFlag = api
|
|
314
|
+
const modelFlag = flagValue(api, "advisor-model");
|
|
297
315
|
if (typeof modelFlag === "string" && modelFlag.trim()) {
|
|
298
316
|
const parsed = parseModelSpec(modelFlag.trim());
|
|
299
317
|
if (parsed && (!registry || modelResolves(registry, parsed.provider, parsed.modelId))) {
|
|
@@ -302,25 +320,25 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
302
320
|
}
|
|
303
321
|
}
|
|
304
322
|
|
|
305
|
-
const thinkingFlag = api
|
|
323
|
+
const thinkingFlag = flagValue(api, "advisor-thinking");
|
|
306
324
|
if (typeof thinkingFlag === "string") {
|
|
307
325
|
const thinkingLevel = parseThinkingLevel(thinkingFlag);
|
|
308
326
|
if (thinkingLevel) config.thinkingLevel = thinkingLevel;
|
|
309
327
|
}
|
|
310
328
|
|
|
311
|
-
const maxUsesFlag = api
|
|
329
|
+
const maxUsesFlag = flagValue(api, "advisor-max-uses");
|
|
312
330
|
if (typeof maxUsesFlag === "string") {
|
|
313
331
|
const maxUses = parsePositiveInt(maxUsesFlag);
|
|
314
332
|
if (maxUses !== undefined) config.maxUses = maxUses;
|
|
315
333
|
}
|
|
316
334
|
|
|
317
|
-
const cacheFlag = api
|
|
335
|
+
const cacheFlag = flagValue(api, "advisor-cache");
|
|
318
336
|
if (typeof cacheFlag === "string") {
|
|
319
337
|
const cacheRetention = parseCacheRetention(cacheFlag);
|
|
320
338
|
if (cacheRetention) config.cacheRetention = cacheRetention;
|
|
321
339
|
}
|
|
322
340
|
|
|
323
|
-
const maxTranscriptCharsFlag = api
|
|
341
|
+
const maxTranscriptCharsFlag = flagValue(api, "advisor-max-transcript-chars");
|
|
324
342
|
if (typeof maxTranscriptCharsFlag === "string") {
|
|
325
343
|
const maxTranscriptChars = parsePositiveInt(maxTranscriptCharsFlag);
|
|
326
344
|
if (maxTranscriptChars !== undefined) config.maxTranscriptChars = maxTranscriptChars;
|
|
@@ -344,10 +362,23 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
344
362
|
}
|
|
345
363
|
}
|
|
346
364
|
|
|
365
|
+
async function persistPreferencesForEdit(ctx: ExtensionContext, patch: AdvisorPreferencesPatch): Promise<void> {
|
|
366
|
+
if (await savePreferencesPatch(patch)) return;
|
|
367
|
+
if (ctx.hasUI) {
|
|
368
|
+
ctx.ui.notify("Could not save advisor-pi preferences; this change applies to the current session only.", "warning");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
347
372
|
function handleCommand(
|
|
348
373
|
args: string,
|
|
349
374
|
ctx: ExtensionContext,
|
|
350
|
-
): {
|
|
375
|
+
): {
|
|
376
|
+
message: string;
|
|
377
|
+
level: "info" | "warning" | "error";
|
|
378
|
+
persist: boolean;
|
|
379
|
+
preferencePatch?: AdvisorPreferencesPatch;
|
|
380
|
+
updateToolState: boolean;
|
|
381
|
+
} {
|
|
351
382
|
if (!args || args === "status") {
|
|
352
383
|
return {
|
|
353
384
|
message: formatStatus(ctx),
|
|
@@ -363,10 +394,22 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
363
394
|
switch (command) {
|
|
364
395
|
case "enable":
|
|
365
396
|
config.enabled = true;
|
|
366
|
-
return {
|
|
397
|
+
return {
|
|
398
|
+
message: "advisor-pi enabled",
|
|
399
|
+
level: "info",
|
|
400
|
+
persist: true,
|
|
401
|
+
preferencePatch: { enabled: true },
|
|
402
|
+
updateToolState: true,
|
|
403
|
+
};
|
|
367
404
|
case "disable":
|
|
368
405
|
config.enabled = false;
|
|
369
|
-
return {
|
|
406
|
+
return {
|
|
407
|
+
message: "advisor-pi disabled",
|
|
408
|
+
level: "info",
|
|
409
|
+
persist: true,
|
|
410
|
+
preferencePatch: { enabled: false },
|
|
411
|
+
updateToolState: true,
|
|
412
|
+
};
|
|
370
413
|
case "reset":
|
|
371
414
|
useCount = 0;
|
|
372
415
|
return { message: "advisor-pi use count reset", level: "info", persist: true, updateToolState: false };
|
|
@@ -395,6 +438,7 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
395
438
|
message: `advisor-pi model set to ${config.provider}/${config.modelId}`,
|
|
396
439
|
level: "info",
|
|
397
440
|
persist: true,
|
|
441
|
+
preferencePatch: { provider: config.provider, modelId: config.modelId },
|
|
398
442
|
updateToolState: false,
|
|
399
443
|
};
|
|
400
444
|
}
|
|
@@ -413,6 +457,7 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
413
457
|
message: `advisor-pi thinking level set to ${thinkingLevel}`,
|
|
414
458
|
level: "info",
|
|
415
459
|
persist: true,
|
|
460
|
+
preferencePatch: { thinkingLevel },
|
|
416
461
|
updateToolState: false,
|
|
417
462
|
};
|
|
418
463
|
}
|
|
@@ -431,6 +476,7 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
431
476
|
message: `advisor-pi max uses set to ${maxUses}`,
|
|
432
477
|
level: "info",
|
|
433
478
|
persist: true,
|
|
479
|
+
preferencePatch: { maxUses },
|
|
434
480
|
updateToolState: false,
|
|
435
481
|
};
|
|
436
482
|
}
|
|
@@ -449,6 +495,7 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
449
495
|
message: `advisor-pi cache set to ${cacheRetention}`,
|
|
450
496
|
level: "info",
|
|
451
497
|
persist: true,
|
|
498
|
+
preferencePatch: { cacheRetention },
|
|
452
499
|
updateToolState: false,
|
|
453
500
|
};
|
|
454
501
|
}
|
|
@@ -467,6 +514,7 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
467
514
|
message: `advisor-pi max transcript chars set to ${maxTranscriptChars}`,
|
|
468
515
|
level: "info",
|
|
469
516
|
persist: true,
|
|
517
|
+
preferencePatch: { maxTranscriptChars },
|
|
470
518
|
updateToolState: false,
|
|
471
519
|
};
|
|
472
520
|
}
|
|
@@ -481,6 +529,110 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
|
|
|
481
529
|
}
|
|
482
530
|
}
|
|
483
531
|
|
|
532
|
+
async function applyCommand(
|
|
533
|
+
args: string,
|
|
534
|
+
ctx: ExtensionContext,
|
|
535
|
+
options: { redactModelErrors?: boolean } = {},
|
|
536
|
+
): Promise<void> {
|
|
537
|
+
const result = handleCommand(args, ctx);
|
|
538
|
+
if (result.persist) {
|
|
539
|
+
persistState(pi, config, useCount);
|
|
540
|
+
if (result.preferencePatch) await persistPreferencesForEdit(ctx, result.preferencePatch);
|
|
541
|
+
}
|
|
542
|
+
if (result.updateToolState) syncActiveTool(pi);
|
|
543
|
+
updateStatus(ctx);
|
|
544
|
+
const message = options.redactModelErrors && result.level === "error" ? "Advisor model not found" : result.message;
|
|
545
|
+
ctx.ui.notify(message, result.level);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function openConfigPanel(ctx: ExtensionContext): Promise<void> {
|
|
549
|
+
for (;;) {
|
|
550
|
+
const rows = buildMenuRows(config, useCount);
|
|
551
|
+
const choice = await ctx.ui.select("advisor-pi settings", rows.map((row) => row.label));
|
|
552
|
+
const row = rowForLabel(rows, choice);
|
|
553
|
+
// Escape returns undefined, which closes the panel without changing state.
|
|
554
|
+
if (!row || row.key === "close") return;
|
|
555
|
+
await applyMenuChoice(row.key, ctx);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function applyMenuChoice(key: MenuKey, ctx: ExtensionContext): Promise<void> {
|
|
560
|
+
switch (key) {
|
|
561
|
+
case "enabled": {
|
|
562
|
+
const choice = await ctx.ui.select("Advisor enabled", ["enabled", "disabled"]);
|
|
563
|
+
if (choice !== "enabled" && choice !== "disabled") return;
|
|
564
|
+
const enabled = choice === "enabled";
|
|
565
|
+
if (enabled === config.enabled) return;
|
|
566
|
+
const confirmed = await ctx.ui.confirm(
|
|
567
|
+
enabled ? "Enable advisor-pi?" : "Disable advisor-pi?",
|
|
568
|
+
enabled
|
|
569
|
+
? "The advisor tool will be available to the executor."
|
|
570
|
+
: "The advisor tool will be removed from the active tool list.",
|
|
571
|
+
);
|
|
572
|
+
if (!confirmed) return;
|
|
573
|
+
await applyCommand(enabled ? "enable" : "disable", ctx);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
case "model": {
|
|
577
|
+
const typed = await ctx.ui.input("Advisor model", "provider/model");
|
|
578
|
+
if (typed === undefined) return;
|
|
579
|
+
const parsed = parseModelSpec(typed);
|
|
580
|
+
if (!parsed) {
|
|
581
|
+
ctx.ui.notify("Expected provider/model", "error");
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
let model: unknown;
|
|
585
|
+
try {
|
|
586
|
+
model = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
|
|
587
|
+
} catch {
|
|
588
|
+
model = undefined;
|
|
589
|
+
}
|
|
590
|
+
if (!model) {
|
|
591
|
+
ctx.ui.notify("Advisor model not found", "error");
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
await applyCommand(`model ${parsed.provider}/${parsed.modelId}`, ctx, { redactModelErrors: true });
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
case "thinking": {
|
|
598
|
+
const choice = await ctx.ui.select("Advisor thinking", ["minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
599
|
+
if (!parseThinkingLevel(choice)) return;
|
|
600
|
+
await applyCommand(`thinking ${choice}`, ctx);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
case "max-uses": {
|
|
604
|
+
const value = await ctx.ui.input("Maximum advisor uses", String(config.maxUses));
|
|
605
|
+
if (value === undefined) return;
|
|
606
|
+
await applyCommand(`max-uses ${value}`, ctx);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
case "max-transcript-chars": {
|
|
610
|
+
const value = await ctx.ui.input("Maximum transcript characters", String(config.maxTranscriptChars));
|
|
611
|
+
if (value === undefined) return;
|
|
612
|
+
await applyCommand(`max-transcript-chars ${value}`, ctx);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
case "cache": {
|
|
616
|
+
const choice = await ctx.ui.select("Advisor cache retention", ["none", "short", "long"]);
|
|
617
|
+
if (!parseCacheRetention(choice)) return;
|
|
618
|
+
await applyCommand(`cache ${choice}`, ctx);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
case "reset": {
|
|
622
|
+
if (useCount <= 0) return;
|
|
623
|
+
const confirmed = await ctx.ui.confirm(
|
|
624
|
+
"Reset advisor use count?",
|
|
625
|
+
`This clears ${useCount} advisor use${useCount === 1 ? "" : "s"} for the current session branch.`,
|
|
626
|
+
);
|
|
627
|
+
if (!confirmed) return;
|
|
628
|
+
await applyCommand("reset", ctx);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
case "close":
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
484
636
|
function syncActiveTool(api: ExtensionAPI): void {
|
|
485
637
|
const activeTools = api.getActiveTools();
|
|
486
638
|
const hasAdvisor = activeTools.includes(TOOL_NAME);
|
|
@@ -651,24 +803,35 @@ export function normalizeConfig(
|
|
|
651
803
|
input: Partial<AdvisorConfig>,
|
|
652
804
|
fallback: AdvisorConfig,
|
|
653
805
|
registry?: ModelRegistryLike,
|
|
806
|
+
options: NormalizeConfigOptions = {},
|
|
654
807
|
): AdvisorConfig {
|
|
655
808
|
const normalized: AdvisorConfig = {
|
|
656
809
|
enabled: typeof input.enabled === "boolean" ? input.enabled : fallback.enabled,
|
|
657
|
-
provider: typeof input.provider === "string" && input.provider ? input.provider : fallback.provider,
|
|
658
|
-
modelId: typeof input.modelId === "string" && input.modelId ? input.modelId : fallback.modelId,
|
|
810
|
+
provider: typeof input.provider === "string" && input.provider.trim() ? input.provider.trim() : fallback.provider,
|
|
811
|
+
modelId: typeof input.modelId === "string" && input.modelId.trim() ? input.modelId.trim() : fallback.modelId,
|
|
659
812
|
thinkingLevel: parseThinkingLevel(input.thinkingLevel) ?? fallback.thinkingLevel,
|
|
660
|
-
maxUses:
|
|
813
|
+
maxUses:
|
|
814
|
+
typeof input.maxUses === "number" && Number.isFinite(input.maxUses) && Math.floor(input.maxUses) > 0
|
|
815
|
+
? Math.floor(input.maxUses)
|
|
816
|
+
: fallback.maxUses,
|
|
661
817
|
cacheRetention: parseCacheRetention(input.cacheRetention) ?? fallback.cacheRetention,
|
|
662
|
-
maxTokens:
|
|
663
|
-
|
|
818
|
+
maxTokens:
|
|
819
|
+
typeof input.maxTokens === "number" && Number.isFinite(input.maxTokens) && Math.floor(input.maxTokens) > 0
|
|
820
|
+
? Math.floor(input.maxTokens)
|
|
821
|
+
: fallback.maxTokens,
|
|
822
|
+
timeoutMs:
|
|
823
|
+
typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) && Math.floor(input.timeoutMs) > 0
|
|
824
|
+
? Math.floor(input.timeoutMs)
|
|
825
|
+
: fallback.timeoutMs,
|
|
664
826
|
maxTranscriptChars:
|
|
665
|
-
typeof input.maxTranscriptChars === "number" && input.maxTranscriptChars > 0
|
|
827
|
+
typeof input.maxTranscriptChars === "number" && Number.isFinite(input.maxTranscriptChars) && Math.floor(input.maxTranscriptChars) > 0
|
|
666
828
|
? Math.floor(input.maxTranscriptChars)
|
|
667
829
|
: fallback.maxTranscriptChars,
|
|
668
830
|
};
|
|
669
831
|
|
|
670
832
|
const legacyDefault = parseModelSpec(LEGACY_DEFAULT_ADVISOR_MODEL);
|
|
671
833
|
if (
|
|
834
|
+
options.migrateLegacyDefault !== false &&
|
|
672
835
|
input.thinkingLevel === undefined &&
|
|
673
836
|
legacyDefault &&
|
|
674
837
|
normalized.provider === legacyDefault.provider &&
|
|
@@ -724,17 +887,24 @@ export function resolveAdvisorModel(
|
|
|
724
887
|
}
|
|
725
888
|
|
|
726
889
|
export function parseModelSpec(value: string): { provider: string; modelId: string } | undefined {
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
return
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
890
|
+
const trimmed = value.trim();
|
|
891
|
+
const slash = trimmed.indexOf("/");
|
|
892
|
+
if (slash <= 0 || slash === trimmed.length - 1) return undefined;
|
|
893
|
+
const provider = trimmed.slice(0, slash).trim();
|
|
894
|
+
const modelId = trimmed.slice(slash + 1).trim();
|
|
895
|
+
if (!provider || !modelId) return undefined;
|
|
896
|
+
return { provider, modelId };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function flagValue(api: ExtensionAPI, name: string): boolean | string | undefined {
|
|
900
|
+
return typeof api.getFlag === "function" ? api.getFlag(name) : undefined;
|
|
733
901
|
}
|
|
734
902
|
|
|
735
903
|
function parsePositiveInt(value: string): number | undefined {
|
|
736
|
-
const
|
|
737
|
-
if (
|
|
904
|
+
const trimmed = value.trim();
|
|
905
|
+
if (!/^\d+$/.test(trimmed)) return undefined;
|
|
906
|
+
const parsed = Number(trimmed);
|
|
907
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) return undefined;
|
|
738
908
|
return parsed;
|
|
739
909
|
}
|
|
740
910
|
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
export const PREFERENCES_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
/** Fixed allow-listed names within the extension-specific preferences directory. */
|
|
18
|
+
export const PREFERENCE_FILE_NAMES = Object.freeze({
|
|
19
|
+
enabled: "enabled.json",
|
|
20
|
+
model: "model.json",
|
|
21
|
+
thinkingLevel: "thinkingLevel.json",
|
|
22
|
+
maxUses: "maxUses.json",
|
|
23
|
+
cacheRetention: "cacheRetention.json",
|
|
24
|
+
maxTranscriptChars: "maxTranscriptChars.json",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type AdvisorPreferencesPatch = {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
provider?: string;
|
|
30
|
+
modelId?: string;
|
|
31
|
+
thinkingLevel?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
32
|
+
maxUses?: number;
|
|
33
|
+
cacheRetention?: "none" | "short" | "long";
|
|
34
|
+
maxTranscriptChars?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AdvisorPreferences = AdvisorPreferencesPatch & { version: typeof PREFERENCES_VERSION };
|
|
38
|
+
|
|
39
|
+
/** Return the directory containing advisor-pi's per-setting files. */
|
|
40
|
+
export function preferencesPath(agentDir: string = getAgentDir()): string {
|
|
41
|
+
return join(agentDir, "advisor-pi-preferences");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read and independently validate the durable advisor settings. */
|
|
45
|
+
export function loadPreferences(agentDir: string = getAgentDir()): AdvisorPreferencesPatch {
|
|
46
|
+
const patch: AdvisorPreferencesPatch = {};
|
|
47
|
+
const enabled = readPreference(agentDir, PREFERENCE_FILE_NAMES.enabled);
|
|
48
|
+
if (enabled && typeof enabled.enabled === "boolean") patch.enabled = enabled.enabled;
|
|
49
|
+
|
|
50
|
+
const model = readPreference(agentDir, PREFERENCE_FILE_NAMES.model);
|
|
51
|
+
if (model && typeof model.provider === "string" && model.provider.trim() && typeof model.modelId === "string" && model.modelId.trim()) {
|
|
52
|
+
patch.provider = model.provider.trim();
|
|
53
|
+
patch.modelId = model.modelId.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const thinkingLevel = parseThinkingLevel(readPreference(agentDir, PREFERENCE_FILE_NAMES.thinkingLevel)?.thinkingLevel);
|
|
57
|
+
if (thinkingLevel) patch.thinkingLevel = thinkingLevel;
|
|
58
|
+
|
|
59
|
+
const maxUses = readPreference(agentDir, PREFERENCE_FILE_NAMES.maxUses)?.maxUses;
|
|
60
|
+
if (isPositiveSafeInteger(maxUses)) patch.maxUses = maxUses;
|
|
61
|
+
|
|
62
|
+
const cacheRetention = parseCacheRetention(readPreference(agentDir, PREFERENCE_FILE_NAMES.cacheRetention)?.cacheRetention);
|
|
63
|
+
if (cacheRetention) patch.cacheRetention = cacheRetention;
|
|
64
|
+
|
|
65
|
+
const maxTranscriptChars = readPreference(agentDir, PREFERENCE_FILE_NAMES.maxTranscriptChars)?.maxTranscriptChars;
|
|
66
|
+
if (isPositiveSafeInteger(maxTranscriptChars)) patch.maxTranscriptChars = maxTranscriptChars;
|
|
67
|
+
|
|
68
|
+
return patch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Validate and persist supplied logical settings without any aggregate snapshot. */
|
|
72
|
+
export async function savePreferences(config: AdvisorPreferencesPatch, agentDir: string = getAgentDir()): Promise<boolean> {
|
|
73
|
+
return saveValidatedPreferences(config, agentDir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Validate and persist one or more deliberate logical setting edits. */
|
|
77
|
+
export async function savePreferencesPatch(patch: AdvisorPreferencesPatch, agentDir: string = getAgentDir()): Promise<boolean> {
|
|
78
|
+
return saveValidatedPreferences(patch, agentDir);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type PreferenceWrite = { fileName: string; payload: Record<string, unknown> };
|
|
82
|
+
|
|
83
|
+
async function saveValidatedPreferences(input: unknown, agentDir: string): Promise<boolean> {
|
|
84
|
+
const writes = prepareWrites(input);
|
|
85
|
+
if (!writes) return false;
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(preferencesPath(agentDir), { recursive: true, mode: 0o700 });
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
for (const write of writes) {
|
|
92
|
+
if (!writePreference(agentDir, write)) return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function prepareWrites(input: unknown): PreferenceWrite[] | undefined {
|
|
98
|
+
if (!isRecord(input)) return undefined;
|
|
99
|
+
if (input.version !== undefined && input.version !== PREFERENCES_VERSION) return undefined;
|
|
100
|
+
|
|
101
|
+
const keys = Object.keys(input).filter((key) => key !== "version");
|
|
102
|
+
if (keys.length === 0) return undefined;
|
|
103
|
+
const writes: PreferenceWrite[] = [];
|
|
104
|
+
const hasProvider = keys.includes("provider");
|
|
105
|
+
const hasModelId = keys.includes("modelId");
|
|
106
|
+
if (hasProvider || hasModelId) {
|
|
107
|
+
if (!hasProvider || !hasModelId || typeof input.provider !== "string" || !input.provider.trim() || typeof input.modelId !== "string" || !input.modelId.trim()) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
writes.push({
|
|
111
|
+
fileName: PREFERENCE_FILE_NAMES.model,
|
|
112
|
+
payload: {
|
|
113
|
+
version: PREFERENCES_VERSION,
|
|
114
|
+
provider: input.provider.trim(),
|
|
115
|
+
modelId: input.modelId.trim(),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const key of keys) {
|
|
121
|
+
switch (key) {
|
|
122
|
+
case "provider":
|
|
123
|
+
case "modelId":
|
|
124
|
+
break;
|
|
125
|
+
case "enabled":
|
|
126
|
+
if (typeof input.enabled !== "boolean") return undefined;
|
|
127
|
+
writes.push({ fileName: PREFERENCE_FILE_NAMES.enabled, payload: { version: PREFERENCES_VERSION, enabled: input.enabled } });
|
|
128
|
+
break;
|
|
129
|
+
case "thinkingLevel": {
|
|
130
|
+
const thinkingLevel = parseThinkingLevel(input.thinkingLevel);
|
|
131
|
+
if (!thinkingLevel) return undefined;
|
|
132
|
+
writes.push({ fileName: PREFERENCE_FILE_NAMES.thinkingLevel, payload: { version: PREFERENCES_VERSION, thinkingLevel } });
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "maxUses":
|
|
136
|
+
if (!isPositiveSafeInteger(input.maxUses)) return undefined;
|
|
137
|
+
writes.push({ fileName: PREFERENCE_FILE_NAMES.maxUses, payload: { version: PREFERENCES_VERSION, maxUses: input.maxUses } });
|
|
138
|
+
break;
|
|
139
|
+
case "cacheRetention": {
|
|
140
|
+
const cacheRetention = parseCacheRetention(input.cacheRetention);
|
|
141
|
+
if (!cacheRetention) return undefined;
|
|
142
|
+
writes.push({ fileName: PREFERENCE_FILE_NAMES.cacheRetention, payload: { version: PREFERENCES_VERSION, cacheRetention } });
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case "maxTranscriptChars":
|
|
146
|
+
if (!isPositiveSafeInteger(input.maxTranscriptChars)) return undefined;
|
|
147
|
+
writes.push({
|
|
148
|
+
fileName: PREFERENCE_FILE_NAMES.maxTranscriptChars,
|
|
149
|
+
payload: { version: PREFERENCES_VERSION, maxTranscriptChars: input.maxTranscriptChars },
|
|
150
|
+
});
|
|
151
|
+
break;
|
|
152
|
+
default:
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return writes;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readPreference(agentDir: string, fileName: string): Record<string, unknown> | undefined {
|
|
160
|
+
try {
|
|
161
|
+
const parsed: unknown = JSON.parse(readFileSync(join(preferencesPath(agentDir), fileName), "utf8"));
|
|
162
|
+
if (!isRecord(parsed)) return undefined;
|
|
163
|
+
if (parsed.version !== undefined && parsed.version !== PREFERENCES_VERSION) return undefined;
|
|
164
|
+
return parsed;
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function writePreference(agentDir: string, write: PreferenceWrite): boolean {
|
|
171
|
+
const directory = preferencesPath(agentDir);
|
|
172
|
+
const temporaryPath = join(directory, `.${write.fileName}.${process.pid}.${randomUUID()}.tmp`);
|
|
173
|
+
let fileDescriptor: number | undefined;
|
|
174
|
+
try {
|
|
175
|
+
fileDescriptor = openSync(temporaryPath, "wx", 0o600);
|
|
176
|
+
writeFileSync(fileDescriptor, `${JSON.stringify(write.payload, null, 2)}\n`, "utf8");
|
|
177
|
+
fsyncSync(fileDescriptor);
|
|
178
|
+
closeSync(fileDescriptor);
|
|
179
|
+
fileDescriptor = undefined;
|
|
180
|
+
renameSync(temporaryPath, join(directory, write.fileName));
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
} finally {
|
|
185
|
+
if (fileDescriptor !== undefined) {
|
|
186
|
+
try {
|
|
187
|
+
closeSync(fileDescriptor);
|
|
188
|
+
} catch {
|
|
189
|
+
// Best-effort cleanup after a failed write.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
unlinkSync(temporaryPath);
|
|
194
|
+
} catch {
|
|
195
|
+
// The rename succeeded, or the temporary file was never created.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export type AdvisorPreferencesConfig = {
|
|
201
|
+
enabled: boolean;
|
|
202
|
+
provider: string;
|
|
203
|
+
modelId: string;
|
|
204
|
+
thinkingLevel: NonNullable<AdvisorPreferencesPatch["thinkingLevel"]>;
|
|
205
|
+
maxUses: number;
|
|
206
|
+
cacheRetention: NonNullable<AdvisorPreferencesPatch["cacheRetention"]>;
|
|
207
|
+
maxTranscriptChars: number;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
211
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isPositiveSafeInteger(value: unknown): value is number {
|
|
215
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseCacheRetention(value: unknown): AdvisorPreferencesPatch["cacheRetention"] {
|
|
219
|
+
if (value === "none" || value === "short" || value === "long") return value;
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseThinkingLevel(value: unknown): AdvisorPreferencesPatch["thinkingLevel"] {
|
|
224
|
+
if (value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") {
|
|
225
|
+
return value;
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|