dual-brain 3.0.1 → 3.2.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/CLAUDE.md CHANGED
@@ -32,9 +32,22 @@ Before ending a session with code changes:
32
32
 
33
33
  Gate statuses: `pass` (safe to end), `issues_found` (fix first), `needs_human_review` (GPT unavailable).
34
34
 
35
+ ## Profiles
36
+
37
+ Active profile controls routing posture, budgets, and quality gate behavior.
38
+ Profile persists to `.claude/dual-brain.profile.json` (gitignored).
39
+
40
+ - **balanced** (default): Best model per tier, normal budgets, reviews at medium+ risk
41
+ - **cost-saver**: Prefer cheaper models, lower budgets, skip GPT for non-critical
42
+ - **quality-first**: Dual-brain for medium+ risk, higher budgets, stricter reviews
43
+
44
+ Switch profiles: `npx dual-brain mode cost-saver`
45
+ Check status: `npx dual-brain status`
46
+
35
47
  ## Available Tools
36
48
 
37
49
  - `node .claude/hooks/cost-report.mjs` — activity and cost estimates
38
50
  - `node .claude/hooks/health-check.mjs` — verify system health
39
51
  - `node .claude/hooks/budget-balancer.mjs` — provider balance status
52
+ - `node .claude/hooks/decision-ledger.mjs` — routing outcome insights
40
53
  - `node .claude/hooks/test-orchestrator.mjs` — run self-tests
@@ -48,13 +48,39 @@ const WINDOW_BUDGETS = {
48
48
  },
49
49
  };
50
50
 
51
- /** Estimated tokens consumed per call, by tier */
52
- const TOKENS_PER_CALL = {
51
+ /** Static fallback tokens per call, by tier */
52
+ const TOKENS_PER_CALL_DEFAULT = {
53
53
  search: 2_500,
54
54
  execute: 5_500,
55
55
  think: 11_000,
56
56
  };
57
57
 
58
+ /** Load moving averages from summary checkpoint, fall back to static defaults */
59
+ function getTokensPerCall() {
60
+ try {
61
+ const today = new Date().toISOString().slice(0, 10);
62
+ const summaryPath = join(__dirname, `usage-summary-${today}.json`);
63
+ const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
64
+ const avgs = summary.token_averages || {};
65
+ const result = { ...TOKENS_PER_CALL_DEFAULT };
66
+ for (const tier of ['search', 'execute', 'think']) {
67
+ // Check both providers for averages, prefer whichever has data
68
+ for (const provider of ['claude', 'openai']) {
69
+ const key = `${provider}:${tier}`;
70
+ if (avgs[key]?.count >= 5) {
71
+ result[tier] = Math.round(avgs[key].avg_input + avgs[key].avg_output);
72
+ break;
73
+ }
74
+ }
75
+ }
76
+ return result;
77
+ } catch {
78
+ return { ...TOKENS_PER_CALL_DEFAULT };
79
+ }
80
+ }
81
+
82
+ const TOKENS_PER_CALL = getTokensPerCall();
83
+
58
84
  /** Default pressure thresholds (fraction 0–1) */
59
85
  const DEFAULT_THRESHOLDS = {
60
86
  warm: 0.65,
@@ -286,13 +312,26 @@ function chooseProvider(taskProfile = {}) {
286
312
  score -= PRESSURE_PENALTY[tierStatus.state] ?? 0;
287
313
 
288
314
  // Latency penalty (OpenAI only — Codex has higher startup overhead)
315
+ // Uses adaptive threshold from observed Codex startup times when available
289
316
  if (provider === "openai") {
290
- if (estimatedDurationMs < 180_000) {
291
- score -= 25; // < 3 min: overhead not worth it
317
+ let minTaskMs = 180_000;
318
+ try {
319
+ const today = new Date().toISOString().slice(0, 10);
320
+ const summaryPath = join(__dirname, `usage-summary-${today}.json`);
321
+ const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
322
+ const latencies = (summary.codex_latencies || []).map(l => l.startup_ms).filter(Boolean);
323
+ if (latencies.length >= 5) {
324
+ const sorted = latencies.sort((a, b) => a - b);
325
+ const p75 = sorted[Math.floor(sorted.length * 0.75)];
326
+ minTaskMs = Math.max(90_000, p75 * 4);
327
+ }
328
+ } catch {}
329
+
330
+ if (estimatedDurationMs < minTaskMs) {
331
+ score -= 25;
292
332
  } else if (estimatedDurationMs < 600_000) {
293
- score -= 10; // < 10 min: minor penalty
333
+ score -= 10;
294
334
  }
295
- // >= 10 min: no penalty
296
335
  }
297
336
 
298
337
  // Underused bonus
@@ -12,19 +12,25 @@ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
12
12
  import { dirname, join } from "path";
13
13
  import { fileURLToPath } from "url";
14
14
 
15
- // ---------------------------------------------------------------------------
16
- // Paths
17
- // ---------------------------------------------------------------------------
18
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
19
17
 
20
18
  function usageFile(date) {
21
19
  const d = date || new Date().toISOString().slice(0, 10);
22
20
  return join(__dirname, `usage-${d}.jsonl`);
23
21
  }
24
22
 
25
- // Ensure the hooks dir exists (idempotent, defensive)
26
23
  mkdirSync(__dirname, { recursive: true });
27
24
 
25
+ function loadActiveProfile() {
26
+ try {
27
+ const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
28
+ return data.active || 'balanced';
29
+ } catch { return 'balanced'; }
30
+ }
31
+
32
+ const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.ppid?.toString() || null;
33
+
28
34
  // ---------------------------------------------------------------------------
29
35
  // Tier classification
30
36
  // ---------------------------------------------------------------------------
@@ -135,14 +141,21 @@ function classify(toolName, toolInput = {}, agentModel = null) {
135
141
  // Budget alerts
136
142
  // ---------------------------------------------------------------------------
137
143
 
138
- function checkBudget() {
144
+ async function checkBudget() {
139
145
  let config;
140
146
  try {
141
147
  config = JSON.parse(readFileSync(join(__dirname, '..', 'orchestrator.json'), 'utf8'));
142
148
  } catch { return null; }
143
149
 
144
- const budgets = config.budgets;
150
+ // Merge profile budget overrides on top of config defaults
151
+ let budgets = config.budgets;
145
152
  if (!budgets) return null;
153
+ try {
154
+ const profileData = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
155
+ if (profileData.custom_overrides?.budgets) {
156
+ budgets = { ...budgets, ...profileData.custom_overrides.budgets };
157
+ }
158
+ } catch {}
146
159
 
147
160
  // Rate limit alerts
148
161
  const cooldownFile = join(__dirname, '.budget-alerted');
@@ -152,18 +165,24 @@ function checkBudget() {
152
165
  if (Date.now() - Date.parse(lastAlert) < cooldownMin * 60 * 1000) return null;
153
166
  } catch {}
154
167
 
155
- // Calculate today's estimated cost
156
- const todayFile = usageFile();
157
- let records = [];
168
+ // Use summary checkpoint for fast budget check (O(1) instead of full scan)
169
+ let totalCost = 0;
158
170
  try {
159
- records = readFileSync(todayFile, 'utf8').split('\n').filter(Boolean).map(l => {
160
- try { return JSON.parse(l); } catch { return null; }
161
- }).filter(Boolean);
162
- } catch { return null; }
163
-
164
- // Simple cost estimate using tier heuristics
165
- const RATES = { search: 0.003, execute: 0.012, think: 0.055 };
166
- const totalCost = records.reduce((sum, r) => sum + (RATES[r.tier] || RATES.execute), 0);
171
+ const { readSummary } = await import('./summary-checkpoint.mjs');
172
+ const summary = readSummary();
173
+ totalCost = summary.totals.cost_estimate;
174
+ } catch {
175
+ // Fallback: scan the log (only if summary unavailable)
176
+ const todayFile = usageFile();
177
+ let records = [];
178
+ try {
179
+ records = readFileSync(todayFile, 'utf8').split('\n').filter(Boolean).map(l => {
180
+ try { return JSON.parse(l); } catch { return null; }
181
+ }).filter(Boolean);
182
+ } catch { return null; }
183
+ const RATES = { search: 0.003, execute: 0.012, think: 0.055 };
184
+ totalCost = records.reduce((sum, r) => sum + (RATES[r.tier] || RATES.execute), 0);
185
+ }
167
186
 
168
187
  let msg = null;
169
188
  if (budgets.daily_limit_usd && totalCost >= budgets.daily_limit_usd) {
@@ -215,8 +234,8 @@ async function main() {
215
234
 
216
235
  const status = (payload?.error || payload?.tool_response?.error || payload?.is_error) ? 'error' : 'ok';
217
236
 
218
- const entry = JSON.stringify({
219
- schema_version: 2,
237
+ const entryObj = {
238
+ schema_version: 3,
220
239
  timestamp: new Date().toISOString(),
221
240
  tier,
222
241
  tool: toolName,
@@ -224,19 +243,25 @@ async function main() {
224
243
  provider: detectProvider(model),
225
244
  dispatcher: 'claude-code',
226
245
  status,
227
- session_id: process.env.CLAUDE_SESSION_ID || null,
246
+ session_id: SESSION_ID,
247
+ profile: loadActiveProfile(),
228
248
  input_tokens: inputTokens,
229
249
  output_tokens: outputTokens,
230
- });
250
+ };
251
+
252
+ const entry = JSON.stringify(entryObj);
231
253
 
232
254
  try {
233
255
  appendFileSync(usageFile(), entry + "\n", { encoding: "utf8", flag: "a" });
234
- } catch {
235
- // Disk write failed — silently ignore so the hook never blocks the IDE
236
- }
256
+ } catch {}
257
+
258
+ // Update summary checkpoint (non-blocking, best-effort)
259
+ try {
260
+ const { updateSummary } = await import('./summary-checkpoint.mjs');
261
+ updateSummary(entryObj);
262
+ } catch {}
237
263
 
238
- // Check budget thresholds and emit a systemMessage if over limit
239
- const budgetMsg = checkBudget();
264
+ const budgetMsg = await checkBudget();
240
265
 
241
266
  // PostToolUse hooks must emit a JSON object to stdout
242
267
  if (budgetMsg) {
@@ -200,6 +200,8 @@ function allOpusCost(records, rateMap, datePrefix = null) {
200
200
  // Formatting helpers
201
201
  // ---------------------------------------------------------------------------
202
202
 
203
+ const W = 50;
204
+
203
205
  const TIER_ORDER = ["search", "execute", "think"];
204
206
 
205
207
  const TIER_LABELS = {
@@ -224,8 +226,6 @@ function renderTable(title, aggregated, allOpus, records = []) {
224
226
  const savings = allOpus - totalCost;
225
227
  const savingsPct = allOpus > 0 ? Math.round((savings / allOpus) * 100) : 0;
226
228
 
227
- const W = 50; // total inner width (between ║ chars)
228
-
229
229
  const line = (s) => `║ ${pad(s, W - 2)} ║`;
230
230
  const border = (l, r, m) => l + "═".repeat(W) + r;
231
231
  const sep = () => "╠" + "═".repeat(W) + "╣";
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * decision-ledger.mjs — Routing outcome tracking for the Dual-Brain Orchestrator.
4
+ *
5
+ * Records every routing decision with its context, and later enriches it with
6
+ * outcome data (duration, success, retries, user overrides, follow-up fixes).
7
+ *
8
+ * Over time, this builds a per-repo knowledge base of which provider/model
9
+ * performs best for which task shapes.
10
+ *
11
+ * Exported API:
12
+ * recordDecision(decision) → log a routing decision, returns decision_id
13
+ * recordOutcome(id, outcome) → enrich a decision with its outcome
14
+ * getInsights(opts?) → aggregate patterns from the ledger
15
+ *
16
+ * CLI:
17
+ * node .claude/hooks/decision-ledger.mjs # show insights
18
+ * node .claude/hooks/decision-ledger.mjs --json # JSON output
19
+ * node .claude/hooks/decision-ledger.mjs --recent 20 # last N decisions
20
+ */
21
+
22
+ import { appendFileSync, existsSync, readFileSync } from 'fs';
23
+ import { dirname, join } from 'path';
24
+ import { fileURLToPath } from 'url';
25
+ import { randomBytes } from 'crypto';
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url));
28
+ const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
29
+
30
+ function genId() {
31
+ return randomBytes(6).toString('hex');
32
+ }
33
+
34
+ function recordDecision(decision = {}) {
35
+ const id = genId();
36
+ const entry = JSON.stringify({
37
+ type: 'decision',
38
+ id,
39
+ timestamp: new Date().toISOString(),
40
+ session_id: decision.session_id || process.env.CLAUDE_SESSION_ID || process.ppid?.toString() || null,
41
+ profile: decision.profile || 'balanced',
42
+
43
+ // Routing context
44
+ tier: decision.tier || 'execute',
45
+ provider: decision.provider || 'claude',
46
+ model: decision.model || 'unknown',
47
+ recommended_model: decision.recommended_model || null,
48
+ followed: decision.followed ?? null,
49
+
50
+ // Task shape
51
+ task_type: decision.task_type || null,
52
+ prompt_hash: decision.prompt_hash || null,
53
+ estimated_duration_ms: decision.estimated_duration_ms || null,
54
+ file_count: decision.file_count || null,
55
+ context_coupling: decision.context_coupling || null,
56
+ isolation: decision.isolation || null,
57
+
58
+ // Provider state at decision time
59
+ claude_pressure: decision.claude_pressure || null,
60
+ openai_pressure: decision.openai_pressure || null,
61
+ });
62
+
63
+ try {
64
+ appendFileSync(LEDGER_FILE, entry + '\n');
65
+ } catch {}
66
+
67
+ return id;
68
+ }
69
+
70
+ function recordOutcome(decisionId, outcome = {}) {
71
+ const entry = JSON.stringify({
72
+ type: 'outcome',
73
+ decision_id: decisionId,
74
+ timestamp: new Date().toISOString(),
75
+
76
+ // Timing
77
+ actual_duration_ms: outcome.actual_duration_ms || null,
78
+ codex_startup_ms: outcome.codex_startup_ms || null,
79
+
80
+ // Quality signals
81
+ success: outcome.success ?? null,
82
+ tests_passed: outcome.tests_passed ?? null,
83
+ tests_failed: outcome.tests_failed ?? null,
84
+ retries: outcome.retries || 0,
85
+ user_override: outcome.user_override ?? false,
86
+ followup_fix_needed: outcome.followup_fix_needed ?? false,
87
+
88
+ // Cost
89
+ actual_input_tokens: outcome.actual_input_tokens || null,
90
+ actual_output_tokens: outcome.actual_output_tokens || null,
91
+ estimated_cost_usd: outcome.estimated_cost_usd || null,
92
+
93
+ // Files
94
+ files_changed: outcome.files_changed || null,
95
+ files_read: outcome.files_read || null,
96
+ });
97
+
98
+ try {
99
+ appendFileSync(LEDGER_FILE, entry + '\n');
100
+ } catch {}
101
+ }
102
+
103
+ function loadLedger() {
104
+ if (!existsSync(LEDGER_FILE)) return { decisions: [], outcomes: [] };
105
+
106
+ let raw;
107
+ try { raw = readFileSync(LEDGER_FILE, 'utf8'); } catch { return { decisions: [], outcomes: [] }; }
108
+
109
+ const decisions = [];
110
+ const outcomes = [];
111
+
112
+ for (const line of raw.split('\n').filter(Boolean)) {
113
+ try {
114
+ const entry = JSON.parse(line);
115
+ if (entry.type === 'decision') decisions.push(entry);
116
+ else if (entry.type === 'outcome') outcomes.push(entry);
117
+ } catch {}
118
+ }
119
+
120
+ return { decisions, outcomes };
121
+ }
122
+
123
+ function mergeDecisionsWithOutcomes(decisions, outcomes) {
124
+ const outcomeMap = {};
125
+ for (const o of outcomes) {
126
+ outcomeMap[o.decision_id] = o;
127
+ }
128
+ return decisions.map(d => ({
129
+ ...d,
130
+ outcome: outcomeMap[d.id] || null,
131
+ }));
132
+ }
133
+
134
+ function getInsights(opts = {}) {
135
+ const { decisions, outcomes } = loadLedger();
136
+ const merged = mergeDecisionsWithOutcomes(decisions, outcomes);
137
+ const withOutcomes = merged.filter(d => d.outcome);
138
+
139
+ // Provider win rates
140
+ const providerStats = {};
141
+ for (const d of withOutcomes) {
142
+ const key = d.provider;
143
+ if (!providerStats[key]) providerStats[key] = { total: 0, success: 0, overrides: 0, followups: 0, totalDuration: 0, counted: 0 };
144
+ providerStats[key].total++;
145
+ if (d.outcome.success) providerStats[key].success++;
146
+ if (d.outcome.user_override) providerStats[key].overrides++;
147
+ if (d.outcome.followup_fix_needed) providerStats[key].followups++;
148
+ if (d.outcome.actual_duration_ms) {
149
+ providerStats[key].totalDuration += d.outcome.actual_duration_ms;
150
+ providerStats[key].counted++;
151
+ }
152
+ }
153
+
154
+ // Tier performance
155
+ const tierStats = {};
156
+ for (const d of withOutcomes) {
157
+ const key = `${d.provider}:${d.tier}`;
158
+ if (!tierStats[key]) tierStats[key] = { total: 0, success: 0, avgDuration: 0, counted: 0 };
159
+ tierStats[key].total++;
160
+ if (d.outcome.success) tierStats[key].success++;
161
+ if (d.outcome.actual_duration_ms) {
162
+ tierStats[key].counted++;
163
+ tierStats[key].avgDuration += (d.outcome.actual_duration_ms - tierStats[key].avgDuration) / tierStats[key].counted;
164
+ }
165
+ }
166
+
167
+ // Task type patterns
168
+ const taskPatterns = {};
169
+ for (const d of withOutcomes) {
170
+ if (!d.task_type) continue;
171
+ const key = d.task_type;
172
+ if (!taskPatterns[key]) taskPatterns[key] = {};
173
+ const pk = d.provider;
174
+ if (!taskPatterns[key][pk]) taskPatterns[key][pk] = { total: 0, success: 0 };
175
+ taskPatterns[key][pk].total++;
176
+ if (d.outcome.success) taskPatterns[key][pk].success++;
177
+ }
178
+
179
+ // Compliance rate
180
+ const total = decisions.length;
181
+ const followedCount = decisions.filter(d => d.followed === true).length;
182
+ const compliance = total > 0 ? Math.round((followedCount / total) * 100) : 0;
183
+
184
+ // Recommendations
185
+ const recommendations = [];
186
+ for (const [task, providers] of Object.entries(taskPatterns)) {
187
+ const sorted = Object.entries(providers)
188
+ .map(([p, s]) => ({ provider: p, rate: s.total > 0 ? s.success / s.total : 0, total: s.total }))
189
+ .filter(x => x.total >= 3)
190
+ .sort((a, b) => b.rate - a.rate);
191
+ if (sorted.length >= 2 && sorted[0].rate > sorted[1].rate + 0.1) {
192
+ recommendations.push(`${sorted[0].provider} wins ${task} tasks (${Math.round(sorted[0].rate * 100)}% vs ${Math.round(sorted[1].rate * 100)}%)`);
193
+ }
194
+ }
195
+
196
+ return {
197
+ total_decisions: total,
198
+ with_outcomes: withOutcomes.length,
199
+ compliance_rate: compliance,
200
+ provider_stats: providerStats,
201
+ tier_stats: tierStats,
202
+ task_patterns: taskPatterns,
203
+ recommendations,
204
+ };
205
+ }
206
+
207
+ // ─── CLI ────────────────────────────────────────────────────────────────────
208
+
209
+ function printInsights() {
210
+ const insights = getInsights();
211
+
212
+ if (insights.total_decisions === 0) {
213
+ console.log('');
214
+ console.log(' No routing decisions recorded yet.');
215
+ console.log(' The decision ledger builds over time as you use Claude Code.');
216
+ console.log('');
217
+ return;
218
+ }
219
+
220
+ const W = 52;
221
+ const pad = (s, len = W - 2) => {
222
+ s = String(s);
223
+ return s.length >= len ? s.slice(0, len) : s + ' '.repeat(len - s.length);
224
+ };
225
+ const ln = (s) => `║ ${pad(s)} ║`;
226
+ const br = (l, r) => l + '═'.repeat(W) + r;
227
+ const sep = () => '╠' + '═'.repeat(W) + '╣';
228
+
229
+ const lines = [];
230
+ lines.push(br('╔', '╗'));
231
+ lines.push(ln('Decision Ledger Insights'));
232
+ lines.push(sep());
233
+ lines.push(ln(`Total decisions: ${insights.total_decisions}`));
234
+ lines.push(ln(`With outcomes: ${insights.with_outcomes}`));
235
+ lines.push(ln(`Compliance rate: ${insights.compliance_rate}%`));
236
+ lines.push(sep());
237
+
238
+ // Provider stats
239
+ lines.push(ln('Provider Performance'));
240
+ for (const [provider, stats] of Object.entries(insights.provider_stats)) {
241
+ const rate = stats.total > 0 ? Math.round((stats.success / stats.total) * 100) : 0;
242
+ const avgMs = stats.counted > 0 ? Math.round(stats.totalDuration / stats.counted / 1000) : '?';
243
+ lines.push(ln(` ${provider}: ${rate}% success, ${stats.overrides} overrides, avg ${avgMs}s`));
244
+ if (stats.followups > 0) {
245
+ lines.push(ln(` ${stats.followups} follow-up fixes needed`));
246
+ }
247
+ }
248
+
249
+ // Recommendations
250
+ if (insights.recommendations.length > 0) {
251
+ lines.push(sep());
252
+ lines.push(ln('Recommendations'));
253
+ for (const rec of insights.recommendations) {
254
+ lines.push(ln(` ${rec}`));
255
+ }
256
+ }
257
+
258
+ lines.push(br('╚', '╝'));
259
+ console.log('');
260
+ for (const l of lines) console.log(` ${l}`);
261
+ console.log('');
262
+ }
263
+
264
+ function printRecent(n) {
265
+ const { decisions, outcomes } = loadLedger();
266
+ const merged = mergeDecisionsWithOutcomes(decisions, outcomes);
267
+ const recent = merged.slice(-n);
268
+
269
+ if (recent.length === 0) {
270
+ console.log(' No decisions recorded yet.');
271
+ return;
272
+ }
273
+
274
+ console.log('');
275
+ for (const d of recent) {
276
+ const time = d.timestamp?.slice(11, 19) || '??:??:??';
277
+ const status = d.outcome?.success ? '✓' : d.outcome ? '✗' : '?';
278
+ const dur = d.outcome?.actual_duration_ms ? `${Math.round(d.outcome.actual_duration_ms / 1000)}s` : '';
279
+ console.log(` ${status} ${time} ${d.provider}/${d.model} [${d.tier}] ${dur}`);
280
+ }
281
+ console.log('');
282
+ }
283
+
284
+ // CLI entry
285
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
286
+ const args = process.argv.slice(2);
287
+
288
+ if (args.includes('--json')) {
289
+ console.log(JSON.stringify(getInsights(), null, 2));
290
+ } else if (args.includes('--recent')) {
291
+ const idx = args.indexOf('--recent');
292
+ const n = parseInt(args[idx + 1]) || 20;
293
+ printRecent(n);
294
+ } else {
295
+ printInsights();
296
+ }
297
+ }
298
+
299
+ export { recordDecision, recordOutcome, getInsights, loadLedger };
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { execSync, spawnSync } from 'child_process';
16
16
  import { readFileSync } from 'fs';
17
- import { dirname, resolve } from 'path';
17
+ import { dirname, join, resolve } from 'path';
18
18
  import { fileURLToPath } from 'url';
19
19
 
20
20
  const __dirname = dirname(fileURLToPath(import.meta.url));