@cortexkit/opencode-magic-context 0.4.2 → 0.5.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.
Files changed (35) hide show
  1. package/README.md +41 -0
  2. package/dist/cli/config-paths.d.ts +2 -0
  3. package/dist/cli/config-paths.d.ts.map +1 -1
  4. package/dist/cli/doctor.d.ts +2 -0
  5. package/dist/cli/doctor.d.ts.map +1 -0
  6. package/dist/cli/setup.d.ts.map +1 -1
  7. package/dist/cli.js +8549 -125
  8. package/dist/features/builtin-commands/commands.d.ts.map +1 -1
  9. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  10. package/dist/features/magic-context/scheduler.d.ts.map +1 -1
  11. package/dist/features/magic-context/search.d.ts +2 -0
  12. package/dist/features/magic-context/search.d.ts.map +1 -1
  13. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  14. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  15. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  16. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  17. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8653 -238
  20. package/dist/plugin/conflict-warning-hook.d.ts +24 -0
  21. package/dist/plugin/conflict-warning-hook.d.ts.map +1 -0
  22. package/dist/shared/conflict-detector.d.ts +29 -0
  23. package/dist/shared/conflict-detector.d.ts.map +1 -0
  24. package/dist/shared/conflict-fixer.d.ts +3 -0
  25. package/dist/shared/conflict-fixer.d.ts.map +1 -0
  26. package/dist/shared/tui-config.d.ts +10 -0
  27. package/dist/shared/tui-config.d.ts.map +1 -0
  28. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  29. package/dist/tui/data/context-db.d.ts +54 -0
  30. package/dist/tui/data/context-db.d.ts.map +1 -0
  31. package/package.json +20 -1
  32. package/src/tui/data/context-db.ts +584 -0
  33. package/src/tui/index.tsx +461 -0
  34. package/src/tui/slots/sidebar-content.tsx +422 -0
  35. package/src/tui/types/opencode-plugin-tui.d.ts +232 -0
@@ -0,0 +1,584 @@
1
+ import { Database } from "bun:sqlite";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { log } from "../../shared/logger";
5
+
6
+ export interface SidebarSnapshot {
7
+ sessionId: string;
8
+ usagePercentage: number;
9
+ inputTokens: number;
10
+ systemPromptTokens: number;
11
+ compartmentCount: number;
12
+ factCount: number;
13
+ memoryCount: number;
14
+ memoryBlockCount: number;
15
+ pendingOpsCount: number;
16
+ historianRunning: boolean;
17
+ compartmentInProgress: boolean;
18
+ sessionNoteCount: number;
19
+ readySmartNoteCount: number;
20
+ cacheTtl: string;
21
+ lastDreamerRunAt: number | null;
22
+ projectIdentity: string | null;
23
+ // Token estimates for breakdown bar (~4 chars/token)
24
+ compartmentTokens: number;
25
+ factTokens: number;
26
+ memoryTokens: number;
27
+ }
28
+
29
+ /** Extended status info for the status dialog — reads more from DB */
30
+ export interface StatusDetail extends SidebarSnapshot {
31
+ tagCounter: number;
32
+ activeTags: number;
33
+ droppedTags: number;
34
+ totalTags: number;
35
+ activeBytes: number;
36
+ lastResponseTime: number;
37
+ lastNudgeTokens: number;
38
+ lastNudgeBand: string;
39
+ lastTransformError: string | null;
40
+ isSubagent: boolean;
41
+ pendingOps: Array<{ tagId: number; operation: string }>;
42
+ // Derived
43
+ contextLimit: number;
44
+ cacheTtlMs: number;
45
+ cacheRemainingMs: number;
46
+ cacheExpired: boolean;
47
+ // Config-dependent (read from magic-context.jsonc or defaults)
48
+ executeThreshold: number;
49
+ protectedTagCount: number;
50
+ nudgeInterval: number;
51
+ historyBudgetPercentage: number;
52
+ nextNudgeAfter: number;
53
+ // History compression
54
+ historyBlockTokens: number;
55
+ compressionBudget: number | null;
56
+ compressionUsage: string | null;
57
+ }
58
+
59
+ function getContextDbPath(): string {
60
+ const dataDir = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
61
+ return path.join(dataDir, "opencode", "storage", "plugin", "magic-context", "context.db");
62
+ }
63
+
64
+ let cachedDb: Database | null = null;
65
+ let dbPath: string | null = null;
66
+
67
+ function getDb(): Database | null {
68
+ const targetPath = getContextDbPath();
69
+ if (cachedDb && dbPath === targetPath) {
70
+ return cachedDb;
71
+ }
72
+ try {
73
+ // Open without readonly flag — WAL-mode DBs need read-write access to the
74
+ // -shm file even for read-only queries. We never write from the TUI process.
75
+ cachedDb = new Database(targetPath);
76
+ cachedDb.exec("PRAGMA journal_mode = WAL");
77
+ cachedDb.exec("PRAGMA query_only = ON");
78
+ dbPath = targetPath;
79
+ return cachedDb;
80
+ } catch (err) {
81
+ log("[tui] failed to open context.db", err);
82
+ cachedDb = null;
83
+ dbPath = null;
84
+ return null;
85
+ }
86
+ }
87
+
88
+ export function closeDb(): void {
89
+ if (cachedDb) {
90
+ try {
91
+ cachedDb.close();
92
+ } catch {
93
+ // Ignore close errors
94
+ }
95
+ cachedDb = null;
96
+ dbPath = null;
97
+ }
98
+ }
99
+
100
+ function resolveProjectIdentity(directory: string): string | null {
101
+ if (!directory) return null;
102
+ try {
103
+ // Match the plugin's own project identity resolution: git root commit hash
104
+ const { execSync } = require("node:child_process") as typeof import("node:child_process");
105
+ const rootCommit = execSync("git rev-list --max-parents=0 HEAD", {
106
+ cwd: directory,
107
+ encoding: "utf-8",
108
+ timeout: 5000,
109
+ stdio: ["pipe", "pipe", "pipe"],
110
+ })
111
+ .trim()
112
+ .split("\n")[0];
113
+ if (rootCommit && rootCommit.length >= 40) {
114
+ return `git:${rootCommit}`;
115
+ }
116
+ } catch {
117
+ // Not a git repo or git not available
118
+ }
119
+ // Fallback: canonical directory hash (matches plugin's directoryFallback)
120
+ try {
121
+ const realPath = require("node:fs").realpathSync(directory);
122
+ const hash = require("node:crypto").createHash("sha256").update(realPath).digest("hex");
123
+ return `dir:${hash}`;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ export function loadSidebarSnapshot(sessionId: string, directory: string): SidebarSnapshot {
130
+ const empty: SidebarSnapshot = {
131
+ sessionId,
132
+ usagePercentage: 0,
133
+ inputTokens: 0,
134
+ systemPromptTokens: 0,
135
+ compartmentCount: 0,
136
+ factCount: 0,
137
+ memoryCount: 0,
138
+ memoryBlockCount: 0,
139
+ pendingOpsCount: 0,
140
+ historianRunning: false,
141
+ compartmentInProgress: false,
142
+ sessionNoteCount: 0,
143
+ readySmartNoteCount: 0,
144
+ cacheTtl: "5m",
145
+ lastDreamerRunAt: null,
146
+ projectIdentity: null,
147
+ compartmentTokens: 0,
148
+ factTokens: 0,
149
+ memoryTokens: 0,
150
+ };
151
+
152
+ const db = getDb();
153
+ if (!db) return empty;
154
+
155
+ try {
156
+ const projectIdentity = resolveProjectIdentity(directory);
157
+
158
+ // Session meta
159
+ const meta = db
160
+ .query<Record<string, unknown>, [string]>(
161
+ `SELECT * FROM session_meta WHERE session_id = ?`,
162
+ )
163
+ .get(sessionId);
164
+
165
+ const usagePercentage = meta
166
+ ? Number(
167
+ (meta as Record<string, unknown>).last_context_percentage ??
168
+ (meta as Record<string, unknown>).last_usage_percentage ??
169
+ 0,
170
+ )
171
+ : 0;
172
+ const inputTokens = meta
173
+ ? Number((meta as Record<string, unknown>).last_input_tokens ?? 0)
174
+ : 0;
175
+ const systemPromptTokens = meta
176
+ ? Number((meta as Record<string, unknown>).system_prompt_tokens ?? 0)
177
+ : 0;
178
+ const compartmentInProgress = meta
179
+ ? Boolean((meta as Record<string, unknown>).compartment_in_progress)
180
+ : false;
181
+ const cacheTtl = meta ? String((meta as Record<string, unknown>).cache_ttl ?? "5m") : "5m";
182
+
183
+ // Compartments
184
+ const compartmentRow = db
185
+ .query<{ count: number }, [string]>(
186
+ `SELECT COUNT(*) as count FROM compartments WHERE session_id = ?`,
187
+ )
188
+ .get(sessionId);
189
+ const compartmentCount = compartmentRow?.count ?? 0;
190
+
191
+ // Session facts
192
+ const factRow = db
193
+ .query<{ count: number }, [string]>(
194
+ `SELECT COUNT(*) as count FROM session_facts WHERE session_id = ?`,
195
+ )
196
+ .get(sessionId);
197
+ const factCount = factRow?.count ?? 0;
198
+
199
+ // Project memories
200
+ let memoryCount = 0;
201
+ if (projectIdentity) {
202
+ const memRow = db
203
+ .query<{ count: number }, [string]>(
204
+ `SELECT COUNT(*) as count FROM memories WHERE project_path = ? AND status = 'active'`,
205
+ )
206
+ .get(projectIdentity);
207
+ memoryCount = memRow?.count ?? 0;
208
+ }
209
+
210
+ // Memory block count from session meta
211
+ const memoryBlockCount = meta
212
+ ? Number((meta as Record<string, unknown>).memory_block_count ?? 0)
213
+ : 0;
214
+
215
+ // Pending operations
216
+ let pendingOpsCount = 0;
217
+ try {
218
+ const pendingRow = db
219
+ .query<{ count: number }, [string]>(
220
+ `SELECT COUNT(*) as count FROM pending_ops WHERE session_id = ?`,
221
+ )
222
+ .get(sessionId);
223
+ pendingOpsCount = pendingRow?.count ?? 0;
224
+ } catch (pendingErr) {
225
+ log("[magic-context-tui] pending_ops query failed", pendingErr);
226
+ }
227
+
228
+ // Historian running — check if compartmentInProgress is truthy
229
+ const historianRunning = compartmentInProgress;
230
+
231
+ // Session notes (from notes table if it exists)
232
+ let sessionNoteCount = 0;
233
+ try {
234
+ const noteRow = db
235
+ .query<{ count: number }, [string]>(
236
+ `SELECT COUNT(*) as count FROM notes WHERE session_id = ?`,
237
+ )
238
+ .get(sessionId);
239
+ sessionNoteCount = noteRow?.count ?? 0;
240
+ } catch {
241
+ // notes table may not exist
242
+ }
243
+
244
+ // Ready smart notes
245
+ let readySmartNoteCount = 0;
246
+ if (projectIdentity) {
247
+ try {
248
+ const smartRow = db
249
+ .query<{ count: number }, [string]>(
250
+ `SELECT COUNT(*) as count FROM smart_notes WHERE project_path = ? AND status = 'ready'`,
251
+ )
252
+ .get(projectIdentity);
253
+ readySmartNoteCount = smartRow?.count ?? 0;
254
+ } catch {
255
+ // smart_notes table may not exist
256
+ }
257
+ }
258
+
259
+ // Token estimates for breakdown bar (~4 chars/token)
260
+ let compartmentTokens = 0;
261
+ let factTokens = 0;
262
+ let memoryTokens = 0;
263
+ try {
264
+ const compRows = db
265
+ .query<
266
+ { content: string; title: string; start_message: number; end_message: number },
267
+ [string]
268
+ >(
269
+ `SELECT content, title, start_message, end_message FROM compartments WHERE session_id = ?`,
270
+ )
271
+ .all(sessionId);
272
+ for (const c of compRows) {
273
+ compartmentTokens += Math.ceil(
274
+ `<compartment start="${c.start_message}" end="${c.end_message}" title="${c.title}">\n${c.content}\n</compartment>\n`
275
+ .length / 4,
276
+ );
277
+ }
278
+ } catch {
279
+ /* compartments table may not exist */
280
+ }
281
+ try {
282
+ const factRows = db
283
+ .query<{ content: string }, [string]>(
284
+ `SELECT content FROM session_facts WHERE session_id = ?`,
285
+ )
286
+ .all(sessionId);
287
+ for (const f of factRows) {
288
+ factTokens += Math.ceil(`* ${f.content}\n`.length / 4);
289
+ }
290
+ } catch {
291
+ /* session_facts table may not exist */
292
+ }
293
+ // Memory tokens from cached block in session_meta
294
+ if (meta) {
295
+ const cached = (meta as Record<string, unknown>).memory_block_cache;
296
+ if (typeof cached === "string" && cached.length > 0) {
297
+ memoryTokens = Math.ceil(cached.length / 4);
298
+ }
299
+ }
300
+
301
+ // Last dreamer run
302
+ let lastDreamerRunAt: number | null = null;
303
+ if (projectIdentity) {
304
+ try {
305
+ const dreamRow = db
306
+ .query<{ value: string }, [string]>(
307
+ `SELECT value FROM dream_state WHERE key = ?`,
308
+ )
309
+ .get(`last_dream_at:${projectIdentity}`);
310
+ if (dreamRow?.value) {
311
+ lastDreamerRunAt = Number(dreamRow.value) || null;
312
+ }
313
+ } catch {
314
+ // dream_state may not exist
315
+ }
316
+ }
317
+
318
+ const result = {
319
+ sessionId,
320
+ usagePercentage,
321
+ inputTokens,
322
+ systemPromptTokens,
323
+ compartmentCount,
324
+ factCount,
325
+ memoryCount,
326
+ memoryBlockCount,
327
+ pendingOpsCount,
328
+ historianRunning,
329
+ compartmentInProgress,
330
+ sessionNoteCount,
331
+ readySmartNoteCount,
332
+ cacheTtl,
333
+ lastDreamerRunAt,
334
+ projectIdentity,
335
+ compartmentTokens,
336
+ factTokens,
337
+ memoryTokens,
338
+ };
339
+ return result;
340
+ } catch (err) {
341
+ log("[magic-context-tui] snapshot error:", err);
342
+ return empty;
343
+ }
344
+ }
345
+
346
+ export function loadStatusDetail(
347
+ sessionId: string,
348
+ directory: string,
349
+ modelKey?: string,
350
+ ): StatusDetail {
351
+ const base = loadSidebarSnapshot(sessionId, directory);
352
+ const detail: StatusDetail = {
353
+ ...base,
354
+ tagCounter: 0,
355
+ activeTags: 0,
356
+ droppedTags: 0,
357
+ totalTags: 0,
358
+ activeBytes: 0,
359
+ lastResponseTime: 0,
360
+ lastNudgeTokens: 0,
361
+ lastNudgeBand: "",
362
+ lastTransformError: null,
363
+ isSubagent: false,
364
+ pendingOps: [],
365
+ contextLimit: 0,
366
+ cacheTtlMs: 0,
367
+ cacheRemainingMs: 0,
368
+ cacheExpired: false,
369
+ executeThreshold: 65,
370
+ protectedTagCount: 20,
371
+ nudgeInterval: 20000,
372
+ historyBudgetPercentage: 0.15,
373
+ nextNudgeAfter: 0,
374
+ historyBlockTokens: 0,
375
+ compressionBudget: null,
376
+ compressionUsage: null,
377
+ };
378
+
379
+ const db = getDb();
380
+ if (!db) return detail;
381
+
382
+ try {
383
+ // Session meta extras
384
+ const meta = db
385
+ .query<Record<string, unknown>, [string]>(
386
+ `SELECT * FROM session_meta WHERE session_id = ?`,
387
+ )
388
+ .get(sessionId);
389
+ if (meta) {
390
+ detail.tagCounter = Number(meta.counter ?? 0);
391
+ detail.lastResponseTime = Number(meta.last_response_time ?? 0);
392
+ detail.lastNudgeTokens = Number(meta.last_nudge_tokens ?? 0);
393
+ detail.lastNudgeBand = String(meta.last_nudge_band ?? "");
394
+ detail.lastTransformError = meta.last_transform_error
395
+ ? String(meta.last_transform_error)
396
+ : null;
397
+ detail.isSubagent = Boolean(meta.is_subagent);
398
+ }
399
+
400
+ // Tag counts
401
+ try {
402
+ const activeRow = db
403
+ .query<{ count: number; bytes: number }, [string]>(
404
+ `SELECT COUNT(*) as count, COALESCE(SUM(byte_size), 0) as bytes FROM tags WHERE session_id = ? AND status = 'active'`,
405
+ )
406
+ .get(sessionId);
407
+ detail.activeTags = activeRow?.count ?? 0;
408
+ detail.activeBytes = activeRow?.bytes ?? 0;
409
+
410
+ const droppedRow = db
411
+ .query<{ count: number }, [string]>(
412
+ `SELECT COUNT(*) as count FROM tags WHERE session_id = ? AND status = 'dropped'`,
413
+ )
414
+ .get(sessionId);
415
+ detail.droppedTags = droppedRow?.count ?? 0;
416
+ detail.totalTags = detail.activeTags + detail.droppedTags;
417
+ } catch {
418
+ // tags table might have different schema
419
+ }
420
+
421
+ // Pending ops detail
422
+ try {
423
+ const ops = db
424
+ .query<{ tag_id: number; operation: string }, [string]>(
425
+ `SELECT tag_id, operation FROM pending_ops WHERE session_id = ?`,
426
+ )
427
+ .all(sessionId);
428
+ detail.pendingOps = ops.map((o) => ({ tagId: o.tag_id, operation: o.operation }));
429
+ } catch {
430
+ // pending_ops may not exist
431
+ }
432
+
433
+ // Read config for threshold/budget values, resolving per-model overrides
434
+ try {
435
+ const cfg = readMagicContextConfig(directory);
436
+ if (cfg) {
437
+ // execute_threshold_percentage: number | { default, "provider/model" }
438
+ const etp = cfg.execute_threshold_percentage;
439
+ if (typeof etp === "number") {
440
+ detail.executeThreshold = Math.min(etp, 80);
441
+ } else if (etp && typeof etp === "object") {
442
+ const etpObj = etp as Record<string, number>;
443
+ let resolved = etpObj.default ?? 65;
444
+ if (modelKey && typeof etpObj[modelKey] === "number") {
445
+ resolved = etpObj[modelKey];
446
+ } else if (modelKey) {
447
+ const bare = modelKey.split("/").slice(1).join("/");
448
+ if (bare && typeof etpObj[bare] === "number") resolved = etpObj[bare];
449
+ }
450
+ detail.executeThreshold = Math.min(resolved, 80);
451
+ }
452
+
453
+ // cache_ttl: string | { default, "provider/model" }
454
+ const ct = cfg.cache_ttl;
455
+ if (typeof ct === "string") {
456
+ detail.cacheTtl = ct;
457
+ } else if (ct && typeof ct === "object") {
458
+ const ctObj = ct as Record<string, string>;
459
+ let resolved = ctObj.default ?? "5m";
460
+ if (modelKey && typeof ctObj[modelKey] === "string") {
461
+ resolved = ctObj[modelKey];
462
+ } else if (modelKey) {
463
+ const bare = modelKey.split("/").slice(1).join("/");
464
+ if (bare && typeof ctObj[bare] === "string") resolved = ctObj[bare];
465
+ }
466
+ detail.cacheTtl = resolved;
467
+ }
468
+
469
+ if (typeof cfg.protected_tag_count === "number") {
470
+ detail.protectedTagCount = cfg.protected_tag_count;
471
+ }
472
+ if (typeof cfg.nudge_interval_tokens === "number") {
473
+ detail.nudgeInterval = cfg.nudge_interval_tokens;
474
+ }
475
+ if (typeof cfg.history_budget_percentage === "number") {
476
+ detail.historyBudgetPercentage = cfg.history_budget_percentage;
477
+ }
478
+ }
479
+ } catch {
480
+ // config read failure — keep defaults
481
+ }
482
+
483
+ // Derived: context limit
484
+ if (base.usagePercentage > 0) {
485
+ detail.contextLimit = Math.round(base.inputTokens / (base.usagePercentage / 100));
486
+ }
487
+
488
+ // Derived: cache TTL (re-resolve with potentially model-specific value)
489
+ detail.cacheTtlMs = parseTtlString(detail.cacheTtl);
490
+ if (detail.lastResponseTime > 0) {
491
+ const elapsed = Date.now() - detail.lastResponseTime;
492
+ detail.cacheRemainingMs = Math.max(0, detail.cacheTtlMs - elapsed);
493
+ detail.cacheExpired = detail.cacheRemainingMs === 0;
494
+ }
495
+
496
+ // Derived: next nudge
497
+ detail.nextNudgeAfter = detail.lastNudgeTokens + detail.nudgeInterval;
498
+
499
+ // History compression: estimate tokens from compartment/fact content
500
+ try {
501
+ const compartments = db
502
+ .query<
503
+ { content: string; title: string; start_message: number; end_message: number },
504
+ [string]
505
+ >(
506
+ `SELECT content, title, start_message, end_message FROM compartments WHERE session_id = ?`,
507
+ )
508
+ .all(sessionId);
509
+ const facts = db
510
+ .query<{ content: string }, [string]>(
511
+ `SELECT content FROM session_facts WHERE session_id = ?`,
512
+ )
513
+ .all(sessionId);
514
+
515
+ let histTokens = 0;
516
+ for (const c of compartments) {
517
+ // ~4 chars per token estimate (same as plugin's estimateTokens)
518
+ histTokens += Math.ceil(
519
+ `<compartment start="${c.start_message}" end="${c.end_message}" title="${c.title}">\n${c.content}\n</compartment>\n`
520
+ .length / 4,
521
+ );
522
+ }
523
+ for (const f of facts) {
524
+ histTokens += Math.ceil(`* ${f.content}\n`.length / 4);
525
+ }
526
+ detail.historyBlockTokens = histTokens;
527
+
528
+ if (detail.contextLimit > 0) {
529
+ const budget = Math.floor(
530
+ detail.contextLimit *
531
+ (Math.min(detail.executeThreshold, 80) / 100) *
532
+ detail.historyBudgetPercentage,
533
+ );
534
+ detail.compressionBudget = budget;
535
+ detail.compressionUsage = `${((histTokens / budget) * 100).toFixed(0)}%`;
536
+ }
537
+ } catch {
538
+ // compartments/facts read failure
539
+ }
540
+ } catch (err) {
541
+ log("[magic-context-tui] loadStatusDetail error:", err);
542
+ }
543
+
544
+ return detail;
545
+ }
546
+
547
+ function parseTtlString(ttl: string): number {
548
+ const match = ttl.match(/^(\d+)(s|m|h)$/);
549
+ if (!match) return 5 * 60 * 1000; // default 5m
550
+ const value = Number(match[1]);
551
+ switch (match[2]) {
552
+ case "s":
553
+ return value * 1000;
554
+ case "m":
555
+ return value * 60 * 1000;
556
+ case "h":
557
+ return value * 3600 * 1000;
558
+ default:
559
+ return 5 * 60 * 1000;
560
+ }
561
+ }
562
+
563
+ function readMagicContextConfig(directory: string): Record<string, unknown> | null {
564
+ const fs = require("node:fs") as typeof import("node:fs");
565
+ // Try project config first, then user config
566
+ const candidates = [
567
+ path.join(directory, "magic-context.jsonc"),
568
+ path.join(directory, ".opencode", "magic-context.jsonc"),
569
+ ];
570
+ const homeConfig = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
571
+ candidates.push(path.join(homeConfig, "opencode", "magic-context.jsonc"));
572
+
573
+ for (const p of candidates) {
574
+ try {
575
+ const raw = fs.readFileSync(p, "utf-8");
576
+ // Strip JSONC comments
577
+ const stripped = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
578
+ return JSON.parse(stripped);
579
+ } catch {
580
+ // try next candidate
581
+ }
582
+ }
583
+ return null;
584
+ }