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/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 AdvisorConfig = {
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
- refreshStateFromBranch(ctx);
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 result = handleCommand(args.trim(), ctx);
235
- if (result.persist) persistState(pi, config, useCount);
236
- if (result.updateToolState) syncActiveTool(pi);
237
- updateStatus(ctx);
238
- ctx.ui.notify(result.message, result.level);
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
- const restoredFromBranch = restoreStateFromSession(ctx, ctx.modelRegistry);
265
- if (!restoredFromBranch) applyStartupFlags(pi, ctx.modelRegistry);
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.getFlag("advisor-enabled");
311
+ const enabledFlag = flagValue(api, "advisor-enabled");
294
312
  if (typeof enabledFlag === "boolean") config.enabled = enabledFlag;
295
313
 
296
- const modelFlag = api.getFlag("advisor-model");
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.getFlag("advisor-thinking");
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.getFlag("advisor-max-uses");
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.getFlag("advisor-cache");
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.getFlag("advisor-max-transcript-chars");
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
- ): { message: string; level: "info" | "warning" | "error"; persist: boolean; updateToolState: boolean } {
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 { message: "advisor-pi enabled", level: "info", persist: true, updateToolState: true };
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 { message: "advisor-pi disabled", level: "info", persist: true, updateToolState: true };
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: typeof input.maxUses === "number" && input.maxUses > 0 ? Math.floor(input.maxUses) : fallback.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: typeof input.maxTokens === "number" && input.maxTokens > 0 ? Math.floor(input.maxTokens) : fallback.maxTokens,
663
- timeoutMs: typeof input.timeoutMs === "number" && input.timeoutMs > 0 ? Math.floor(input.timeoutMs) : fallback.timeoutMs,
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 slash = value.indexOf("/");
728
- if (slash <= 0 || slash === value.length - 1) return undefined;
729
- return {
730
- provider: value.slice(0, slash),
731
- modelId: value.slice(slash + 1),
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 parsed = Number.parseInt(value, 10);
737
- if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
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
+ }