@aeondave/pi-skill-optimizer 3.0.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 ADDED
@@ -0,0 +1,446 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ import type { OptimizeConfig } from "./optimize.js";
6
+
7
+ export type OutputMode = "off" | "smart" | "extract";
8
+
9
+ export interface OutputConfig {
10
+ mode: OutputMode;
11
+ maxLines: number;
12
+ maxBytes: number;
13
+ minSavingsBytes: number;
14
+ minSavingsRatio: number;
15
+ tools: string[];
16
+ model: string;
17
+ extractMinBytes: number;
18
+ extractExclude: string[];
19
+ archiveTtlMs: number;
20
+ }
21
+
22
+ export interface UsageConfig {
23
+ maxEntries: number;
24
+ staleDays: number;
25
+ halfLifeDays: number;
26
+ }
27
+
28
+ export interface RuntimeConfig extends OptimizeConfig {
29
+ alwaysSkills: string[];
30
+ excludeSkills: string[];
31
+ providers: string[];
32
+ toolPrefetchMax: number;
33
+ toolSearchPageSize: number;
34
+ alwaysTools: string[];
35
+ historyDedupMinBytes: number;
36
+ usageHalfLifeDays: number;
37
+ usageMaxEntries: number;
38
+ usageStaleDays: number;
39
+ outputMode: OutputMode;
40
+ outputMaxLines: number;
41
+ outputMaxBytes: number;
42
+ outputMinSavingsBytes: number;
43
+ outputMinSavingsRatio: number;
44
+ outputTools: string[];
45
+ outputModel: string;
46
+ outputExtractMinBytes: number;
47
+ outputExtractExclude: string[];
48
+ outputArchiveTtlHours: number;
49
+ }
50
+
51
+ export const DEFAULT_CONFIG: Readonly<RuntimeConfig> = Object.freeze({
52
+ catalogBudgetChars: 8_000,
53
+ intentMaxChars: 96,
54
+ prefetchTarget: 5,
55
+ prefetchMin: 3,
56
+ prefetchMax: 8,
57
+ prefetchBudgetChars: 6_000,
58
+ fuzzyCandidateLimit: 8,
59
+ alwaysSkills: [],
60
+ excludeSkills: [],
61
+ providers: [],
62
+ toolPrefetchMax: 5,
63
+ toolSearchPageSize: 8,
64
+ alwaysTools: [],
65
+ historyDedupMinBytes: 4_096,
66
+ usageHalfLifeDays: 30,
67
+ usageMaxEntries: 2_048,
68
+ usageStaleDays: 180,
69
+ outputMode: "smart",
70
+ outputMaxLines: 400,
71
+ outputMaxBytes: 16_000,
72
+ outputMinSavingsBytes: 512,
73
+ outputMinSavingsRatio: 0.1,
74
+ outputTools: ["*"],
75
+ outputModel: "",
76
+ outputExtractMinBytes: 32_000,
77
+ outputExtractExclude: [
78
+ "cat",
79
+ "ls",
80
+ "head",
81
+ "tail",
82
+ "tree",
83
+ "find",
84
+ "dir",
85
+ "type",
86
+ ],
87
+ outputArchiveTtlHours: 168,
88
+ });
89
+
90
+ type JsonObject = Record<string, unknown>;
91
+
92
+ const INTEGER_FIELDS = {
93
+ catalogBudgetChars: [256, 1_000_000],
94
+ intentMaxChars: [24, 1_024],
95
+ prefetchTarget: [0, 1_000],
96
+ prefetchMin: [0, 1_000],
97
+ prefetchMax: [0, 1_000],
98
+ prefetchBudgetChars: [0, 1_000_000],
99
+ fuzzyCandidateLimit: [0, 1_000],
100
+ toolPrefetchMax: [0, 1_000],
101
+ toolSearchPageSize: [1, 100],
102
+ historyDedupMinBytes: [0, 100_000_000],
103
+ usageHalfLifeDays: [1, 3_650],
104
+ usageMaxEntries: [0, 1_000_000],
105
+ usageStaleDays: [1, 3_650],
106
+ outputMaxLines: [1, 1_000_000],
107
+ outputMaxBytes: [256, 100_000_000],
108
+ outputMinSavingsBytes: [0, 100_000_000],
109
+ outputExtractMinBytes: [0, 100_000_000],
110
+ outputArchiveTtlHours: [1, 8_760],
111
+ } as const satisfies Partial<Record<keyof RuntimeConfig, readonly [number, number]>>;
112
+
113
+ const ENV_FIELDS: Readonly<Record<string, keyof RuntimeConfig>> = {
114
+ PI_SKILL_OPTIMIZER_CATALOG_BUDGET_CHARS: "catalogBudgetChars",
115
+ PI_SKILL_OPTIMIZER_INTENT_MAX_CHARS: "intentMaxChars",
116
+ PI_SKILL_OPTIMIZER_PREFETCH_TARGET: "prefetchTarget",
117
+ PI_SKILL_OPTIMIZER_PREFETCH_MIN: "prefetchMin",
118
+ PI_SKILL_OPTIMIZER_PREFETCH_MAX: "prefetchMax",
119
+ PI_SKILL_OPTIMIZER_PREFETCH_BUDGET_CHARS: "prefetchBudgetChars",
120
+ PI_SKILL_OPTIMIZER_FUZZY_CANDIDATE_LIMIT: "fuzzyCandidateLimit",
121
+ PI_SKILL_OPTIMIZER_TOOL_PREFETCH_MAX: "toolPrefetchMax",
122
+ PI_SKILL_OPTIMIZER_TOOL_SEARCH_PAGE_SIZE: "toolSearchPageSize",
123
+ PI_SKILL_OPTIMIZER_HISTORY_DEDUP_MIN_BYTES: "historyDedupMinBytes",
124
+ PI_SKILL_OPTIMIZER_USAGE_HALF_LIFE_DAYS: "usageHalfLifeDays",
125
+ PI_SKILL_OPTIMIZER_USAGE_MAX_ENTRIES: "usageMaxEntries",
126
+ PI_SKILL_OPTIMIZER_USAGE_STALE_DAYS: "usageStaleDays",
127
+ PI_SKILL_OPTIMIZER_OUTPUT_MAX_LINES: "outputMaxLines",
128
+ PI_SKILL_OPTIMIZER_OUTPUT_MAX_BYTES: "outputMaxBytes",
129
+ PI_SKILL_OPTIMIZER_OUTPUT_MIN_SAVINGS_BYTES: "outputMinSavingsBytes",
130
+ PI_SKILL_OPTIMIZER_OUTPUT_EXTRACT_MIN_BYTES: "outputExtractMinBytes",
131
+ PI_SKILL_OPTIMIZER_OUTPUT_ARCHIVE_TTL_HOURS: "outputArchiveTtlHours",
132
+ };
133
+
134
+ const ENV_LIST_FIELDS: Readonly<Record<string, keyof RuntimeConfig>> = {
135
+ PI_SKILL_OPTIMIZER_ALWAYS_SKILLS: "alwaysSkills",
136
+ PI_SKILL_OPTIMIZER_EXCLUDE_SKILLS: "excludeSkills",
137
+ PI_SKILL_OPTIMIZER_PROVIDERS: "providers",
138
+ PI_SKILL_OPTIMIZER_ALWAYS_TOOLS: "alwaysTools",
139
+ PI_SKILL_OPTIMIZER_OUTPUT_TOOLS: "outputTools",
140
+ PI_SKILL_OPTIMIZER_OUTPUT_EXCLUDE: "outputExtractExclude",
141
+ };
142
+
143
+ function isObject(value: unknown): value is JsonObject {
144
+ return typeof value === "object" && value !== null && !Array.isArray(value);
145
+ }
146
+
147
+ function parseInteger(value: unknown, fallback: number, min: number, max: number): number {
148
+ const parsed =
149
+ typeof value === "number"
150
+ ? value
151
+ : typeof value === "string" && value.trim() !== ""
152
+ ? Number(value)
153
+ : Number.NaN;
154
+ return Number.isFinite(parsed)
155
+ ? Math.min(max, Math.max(min, Math.trunc(parsed)))
156
+ : fallback;
157
+ }
158
+
159
+ function parseRatio(value: unknown, fallback: number): number {
160
+ const parsed =
161
+ typeof value === "number"
162
+ ? value
163
+ : typeof value === "string" && value.trim() !== ""
164
+ ? Number(value)
165
+ : Number.NaN;
166
+ return Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : fallback;
167
+ }
168
+
169
+ function parseList(value: unknown, fallback: readonly string[]): string[] {
170
+ const source =
171
+ Array.isArray(value)
172
+ ? value
173
+ : typeof value === "string"
174
+ ? value.split(",")
175
+ : fallback;
176
+ return [
177
+ ...new Set(
178
+ source
179
+ .filter((item): item is string => typeof item === "string")
180
+ .map((item) => item.trim())
181
+ .filter(Boolean),
182
+ ),
183
+ ];
184
+ }
185
+
186
+ function parseOutputMode(value: unknown, fallback: OutputMode): OutputMode {
187
+ return value === "off" || value === "smart" || value === "extract"
188
+ ? value
189
+ : fallback;
190
+ }
191
+
192
+ export function parseBoolean(value: unknown): boolean | undefined {
193
+ if (typeof value === "boolean") return value;
194
+ if (typeof value !== "string") return undefined;
195
+ const normalized = value.trim().toLowerCase();
196
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
197
+ if (["0", "false", "no", "off", ""].includes(normalized)) return false;
198
+ return undefined;
199
+ }
200
+
201
+ function readJsonObject(path: string): JsonObject {
202
+ if (!existsSync(path)) return {};
203
+ try {
204
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
205
+ return isObject(parsed) ? parsed : {};
206
+ } catch {
207
+ return {};
208
+ }
209
+ }
210
+
211
+ function normalizeConfig(raw: JsonObject): RuntimeConfig {
212
+ const next: RuntimeConfig = {
213
+ ...DEFAULT_CONFIG,
214
+ alwaysSkills: [...DEFAULT_CONFIG.alwaysSkills],
215
+ excludeSkills: [...DEFAULT_CONFIG.excludeSkills],
216
+ providers: [...DEFAULT_CONFIG.providers],
217
+ alwaysTools: [...DEFAULT_CONFIG.alwaysTools],
218
+ outputTools: [...DEFAULT_CONFIG.outputTools],
219
+ outputExtractExclude: [...DEFAULT_CONFIG.outputExtractExclude],
220
+ };
221
+
222
+ for (const [field, bounds] of Object.entries(INTEGER_FIELDS) as Array<
223
+ [keyof typeof INTEGER_FIELDS, readonly [number, number]]
224
+ >) {
225
+ const [min, max] = bounds;
226
+ (next as unknown as Record<string, unknown>)[field] = parseInteger(
227
+ raw[field],
228
+ next[field] as number,
229
+ min,
230
+ max,
231
+ );
232
+ }
233
+
234
+ next.alwaysSkills = parseList(raw.alwaysSkills, next.alwaysSkills);
235
+ next.excludeSkills = parseList(raw.excludeSkills, next.excludeSkills);
236
+ next.providers = parseList(raw.providers, next.providers).map((item) =>
237
+ item.toLowerCase(),
238
+ );
239
+ next.alwaysTools = parseList(raw.alwaysTools, next.alwaysTools);
240
+ next.outputTools = parseList(raw.outputTools, next.outputTools);
241
+ next.outputExtractExclude = parseList(
242
+ raw.outputExtractExclude,
243
+ next.outputExtractExclude,
244
+ );
245
+ next.outputMode = parseOutputMode(raw.outputMode, next.outputMode);
246
+ next.outputModel =
247
+ typeof raw.outputModel === "string" ? raw.outputModel.trim() : next.outputModel;
248
+ next.outputMinSavingsRatio = parseRatio(
249
+ raw.outputMinSavingsRatio,
250
+ next.outputMinSavingsRatio,
251
+ );
252
+
253
+ next.prefetchMax = Math.max(next.prefetchMin, next.prefetchMax);
254
+ next.prefetchTarget = Math.min(
255
+ next.prefetchMax,
256
+ Math.max(next.prefetchMin, next.prefetchTarget),
257
+ );
258
+ return next;
259
+ }
260
+
261
+ function applyEnvironment(
262
+ base: RuntimeConfig,
263
+ environment: NodeJS.ProcessEnv,
264
+ ): RuntimeConfig {
265
+ const raw: JsonObject = { ...base };
266
+ for (const [environmentName, field] of Object.entries(ENV_FIELDS)) {
267
+ const value = environment[environmentName];
268
+ if (value !== undefined) raw[field] = value;
269
+ }
270
+ for (const [environmentName, field] of Object.entries(ENV_LIST_FIELDS)) {
271
+ const value = environment[environmentName];
272
+ if (value !== undefined) raw[field] = value;
273
+ }
274
+ if (environment.PI_SKILL_OPTIMIZER_OUTPUT_MODE !== undefined) {
275
+ raw.outputMode = environment.PI_SKILL_OPTIMIZER_OUTPUT_MODE;
276
+ }
277
+ if (environment.PI_SKILL_OPTIMIZER_OUTPUT_MODEL !== undefined) {
278
+ raw.outputModel = environment.PI_SKILL_OPTIMIZER_OUTPUT_MODEL;
279
+ }
280
+ if (environment.PI_SKILL_OPTIMIZER_OUTPUT_MIN_SAVINGS_RATIO !== undefined) {
281
+ raw.outputMinSavingsRatio =
282
+ environment.PI_SKILL_OPTIMIZER_OUTPUT_MIN_SAVINGS_RATIO;
283
+ }
284
+ return normalizeConfig(raw);
285
+ }
286
+
287
+ export function getAgentDir(environment: NodeJS.ProcessEnv = process.env): string {
288
+ return resolve(environment.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"));
289
+ }
290
+
291
+ export function getStateDir(environment: NodeJS.ProcessEnv = process.env): string {
292
+ return join(getAgentDir(environment), "skill-optimizer");
293
+ }
294
+
295
+ export function getConfigPath(environment: NodeJS.ProcessEnv = process.env): string {
296
+ return join(getStateDir(environment), "config.json");
297
+ }
298
+
299
+ export function getGlobalProfilePath(
300
+ environment: NodeJS.ProcessEnv = process.env,
301
+ ): string {
302
+ return join(getStateDir(environment), "profile.json");
303
+ }
304
+
305
+ export function getGlobalUsagePath(
306
+ environment: NodeJS.ProcessEnv = process.env,
307
+ ): string {
308
+ return join(getStateDir(environment), "usage.json");
309
+ }
310
+
311
+ export function getStatsPath(environment: NodeJS.ProcessEnv = process.env): string {
312
+ return join(getStateDir(environment), "stats.json");
313
+ }
314
+
315
+ export function getArchiveDir(environment: NodeJS.ProcessEnv = process.env): string {
316
+ return join(getStateDir(environment), "output");
317
+ }
318
+
319
+ export function getProjectProfilePath(cwd: string = process.cwd()): string {
320
+ return join(resolve(cwd), ".pi", "skill-optimizer", "profile.json");
321
+ }
322
+
323
+ export function getConfigPaths(cwd: string = process.cwd()): {
324
+ global: string;
325
+ project: string;
326
+ } {
327
+ return {
328
+ global: getConfigPath(),
329
+ project: join(resolve(cwd), ".pi", "skill-optimizer", "config.json"),
330
+ };
331
+ }
332
+
333
+ export function getProfilePaths(cwd: string = process.cwd()): {
334
+ global: string;
335
+ project: string;
336
+ } {
337
+ return {
338
+ global: getGlobalProfilePath(),
339
+ project: getProjectProfilePath(cwd),
340
+ };
341
+ }
342
+
343
+ export function getUsageFilePath(_cwd: string = process.cwd()): string {
344
+ return getGlobalUsagePath();
345
+ }
346
+
347
+ export function getStatsFilePath(_cwd: string = process.cwd()): string {
348
+ return getStatsPath();
349
+ }
350
+
351
+ export function ensureGlobalConfigTemplate(
352
+ environment: NodeJS.ProcessEnv = process.env,
353
+ ): string | undefined {
354
+ const path = getConfigPath(environment);
355
+ if (existsSync(path)) return undefined;
356
+ mkdirSync(getStateDir(environment), { recursive: true });
357
+ try {
358
+ writeFileSync(path, defaultConfigJson(), { encoding: "utf8", flag: "wx" });
359
+ } catch (error) {
360
+ const code = (error as NodeJS.ErrnoException).code;
361
+ if (code === "EEXIST") return undefined;
362
+ throw error;
363
+ }
364
+ return path;
365
+ }
366
+
367
+ export function getConfig(
368
+ cwd: string,
369
+ ): RuntimeConfig;
370
+ export function getConfig(
371
+ environment?: NodeJS.ProcessEnv,
372
+ configPath?: string,
373
+ ): RuntimeConfig;
374
+ export function getConfig(
375
+ environmentOrCwd: NodeJS.ProcessEnv | string = process.env,
376
+ configPath?: string,
377
+ ): RuntimeConfig {
378
+ if (typeof environmentOrCwd === "string") {
379
+ const paths = getConfigPaths(environmentOrCwd);
380
+ const raw = {
381
+ ...readJsonObject(paths.global),
382
+ ...readJsonObject(paths.project),
383
+ };
384
+ return applyEnvironment(normalizeConfig(raw), process.env);
385
+ }
386
+ const environment = environmentOrCwd;
387
+ return applyEnvironment(
388
+ normalizeConfig(readJsonObject(configPath ?? getConfigPath(environment))),
389
+ environment,
390
+ );
391
+ }
392
+
393
+ export function getOutputConfig(configOrCwd: RuntimeConfig | string): OutputConfig {
394
+ const config =
395
+ typeof configOrCwd === "string" ? getConfig(configOrCwd) : configOrCwd;
396
+ return {
397
+ mode: config.outputMode,
398
+ maxLines: config.outputMaxLines,
399
+ maxBytes: config.outputMaxBytes,
400
+ minSavingsBytes: config.outputMinSavingsBytes,
401
+ minSavingsRatio: config.outputMinSavingsRatio,
402
+ tools: [...config.outputTools],
403
+ model: config.outputModel,
404
+ extractMinBytes: config.outputExtractMinBytes,
405
+ extractExclude: [...config.outputExtractExclude],
406
+ archiveTtlMs: config.outputArchiveTtlHours * 60 * 60 * 1_000,
407
+ };
408
+ }
409
+
410
+ export function getUsageConfig(configOrCwd: RuntimeConfig | string): UsageConfig {
411
+ const config =
412
+ typeof configOrCwd === "string" ? getConfig(configOrCwd) : configOrCwd;
413
+ return {
414
+ maxEntries: config.usageMaxEntries,
415
+ staleDays: config.usageStaleDays,
416
+ halfLifeDays: config.usageHalfLifeDays,
417
+ };
418
+ }
419
+
420
+ export function isOptimizerDisabled(
421
+ environment: NodeJS.ProcessEnv = process.env,
422
+ ): boolean {
423
+ return parseBoolean(environment.PI_SKILL_OPTIMIZER_DISABLE) === true;
424
+ }
425
+
426
+ export function isDisabled(_cwd: string = process.cwd()): boolean {
427
+ return isOptimizerDisabled();
428
+ }
429
+
430
+ export function getScopeProviders(cwd: string = process.cwd()): string[] | undefined {
431
+ const providers = getConfig(cwd).providers;
432
+ return providers.length > 0 ? providers : undefined;
433
+ }
434
+
435
+ export function isProviderEnabled(
436
+ config: Pick<RuntimeConfig, "providers">,
437
+ provider: string | undefined,
438
+ ): boolean {
439
+ if (config.providers.length === 0) return true;
440
+ if (!provider) return false;
441
+ return config.providers.includes(provider.toLowerCase());
442
+ }
443
+
444
+ export function defaultConfigJson(): string {
445
+ return JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n";
446
+ }