pi-openai-toolkit 0.13.0 → 0.14.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/config.ts CHANGED
@@ -1,620 +1,582 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
- import {
6
- DEFAULT_COMPACTION_CONFIG,
7
- DEFAULT_TOOLKIT_CONFIG,
8
- DEFAULT_AUTO_MODE_CONFIG,
9
- DEFAULT_IMAGE_GENERATION_CONFIG,
10
- DEFAULT_CODEX_ASTRA_CONFIG,
11
- DEFAULT_NATIVE_FALLBACK_CONFIG,
12
- DEFAULT_WEB_SEARCH_CONFIG,
13
- RESPONSES_COMPACT_CAPABLE_APIS,
14
- BREAKER_LIMIT_MAX,
15
- BREAKER_LIMIT_MIN,
16
- BREAKER_WINDOW_MAX,
17
- BREAKER_WINDOW_MIN,
18
- CLASSIFIER_MAX_LAG_MAX,
19
- CLASSIFIER_MAX_LAG_MIN,
20
- DEFAULT_CLASSIFIER_TIMEOUT_MS,
21
- EVIDENCE_ROUNDS_MAX,
22
- EVIDENCE_ROUNDS_MIN,
23
- REVIEWER_TIMEOUT_MAX_MS,
24
- REVIEWER_TIMEOUT_MIN_MS,
25
- THINKING_LEVELS,
26
- TOOLKIT_ID,
27
- type AutoModeCircuitBreakerConfig,
28
- type AutoModeClassifierConfig,
29
- type AutoModeConfig,
30
- type AutoModeGate,
31
- type CodexAstraConfig,
32
- type CompactionConfig,
33
- type ContextManagementMode,
34
- type ImageGenerationConfig,
35
- type LoadedToolkitConfig,
36
- type NativeFallbackConfig,
37
- type ToolkitConfig,
38
- type WebSearchConfig,
39
- } from "./types";
40
-
41
- export const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", TOOLKIT_ID);
42
- export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
43
-
44
- const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch", "imageGeneration", "autoMode", "codexAstra"]);
45
- const COMPACTION_FIELDS = new Set([
46
- "enabled",
47
- "contextManagement",
48
- "codexGatewayModels",
49
- "allowCompactionContinuityBreak",
50
- "remoteCompactModel",
51
- "nativeFallback",
52
- "responsesApis",
53
- "contextReminderThresholdPercent",
54
- "notifyOnLoad",
55
- "debug",
56
- "logProviderPayloads",
57
- "logCompactResponses",
58
- "redactSensitiveData",
59
- "artifactRoot",
60
- ]);
61
- const NATIVE_FALLBACK_FIELDS = new Set(["enabled", "model", "thinkingLevel"]);
62
- const WEB_SEARCH_FIELDS = new Set(["enabled", "models"]);
63
- const IMAGE_GENERATION_FIELDS = new Set(["enabled"]);
64
- const CODEX_ASTRA_FIELDS = new Set(["enabled", "models"]);
65
- const AUTO_MODE_FIELDS = new Set([
66
- "enabled",
67
- "models",
68
- "reviewerModel",
69
- "gate",
70
- "extraTools",
71
- "timeoutMs",
72
- "transcript",
73
- "evidenceTools",
74
- "maxEvidenceRounds",
75
- "classifier",
76
- "circuitBreaker",
77
- ]);
78
- const AUTO_MODE_CLASSIFIER_FIELDS = new Set(["enabled", "model", "timeoutMs", "maxLag"]);
79
- const AUTO_MODE_BREAKER_FIELDS = new Set(["consecutiveDenials", "recentDenials", "windowSize"]);
80
-
81
- function isRecord(value: unknown): value is Record<string, unknown> {
82
- return !!value && typeof value === "object" && !Array.isArray(value);
83
- }
84
-
85
- function isFile(filePath: string): boolean {
86
- try {
87
- return fs.statSync(filePath).isFile();
88
- } catch {
89
- return false;
90
- }
91
- }
92
-
93
- function readJsonObject(filePath: string, warnings: string[]): Record<string, unknown> | undefined {
94
- if (!isFile(filePath)) return undefined;
95
-
96
- try {
97
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
98
- if (isRecord(parsed)) return parsed;
99
- warnings.push(`Ignoring ${filePath}: expected a JSON object at the top level.`);
100
- return undefined;
101
- } catch (error) {
102
- const message = error instanceof Error ? error.message : String(error);
103
- warnings.push(`Ignoring ${filePath}: ${message}`);
104
- return undefined;
105
- }
106
- }
107
-
108
- function resolveConfiguredPath(rawPath: string, baseDir: string): string {
109
- if (rawPath.startsWith("~/")) {
110
- return path.join(os.homedir(), rawPath.slice(2));
111
- }
112
- if (path.isAbsolute(rawPath)) {
113
- return path.resolve(rawPath);
114
- }
115
- return path.resolve(baseDir, rawPath);
116
- }
117
-
118
- function warnUnknownFields(
119
- value: Record<string, unknown>,
120
- knownFields: ReadonlySet<string>,
121
- fieldPath: string,
122
- warnings: string[],
123
- ): void {
124
- for (const key of Object.keys(value)) {
125
- if (!knownFields.has(key)) {
126
- warnings.push(`Ignoring ${fieldPath ? `${fieldPath}.` : ""}${key}: unknown field.`);
127
- }
128
- }
129
- }
130
-
131
- function toBoolean(value: unknown, fieldPath: string, warnings: string[]): boolean | undefined {
132
- if (value === undefined) return undefined;
133
- if (typeof value === "boolean") return value;
134
- warnings.push(`Ignoring ${fieldPath}: expected a boolean.`);
135
- return undefined;
136
- }
137
-
138
- function toContextManagementMode(
139
- value: unknown,
140
- fieldPath: string,
141
- warnings: string[],
142
- ): ContextManagementMode | undefined {
143
- if (value === undefined) return undefined;
144
- if (typeof value === "string") {
145
- const normalized = value.trim();
146
- if (normalized === "off" || normalized === "remote") return normalized;
147
- }
148
- warnings.push(`Ignoring ${fieldPath}: expected one of off, remote.`);
149
- return undefined;
150
- }
151
-
152
- function toModelSpec(value: unknown, fieldPath: string, warnings: string[]): string | null | undefined {
153
- if (value === undefined) return undefined;
154
- if (value === null) return null;
155
- if (typeof value === "string" && value.trim().length > 0) {
156
- return value.trim();
157
- }
158
- warnings.push(`Ignoring ${fieldPath}: expected "provider/model-id" or null.`);
159
- return undefined;
160
- }
161
-
162
- function toThinkingLevel(value: unknown, fieldPath: string, warnings: string[]): ThinkingLevel | undefined {
163
- if (value === undefined) return undefined;
164
- if (typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value)) {
165
- return value as ThinkingLevel;
166
- }
167
- warnings.push(`Ignoring ${fieldPath}: expected one of ${THINKING_LEVELS.join(", ")}.`);
168
- return undefined;
169
- }
170
-
171
- function toBoundedInteger(
172
- value: unknown,
173
- fieldPath: string,
174
- warnings: string[],
175
- min: number,
176
- max: number,
177
- ): number | undefined {
178
- if (value === undefined) return undefined;
179
- if (typeof value === "number" && Number.isInteger(value) && Number.isFinite(value) && value >= min && value <= max) {
180
- return value;
181
- }
182
- warnings.push(`Ignoring ${fieldPath}: expected an integer between ${min} and ${max}.`);
183
- return undefined;
184
- }
185
-
186
- function toSupportedApis(
187
- value: unknown,
188
- fieldPath: string,
189
- capableApis: readonly string[],
190
- warnings: string[],
191
- ): string[] | undefined {
192
- if (value === undefined) return undefined;
193
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
194
- warnings.push(`Ignoring ${fieldPath}: expected a string array.`);
195
- return undefined;
196
- }
197
-
198
- const capable = new Set(capableApis);
199
- const accepted: string[] = [];
200
- for (const item of new Set(value.map((entry) => entry.trim()).filter(Boolean))) {
201
- if (capable.has(item)) {
202
- accepted.push(item);
203
- } else {
204
- warnings.push(
205
- `Ignoring ${fieldPath} entry "${item}": only ${capableApis.join(", ")} are supported.`,
206
- );
207
- }
208
- }
209
-
210
- return accepted;
211
- }
212
-
213
- function toStringList(value: unknown, fieldPath: string, warnings: string[]): string[] | undefined {
214
- if (value === undefined) return undefined;
215
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
216
- warnings.push(`Ignoring ${fieldPath}: expected a string array.`);
217
- return undefined;
218
- }
219
-
220
- return [...new Set(value.map((entry) => entry.trim()).filter(Boolean))];
221
- }
222
-
223
- function toAutoModeGate(value: unknown, fieldPath: string, warnings: string[]): AutoModeGate | undefined {
224
- if (value === undefined) return undefined;
225
- if (value === "side-effect" || value === "all") return value;
226
- warnings.push(`Ignoring ${fieldPath}: expected one of side-effect, all.`);
227
- return undefined;
228
- }
229
-
230
- function toReviewerTimeoutMs(value: unknown, fieldPath: string, warnings: string[]): number | undefined {
231
- if (value === undefined) return undefined;
232
- if (
233
- typeof value === "number" &&
234
- Number.isInteger(value) &&
235
- value >= REVIEWER_TIMEOUT_MIN_MS &&
236
- value <= REVIEWER_TIMEOUT_MAX_MS
237
- ) {
238
- return value;
239
- }
240
- warnings.push(
241
- `Ignoring ${fieldPath}: expected an integer between ${REVIEWER_TIMEOUT_MIN_MS} and ${REVIEWER_TIMEOUT_MAX_MS} ms.`,
242
- );
243
- return undefined;
244
- }
245
-
246
- function cloneDefaults(): ToolkitConfig {
247
- return {
248
- compaction: {
249
- ...DEFAULT_COMPACTION_CONFIG,
250
- nativeFallback: { ...DEFAULT_NATIVE_FALLBACK_CONFIG },
251
- responsesApis: [...DEFAULT_COMPACTION_CONFIG.responsesApis],
252
- },
253
- webSearch: {
254
- ...DEFAULT_WEB_SEARCH_CONFIG,
255
- models: [...DEFAULT_WEB_SEARCH_CONFIG.models],
256
- },
257
- imageGeneration: { ...DEFAULT_IMAGE_GENERATION_CONFIG },
258
- codexAstra: {
259
- ...DEFAULT_CODEX_ASTRA_CONFIG,
260
- models: [...DEFAULT_CODEX_ASTRA_CONFIG.models],
261
- },
262
- autoMode: {
263
- ...DEFAULT_AUTO_MODE_CONFIG,
264
- models: [...DEFAULT_AUTO_MODE_CONFIG.models],
265
- extraTools: [...DEFAULT_AUTO_MODE_CONFIG.extraTools],
266
- classifier: { ...DEFAULT_AUTO_MODE_CONFIG.classifier },
267
- circuitBreaker: { ...DEFAULT_AUTO_MODE_CONFIG.circuitBreaker },
268
- },
269
- };
270
- }
271
-
272
- function applyNativeFallbackConfig(
273
- raw: Record<string, unknown>,
274
- resolved: NativeFallbackConfig,
275
- warnings: string[],
276
- ): void {
277
- warnUnknownFields(raw, NATIVE_FALLBACK_FIELDS, "compaction.nativeFallback", warnings);
278
- resolved.enabled =
279
- toBoolean(raw.enabled, "compaction.nativeFallback.enabled", warnings) ?? resolved.enabled;
280
-
281
- const modelSpec = toModelSpec(raw.model, "compaction.nativeFallback.model", warnings);
282
- if (modelSpec !== undefined) {
283
- resolved.model = modelSpec === null ? undefined : modelSpec;
284
- }
285
-
286
- resolved.thinkingLevel =
287
- toThinkingLevel(raw.thinkingLevel, "compaction.nativeFallback.thinkingLevel", warnings) ??
288
- resolved.thinkingLevel;
289
- }
290
-
291
- function applyCompactionConfig(
292
- raw: Record<string, unknown>,
293
- resolved: CompactionConfig,
294
- warnings: string[],
295
- ): void {
296
- warnUnknownFields(raw, COMPACTION_FIELDS, "compaction", warnings);
297
-
298
- resolved.enabled = toBoolean(raw.enabled, "compaction.enabled", warnings) ?? resolved.enabled;
299
- resolved.contextManagement =
300
- toContextManagementMode(raw.contextManagement, "compaction.contextManagement", warnings) ??
301
- resolved.contextManagement;
302
- const codexGatewayModels = toStringList(
303
- raw.codexGatewayModels,
304
- "compaction.codexGatewayModels",
305
- warnings,
306
- );
307
- if (codexGatewayModels !== undefined) {
308
- resolved.codexGatewayModels = codexGatewayModels;
309
- }
310
- resolved.allowCompactionContinuityBreak =
311
- toBoolean(
312
- raw.allowCompactionContinuityBreak,
313
- "compaction.allowCompactionContinuityBreak",
314
- warnings,
315
- ) ?? resolved.allowCompactionContinuityBreak;
316
- resolved.notifyOnLoad =
317
- toBoolean(raw.notifyOnLoad, "compaction.notifyOnLoad", warnings) ?? resolved.notifyOnLoad;
318
- const reminderPercent = toBoundedInteger(
319
- raw.contextReminderThresholdPercent,
320
- "compaction.contextReminderThresholdPercent",
321
- warnings,
322
- 0,
323
- 100,
324
- );
325
- if (reminderPercent !== undefined) {
326
- resolved.contextReminderThresholdPercent = reminderPercent;
327
- }
328
- resolved.debug = toBoolean(raw.debug, "compaction.debug", warnings) ?? resolved.debug;
329
- resolved.logProviderPayloads =
330
- toBoolean(raw.logProviderPayloads, "compaction.logProviderPayloads", warnings) ??
331
- resolved.logProviderPayloads;
332
- resolved.logCompactResponses =
333
- toBoolean(raw.logCompactResponses, "compaction.logCompactResponses", warnings) ??
334
- resolved.logCompactResponses;
335
- resolved.redactSensitiveData =
336
- toBoolean(raw.redactSensitiveData, "compaction.redactSensitiveData", warnings) ??
337
- resolved.redactSensitiveData;
338
-
339
- const remoteCompactModelSpec = toModelSpec(
340
- raw.remoteCompactModel,
341
- "compaction.remoteCompactModel",
342
- warnings,
343
- );
344
- if (remoteCompactModelSpec !== undefined) {
345
- resolved.remoteCompactModel = remoteCompactModelSpec === null ? undefined : remoteCompactModelSpec;
346
- }
347
-
348
- if (raw.nativeFallback !== undefined) {
349
- if (isRecord(raw.nativeFallback)) {
350
- applyNativeFallbackConfig(raw.nativeFallback, resolved.nativeFallback, warnings);
351
- } else {
352
- warnings.push("Ignoring compaction.nativeFallback: expected a JSON object.");
353
- }
354
- }
355
-
356
- const apis = toSupportedApis(
357
- raw.responsesApis,
358
- "compaction.responsesApis",
359
- RESPONSES_COMPACT_CAPABLE_APIS,
360
- warnings,
361
- );
362
- if (apis !== undefined) {
363
- resolved.responsesApis = apis;
364
- }
365
-
366
- if (typeof raw.artifactRoot === "string" && raw.artifactRoot.trim().length > 0) {
367
- resolved.artifactRoot = raw.artifactRoot.trim();
368
- } else if (raw.artifactRoot !== undefined) {
369
- warnings.push("Ignoring compaction.artifactRoot: expected a non-empty string.");
370
- }
371
- }
372
-
373
- function applyWebSearchConfig(
374
- raw: Record<string, unknown>,
375
- resolved: WebSearchConfig,
376
- warnings: string[],
377
- ): void {
378
- warnUnknownFields(raw, WEB_SEARCH_FIELDS, "webSearch", warnings);
379
- resolved.enabled = toBoolean(raw.enabled, "webSearch.enabled", warnings) ?? resolved.enabled;
380
-
381
- const models = toStringList(raw.models, "webSearch.models", warnings);
382
- if (models !== undefined) {
383
- resolved.models = models;
384
- }
385
- }
386
-
387
- function applyImageGenerationConfig(
388
- raw: Record<string, unknown>,
389
- resolved: ImageGenerationConfig,
390
- warnings: string[],
391
- ): void {
392
- warnUnknownFields(raw, IMAGE_GENERATION_FIELDS, "imageGeneration", warnings);
393
- resolved.enabled =
394
- toBoolean(raw.enabled, "imageGeneration.enabled", warnings) ?? resolved.enabled;
395
- }
396
-
397
- function applyCodexAstraConfig(
398
- raw: Record<string, unknown>,
399
- resolved: CodexAstraConfig,
400
- warnings: string[],
401
- ): void {
402
- warnUnknownFields(raw, CODEX_ASTRA_FIELDS, "codexAstra", warnings);
403
- resolved.enabled = toBoolean(raw.enabled, "codexAstra.enabled", warnings) ?? resolved.enabled;
404
-
405
- const models = toStringList(raw.models, "codexAstra.models", warnings);
406
- if (models !== undefined) {
407
- resolved.models = models;
408
- }
409
- }
410
-
411
- function applyAutoModeConfig(
412
- raw: Record<string, unknown>,
413
- resolved: AutoModeConfig,
414
- warnings: string[],
415
- ): void {
416
- warnUnknownFields(raw, AUTO_MODE_FIELDS, "autoMode", warnings);
417
- resolved.enabled = toBoolean(raw.enabled, "autoMode.enabled", warnings) ?? resolved.enabled;
418
-
419
- const models = toStringList(raw.models, "autoMode.models", warnings);
420
- if (models !== undefined) {
421
- resolved.models = models;
422
- }
423
-
424
- const reviewerModel = toModelSpec(raw.reviewerModel, "autoMode.reviewerModel", warnings);
425
- if (reviewerModel !== undefined) {
426
- resolved.reviewerModel = reviewerModel === null ? undefined : reviewerModel;
427
- }
428
-
429
- resolved.gate = toAutoModeGate(raw.gate, "autoMode.gate", warnings) ?? resolved.gate;
430
-
431
- const extraTools = toStringList(raw.extraTools, "autoMode.extraTools", warnings);
432
- if (extraTools !== undefined) {
433
- resolved.extraTools = extraTools;
434
- }
435
-
436
- const timeoutMs = toReviewerTimeoutMs(raw.timeoutMs, "autoMode.timeoutMs", warnings);
437
- if (timeoutMs !== undefined) {
438
- resolved.timeoutMs = timeoutMs;
439
- }
440
-
441
- resolved.transcript = toBoolean(raw.transcript, "autoMode.transcript", warnings) ?? resolved.transcript;
442
- resolved.evidenceTools =
443
- toBoolean(raw.evidenceTools, "autoMode.evidenceTools", warnings) ?? resolved.evidenceTools;
444
-
445
- const maxEvidenceRounds = toBoundedInteger(
446
- raw.maxEvidenceRounds,
447
- "autoMode.maxEvidenceRounds",
448
- warnings,
449
- EVIDENCE_ROUNDS_MIN,
450
- EVIDENCE_ROUNDS_MAX,
451
- );
452
- if (maxEvidenceRounds !== undefined) {
453
- resolved.maxEvidenceRounds = maxEvidenceRounds;
454
- }
455
-
456
- if (raw.classifier !== undefined) {
457
- if (isRecord(raw.classifier)) {
458
- applyAutoModeClassifierConfig(raw.classifier, resolved.classifier, warnings);
459
- } else {
460
- warnings.push("Ignoring autoMode.classifier: expected a JSON object.");
461
- }
462
- }
463
-
464
- if (raw.circuitBreaker !== undefined) {
465
- if (isRecord(raw.circuitBreaker)) {
466
- applyAutoModeBreakerConfig(raw.circuitBreaker, resolved.circuitBreaker, warnings);
467
- } else {
468
- warnings.push("Ignoring autoMode.circuitBreaker: expected a JSON object.");
469
- }
470
- }
471
- }
472
-
473
- function applyAutoModeClassifierConfig(
474
- raw: Record<string, unknown>,
475
- resolved: AutoModeClassifierConfig,
476
- warnings: string[],
477
- ): void {
478
- warnUnknownFields(raw, AUTO_MODE_CLASSIFIER_FIELDS, "autoMode.classifier", warnings);
479
- resolved.enabled =
480
- toBoolean(raw.enabled, "autoMode.classifier.enabled", warnings) ?? resolved.enabled;
481
-
482
- const model = toModelSpec(raw.model, "autoMode.classifier.model", warnings);
483
- if (model !== undefined) {
484
- resolved.model = model === null ? undefined : model;
485
- }
486
-
487
- const timeoutMs =
488
- toBoundedInteger(
489
- raw.timeoutMs,
490
- "autoMode.classifier.timeoutMs",
491
- warnings,
492
- REVIEWER_TIMEOUT_MIN_MS,
493
- REVIEWER_TIMEOUT_MAX_MS,
494
- ) ?? DEFAULT_CLASSIFIER_TIMEOUT_MS;
495
- if (raw.timeoutMs !== undefined) {
496
- resolved.timeoutMs = timeoutMs;
497
- }
498
-
499
- const maxLag = toBoundedInteger(
500
- raw.maxLag,
501
- "autoMode.classifier.maxLag",
502
- warnings,
503
- CLASSIFIER_MAX_LAG_MIN,
504
- CLASSIFIER_MAX_LAG_MAX,
505
- );
506
- if (maxLag !== undefined) {
507
- resolved.maxLag = maxLag;
508
- }
509
- }
510
-
511
- function applyAutoModeBreakerConfig(
512
- raw: Record<string, unknown>,
513
- resolved: AutoModeCircuitBreakerConfig,
514
- warnings: string[],
515
- ): void {
516
- warnUnknownFields(raw, AUTO_MODE_BREAKER_FIELDS, "autoMode.circuitBreaker", warnings);
517
-
518
- const consecutiveDenials = toBoundedInteger(
519
- raw.consecutiveDenials,
520
- "autoMode.circuitBreaker.consecutiveDenials",
521
- warnings,
522
- BREAKER_LIMIT_MIN,
523
- BREAKER_LIMIT_MAX,
524
- );
525
- if (consecutiveDenials !== undefined) {
526
- resolved.consecutiveDenials = consecutiveDenials;
527
- }
528
-
529
- const recentDenials = toBoundedInteger(
530
- raw.recentDenials,
531
- "autoMode.circuitBreaker.recentDenials",
532
- warnings,
533
- BREAKER_LIMIT_MIN,
534
- BREAKER_LIMIT_MAX,
535
- );
536
- if (recentDenials !== undefined) {
537
- resolved.recentDenials = recentDenials;
538
- }
539
-
540
- const windowSize = toBoundedInteger(
541
- raw.windowSize,
542
- "autoMode.circuitBreaker.windowSize",
543
- warnings,
544
- BREAKER_WINDOW_MIN,
545
- BREAKER_WINDOW_MAX,
546
- );
547
- if (windowSize !== undefined) {
548
- resolved.windowSize = windowSize;
549
- }
550
- }
551
-
552
- /**
553
- * Load the canonical toolkit config from
554
- * `~/.pi/agent/extensions/pi-openai-toolkit/config.json`.
555
- * A missing file silently yields defaults; legacy branded paths are never read.
556
- */
557
- export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolkitConfig {
558
- const warnings: string[] = [];
559
- const resolved = cloneDefaults();
560
- let source: string | undefined;
561
-
562
- const raw = readJsonObject(configPath, warnings);
563
- if (raw) {
564
- source = configPath;
565
- warnUnknownFields(raw, TOP_LEVEL_FIELDS, "", warnings);
566
-
567
- if (raw.compaction !== undefined) {
568
- if (isRecord(raw.compaction)) {
569
- applyCompactionConfig(raw.compaction, resolved.compaction, warnings);
570
- } else {
571
- warnings.push("Ignoring compaction: expected a JSON object.");
572
- }
573
- }
574
-
575
- if (raw.webSearch !== undefined) {
576
- if (isRecord(raw.webSearch)) {
577
- applyWebSearchConfig(raw.webSearch, resolved.webSearch, warnings);
578
- } else {
579
- warnings.push("Ignoring webSearch: expected a JSON object.");
580
- }
581
- }
582
-
583
- if (raw.imageGeneration !== undefined) {
584
- if (isRecord(raw.imageGeneration)) {
585
- applyImageGenerationConfig(raw.imageGeneration, resolved.imageGeneration, warnings);
586
- } else {
587
- warnings.push("Ignoring imageGeneration: expected a JSON object.");
588
- }
589
- }
590
-
591
- if (raw.autoMode !== undefined) {
592
- if (isRecord(raw.autoMode)) {
593
- applyAutoModeConfig(raw.autoMode, resolved.autoMode, warnings);
594
- } else {
595
- warnings.push("Ignoring autoMode: expected a JSON object.");
596
- }
597
- }
598
-
599
- if (raw.codexAstra !== undefined) {
600
- if (isRecord(raw.codexAstra)) {
601
- applyCodexAstraConfig(raw.codexAstra, resolved.codexAstra, warnings);
602
- } else {
603
- warnings.push("Ignoring codexAstra: expected a JSON object.");
604
- }
605
- }
606
- }
607
-
608
- resolved.compaction.artifactRoot = resolveConfiguredPath(
609
- resolved.compaction.artifactRoot,
610
- path.dirname(configPath),
611
- );
612
-
613
- return {
614
- config: resolved,
615
- source,
616
- warnings,
617
- };
618
- }
619
-
620
- export { DEFAULT_TOOLKIT_CONFIG };
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
+ import {
6
+ DEFAULT_COMPACTION_CONFIG,
7
+ DEFAULT_TOOLKIT_CONFIG,
8
+ DEFAULT_AUTO_MODE_CONFIG,
9
+ DEFAULT_IMAGE_GENERATION_CONFIG,
10
+ DEFAULT_NATIVE_FALLBACK_CONFIG,
11
+ DEFAULT_WEB_SEARCH_CONFIG,
12
+ RESPONSES_COMPACT_CAPABLE_APIS,
13
+ BREAKER_LIMIT_MAX,
14
+ BREAKER_LIMIT_MIN,
15
+ BREAKER_WINDOW_MAX,
16
+ BREAKER_WINDOW_MIN,
17
+ CLASSIFIER_MAX_LAG_MAX,
18
+ CLASSIFIER_MAX_LAG_MIN,
19
+ DEFAULT_CLASSIFIER_TIMEOUT_MS,
20
+ EVIDENCE_ROUNDS_MAX,
21
+ EVIDENCE_ROUNDS_MIN,
22
+ REVIEWER_TIMEOUT_MAX_MS,
23
+ REVIEWER_TIMEOUT_MIN_MS,
24
+ THINKING_LEVELS,
25
+ TOOLKIT_ID,
26
+ type AutoModeCircuitBreakerConfig,
27
+ type AutoModeClassifierConfig,
28
+ type AutoModeConfig,
29
+ type AutoModeGate,
30
+ type CompactionConfig,
31
+ type ContextManagementMode,
32
+ type ImageGenerationConfig,
33
+ type LoadedToolkitConfig,
34
+ type NativeFallbackConfig,
35
+ type ToolkitConfig,
36
+ type WebSearchConfig,
37
+ } from "./types";
38
+
39
+ export const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", TOOLKIT_ID);
40
+ export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
41
+
42
+ const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch", "imageGeneration", "autoMode"]);
43
+ const COMPACTION_FIELDS = new Set([
44
+ "enabled",
45
+ "contextManagement",
46
+ "allowCompactionContinuityBreak",
47
+ "remoteCompactModel",
48
+ "nativeFallback",
49
+ "responsesApis",
50
+ "contextReminderThresholdPercent",
51
+ "notifyOnLoad",
52
+ "debug",
53
+ "logProviderPayloads",
54
+ "logCompactResponses",
55
+ "redactSensitiveData",
56
+ "artifactRoot",
57
+ ]);
58
+ const NATIVE_FALLBACK_FIELDS = new Set(["enabled", "model", "thinkingLevel"]);
59
+ const WEB_SEARCH_FIELDS = new Set(["enabled", "models"]);
60
+ const IMAGE_GENERATION_FIELDS = new Set(["enabled"]);
61
+ const AUTO_MODE_FIELDS = new Set([
62
+ "enabled",
63
+ "models",
64
+ "reviewerModel",
65
+ "gate",
66
+ "extraTools",
67
+ "timeoutMs",
68
+ "transcript",
69
+ "evidenceTools",
70
+ "maxEvidenceRounds",
71
+ "classifier",
72
+ "circuitBreaker",
73
+ ]);
74
+ const AUTO_MODE_CLASSIFIER_FIELDS = new Set(["enabled", "model", "timeoutMs", "maxLag"]);
75
+ const AUTO_MODE_BREAKER_FIELDS = new Set(["consecutiveDenials", "recentDenials", "windowSize"]);
76
+
77
+ function isRecord(value: unknown): value is Record<string, unknown> {
78
+ return !!value && typeof value === "object" && !Array.isArray(value);
79
+ }
80
+
81
+ function isFile(filePath: string): boolean {
82
+ try {
83
+ return fs.statSync(filePath).isFile();
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ function readJsonObject(filePath: string, warnings: string[]): Record<string, unknown> | undefined {
90
+ if (!isFile(filePath)) return undefined;
91
+
92
+ try {
93
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
94
+ if (isRecord(parsed)) return parsed;
95
+ warnings.push(`Ignoring ${filePath}: expected a JSON object at the top level.`);
96
+ return undefined;
97
+ } catch (error) {
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ warnings.push(`Ignoring ${filePath}: ${message}`);
100
+ return undefined;
101
+ }
102
+ }
103
+
104
+ function resolveConfiguredPath(rawPath: string, baseDir: string): string {
105
+ if (rawPath.startsWith("~/")) {
106
+ return path.join(os.homedir(), rawPath.slice(2));
107
+ }
108
+ if (path.isAbsolute(rawPath)) {
109
+ return path.resolve(rawPath);
110
+ }
111
+ return path.resolve(baseDir, rawPath);
112
+ }
113
+
114
+ function warnUnknownFields(
115
+ value: Record<string, unknown>,
116
+ knownFields: ReadonlySet<string>,
117
+ fieldPath: string,
118
+ warnings: string[],
119
+ ): void {
120
+ for (const key of Object.keys(value)) {
121
+ if (!knownFields.has(key)) {
122
+ warnings.push(`Ignoring ${fieldPath ? `${fieldPath}.` : ""}${key}: unknown field.`);
123
+ }
124
+ }
125
+ }
126
+
127
+ function toBoolean(value: unknown, fieldPath: string, warnings: string[]): boolean | undefined {
128
+ if (value === undefined) return undefined;
129
+ if (typeof value === "boolean") return value;
130
+ warnings.push(`Ignoring ${fieldPath}: expected a boolean.`);
131
+ return undefined;
132
+ }
133
+
134
+ function toContextManagementMode(
135
+ value: unknown,
136
+ fieldPath: string,
137
+ warnings: string[],
138
+ ): ContextManagementMode | undefined {
139
+ if (value === undefined) return undefined;
140
+ if (typeof value === "string") {
141
+ const normalized = value.trim();
142
+ if (normalized === "off" || normalized === "remote") return normalized;
143
+ }
144
+ warnings.push(`Ignoring ${fieldPath}: expected one of off, remote.`);
145
+ return undefined;
146
+ }
147
+
148
+ function toModelSpec(value: unknown, fieldPath: string, warnings: string[]): string | null | undefined {
149
+ if (value === undefined) return undefined;
150
+ if (value === null) return null;
151
+ if (typeof value === "string" && value.trim().length > 0) {
152
+ return value.trim();
153
+ }
154
+ warnings.push(`Ignoring ${fieldPath}: expected "provider/model-id" or null.`);
155
+ return undefined;
156
+ }
157
+
158
+ function toThinkingLevel(value: unknown, fieldPath: string, warnings: string[]): ThinkingLevel | undefined {
159
+ if (value === undefined) return undefined;
160
+ if (typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value)) {
161
+ return value as ThinkingLevel;
162
+ }
163
+ warnings.push(`Ignoring ${fieldPath}: expected one of ${THINKING_LEVELS.join(", ")}.`);
164
+ return undefined;
165
+ }
166
+
167
+ function toBoundedInteger(
168
+ value: unknown,
169
+ fieldPath: string,
170
+ warnings: string[],
171
+ min: number,
172
+ max: number,
173
+ ): number | undefined {
174
+ if (value === undefined) return undefined;
175
+ if (typeof value === "number" && Number.isInteger(value) && Number.isFinite(value) && value >= min && value <= max) {
176
+ return value;
177
+ }
178
+ warnings.push(`Ignoring ${fieldPath}: expected an integer between ${min} and ${max}.`);
179
+ return undefined;
180
+ }
181
+
182
+ function toSupportedApis(
183
+ value: unknown,
184
+ fieldPath: string,
185
+ capableApis: readonly string[],
186
+ warnings: string[],
187
+ ): string[] | undefined {
188
+ if (value === undefined) return undefined;
189
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
190
+ warnings.push(`Ignoring ${fieldPath}: expected a string array.`);
191
+ return undefined;
192
+ }
193
+
194
+ const capable = new Set(capableApis);
195
+ const accepted: string[] = [];
196
+ for (const item of new Set(value.map((entry) => entry.trim()).filter(Boolean))) {
197
+ if (capable.has(item)) {
198
+ accepted.push(item);
199
+ } else {
200
+ warnings.push(
201
+ `Ignoring ${fieldPath} entry "${item}": only ${capableApis.join(", ")} are supported.`,
202
+ );
203
+ }
204
+ }
205
+
206
+ return accepted;
207
+ }
208
+
209
+ function toStringList(value: unknown, fieldPath: string, warnings: string[]): string[] | undefined {
210
+ if (value === undefined) return undefined;
211
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
212
+ warnings.push(`Ignoring ${fieldPath}: expected a string array.`);
213
+ return undefined;
214
+ }
215
+
216
+ return [...new Set(value.map((entry) => entry.trim()).filter(Boolean))];
217
+ }
218
+
219
+ function toAutoModeGate(value: unknown, fieldPath: string, warnings: string[]): AutoModeGate | undefined {
220
+ if (value === undefined) return undefined;
221
+ if (value === "side-effect" || value === "all") return value;
222
+ warnings.push(`Ignoring ${fieldPath}: expected one of side-effect, all.`);
223
+ return undefined;
224
+ }
225
+
226
+ function toReviewerTimeoutMs(value: unknown, fieldPath: string, warnings: string[]): number | undefined {
227
+ if (value === undefined) return undefined;
228
+ if (
229
+ typeof value === "number" &&
230
+ Number.isInteger(value) &&
231
+ value >= REVIEWER_TIMEOUT_MIN_MS &&
232
+ value <= REVIEWER_TIMEOUT_MAX_MS
233
+ ) {
234
+ return value;
235
+ }
236
+ warnings.push(
237
+ `Ignoring ${fieldPath}: expected an integer between ${REVIEWER_TIMEOUT_MIN_MS} and ${REVIEWER_TIMEOUT_MAX_MS} ms.`,
238
+ );
239
+ return undefined;
240
+ }
241
+
242
+ function cloneDefaults(): ToolkitConfig {
243
+ return {
244
+ compaction: {
245
+ ...DEFAULT_COMPACTION_CONFIG,
246
+ nativeFallback: { ...DEFAULT_NATIVE_FALLBACK_CONFIG },
247
+ responsesApis: [...DEFAULT_COMPACTION_CONFIG.responsesApis],
248
+ },
249
+ webSearch: {
250
+ ...DEFAULT_WEB_SEARCH_CONFIG,
251
+ models: [...DEFAULT_WEB_SEARCH_CONFIG.models],
252
+ },
253
+ imageGeneration: { ...DEFAULT_IMAGE_GENERATION_CONFIG },
254
+ autoMode: {
255
+ ...DEFAULT_AUTO_MODE_CONFIG,
256
+ models: [...DEFAULT_AUTO_MODE_CONFIG.models],
257
+ extraTools: [...DEFAULT_AUTO_MODE_CONFIG.extraTools],
258
+ classifier: { ...DEFAULT_AUTO_MODE_CONFIG.classifier },
259
+ circuitBreaker: { ...DEFAULT_AUTO_MODE_CONFIG.circuitBreaker },
260
+ },
261
+ };
262
+ }
263
+
264
+ function applyNativeFallbackConfig(
265
+ raw: Record<string, unknown>,
266
+ resolved: NativeFallbackConfig,
267
+ warnings: string[],
268
+ ): void {
269
+ warnUnknownFields(raw, NATIVE_FALLBACK_FIELDS, "compaction.nativeFallback", warnings);
270
+ resolved.enabled =
271
+ toBoolean(raw.enabled, "compaction.nativeFallback.enabled", warnings) ?? resolved.enabled;
272
+
273
+ const modelSpec = toModelSpec(raw.model, "compaction.nativeFallback.model", warnings);
274
+ if (modelSpec !== undefined) {
275
+ resolved.model = modelSpec === null ? undefined : modelSpec;
276
+ }
277
+
278
+ resolved.thinkingLevel =
279
+ toThinkingLevel(raw.thinkingLevel, "compaction.nativeFallback.thinkingLevel", warnings) ??
280
+ resolved.thinkingLevel;
281
+ }
282
+
283
+ function applyCompactionConfig(
284
+ raw: Record<string, unknown>,
285
+ resolved: CompactionConfig,
286
+ warnings: string[],
287
+ ): void {
288
+ warnUnknownFields(raw, COMPACTION_FIELDS, "compaction", warnings);
289
+
290
+ resolved.enabled = toBoolean(raw.enabled, "compaction.enabled", warnings) ?? resolved.enabled;
291
+ resolved.contextManagement =
292
+ toContextManagementMode(raw.contextManagement, "compaction.contextManagement", warnings) ??
293
+ resolved.contextManagement;
294
+ resolved.allowCompactionContinuityBreak =
295
+ toBoolean(
296
+ raw.allowCompactionContinuityBreak,
297
+ "compaction.allowCompactionContinuityBreak",
298
+ warnings,
299
+ ) ?? resolved.allowCompactionContinuityBreak;
300
+ resolved.notifyOnLoad =
301
+ toBoolean(raw.notifyOnLoad, "compaction.notifyOnLoad", warnings) ?? resolved.notifyOnLoad;
302
+ const reminderPercent = toBoundedInteger(
303
+ raw.contextReminderThresholdPercent,
304
+ "compaction.contextReminderThresholdPercent",
305
+ warnings,
306
+ 0,
307
+ 100,
308
+ );
309
+ if (reminderPercent !== undefined) {
310
+ resolved.contextReminderThresholdPercent = reminderPercent;
311
+ }
312
+ resolved.debug = toBoolean(raw.debug, "compaction.debug", warnings) ?? resolved.debug;
313
+ resolved.logProviderPayloads =
314
+ toBoolean(raw.logProviderPayloads, "compaction.logProviderPayloads", warnings) ??
315
+ resolved.logProviderPayloads;
316
+ resolved.logCompactResponses =
317
+ toBoolean(raw.logCompactResponses, "compaction.logCompactResponses", warnings) ??
318
+ resolved.logCompactResponses;
319
+ resolved.redactSensitiveData =
320
+ toBoolean(raw.redactSensitiveData, "compaction.redactSensitiveData", warnings) ??
321
+ resolved.redactSensitiveData;
322
+
323
+ const remoteCompactModelSpec = toModelSpec(
324
+ raw.remoteCompactModel,
325
+ "compaction.remoteCompactModel",
326
+ warnings,
327
+ );
328
+ if (remoteCompactModelSpec !== undefined) {
329
+ resolved.remoteCompactModel = remoteCompactModelSpec === null ? undefined : remoteCompactModelSpec;
330
+ }
331
+
332
+ if (raw.nativeFallback !== undefined) {
333
+ if (isRecord(raw.nativeFallback)) {
334
+ applyNativeFallbackConfig(raw.nativeFallback, resolved.nativeFallback, warnings);
335
+ } else {
336
+ warnings.push("Ignoring compaction.nativeFallback: expected a JSON object.");
337
+ }
338
+ }
339
+
340
+ const apis = toSupportedApis(
341
+ raw.responsesApis,
342
+ "compaction.responsesApis",
343
+ RESPONSES_COMPACT_CAPABLE_APIS,
344
+ warnings,
345
+ );
346
+ if (apis !== undefined) {
347
+ resolved.responsesApis = apis;
348
+ }
349
+
350
+ if (typeof raw.artifactRoot === "string" && raw.artifactRoot.trim().length > 0) {
351
+ resolved.artifactRoot = raw.artifactRoot.trim();
352
+ } else if (raw.artifactRoot !== undefined) {
353
+ warnings.push("Ignoring compaction.artifactRoot: expected a non-empty string.");
354
+ }
355
+ }
356
+
357
+ function applyWebSearchConfig(
358
+ raw: Record<string, unknown>,
359
+ resolved: WebSearchConfig,
360
+ warnings: string[],
361
+ ): void {
362
+ warnUnknownFields(raw, WEB_SEARCH_FIELDS, "webSearch", warnings);
363
+ resolved.enabled = toBoolean(raw.enabled, "webSearch.enabled", warnings) ?? resolved.enabled;
364
+
365
+ const models = toStringList(raw.models, "webSearch.models", warnings);
366
+ if (models !== undefined) {
367
+ resolved.models = models;
368
+ }
369
+ }
370
+
371
+ function applyImageGenerationConfig(
372
+ raw: Record<string, unknown>,
373
+ resolved: ImageGenerationConfig,
374
+ warnings: string[],
375
+ ): void {
376
+ warnUnknownFields(raw, IMAGE_GENERATION_FIELDS, "imageGeneration", warnings);
377
+ resolved.enabled =
378
+ toBoolean(raw.enabled, "imageGeneration.enabled", warnings) ?? resolved.enabled;
379
+ }
380
+
381
+ function applyAutoModeConfig(
382
+ raw: Record<string, unknown>,
383
+ resolved: AutoModeConfig,
384
+ warnings: string[],
385
+ ): void {
386
+ warnUnknownFields(raw, AUTO_MODE_FIELDS, "autoMode", warnings);
387
+ resolved.enabled = toBoolean(raw.enabled, "autoMode.enabled", warnings) ?? resolved.enabled;
388
+
389
+ const models = toStringList(raw.models, "autoMode.models", warnings);
390
+ if (models !== undefined) {
391
+ resolved.models = models;
392
+ }
393
+
394
+ const reviewerModel = toModelSpec(raw.reviewerModel, "autoMode.reviewerModel", warnings);
395
+ if (reviewerModel !== undefined) {
396
+ resolved.reviewerModel = reviewerModel === null ? undefined : reviewerModel;
397
+ }
398
+
399
+ resolved.gate = toAutoModeGate(raw.gate, "autoMode.gate", warnings) ?? resolved.gate;
400
+
401
+ const extraTools = toStringList(raw.extraTools, "autoMode.extraTools", warnings);
402
+ if (extraTools !== undefined) {
403
+ resolved.extraTools = extraTools;
404
+ }
405
+
406
+ const timeoutMs = toReviewerTimeoutMs(raw.timeoutMs, "autoMode.timeoutMs", warnings);
407
+ if (timeoutMs !== undefined) {
408
+ resolved.timeoutMs = timeoutMs;
409
+ }
410
+
411
+ resolved.transcript = toBoolean(raw.transcript, "autoMode.transcript", warnings) ?? resolved.transcript;
412
+ resolved.evidenceTools =
413
+ toBoolean(raw.evidenceTools, "autoMode.evidenceTools", warnings) ?? resolved.evidenceTools;
414
+
415
+ const maxEvidenceRounds = toBoundedInteger(
416
+ raw.maxEvidenceRounds,
417
+ "autoMode.maxEvidenceRounds",
418
+ warnings,
419
+ EVIDENCE_ROUNDS_MIN,
420
+ EVIDENCE_ROUNDS_MAX,
421
+ );
422
+ if (maxEvidenceRounds !== undefined) {
423
+ resolved.maxEvidenceRounds = maxEvidenceRounds;
424
+ }
425
+
426
+ if (raw.classifier !== undefined) {
427
+ if (isRecord(raw.classifier)) {
428
+ applyAutoModeClassifierConfig(raw.classifier, resolved.classifier, warnings);
429
+ } else {
430
+ warnings.push("Ignoring autoMode.classifier: expected a JSON object.");
431
+ }
432
+ }
433
+
434
+ if (raw.circuitBreaker !== undefined) {
435
+ if (isRecord(raw.circuitBreaker)) {
436
+ applyAutoModeBreakerConfig(raw.circuitBreaker, resolved.circuitBreaker, warnings);
437
+ } else {
438
+ warnings.push("Ignoring autoMode.circuitBreaker: expected a JSON object.");
439
+ }
440
+ }
441
+ }
442
+
443
+ function applyAutoModeClassifierConfig(
444
+ raw: Record<string, unknown>,
445
+ resolved: AutoModeClassifierConfig,
446
+ warnings: string[],
447
+ ): void {
448
+ warnUnknownFields(raw, AUTO_MODE_CLASSIFIER_FIELDS, "autoMode.classifier", warnings);
449
+ resolved.enabled =
450
+ toBoolean(raw.enabled, "autoMode.classifier.enabled", warnings) ?? resolved.enabled;
451
+
452
+ const model = toModelSpec(raw.model, "autoMode.classifier.model", warnings);
453
+ if (model !== undefined) {
454
+ resolved.model = model === null ? undefined : model;
455
+ }
456
+
457
+ const timeoutMs =
458
+ toBoundedInteger(
459
+ raw.timeoutMs,
460
+ "autoMode.classifier.timeoutMs",
461
+ warnings,
462
+ REVIEWER_TIMEOUT_MIN_MS,
463
+ REVIEWER_TIMEOUT_MAX_MS,
464
+ ) ?? DEFAULT_CLASSIFIER_TIMEOUT_MS;
465
+ if (raw.timeoutMs !== undefined) {
466
+ resolved.timeoutMs = timeoutMs;
467
+ }
468
+
469
+ const maxLag = toBoundedInteger(
470
+ raw.maxLag,
471
+ "autoMode.classifier.maxLag",
472
+ warnings,
473
+ CLASSIFIER_MAX_LAG_MIN,
474
+ CLASSIFIER_MAX_LAG_MAX,
475
+ );
476
+ if (maxLag !== undefined) {
477
+ resolved.maxLag = maxLag;
478
+ }
479
+ }
480
+
481
+ function applyAutoModeBreakerConfig(
482
+ raw: Record<string, unknown>,
483
+ resolved: AutoModeCircuitBreakerConfig,
484
+ warnings: string[],
485
+ ): void {
486
+ warnUnknownFields(raw, AUTO_MODE_BREAKER_FIELDS, "autoMode.circuitBreaker", warnings);
487
+
488
+ const consecutiveDenials = toBoundedInteger(
489
+ raw.consecutiveDenials,
490
+ "autoMode.circuitBreaker.consecutiveDenials",
491
+ warnings,
492
+ BREAKER_LIMIT_MIN,
493
+ BREAKER_LIMIT_MAX,
494
+ );
495
+ if (consecutiveDenials !== undefined) {
496
+ resolved.consecutiveDenials = consecutiveDenials;
497
+ }
498
+
499
+ const recentDenials = toBoundedInteger(
500
+ raw.recentDenials,
501
+ "autoMode.circuitBreaker.recentDenials",
502
+ warnings,
503
+ BREAKER_LIMIT_MIN,
504
+ BREAKER_LIMIT_MAX,
505
+ );
506
+ if (recentDenials !== undefined) {
507
+ resolved.recentDenials = recentDenials;
508
+ }
509
+
510
+ const windowSize = toBoundedInteger(
511
+ raw.windowSize,
512
+ "autoMode.circuitBreaker.windowSize",
513
+ warnings,
514
+ BREAKER_WINDOW_MIN,
515
+ BREAKER_WINDOW_MAX,
516
+ );
517
+ if (windowSize !== undefined) {
518
+ resolved.windowSize = windowSize;
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Load the canonical toolkit config from
524
+ * `~/.pi/agent/extensions/pi-openai-toolkit/config.json`.
525
+ * A missing file silently yields defaults; legacy branded paths are never read.
526
+ */
527
+ export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolkitConfig {
528
+ const warnings: string[] = [];
529
+ const resolved = cloneDefaults();
530
+ let source: string | undefined;
531
+
532
+ const raw = readJsonObject(configPath, warnings);
533
+ if (raw) {
534
+ source = configPath;
535
+ warnUnknownFields(raw, TOP_LEVEL_FIELDS, "", warnings);
536
+
537
+ if (raw.compaction !== undefined) {
538
+ if (isRecord(raw.compaction)) {
539
+ applyCompactionConfig(raw.compaction, resolved.compaction, warnings);
540
+ } else {
541
+ warnings.push("Ignoring compaction: expected a JSON object.");
542
+ }
543
+ }
544
+
545
+ if (raw.webSearch !== undefined) {
546
+ if (isRecord(raw.webSearch)) {
547
+ applyWebSearchConfig(raw.webSearch, resolved.webSearch, warnings);
548
+ } else {
549
+ warnings.push("Ignoring webSearch: expected a JSON object.");
550
+ }
551
+ }
552
+
553
+ if (raw.imageGeneration !== undefined) {
554
+ if (isRecord(raw.imageGeneration)) {
555
+ applyImageGenerationConfig(raw.imageGeneration, resolved.imageGeneration, warnings);
556
+ } else {
557
+ warnings.push("Ignoring imageGeneration: expected a JSON object.");
558
+ }
559
+ }
560
+
561
+ if (raw.autoMode !== undefined) {
562
+ if (isRecord(raw.autoMode)) {
563
+ applyAutoModeConfig(raw.autoMode, resolved.autoMode, warnings);
564
+ } else {
565
+ warnings.push("Ignoring autoMode: expected a JSON object.");
566
+ }
567
+ }
568
+ }
569
+
570
+ resolved.compaction.artifactRoot = resolveConfiguredPath(
571
+ resolved.compaction.artifactRoot,
572
+ path.dirname(configPath),
573
+ );
574
+
575
+ return {
576
+ config: resolved,
577
+ source,
578
+ warnings,
579
+ };
580
+ }
581
+
582
+ export { DEFAULT_TOOLKIT_CONFIG };