pi-blackhole 0.4.2 → 0.4.3
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 +15 -0
- package/dist/index.js +11660 -0
- package/dist/index.js.map +1 -0
- package/example-config.json +1 -1
- package/index.ts +37 -63
- package/package.json +21 -9
- package/src/commands/cleanup.ts +279 -240
- package/src/commands/memory.ts +236 -184
- package/src/commands/pi-vcc.ts +202 -152
- package/src/commands/vcc-recall.ts +126 -95
- package/src/core/brief.ts +167 -33
- package/src/core/build-sections.ts +8 -2
- package/src/core/config-env.ts +117 -0
- package/src/core/content.ts +31 -7
- package/src/core/drill-down.ts +41 -11
- package/src/core/filter-noise.ts +9 -3
- package/src/core/format-recall.ts +15 -6
- package/src/core/format.ts +14 -4
- package/src/core/lineage.ts +9 -3
- package/src/core/load-messages.ts +24 -5
- package/src/core/normalize.ts +38 -14
- package/src/core/recall-scope.ts +11 -3
- package/src/core/render-entries.ts +22 -6
- package/src/core/sanitize.ts +5 -1
- package/src/core/search-entries.ts +111 -19
- package/src/core/settings.ts +1 -3
- package/src/core/summarize.ts +42 -21
- package/src/core/unified-config.ts +549 -411
- package/src/extract/commits.ts +4 -2
- package/src/extract/files.ts +10 -5
- package/src/extract/goals.ts +7 -2
- package/src/hooks/before-compact.ts +210 -88
- package/src/om/agents/dropper/agent.ts +380 -265
- package/src/om/agents/dropper/coverage.ts +102 -82
- package/src/om/agents/observer/agent.ts +242 -206
- package/src/om/agents/reflector/agent.ts +212 -153
- package/src/om/cleanup.ts +239 -218
- package/src/om/clipboard.ts +59 -51
- package/src/om/compaction-trigger.ts +448 -333
- package/src/om/config.ts +13 -6
- package/src/om/configure-overlay.ts +518 -355
- package/src/om/consolidation.ts +1460 -953
- package/src/om/cooldown.ts +75 -65
- package/src/om/debug-log.ts +86 -68
- package/src/om/ids.ts +1 -1
- package/src/om/ledger/fold.ts +89 -78
- package/src/om/ledger/progress.ts +181 -153
- package/src/om/ledger/projection.ts +248 -185
- package/src/om/ledger/recall.ts +247 -196
- package/src/om/ledger/render-summary.ts +79 -50
- package/src/om/ledger/types.ts +146 -117
- package/src/om/model-budget.ts +23 -13
- package/src/om/pending.ts +243 -179
- package/src/om/provider-stream.ts +52 -7
- package/src/om/retryable-error.ts +12 -16
- package/src/om/reverse-recall.ts +97 -91
- package/src/om/runtime.ts +474 -375
- package/src/om/serialize.ts +190 -166
- package/src/om/status-overlay.ts +246 -195
- package/src/om/tokens.ts +28 -21
- package/src/pi-base/blackhole-settings.ts +437 -0
- package/src/pi-base/config-manager.ts +440 -0
- package/src/pi-base/config.ts +469 -0
- package/src/pi-base/env.ts +43 -0
- package/src/pi-base/paths.ts +47 -0
- package/src/pi-base/settings/body.ts +1648 -0
- package/src/pi-base/settings/fields/action.ts +43 -0
- package/src/pi-base/settings/fields/boolean.ts +47 -0
- package/src/pi-base/settings/fields/custom.ts +72 -0
- package/src/pi-base/settings/fields/enum.ts +310 -0
- package/src/pi-base/settings/fields/index.ts +46 -0
- package/src/pi-base/settings/fields/model.ts +452 -0
- package/src/pi-base/settings/fields/string.ts +527 -0
- package/src/pi-base/settings/fields/text.ts +115 -0
- package/src/pi-base/settings/frame.ts +197 -0
- package/src/pi-base/settings/index.ts +77 -0
- package/src/pi-base/settings/inline-edit.ts +313 -0
- package/src/pi-base/settings/modal.ts +152 -0
- package/src/pi-base/settings/types.ts +500 -0
- package/src/pi-base/settings/validate-field.ts +113 -0
- package/src/pi-base/shell.ts +117 -0
- package/src/pi-base/types.ts +6 -0
- package/src/pi-base/ui.ts +32 -0
- package/src/tools/recall.ts +347 -225
- package/src/types.ts +20 -3
- package/tsup.config.ts +23 -0
- package/vitest.config.ts +15 -15
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.ts — Unified configuration API for pi extensions.
|
|
3
|
+
*
|
|
4
|
+
* Named-file config (one file per package):
|
|
5
|
+
* ~/.pi/agent/extensions/<package>-config.json (global)
|
|
6
|
+
* <cwd>/.pi/<package>-config.json (project-local)
|
|
7
|
+
*
|
|
8
|
+
* Resolution order: defaults ← global file ← project-local file.
|
|
9
|
+
*
|
|
10
|
+
* Test safety: writeConfig / deleteConfig refuse to operate when vitest
|
|
11
|
+
* is detected AND no explicit `configDir` is provided. Pass `configDir`
|
|
12
|
+
* in tests (e.g. a temp dir) to disable the gate.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
statSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { getPiAgentDir } from "./paths.js";
|
|
24
|
+
|
|
25
|
+
interface CacheHit {
|
|
26
|
+
mtime: number;
|
|
27
|
+
size: number;
|
|
28
|
+
data: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const _configCache = new Map<string, CacheHit>();
|
|
32
|
+
const CACHE_LIMIT = 128;
|
|
33
|
+
|
|
34
|
+
function cacheSet(key: string, value: CacheHit): void {
|
|
35
|
+
_configCache.delete(key);
|
|
36
|
+
_configCache.set(key, value);
|
|
37
|
+
if (_configCache.size > CACHE_LIMIT) {
|
|
38
|
+
const firstKey = _configCache.keys().next().value;
|
|
39
|
+
if (firstKey !== undefined) {
|
|
40
|
+
_configCache.delete(firstKey);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Path helpers ──────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Canonical config directory: ~/.pi/agent/extensions/
|
|
49
|
+
*/
|
|
50
|
+
export function getExtensionsDir(): string {
|
|
51
|
+
return join(getPiAgentDir(), "extensions");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveConfigDir(configDir: string | undefined): string {
|
|
55
|
+
return configDir ?? getExtensionsDir();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Check if a directory path points to a real user home (not a test temp). */
|
|
59
|
+
function isRealDir(dir: string): boolean {
|
|
60
|
+
return !dir.startsWith("/tmp") && !dir.startsWith("/var/folders");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Guard writes/deletes against the real user config directory in tests.
|
|
65
|
+
* Returns true if the operation should proceed.
|
|
66
|
+
*/
|
|
67
|
+
function guardRealDir(
|
|
68
|
+
filename: string,
|
|
69
|
+
configDir: string | undefined,
|
|
70
|
+
operation: "write" | "delete",
|
|
71
|
+
): boolean {
|
|
72
|
+
// Explicit configDir — caller's responsibility
|
|
73
|
+
if (configDir !== undefined) return true;
|
|
74
|
+
// Not in vitest — real pi process, allowed
|
|
75
|
+
if (process.env.VITEST !== "true") return true;
|
|
76
|
+
|
|
77
|
+
const dir = resolveConfigDir(configDir);
|
|
78
|
+
if (isRealDir(dir)) {
|
|
79
|
+
console.warn(
|
|
80
|
+
`[pi-base] Blocked ${operation} of "${filename}" — running in vitest ` +
|
|
81
|
+
`without explicit configDir, and the target directory (${dir}) ` +
|
|
82
|
+
`looks like a real user home. ` +
|
|
83
|
+
`Pass an explicit configDir parameter to enable ${operation}s in tests.`,
|
|
84
|
+
);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Read ──────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read a named config file. Returns parsed object or null.
|
|
94
|
+
* Optimized with in-memory caching and mtime validation to bypass redundant filesystem checks.
|
|
95
|
+
*
|
|
96
|
+
* @param filename e.g. "pi-window-title-config.json"
|
|
97
|
+
* @param configDir Directory to read from. Defaults to getExtensionsDir().
|
|
98
|
+
*/
|
|
99
|
+
export function readConfig<T>(filename: string, configDir?: string): T | null {
|
|
100
|
+
const path = join(resolveConfigDir(configDir), filename);
|
|
101
|
+
try {
|
|
102
|
+
// Utilize { throwIfNoEntry: false } to avoid expensive exceptions when configuration files are missing
|
|
103
|
+
const stats = statSync(path, { throwIfNoEntry: false });
|
|
104
|
+
if (!stats) {
|
|
105
|
+
_configCache.delete(path);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const hit = _configCache.get(path);
|
|
110
|
+
if (hit && hit.mtime === stats.mtimeMs && hit.size === stats.size) {
|
|
111
|
+
// Refresh LRU order on hit
|
|
112
|
+
_configCache.delete(path);
|
|
113
|
+
_configCache.set(path, hit);
|
|
114
|
+
return structuredClone(hit.data) as T;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const raw = readFileSync(path, "utf-8");
|
|
118
|
+
const trimmed = raw.trim();
|
|
119
|
+
if (trimmed.length === 0) {
|
|
120
|
+
cacheSet(path, { data: null, mtime: stats.mtimeMs, size: stats.size });
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const parsed = JSON.parse(trimmed);
|
|
124
|
+
if (
|
|
125
|
+
typeof parsed !== "object" ||
|
|
126
|
+
parsed === null ||
|
|
127
|
+
Array.isArray(parsed)
|
|
128
|
+
) {
|
|
129
|
+
cacheSet(path, { data: null, mtime: stats.mtimeMs, size: stats.size });
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Cache the parsed JSON data, validating both mtime and size
|
|
134
|
+
cacheSet(path, {
|
|
135
|
+
data: structuredClone(parsed),
|
|
136
|
+
mtime: stats.mtimeMs,
|
|
137
|
+
size: stats.size,
|
|
138
|
+
});
|
|
139
|
+
return parsed as T;
|
|
140
|
+
} catch {
|
|
141
|
+
_configCache.delete(path);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Write ─────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Write a named config file. Returns true on success.
|
|
150
|
+
*
|
|
151
|
+
* Safety gate: in vitest, if no explicit `configDir` is provided,
|
|
152
|
+
* warns and returns false to prevent accidental writes to the real
|
|
153
|
+
* ~/.pi/agent/extensions/ directory.
|
|
154
|
+
*
|
|
155
|
+
* @param filename e.g. "pi-window-title-config.json"
|
|
156
|
+
* @param data Object to serialize as JSON
|
|
157
|
+
* @param configDir Directory to write to. Defaults to getExtensionsDir().
|
|
158
|
+
*/
|
|
159
|
+
export function writeConfig<T>(
|
|
160
|
+
filename: string,
|
|
161
|
+
data: T,
|
|
162
|
+
configDir?: string,
|
|
163
|
+
): boolean {
|
|
164
|
+
if (!guardRealDir(filename, configDir, "write")) return false;
|
|
165
|
+
const path = join(resolveConfigDir(configDir), filename);
|
|
166
|
+
_configCache.delete(path);
|
|
167
|
+
try {
|
|
168
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
169
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
170
|
+
return true;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.warn(
|
|
173
|
+
`[pi-base] Failed to write "${path}": ${error instanceof Error ? error.message : String(error)}`,
|
|
174
|
+
);
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Delete ────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Delete a named config file. No-op if the file doesn't exist.
|
|
183
|
+
*/
|
|
184
|
+
export function deleteConfig(filename: string, configDir?: string): void {
|
|
185
|
+
if (!guardRealDir(filename, configDir, "delete")) return;
|
|
186
|
+
const path = join(resolveConfigDir(configDir), filename);
|
|
187
|
+
_configCache.delete(path);
|
|
188
|
+
try {
|
|
189
|
+
rmSync(path, { force: true });
|
|
190
|
+
} catch {
|
|
191
|
+
// No-op if file doesn't exist or can't be deleted
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── deepMerge ─────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Recursively merge `overrides` into `base`.
|
|
199
|
+
*
|
|
200
|
+
* - Plain objects are recursively merged (not replaced).
|
|
201
|
+
* - `null` in overrides replaces the base value (explicit reset).
|
|
202
|
+
* - `undefined` in overrides leaves the base value unchanged.
|
|
203
|
+
* - Arrays, primitives, and non-plain objects are replaced wholesale.
|
|
204
|
+
* - Does not mutate the base object (returns a new object).
|
|
205
|
+
*/
|
|
206
|
+
export function deepMerge<T extends Record<string, unknown>>(
|
|
207
|
+
base: T,
|
|
208
|
+
overrides: Partial<T>,
|
|
209
|
+
): T {
|
|
210
|
+
if (!overrides || typeof overrides !== "object") {
|
|
211
|
+
return { ...base };
|
|
212
|
+
}
|
|
213
|
+
const result = { ...base };
|
|
214
|
+
|
|
215
|
+
for (const key of Object.keys(overrides)) {
|
|
216
|
+
const overrideVal = (overrides as Record<string, unknown>)[key];
|
|
217
|
+
|
|
218
|
+
if (overrideVal === undefined) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (overrideVal === null) {
|
|
223
|
+
(result as Record<string, unknown>)[key] = null;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const baseVal = (result as Record<string, unknown>)[key];
|
|
228
|
+
|
|
229
|
+
if (isPlainObject(baseVal) && isPlainObject(overrideVal)) {
|
|
230
|
+
(result as Record<string, unknown>)[key] = deepMerge(
|
|
231
|
+
baseVal as Record<string, unknown>,
|
|
232
|
+
overrideVal as Record<string, unknown>,
|
|
233
|
+
);
|
|
234
|
+
} else {
|
|
235
|
+
(result as Record<string, unknown>)[key] = overrideVal;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result as T;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
243
|
+
return (
|
|
244
|
+
typeof value === "object" &&
|
|
245
|
+
value !== null &&
|
|
246
|
+
!Array.isArray(value) &&
|
|
247
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── deepEqual ─────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Deep equality check for two values.
|
|
255
|
+
* - Handles primitives, Date, RegExp, Map, Set, arrays, and plain objects.
|
|
256
|
+
* - Non-plain-object instances are compared by reference (strict equality).
|
|
257
|
+
*/
|
|
258
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
259
|
+
if (Object.is(a, b)) return true;
|
|
260
|
+
|
|
261
|
+
if (a === null || b === null || typeof a !== typeof b) return false;
|
|
262
|
+
|
|
263
|
+
// Date
|
|
264
|
+
if (a instanceof Date && b instanceof Date) {
|
|
265
|
+
return a.getTime() === b.getTime();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// RegExp
|
|
269
|
+
if (a instanceof RegExp && b instanceof RegExp) {
|
|
270
|
+
return a.source === b.source && a.flags === b.flags;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Map
|
|
274
|
+
if (a instanceof Map && b instanceof Map) {
|
|
275
|
+
if (a.size !== b.size) return false;
|
|
276
|
+
for (const [k, v] of a) {
|
|
277
|
+
if (!b.has(k) || !deepEqual(v, b.get(k))) return false;
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Set
|
|
283
|
+
if (a instanceof Set && b instanceof Set) {
|
|
284
|
+
if (a.size !== b.size) return false;
|
|
285
|
+
for (const v of a) {
|
|
286
|
+
if (!b.has(v)) return false;
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Arrays
|
|
292
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
293
|
+
if (a.length !== b.length) return false;
|
|
294
|
+
for (let i = 0; i < a.length; i++) {
|
|
295
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Plain objects
|
|
301
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
302
|
+
const keysA = Object.keys(a);
|
|
303
|
+
const keysB = Object.keys(b);
|
|
304
|
+
if (keysA.length !== keysB.length) return false;
|
|
305
|
+
for (const key of keysA) {
|
|
306
|
+
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
|
307
|
+
if (!deepEqual(a[key], b[key])) return false;
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── checkConfigFile ───────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
export interface ConfigFileStatus {
|
|
318
|
+
/** Whether the file exists on disk */
|
|
319
|
+
exists: boolean;
|
|
320
|
+
/** Whether the file contains valid config data (valid JSON + a plain object) */
|
|
321
|
+
valid: boolean;
|
|
322
|
+
/** Human-readable error description when valid is false */
|
|
323
|
+
error?: string;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Check whether a config file exists and has valid JSON content.
|
|
328
|
+
* Does NOT cache — every call reads the file header / full content.
|
|
329
|
+
*
|
|
330
|
+
* Use this before `readConfig` to distinguish "file not found" from
|
|
331
|
+
* "file exists but is malformed".
|
|
332
|
+
*/
|
|
333
|
+
export function checkConfigFile(
|
|
334
|
+
filename: string,
|
|
335
|
+
configDir?: string,
|
|
336
|
+
): ConfigFileStatus {
|
|
337
|
+
const path = join(resolveConfigDir(configDir), filename);
|
|
338
|
+
try {
|
|
339
|
+
const stats = statSync(path, { throwIfNoEntry: false });
|
|
340
|
+
if (!stats) {
|
|
341
|
+
return { exists: false, valid: true };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const raw = readFileSync(path, "utf-8");
|
|
345
|
+
const trimmed = raw.trim();
|
|
346
|
+
|
|
347
|
+
if (trimmed.length === 0) {
|
|
348
|
+
return { exists: true, valid: false, error: "Config file is empty" };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let parsed: unknown;
|
|
352
|
+
try {
|
|
353
|
+
parsed = JSON.parse(trimmed);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
return {
|
|
356
|
+
exists: true,
|
|
357
|
+
valid: false,
|
|
358
|
+
error: `Invalid JSON: ${(e as Error).message}`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!isPlainObject(parsed)) {
|
|
363
|
+
return {
|
|
364
|
+
exists: true,
|
|
365
|
+
valid: false,
|
|
366
|
+
error: "Config file must contain a plain JSON object at the top level",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { exists: true, valid: true };
|
|
371
|
+
} catch {
|
|
372
|
+
return { exists: true, valid: false, error: "Cannot read config file" };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── loadConfig ────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
export interface LoadConfigOptions {
|
|
379
|
+
/**
|
|
380
|
+
* Working directory for project-local overrides.
|
|
381
|
+
* When set, reads from `<cwd>/.pi/<filename>` in addition to global.
|
|
382
|
+
*/
|
|
383
|
+
cwd?: string;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Merge strategy for combining defaults ← global ← project.
|
|
387
|
+
* - "shallow" (default): `{ ...defaults, ...global, ...project }`
|
|
388
|
+
* - "deep": recursively merges nested objects.
|
|
389
|
+
*/
|
|
390
|
+
merge?: "shallow" | "deep";
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Explicit config directory. Defaults to getExtensionsDir().
|
|
394
|
+
* Use in tests to read/write from a temporary or fixture directory.
|
|
395
|
+
*/
|
|
396
|
+
configDir?: string;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Load configuration with defaults, global file, and optional project-local override.
|
|
401
|
+
*
|
|
402
|
+
* Resolution order: defaults ← global file ← project-local file.
|
|
403
|
+
* Each layer shallow-merges (or deep-merges if `merge: "deep"`).
|
|
404
|
+
*
|
|
405
|
+
* Always returns a full config object — null is never returned.
|
|
406
|
+
* Missing keys fall back to defaults.
|
|
407
|
+
*/
|
|
408
|
+
export function loadConfig<T extends object>(
|
|
409
|
+
filename: string,
|
|
410
|
+
defaults: T,
|
|
411
|
+
opts: LoadConfigOptions = {},
|
|
412
|
+
): T {
|
|
413
|
+
const dir = resolveConfigDir(opts.configDir);
|
|
414
|
+
const mergeFn =
|
|
415
|
+
opts.merge === "deep"
|
|
416
|
+
? (base: T, over: Partial<T>) =>
|
|
417
|
+
deepMerge(
|
|
418
|
+
base as Record<string, unknown>,
|
|
419
|
+
over as Record<string, unknown>,
|
|
420
|
+
) as unknown as T
|
|
421
|
+
: shallowMerge<T>;
|
|
422
|
+
|
|
423
|
+
let config = defaults;
|
|
424
|
+
|
|
425
|
+
// Layer 1: global
|
|
426
|
+
config = mergeFn(
|
|
427
|
+
config,
|
|
428
|
+
readConfig<Partial<T>>(filename, dir) ?? ({} as Partial<T>),
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
// Layer 2: project-local
|
|
432
|
+
if (opts.cwd) {
|
|
433
|
+
const projectDir = join(opts.cwd, ".pi");
|
|
434
|
+
config = mergeFn(
|
|
435
|
+
config,
|
|
436
|
+
readConfig<Partial<T>>(filename, projectDir) ?? ({} as Partial<T>),
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return config;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function shallowMerge<T extends object>(base: T, overrides: Partial<T>): T {
|
|
444
|
+
const result = { ...base } as Record<string, unknown>;
|
|
445
|
+
for (const key of Object.keys(overrides as Record<string, unknown>)) {
|
|
446
|
+
const val = (overrides as Record<string, unknown>)[key];
|
|
447
|
+
if (val !== undefined) {
|
|
448
|
+
result[key] = val;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return result as unknown as T;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Env helpers ───────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
export function readBooleanEnv(name: string, fallback: boolean): boolean {
|
|
457
|
+
const raw = process.env[name]?.trim().toLowerCase();
|
|
458
|
+
if (!raw) return fallback;
|
|
459
|
+
if (["1", "true", "yes", "on"].includes(raw)) return true;
|
|
460
|
+
if (["0", "false", "no", "off"].includes(raw)) return false;
|
|
461
|
+
return fallback;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function readPositiveIntEnv(name: string, fallback: number): number {
|
|
465
|
+
const raw = process.env[name]?.trim();
|
|
466
|
+
if (!raw) return fallback;
|
|
467
|
+
const value = Number.parseInt(raw, 10);
|
|
468
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
469
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function isTmux(): boolean {
|
|
4
|
+
return Boolean(process.env.TMUX);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isKitty(): boolean {
|
|
8
|
+
return Boolean(process.env.KITTY_WINDOW_ID);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isGhostty(): boolean {
|
|
12
|
+
return process.env.TERM_PROGRAM === "ghostty";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isIterm(): boolean {
|
|
16
|
+
return (
|
|
17
|
+
process.env.TERM_PROGRAM === "iTerm.app" ||
|
|
18
|
+
Boolean(process.env.ITERM_SESSION_ID)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isWindowsTerminal(): boolean {
|
|
23
|
+
return Boolean(process.env.WT_SESSION) || process.platform === "win32";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isWSL(): boolean {
|
|
27
|
+
return Boolean(process.env.WSL_DISTRO_NAME);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isAlacritty(): boolean {
|
|
31
|
+
return (process.env.TERM ?? "").toLowerCase().includes("alacritty");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getClientTty(): string {
|
|
35
|
+
if (!isTmux()) return "/dev/tty";
|
|
36
|
+
try {
|
|
37
|
+
return execFileSync("tmux", ["display-message", "-p", "#{client_tty}"], {
|
|
38
|
+
encoding: "utf-8",
|
|
39
|
+
}).trim();
|
|
40
|
+
} catch {
|
|
41
|
+
return "/dev/tty";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
let lastEnvVal: string | undefined = undefined;
|
|
4
|
+
let cachedPiAgentDir: string | null = null;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the root directory for pi-agent data.
|
|
8
|
+
* Optimized with in-memory memoization that detects environmental updates.
|
|
9
|
+
*/
|
|
10
|
+
export function getPiAgentDir(): string {
|
|
11
|
+
const currentEnvVal = process.env.PI_CODING_AGENT_DIR;
|
|
12
|
+
if (cachedPiAgentDir !== null && currentEnvVal === lastEnvVal) {
|
|
13
|
+
return cachedPiAgentDir;
|
|
14
|
+
}
|
|
15
|
+
lastEnvVal = currentEnvVal;
|
|
16
|
+
cachedPiAgentDir = currentEnvVal?.trim() || getAgentDir();
|
|
17
|
+
return cachedPiAgentDir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shorten a working directory path by replacing the user's home directory
|
|
22
|
+
* prefix with `~`. Returns the path unchanged if not under $HOME.
|
|
23
|
+
*
|
|
24
|
+
* @param cwd The current working directory to shorten
|
|
25
|
+
* @param home Optional home directory override (defaults to `$HOME`)
|
|
26
|
+
*/
|
|
27
|
+
export function shortCwd(cwd: string, home?: string): string {
|
|
28
|
+
const h = (home ?? process.env.HOME ?? process.env.USERPROFILE ?? "").replace(
|
|
29
|
+
/[\\/]+$/,
|
|
30
|
+
"",
|
|
31
|
+
);
|
|
32
|
+
if (!h) return cwd;
|
|
33
|
+
if (cwd === h) return "~";
|
|
34
|
+
const sep = h.includes("\\") ? "\\" : "/";
|
|
35
|
+
if (cwd.startsWith(h + sep)) {
|
|
36
|
+
return `~${cwd.slice(h.length)}`;
|
|
37
|
+
}
|
|
38
|
+
return cwd;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Shorten a working directory path by replacing the user's home directory
|
|
43
|
+
* prefix with `~`. Returns the path unchanged if not under $HOME.
|
|
44
|
+
*
|
|
45
|
+
* @param cwd The current working directory to shorten
|
|
46
|
+
* @param home Optional home directory override (defaults to `$HOME`)
|
|
47
|
+
*/
|