@xynogen/pix-data 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,7 +74,7 @@ heuristic = 0.30·coding_score + 0.60·agentic_score + 0.10·reasoning_score
74
74
  score = round(clamp₀₁₀₀(120.6·heuristic − 10.6)) // fitted to the index
75
75
  ```
76
76
 
77
- 3. **benchlm.ai fallback** — if the model exists in benchlm but modelgrep has
77
+ 1. **benchlm.ai fallback** — if the model exists in benchlm but modelgrep has
78
78
  no AA index and no raw benches, look up the benchlm `overallScore` (0–100)
79
79
  and use it verbatim. Match strategy (in `lookupBenchlmScore`): exact
80
80
  normalized slug, then prefix overlap either way, then take the
@@ -119,6 +119,89 @@ place if your priorities differ.
119
119
  | `lookupModelsDev` | Sync lookup by id from in-memory cache (joined on slug) |
120
120
  | `lookupBenchmark` | Sync lookup a model by id — returns score + rank + pricing |
121
121
  | `benchScoreColor` | Map a 0–100 score to a `success`/`warning`/`error`/`muted` token |
122
+ | `pixConfig` | `@xynogen/pix-data/pix-config` — load/access the unified `pix.json` config |
123
+ | `reloadPixConfig` | Force a fresh read of `pix.json` from disk |
124
+ | `shouldCollapse` | `@xynogen/pix-data/collapse` — whether a tool's output card should auto-collapse |
125
+ | `collapseDelayMs` | Configured delay (ms) before a card collapses (default 10 000) |
126
+ | `tickCollapse` | Call in `renderResult` to schedule the timed auto-collapse for a card |
127
+
128
+ ## Unified config — `~/.pi/agent/pix.json`
129
+
130
+ pix-data hosts the **single shared config file** consumed by every `pix-*` package. The file is auto-created with defaults on the first session that loads pix-data — you never need to create it manually.
131
+
132
+ **Location:** `~/.pi/agent/pix.json`
133
+
134
+ ### Full schema
135
+
136
+ ```jsonc
137
+ {
138
+ // Auto-collapse for tool output cards (pix-bash, pix-read, pix-grep, …)
139
+ "collapse": {
140
+ "enabled": true, // master switch
141
+ "delayMs": 10000, // ms before collapse fires (default 10s)
142
+ "tools": {
143
+ // per-tool overrides — set false to disable for a specific tool
144
+ "bash": true,
145
+ "read": true,
146
+ "grep": true,
147
+ "edit": true,
148
+ "write": true,
149
+ "find": true,
150
+ "ls": true,
151
+ "todo": true
152
+ }
153
+ },
154
+
155
+ // Rendering options (pix-pretty)
156
+ "pretty": {
157
+ "theme": "monokai", // syntax-highlight theme (overrides PRETTY_THEME)
158
+ "icons": "nerd", // icon mode: nerd | unicode | ascii (overrides PRETTY_ICONS)
159
+ "maxPreviewLines": 50, // overrides PRETTY_MAX_PREVIEW_LINES
160
+ "diffColors": true // colored diff output
161
+ },
162
+
163
+ // Optimizer initial state (pix-optimizer)
164
+ "optimizer": {
165
+ "caveman": "off", // off | lite | full | ultra | micro
166
+ "rtk": false,
167
+ "toon": false,
168
+ "ponytail": "off" // off | lite | full | ultra
169
+ },
170
+
171
+ // Gate rules (pix-gate)
172
+ "gate": {
173
+ "disableDefaults": false,
174
+ "extraRules": [], // same shape as pix-gate.json extraRules
175
+ "autoApprove": [] // regex strings that skip the dialog
176
+ }
177
+ }
178
+ ```
179
+
180
+ All sections are optional — missing keys fall back to the defaults shown above. Environment variables (e.g. `PRETTY_THEME`) still take precedence over `pix.json` values.
181
+
182
+ ### API — `@xynogen/pix-data/pix-config`
183
+
184
+ ```ts
185
+ import { pixConfig, reloadPixConfig } from "@xynogen/pix-data/pix-config";
186
+
187
+ const cfg = pixConfig(); // returns cached PixConfig (loaded once per session)
188
+ await reloadPixConfig(); // force re-read from disk (e.g. after /config reload)
189
+ ```
190
+
191
+ ### API — `@xynogen/pix-data/collapse`
192
+
193
+ ```ts
194
+ import { shouldCollapse, collapseDelayMs, tickCollapse } from "@xynogen/pix-data/collapse";
195
+
196
+ // In a tool's renderResult:
197
+ if (shouldCollapse("bash")) {
198
+ tickCollapse(card, collapseDelayMs()); // schedules timed collapse
199
+ }
200
+ ```
201
+
202
+ - `shouldCollapse(tool)` — returns `true` when `collapse.enabled` is true and the named tool is not opted out.
203
+ - `collapseDelayMs()` — returns `collapse.delayMs` from config (default `10000`).
204
+ - `tickCollapse(card, delayMs)` — sets a timeout that calls `card.collapse()` after the delay. Safe to call multiple times — only the first registered timeout fires.
122
205
 
123
206
  ## Install
124
207
 
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "@xynogen/pix-data",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "Pi extension — shared model data layer (models.dev + BenchLM), cached at ~/.cache/pi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./src/*": "./src/*",
10
+ "./pix-config": "./src/pix-config.ts",
11
+ "./collapse": "./src/collapse.ts"
12
+ },
7
13
  "scripts": {
8
14
  "test": "bun test"
9
15
  },
@@ -0,0 +1,40 @@
1
+ /**
2
+ * collapse.ts — auto-collapse helper for tool renderResult cards.
3
+ *
4
+ * Provides a timer-based collapse mechanism: show full output for N seconds,
5
+ * then collapse to a one-line dim summary. Each tool call gets its own card
6
+ * with its own timer, so the latest call stays expanded while older ones fold.
7
+ *
8
+ * Configuration is read from ~/.pi/agent/pix.json via pix-config.
9
+ */
10
+
11
+ import { collapseDelayMs, shouldCollapse } from "./pix-config.js";
12
+
13
+ export interface CollapseState {
14
+ collapsed?: boolean;
15
+ timer?: ReturnType<typeof setTimeout>;
16
+ }
17
+
18
+ /**
19
+ * Run the collapse timer for a tool card. Call this inside `renderResult`.
20
+ *
21
+ * @param toolName — the tool name (e.g. "bash", "read") for per-tool config
22
+ * @param state — the render context's `state` bag (mutable, per-card)
23
+ * @param invalidate — `context.invalidate()` to trigger re-render
24
+ * @returns `true` if the card is currently collapsed
25
+ */
26
+ export function tickCollapse(
27
+ toolName: string,
28
+ state: CollapseState,
29
+ invalidate: () => void,
30
+ ): boolean {
31
+ if (!shouldCollapse(toolName)) return false;
32
+ if (state.collapsed) return true;
33
+ if (!state.timer) {
34
+ state.timer = setTimeout(() => {
35
+ state.collapsed = true;
36
+ invalidate();
37
+ }, collapseDelayMs());
38
+ }
39
+ return false;
40
+ }
package/src/data.test.ts CHANGED
@@ -100,33 +100,25 @@ describe("lookupInIndex", () => {
100
100
  ]);
101
101
 
102
102
  it("finds exact match", () => {
103
- expect(lookupInIndex("claude-sonnet-4-5", index)?.name).toBe(
104
- "Claude Sonnet 4.5",
105
- );
103
+ expect(lookupInIndex("claude-sonnet-4-5", index)?.name).toBe("Claude Sonnet 4.5");
106
104
  });
107
105
 
108
106
  it("strips provider prefix (provider/model)", () => {
109
- expect(lookupInIndex("anthropic/claude-opus-4", index)?.name).toBe(
110
- "Claude Opus 4",
111
- );
107
+ expect(lookupInIndex("anthropic/claude-opus-4", index)?.name).toBe("Claude Opus 4");
112
108
  });
113
109
 
114
110
  it("strips deep prefix (cc/model)", () => {
115
- expect(lookupInIndex("cc/claude-opus-4", index)?.name).toBe(
116
- "Claude Opus 4",
117
- );
111
+ expect(lookupInIndex("cc/claude-opus-4", index)?.name).toBe("Claude Opus 4");
118
112
  });
119
113
 
120
114
  it("strips date suffix", () => {
121
- expect(lookupInIndex("claude-sonnet-4-5-20250514", index)?.name).toBe(
122
- "Claude Sonnet 4.5",
123
- );
115
+ expect(lookupInIndex("claude-sonnet-4-5-20250514", index)?.name).toBe("Claude Sonnet 4.5");
124
116
  });
125
117
 
126
118
  it("strips provider prefix + date suffix", () => {
127
- expect(
128
- lookupInIndex("anthropic/claude-sonnet-4-5-20250514", index)?.name,
129
- ).toBe("Claude Sonnet 4.5");
119
+ expect(lookupInIndex("anthropic/claude-sonnet-4-5-20250514", index)?.name).toBe(
120
+ "Claude Sonnet 4.5",
121
+ );
130
122
  });
131
123
 
132
124
  it("returns undefined for unknown model", () => {
@@ -183,10 +175,7 @@ describe("modelgrep adapters", () => {
183
175
  });
184
176
 
185
177
  it("lookupModelsDev finds hy3 via prefix + suffix strip", () => {
186
- expect(
187
- lookupModelsDev("openrouter", "tencent/hy3-preview:nitro")?.limit
188
- ?.context,
189
- ).toBe(256000);
178
+ expect(lookupModelsDev("openrouter", "tencent/hy3-preview:nitro")?.limit?.context).toBe(256000);
190
179
  });
191
180
 
192
181
  it("lookupModelsDev returns undefined for unknown model", () => {
@@ -251,8 +240,7 @@ describe("benchlm fallback", () => {
251
240
 
252
241
  beforeEach(() => {
253
242
  (modelgrep as unknown as { _mem: ModelGrepModel[] })._mem = catalog;
254
- (benchlm as unknown as { _mem: typeof benchlmEntries })._mem =
255
- benchlmEntries;
243
+ (benchlm as unknown as { _mem: typeof benchlmEntries })._mem = benchlmEntries;
256
244
  });
257
245
  afterEach(() => {
258
246
  (modelgrep as unknown as { _mem: ModelGrepModel[] | null })._mem = null;
@@ -293,14 +281,11 @@ describe("modelgrep AA primary wins over benchlm", () => {
293
281
  bench: { intelligence: 60 }, // AA index: 60/65 → 92
294
282
  }),
295
283
  ];
296
- const benchlmEntries = [
297
- { rank: 1, model: "Claude Opus 4.8", overallScore: 50 },
298
- ];
284
+ const benchlmEntries = [{ rank: 1, model: "Claude Opus 4.8", overallScore: 50 }];
299
285
 
300
286
  beforeEach(() => {
301
287
  (modelgrep as unknown as { _mem: ModelGrepModel[] })._mem = catalog;
302
- (benchlm as unknown as { _mem: typeof benchlmEntries })._mem =
303
- benchlmEntries;
288
+ (benchlm as unknown as { _mem: typeof benchlmEntries })._mem = benchlmEntries;
304
289
  });
305
290
  afterEach(() => {
306
291
  (modelgrep as unknown as { _mem: ModelGrepModel[] | null })._mem = null;
package/src/data.ts CHANGED
@@ -40,10 +40,7 @@ export interface ModelsDevModel {
40
40
  };
41
41
  }
42
42
 
43
- export type ModelsDevApi = Record<
44
- string,
45
- { models?: Record<string, ModelsDevModel> }
46
- >;
43
+ export type ModelsDevApi = Record<string, { models?: Record<string, ModelsDevModel> }>;
47
44
 
48
45
  export interface BenchmarkEntry {
49
46
  rank: number;
@@ -158,13 +155,8 @@ export class DataSource<T> {
158
155
  return val;
159
156
  }
160
157
  try {
161
- const url =
162
- typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
163
- const raw = await this.opts.fetchRaw(
164
- url,
165
- this.opts.headers(),
166
- this.opts.timeoutMs,
167
- );
158
+ const url = typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
159
+ const raw = await this.opts.fetchRaw(url, this.opts.headers(), this.opts.timeoutMs);
168
160
  const val = this.opts.parse(raw);
169
161
  this._mem = val;
170
162
  void this._writeCache(raw);
@@ -172,9 +164,7 @@ export class DataSource<T> {
172
164
  } catch (error) {
173
165
  const msg = error instanceof Error ? error.message : String(error);
174
166
  if (cached !== undefined) {
175
- console.warn(
176
- `${this.opts.label} fetch failed, using stale cache: ${msg}`,
177
- );
167
+ console.warn(`${this.opts.label} fetch failed, using stale cache: ${msg}`);
178
168
  const val = this.opts.parseCache(cached.data);
179
169
  this._mem = val;
180
170
  return val;
@@ -184,9 +174,7 @@ export class DataSource<T> {
184
174
  }
185
175
  }
186
176
 
187
- private async _readCache(): Promise<
188
- { ts: number; data: unknown } | undefined
189
- > {
177
+ private async _readCache(): Promise<{ ts: number; data: unknown } | undefined> {
190
178
  try {
191
179
  const raw = await readFile(this.opts.cachePath, "utf8");
192
180
  const parsed = JSON.parse(raw) as { ts: number; data: unknown };
@@ -200,10 +188,7 @@ export class DataSource<T> {
200
188
  private async _writeCache(data: unknown): Promise<void> {
201
189
  try {
202
190
  await mkdir(dirname(this.opts.cachePath), { recursive: true });
203
- await writeFile(
204
- this.opts.cachePath,
205
- JSON.stringify({ ts: Date.now(), data }),
206
- );
191
+ await writeFile(this.opts.cachePath, JSON.stringify({ ts: Date.now(), data }));
207
192
  } catch {
208
193
  // Write failure is non-fatal — stale cache used on next run
209
194
  }
@@ -217,9 +202,7 @@ function fetchWithTimeout(
217
202
  ): Promise<Response> {
218
203
  const controller = new AbortController();
219
204
  const timer = setTimeout(() => controller.abort(), timeoutMs);
220
- return fetch(url, { signal: controller.signal, headers }).finally(() =>
221
- clearTimeout(timer),
222
- );
205
+ return fetch(url, { signal: controller.signal, headers }).finally(() => clearTimeout(timer));
223
206
  }
224
207
 
225
208
  /** Single-request raw fetch — the default DataSource fetch strategy. */
@@ -270,10 +253,7 @@ async function fetchModelGrepAll(
270
253
 
271
254
  // ── Cache dir ─────────────────────────────────────────────────────────────────
272
255
 
273
- export const CACHE_DIR = join(
274
- process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
275
- "pi",
276
- );
256
+ export const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "pi");
277
257
 
278
258
  // ── Data sources ──────────────────────────────────────────────────────────────
279
259
 
@@ -375,9 +355,7 @@ function toModelsDevModel(g: ModelGrepModel): ModelsDevModel {
375
355
  };
376
356
  }
377
357
 
378
- export function buildModelsDevIndex(
379
- source: ModelGrepModel[],
380
- ): Map<string, ModelsDevModel> {
358
+ export function buildModelsDevIndex(source: ModelGrepModel[]): Map<string, ModelsDevModel> {
381
359
  const index = new Map<string, ModelsDevModel>();
382
360
  for (const g of source) {
383
361
  const m = toModelsDevModel(g);
@@ -388,18 +366,13 @@ export function buildModelsDevIndex(
388
366
  return index;
389
367
  }
390
368
 
391
- export function lookupModelsDev(
392
- _provider: string,
393
- id: string,
394
- ): ModelsDevModel | undefined {
369
+ export function lookupModelsDev(_provider: string, id: string): ModelsDevModel | undefined {
395
370
  // Provider prefix differs between Pi routing (cc/ds/openrouter) and modelgrep
396
371
  // (anthropic/tencent), so join on the model slug only via the normalized index.
397
372
  return findInIndex(id, buildModelsDevIndex(modelgrep.getCached()));
398
373
  }
399
374
 
400
- export async function fetchModelsDevIndex(): Promise<
401
- Map<string, ModelsDevModel>
402
- > {
375
+ export async function fetchModelsDevIndex(): Promise<Map<string, ModelsDevModel>> {
403
376
  return buildModelsDevIndex(await modelgrep.get());
404
377
  }
405
378
 
@@ -438,9 +411,7 @@ const clamp01to100 = (x: number) => Math.max(0, Math.min(100, x));
438
411
  // agentic-heavy (.60) since tool-call matters most, coding (.30), reasoning a
439
412
  // .10 tiebreaker. Sub-weights likewise fit — tau2 dominates the agentic group.
440
413
  function heuristicScore(
441
- aa: NonNullable<
442
- NonNullable<ModelGrepModel["benchmarks"]>["artificial_analysis"]
443
- >,
414
+ aa: NonNullable<NonNullable<ModelGrepModel["benchmarks"]>["artificial_analysis"]>,
444
415
  ): number | null {
445
416
  const coding = blend([
446
417
  [0.6, frac(aa.coding)],
@@ -464,17 +435,13 @@ function heuristicScore(
464
435
  // Model score 0–100. Prefer AA's Intelligence Index (authoritative 9-eval
465
436
  // composite); when absent, map our heuristic onto the index scale via the
466
437
  // fitted line. Null only when nothing is benchmarked.
467
- function codingScore(
468
- bench: NonNullable<ModelGrepModel["benchmarks"]>,
469
- ): number | null {
438
+ function codingScore(bench: NonNullable<ModelGrepModel["benchmarks"]>): number | null {
470
439
  const aa = bench.artificial_analysis ?? {};
471
440
  if (aa.intelligence != null) {
472
441
  return Math.round((aa.intelligence / INTELLIGENCE_MAX) * 100);
473
442
  }
474
443
  const h = heuristicScore(aa);
475
- return h == null
476
- ? null
477
- : Math.round(clamp01to100(FALLBACK_SLOPE * h + FALLBACK_INTERCEPT));
444
+ return h == null ? null : Math.round(clamp01to100(FALLBACK_SLOPE * h + FALLBACK_INTERCEPT));
478
445
  }
479
446
 
480
447
  function buildBenchIndex(): Map<string, BenchmarkEntry> {
@@ -508,8 +475,7 @@ function buildBenchIndex(): Map<string, BenchmarkEntry> {
508
475
  inputPrice: g.pricing?.input ?? null,
509
476
  outputPrice: g.pricing?.output ?? null,
510
477
  };
511
- for (const k of [slug, normalize(slug)])
512
- if (!index.has(k)) index.set(k, entry);
478
+ for (const k of [slug, normalize(slug)]) if (!index.has(k)) index.set(k, entry);
513
479
  });
514
480
  return index;
515
481
  }
@@ -544,8 +510,7 @@ function lookupBenchlmScore(
544
510
  if (direct) candidates.push(...direct);
545
511
  for (const [key, entries] of benchlmByNorm) {
546
512
  if (key === norm) continue;
547
- if (key.startsWith(norm) || norm.startsWith(key))
548
- candidates.push(...entries);
513
+ if (key.startsWith(norm) || norm.startsWith(key)) candidates.push(...entries);
549
514
  }
550
515
  if (candidates.length === 0) return null;
551
516
 
@@ -555,10 +520,7 @@ function lookupBenchlmScore(
555
520
  const sa = a.overallScore ?? -Infinity;
556
521
  const sb = b.overallScore ?? -Infinity;
557
522
  if (sa !== sb) return sb - sa;
558
- return (
559
- normalizeBenchlmName(a.model).length -
560
- normalizeBenchlmName(b.model).length
561
- );
523
+ return normalizeBenchlmName(a.model).length - normalizeBenchlmName(b.model).length;
562
524
  });
563
525
  const best = sorted[0];
564
526
  if (!best) return null;
@@ -0,0 +1,299 @@
1
+ /**
2
+ * pix-config.ts — unified config loader for ~/.pi/agent/pix.json
3
+ *
4
+ * Single source of truth for all pix-* configuration. The file is read once
5
+ * on first access and cached in-process. A `reloadPixConfig()` function is
6
+ * exposed for slash-commands that edit the file live.
7
+ *
8
+ * Design:
9
+ * - Every key is explicit: absence → default, `false` → disabled.
10
+ * - Env vars still win when set (backward compat), but the JSON file is the
11
+ * primary config surface.
12
+ * - Schema is flat-ish with namespaced top-level sections.
13
+ *
14
+ * File: ~/.pi/agent/pix.json
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+
20
+ // ── Types ────────────────────────────────────────────────────────────────────
21
+
22
+ export interface CollapseConfig {
23
+ /** Master toggle. `false` = never collapse any tool. Default: `true`. */
24
+ enabled: boolean;
25
+ /** Seconds before a tool card collapses. Default: `10`. */
26
+ delaySec: number;
27
+ /** Per-tool overrides. Missing key = follows `enabled`. */
28
+ tools: Partial<Record<string, boolean>>;
29
+ }
30
+
31
+ export interface DiffColors {
32
+ splitMinWidth: number;
33
+ splitMinCodeWidth: number;
34
+ bgAdd: string;
35
+ bgDel: string;
36
+ bgAddHighlight: string;
37
+ bgDelHighlight: string;
38
+ bgGutterAdd: string;
39
+ bgGutterDel: string;
40
+ fgAdd: string;
41
+ fgDel: string;
42
+ }
43
+
44
+ export interface PrettyConfig {
45
+ theme: string;
46
+ icons: string;
47
+ maxPreviewLines: number;
48
+ maxRenderLines: number;
49
+ maxHighlightChars: number;
50
+ cacheLimit: number;
51
+ diff: DiffColors;
52
+ }
53
+
54
+ export interface OptimizerConfig {
55
+ caveman: string;
56
+ rtk: string;
57
+ toon: string;
58
+ ponytail: string;
59
+ }
60
+
61
+ export interface GateRuleConfig {
62
+ pattern: string;
63
+ flags?: string;
64
+ severity?: string;
65
+ reason?: string;
66
+ }
67
+
68
+ export interface GateConfig {
69
+ disableDefaults: boolean;
70
+ autoApprove: string[];
71
+ extraRules: GateRuleConfig[];
72
+ }
73
+
74
+ export interface PixConfig {
75
+ collapse: CollapseConfig;
76
+ pretty: PrettyConfig;
77
+ optimizer: OptimizerConfig;
78
+ gate: GateConfig;
79
+ }
80
+
81
+ // ── Defaults ─────────────────────────────────────────────────────────────────
82
+
83
+ const DEFAULT_DIFF: DiffColors = {
84
+ splitMinWidth: 150,
85
+ splitMinCodeWidth: 60,
86
+ bgAdd: "#163826",
87
+ bgDel: "#2d1919",
88
+ bgAddHighlight: "#234b32",
89
+ bgDelHighlight: "#502323",
90
+ bgGutterAdd: "#12201a",
91
+ bgGutterDel: "#261616",
92
+ fgAdd: "#64b478",
93
+ fgDel: "#c86464",
94
+ };
95
+
96
+ const DEFAULT_COLLAPSE: CollapseConfig = {
97
+ enabled: true,
98
+ delaySec: 10,
99
+ tools: {},
100
+ };
101
+
102
+ const DEFAULT_PRETTY: PrettyConfig = {
103
+ theme: "github-dark",
104
+ icons: "nerd",
105
+ maxPreviewLines: 80,
106
+ maxRenderLines: 150,
107
+ maxHighlightChars: 80_000,
108
+ cacheLimit: 128,
109
+ diff: { ...DEFAULT_DIFF },
110
+ };
111
+
112
+ const DEFAULT_OPTIMIZER: OptimizerConfig = {
113
+ caveman: "off",
114
+ rtk: "off",
115
+ toon: "off",
116
+ ponytail: "off",
117
+ };
118
+
119
+ const DEFAULT_GATE: GateConfig = {
120
+ disableDefaults: false,
121
+ autoApprove: [],
122
+ extraRules: [],
123
+ };
124
+
125
+ /** Full default config — used by tests and documentation. */
126
+ export const DEFAULT_CONFIG: PixConfig = {
127
+ collapse: { ...DEFAULT_COLLAPSE },
128
+ pretty: { ...DEFAULT_PRETTY },
129
+ optimizer: { ...DEFAULT_OPTIMIZER },
130
+ gate: { ...DEFAULT_GATE },
131
+ };
132
+
133
+ // ── Loader ───────────────────────────────────────────────────────────────────
134
+
135
+ let cached: PixConfig | null = null;
136
+
137
+ function configPath(): string | undefined {
138
+ const home = process.env.HOME ?? "";
139
+ if (!home) return undefined;
140
+ return join(home, ".pi/agent", "pix.json");
141
+ }
142
+
143
+ /** Seed file written on first load so users have a reference to edit. */
144
+ function seedConfigFile(p: string): void {
145
+ try {
146
+ mkdirSync(dirname(p), { recursive: true });
147
+ writeFileSync(p, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, {
148
+ flag: "wx", // exclusive create — no-op if file appeared between check and write
149
+ });
150
+ } catch {
151
+ /* race / permission — harmless */
152
+ }
153
+ }
154
+
155
+ function readRawConfig(): Record<string, unknown> {
156
+ try {
157
+ const p = configPath();
158
+ if (!p) return {};
159
+ if (!existsSync(p)) {
160
+ seedConfigFile(p);
161
+ return {};
162
+ }
163
+ return JSON.parse(readFileSync(p, "utf-8")) as Record<string, unknown>;
164
+ } catch {
165
+ return {};
166
+ }
167
+ }
168
+
169
+ // ── Merge helpers ────────────────────────────────────────────────────────────
170
+
171
+ function isObj(v: unknown): v is Record<string, unknown> {
172
+ return v !== null && typeof v === "object" && !Array.isArray(v);
173
+ }
174
+
175
+ function num(v: unknown, fallback: number): number {
176
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : fallback;
177
+ }
178
+
179
+ function str(v: unknown, fallback: string): string {
180
+ return typeof v === "string" && v.length > 0 ? v : fallback;
181
+ }
182
+
183
+ function bool(v: unknown, fallback: boolean): boolean {
184
+ return typeof v === "boolean" ? v : fallback;
185
+ }
186
+
187
+ function strArr(v: unknown): string[] {
188
+ if (!Array.isArray(v)) return [];
189
+ return v.filter((x): x is string => typeof x === "string");
190
+ }
191
+
192
+ function mergeCollapse(raw: unknown): CollapseConfig {
193
+ if (!isObj(raw)) return { ...DEFAULT_COLLAPSE };
194
+ const tools: Partial<Record<string, boolean>> = {};
195
+ if (isObj(raw.tools)) {
196
+ for (const [k, v] of Object.entries(raw.tools)) {
197
+ if (typeof v === "boolean") tools[k] = v;
198
+ }
199
+ }
200
+ return {
201
+ enabled: bool(raw.enabled, DEFAULT_COLLAPSE.enabled),
202
+ delaySec: num(raw.delaySec, DEFAULT_COLLAPSE.delaySec),
203
+ tools,
204
+ };
205
+ }
206
+
207
+ function mergeDiff(raw: unknown): DiffColors {
208
+ if (!isObj(raw)) return { ...DEFAULT_DIFF };
209
+ return {
210
+ splitMinWidth: num(raw.splitMinWidth, DEFAULT_DIFF.splitMinWidth),
211
+ splitMinCodeWidth: num(raw.splitMinCodeWidth, DEFAULT_DIFF.splitMinCodeWidth),
212
+ bgAdd: str(raw.bgAdd, DEFAULT_DIFF.bgAdd),
213
+ bgDel: str(raw.bgDel, DEFAULT_DIFF.bgDel),
214
+ bgAddHighlight: str(raw.bgAddHighlight, DEFAULT_DIFF.bgAddHighlight),
215
+ bgDelHighlight: str(raw.bgDelHighlight, DEFAULT_DIFF.bgDelHighlight),
216
+ bgGutterAdd: str(raw.bgGutterAdd, DEFAULT_DIFF.bgGutterAdd),
217
+ bgGutterDel: str(raw.bgGutterDel, DEFAULT_DIFF.bgGutterDel),
218
+ fgAdd: str(raw.fgAdd, DEFAULT_DIFF.fgAdd),
219
+ fgDel: str(raw.fgDel, DEFAULT_DIFF.fgDel),
220
+ };
221
+ }
222
+
223
+ function mergePretty(raw: unknown): PrettyConfig {
224
+ if (!isObj(raw)) return { ...DEFAULT_PRETTY };
225
+ return {
226
+ theme: str(raw.theme, DEFAULT_PRETTY.theme),
227
+ icons: str(raw.icons, DEFAULT_PRETTY.icons),
228
+ maxPreviewLines: num(raw.maxPreviewLines, DEFAULT_PRETTY.maxPreviewLines),
229
+ maxRenderLines: num(raw.maxRenderLines, DEFAULT_PRETTY.maxRenderLines),
230
+ maxHighlightChars: num(raw.maxHighlightChars, DEFAULT_PRETTY.maxHighlightChars),
231
+ cacheLimit: num(raw.cacheLimit, DEFAULT_PRETTY.cacheLimit),
232
+ diff: mergeDiff(raw.diff),
233
+ };
234
+ }
235
+
236
+ function mergeOptimizer(raw: unknown): OptimizerConfig {
237
+ if (!isObj(raw)) return { ...DEFAULT_OPTIMIZER };
238
+ return {
239
+ caveman: str(raw.caveman, DEFAULT_OPTIMIZER.caveman),
240
+ rtk: str(raw.rtk, DEFAULT_OPTIMIZER.rtk),
241
+ toon: str(raw.toon, DEFAULT_OPTIMIZER.toon),
242
+ ponytail: str(raw.ponytail, DEFAULT_OPTIMIZER.ponytail),
243
+ };
244
+ }
245
+
246
+ function mergeGateRules(raw: unknown): GateRuleConfig[] {
247
+ if (!Array.isArray(raw)) return [];
248
+ return raw.filter(
249
+ (r): r is GateRuleConfig =>
250
+ isObj(r) && typeof (r as Record<string, unknown>).pattern === "string",
251
+ );
252
+ }
253
+
254
+ function mergeGate(raw: unknown): GateConfig {
255
+ if (!isObj(raw)) return { ...DEFAULT_GATE };
256
+ return {
257
+ disableDefaults: bool(raw.disableDefaults, DEFAULT_GATE.disableDefaults),
258
+ autoApprove: strArr(raw.autoApprove),
259
+ extraRules: mergeGateRules(raw.extraRules),
260
+ };
261
+ }
262
+
263
+ function buildConfig(raw: Record<string, unknown>): PixConfig {
264
+ return {
265
+ collapse: mergeCollapse(raw.collapse),
266
+ pretty: mergePretty(raw.pretty),
267
+ optimizer: mergeOptimizer(raw.optimizer),
268
+ gate: mergeGate(raw.gate),
269
+ };
270
+ }
271
+
272
+ // ── Public API ───────────────────────────────────────────────────────────────
273
+
274
+ /** Get the resolved pix config. Loads from disk on first call, cached after. */
275
+ export function pixConfig(): PixConfig {
276
+ if (!cached) cached = buildConfig(readRawConfig());
277
+ return cached;
278
+ }
279
+
280
+ /** Re-read pix.json from disk. Call after editing the file live. */
281
+ export function reloadPixConfig(): PixConfig {
282
+ cached = buildConfig(readRawConfig());
283
+ return cached;
284
+ }
285
+
286
+ /** Check if a tool should auto-collapse its output card. */
287
+ export function shouldCollapse(toolName: string): boolean {
288
+ const c = pixConfig().collapse;
289
+ // Per-tool override wins.
290
+ const perTool = c.tools[toolName];
291
+ if (typeof perTool === "boolean") return perTool;
292
+ // Fall back to master toggle.
293
+ return c.enabled;
294
+ }
295
+
296
+ /** Get the collapse delay in milliseconds. */
297
+ export function collapseDelayMs(): number {
298
+ return pixConfig().collapse.delaySec * 1000;
299
+ }