advisor-pi 1.0.3 → 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.
@@ -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
+ }