dual-brain 4.2.0 → 4.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.
@@ -2,9 +2,22 @@
2
2
  /**
3
3
  * risk-classifier.mjs — File-path risk classification for adaptive routing.
4
4
  *
5
- * Export: classifyRisk(paths) → { level, reason }
5
+ * Exports:
6
+ * classifyRisk(paths) → { level, reason } (static, backward-compat)
7
+ * classifyRiskEnhanced(filePath) → { risk, basis, details } (empirical, v4.3.0+)
8
+ * getGitChurn(filePath, days?) → { commits, isHot } | null
9
+ * getFileRiskHistory(filePath) → { total, failures, success_rate, risk_adjustment }
10
+ * extractPaths(text) → string[]
6
11
  */
7
12
 
13
+ import { execSync } from 'child_process';
14
+ import { existsSync, readFileSync } from 'fs';
15
+ import { dirname, join } from 'path';
16
+ import { fileURLToPath } from 'url';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
20
+
8
21
  const PATTERNS = [
9
22
  { level: 'critical', regex: /\b(auth|credential|secret|\.env|key[s]?|token[s]?|password|encrypt|certificate|cert[s]?|\.pem|\.key)\b/i, label: 'security-sensitive' },
10
23
  { level: 'high', regex: /\b(billing|payment|migration|deploy|ci[-/]cd|\.github\/workflows|security|permission|policy|schema\.prisma|schema\.sql|api[-_]?contract|openapi|swagger)\b/i, label: 'high-impact infrastructure' },
@@ -13,6 +26,7 @@ const PATTERNS = [
13
26
  ];
14
27
 
15
28
  const LEVEL_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
29
+ const LEVEL_UP = { low: 'medium', medium: 'high', high: 'critical', critical: 'critical' };
16
30
 
17
31
  function classifyRisk(paths) {
18
32
  if (!paths || paths.length === 0) return { level: 'low', reason: 'no file paths detected' };
@@ -31,6 +45,125 @@ function classifyRisk(paths) {
31
45
  return highest;
32
46
  }
33
47
 
48
+ /**
49
+ * Count how many commits touched a file in the last N days using git log.
50
+ * Returns { commits, isHot: commits > 10 }, or null if git is unavailable.
51
+ */
52
+ function getGitChurn(filePath, days = 30) {
53
+ try {
54
+ const output = execSync(
55
+ `git log --oneline --since="${days} days ago" -- "${filePath}"`,
56
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
57
+ );
58
+ const commits = output.split('\n').filter(Boolean).length;
59
+ return { commits, isHot: commits > 10 };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Read decision-ledger.jsonl and compute success rate for entries that
67
+ * touched this file path or its parent directory.
68
+ *
69
+ * Returns { total, failures, success_rate, risk_adjustment } where
70
+ * risk_adjustment is 'escalate' if success_rate < 60% with 3+ entries,
71
+ * 'normal' otherwise.
72
+ */
73
+ function getFileRiskHistory(filePath) {
74
+ const empty = { total: 0, failures: 0, success_rate: 100, risk_adjustment: 'normal' };
75
+ if (!existsSync(LEDGER_FILE)) return empty;
76
+
77
+ let raw;
78
+ try { raw = readFileSync(LEDGER_FILE, 'utf8'); } catch { return empty; }
79
+
80
+ // Normalize the file path and compute its parent directory prefix
81
+ const normalizedPath = filePath.replace(/\\/g, '/');
82
+ const parentDir = normalizedPath.includes('/') ? normalizedPath.slice(0, normalizedPath.lastIndexOf('/')) : '';
83
+
84
+ let total = 0;
85
+ let failures = 0;
86
+
87
+ for (const line of raw.split('\n').filter(Boolean)) {
88
+ try {
89
+ const entry = JSON.parse(line);
90
+ if (entry.type !== 'outcome') continue;
91
+
92
+ const files = entry.files_changed || entry.files_read || [];
93
+ if (!Array.isArray(files)) continue;
94
+
95
+ const matches = files.some(f => {
96
+ const nf = String(f).replace(/\\/g, '/');
97
+ return nf === normalizedPath ||
98
+ nf.includes(normalizedPath) ||
99
+ (parentDir && nf.includes(parentDir));
100
+ });
101
+
102
+ if (!matches) continue;
103
+
104
+ total++;
105
+ if (entry.success === false) failures++;
106
+ } catch {}
107
+ }
108
+
109
+ if (total === 0) return empty;
110
+
111
+ const success_rate = Math.round(((total - failures) / total) * 100);
112
+ const risk_adjustment = (success_rate < 60 && total >= 3) ? 'escalate' : 'normal';
113
+
114
+ return { total, failures, success_rate, risk_adjustment };
115
+ }
116
+
117
+ /**
118
+ * Enhanced risk classifier that combines static patterns with empirical data.
119
+ *
120
+ * Returns { risk, basis, details } where:
121
+ * risk — 'low' | 'medium' | 'high' | 'critical'
122
+ * basis — 'static' | 'churn' | 'history' | 'churn+history'
123
+ * details — { static_risk, churn_commits, history_success_rate }
124
+ */
125
+ function classifyRiskEnhanced(filePath) {
126
+ // Step 1: static pattern classification
127
+ const staticResult = classifyRisk([filePath]);
128
+ let risk = staticResult.level;
129
+ const details = {
130
+ static_risk: risk,
131
+ churn_commits: null,
132
+ history_success_rate: null,
133
+ };
134
+
135
+ let bumpedByChurn = false;
136
+ let bumpedByHistory = false;
137
+
138
+ // Step 2: git churn check
139
+ const churn = getGitChurn(filePath);
140
+ if (churn !== null) {
141
+ details.churn_commits = churn.commits;
142
+ if (churn.isHot && risk !== 'critical') {
143
+ risk = LEVEL_UP[risk];
144
+ bumpedByChurn = true;
145
+ }
146
+ }
147
+
148
+ // Step 3: file risk history check
149
+ const history = getFileRiskHistory(filePath);
150
+ details.history_success_rate = history.success_rate;
151
+ if (history.risk_adjustment === 'escalate' && risk !== 'critical') {
152
+ risk = LEVEL_UP[risk];
153
+ bumpedByHistory = true;
154
+ }
155
+
156
+ // Cap at critical
157
+ if (LEVEL_ORDER[risk] > LEVEL_ORDER['critical']) risk = 'critical';
158
+
159
+ let basis = 'static';
160
+ if (bumpedByChurn && bumpedByHistory) basis = 'churn+history';
161
+ else if (bumpedByChurn) basis = 'churn';
162
+ else if (bumpedByHistory) basis = 'history';
163
+
164
+ return { risk, basis, details };
165
+ }
166
+
34
167
  function extractPaths(text) {
35
168
  if (!text) return [];
36
169
  const matches = text.match(/(?:^|\s|["'`])([./~]?(?:[\w@.-]+\/)+[\w@.*-]+(?:\.\w+)?)/g);
@@ -38,4 +171,4 @@ function extractPaths(text) {
38
171
  return matches.map(m => m.trim().replace(/^["'`]/, ''));
39
172
  }
40
173
 
41
- export { classifyRisk, extractPaths };
174
+ export { classifyRisk, classifyRiskEnhanced, getGitChurn, getFileRiskHistory, extractPaths };
@@ -61,56 +61,34 @@ function boxTitle(s) {
61
61
  // ---------------------------------------------------------------------------
62
62
  function padR(s, n) { s = String(s); return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length); }
63
63
  function padL(s, n) { s = String(s); return s.length >= n ? s.slice(0, n) : ' '.repeat(n - s.length) + s; }
64
- function fmt$(n) { return '$' + n.toFixed(2); }
65
64
 
66
65
  // ---------------------------------------------------------------------------
67
- // Load orchestrator config
66
+ // Load orchestrator config (used by drift section)
68
67
  // ---------------------------------------------------------------------------
69
68
  function loadConfig() {
70
69
  try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return null; }
71
70
  }
72
71
 
73
- function buildRateMap(config) {
74
- const rates = {};
75
- if (!config?.subscriptions) return rates;
76
- for (const provider of Object.values(config.subscriptions)) {
77
- for (const [modelKey, data] of Object.entries(provider.models || {})) {
78
- rates[modelKey] = {
79
- tier: data.tier,
80
- input_per_mtok: data.input_per_mtok,
81
- output_per_mtok: data.output_per_mtok,
82
- };
83
- }
84
- }
85
- return rates;
86
- }
87
-
88
72
  // ---------------------------------------------------------------------------
89
- // Token heuristics (mirrors cost-report.mjs)
73
+ // Activity scoring (mirrors summary-checkpoint.mjs formula)
90
74
  // ---------------------------------------------------------------------------
91
- const TOKEN_HEURISTICS = {
92
- search: { input: 2_000, output: 500 },
93
- execute: { input: 4_000, output: 1_500 },
94
- think: { input: 8_000, output: 3_000 },
95
- };
96
-
97
- function estimateCost(tier, model, rateMap, record = {}) {
98
- const heuristic = TOKEN_HEURISTICS[tier] || TOKEN_HEURISTICS.execute;
75
+ const TIER_ACTIVITY_WEIGHTS = { search: 3, execute: 10, think: 25 };
76
+ const SESSION_ACTIVITY_CEILING = 5_000_000;
77
+
78
+ function computeActivity(tier, record = {}) {
99
79
  const hasActual = record.input_tokens != null && record.output_tokens != null;
100
- const inputTok = hasActual ? record.input_tokens : heuristic.input;
101
- const outputTok = hasActual ? record.output_tokens : heuristic.output;
102
- const rate = rateMap[model] || rateMap['main-session'];
103
- if (!rate) {
104
- const fallbackTier = (model === 'main-session' || model === 'unknown') ? 'think' : tier;
105
- const tierRate =
106
- Object.values(rateMap).find(r => r.tier === fallbackTier) ||
107
- Object.values(rateMap).find(r => r.tier === tier);
108
- if (!tierRate) return 0;
109
- return (inputTok / 1_000_000) * tierRate.input_per_mtok +
110
- (outputTok / 1_000_000) * tierRate.output_per_mtok;
80
+ if (hasActual) {
81
+ return (record.input_tokens * 1) + (record.output_tokens * 3);
111
82
  }
112
- return (inputTok / 1_000_000) * rate.input_per_mtok +
113
- (outputTok / 1_000_000) * rate.output_per_mtok;
83
+ return TIER_ACTIVITY_WEIGHTS[tier] || TIER_ACTIVITY_WEIGHTS.execute;
84
+ }
85
+
86
+ function activityLabel(score) {
87
+ if (score <= 10) return 'minimal';
88
+ if (score <= 30) return 'light';
89
+ if (score <= 60) return 'moderate';
90
+ if (score <= 85) return 'heavy';
91
+ return 'intense';
114
92
  }
115
93
 
116
94
  // ---------------------------------------------------------------------------
@@ -153,52 +131,54 @@ function loadTodayRecords() {
153
131
  const TIER_ORDER = ['search', 'execute', 'think'];
154
132
  const TIER_LABELS = { search: 'Search ', execute: 'Execute', think: 'Think ' };
155
133
 
156
- function buildActivitySection(records, rateMap) {
134
+ function buildActivitySection(records) {
157
135
  // Aggregate by tier — only non-recommendation records
158
136
  const activity = records.filter(r => r.type !== 'tier_recommendation');
159
137
 
160
138
  const buckets = {};
161
139
  for (const r of activity) {
162
140
  const tier = r.tier || 'execute';
163
- const model = r.model || 'unknown';
164
- if (!buckets[tier]) buckets[tier] = { calls: 0, cost: 0, actualCount: 0 };
141
+ if (!buckets[tier]) buckets[tier] = { calls: 0, activityRaw: 0, actualCount: 0 };
165
142
  buckets[tier].calls += 1;
166
- buckets[tier].cost += estimateCost(tier, model, rateMap, r);
143
+ buckets[tier].activityRaw += computeActivity(tier, r);
167
144
  if (r.input_tokens != null && r.output_tokens != null) buckets[tier].actualCount += 1;
168
145
  }
169
146
 
147
+ const totalRaw = Object.values(buckets).reduce((s, b) => s + b.activityRaw, 0);
148
+ const totalScore = Math.min(100, Math.round((totalRaw / SESSION_ACTIVITY_CEILING) * 100));
149
+
170
150
  const lines = [];
171
151
  lines.push(boxLine('Activity Summary'));
172
152
  lines.push(boxLine('─'.repeat(INNER)));
173
153
 
174
- // Column widths: Tier(8) │ Calls(6) │ Est. Cost(10)
175
- const header = padR('Tier', 8) + ' │ ' + padL('Calls', 5) + ' │ ' + padL('Est. Cost', 10);
154
+ // Column widths: Tier(8) │ Calls(6) │ Activity %(10)
155
+ const header = padR('Tier', 8) + ' │ ' + padL('Calls', 5) + ' │ ' + padL('Activity %', 10);
176
156
  const divRow = '─'.repeat(8) + '─┼─' + '─'.repeat(5) + '─┼─' + '─'.repeat(10);
177
157
  lines.push(boxLine(header));
178
158
  lines.push(boxLine(divRow));
179
159
 
180
160
  let totalCalls = 0;
181
- let totalCost = 0;
182
161
 
183
162
  for (const tier of TIER_ORDER) {
184
163
  const b = buckets[tier];
185
164
  if (!b) continue;
186
165
  const label = padR(TIER_LABELS[tier] || tier, 8);
187
166
  const calls = padL(String(b.calls), 5);
188
- const cost = padL(fmt$(b.cost), 10);
189
- lines.push(boxLine(`${label} │ ${calls} │ ${cost}`));
167
+ const pct = totalRaw > 0 ? Math.round((b.activityRaw / totalRaw) * 100) : 0;
168
+ const pctStr = padL(`${pct}%`, 10);
169
+ lines.push(boxLine(`${label} │ ${calls} │ ${pctStr}`));
190
170
  totalCalls += b.calls;
191
- totalCost += b.cost;
192
171
  }
193
172
 
194
173
  lines.push(boxLine(divRow));
195
- lines.push(boxLine(padR('Total', 8) + ' │ ' + padL(String(totalCalls), 5) + ' │ ' + padL(fmt$(totalCost), 10)));
174
+ lines.push(boxLine(padR('Total', 8) + ' │ ' + padL(String(totalCalls), 5) + ' │ ' + padL(`${totalScore}/100`, 10)));
175
+ lines.push(boxLine(`Activity: ${totalScore}/100 (${activityLabel(totalScore)})`));
196
176
 
197
177
  if (totalCalls === 0) {
198
178
  lines.push(boxLine(' (no usage data recorded today)'));
199
179
  }
200
180
 
201
- return { lines, totalCalls, totalCost, buckets };
181
+ return { lines, totalCalls, totalScore, buckets };
202
182
  }
203
183
 
204
184
  // ---------------------------------------------------------------------------
@@ -267,7 +247,7 @@ function buildProviderBalanceSection(records) {
267
247
  // ---------------------------------------------------------------------------
268
248
  // Section 2: Routing Compliance
269
249
  // ---------------------------------------------------------------------------
270
- function buildComplianceSection(records, rateMap) {
250
+ function buildComplianceSection(records) {
271
251
  const recs = records.filter(r => r.type === 'tier_recommendation');
272
252
 
273
253
  const total = recs.length;
@@ -276,24 +256,15 @@ function buildComplianceSection(records, rateMap) {
276
256
  const followPct = total > 0 ? Math.round((followed / total) * 100) : 0;
277
257
  const ignorePct = total > 0 ? 100 - followPct : 0;
278
258
 
279
- // Overspend: for each ignored rec, diff between actual-tier cost and recommended-tier cost
280
- let overspend = 0;
259
+ // Activity waste: diff between actual-tier weight and recommended-tier weight
260
+ let wastedActivity = 0;
281
261
  for (const r of recs) {
282
262
  if (r.followed === true) continue;
283
263
  if (!r.recommended_tier || !r.actual_tier) continue;
284
- const recommended = TOKEN_HEURISTICS[r.recommended_tier] || TOKEN_HEURISTICS.execute;
285
- const actual = TOKEN_HEURISTICS[r.actual_tier] || TOKEN_HEURISTICS.execute;
286
-
287
- const recRate = Object.values(rateMap).find(x => x.tier === r.recommended_tier);
288
- const actRate = Object.values(rateMap).find(x => x.tier === r.actual_tier);
289
- if (!recRate || !actRate) continue;
290
-
291
- const recCost = (recommended.input / 1_000_000) * recRate.input_per_mtok +
292
- (recommended.output / 1_000_000) * recRate.output_per_mtok;
293
- const actCost = (actual.input / 1_000_000) * actRate.input_per_mtok +
294
- (actual.output / 1_000_000) * actRate.output_per_mtok;
295
- const delta = actCost - recCost;
296
- if (delta > 0) overspend += delta;
264
+ const recWeight = TIER_ACTIVITY_WEIGHTS[r.recommended_tier] || TIER_ACTIVITY_WEIGHTS.execute;
265
+ const actWeight = TIER_ACTIVITY_WEIGHTS[r.actual_tier] || TIER_ACTIVITY_WEIGHTS.execute;
266
+ const delta = actWeight - recWeight;
267
+ if (delta > 0) wastedActivity += delta;
297
268
  }
298
269
 
299
270
  const lines = [];
@@ -302,7 +273,7 @@ function buildComplianceSection(records, rateMap) {
302
273
  lines.push(boxLine(`Recommendations: ${total}`));
303
274
  lines.push(boxLine(`Followed: ${followed} (${followPct}%)`));
304
275
  lines.push(boxLine(`Ignored: ${ignored} (${ignorePct}%)`));
305
- lines.push(boxLine(`Estimated overspend: ~${fmt$(overspend)}`));
276
+ lines.push(boxLine(`Wasted activity: ${wastedActivity} units (from misrouted calls)`));
306
277
 
307
278
  return { lines };
308
279
  }
@@ -463,7 +434,6 @@ function buildDriftSection(config) {
463
434
  // ---------------------------------------------------------------------------
464
435
  function main() {
465
436
  const config = loadConfig();
466
- const rateMap = buildRateMap(config);
467
437
  const records = loadTodayRecords();
468
438
 
469
439
  const output = [];
@@ -473,7 +443,7 @@ function main() {
473
443
  output.push(boxDiv());
474
444
 
475
445
  // --- Section 1: Activity Summary ---
476
- const { lines: actLines } = buildActivitySection(records, rateMap);
446
+ const { lines: actLines } = buildActivitySection(records);
477
447
  output.push(...actLines);
478
448
  output.push(boxBlank());
479
449
 
@@ -483,7 +453,7 @@ function main() {
483
453
  output.push(boxBlank());
484
454
 
485
455
  // --- Section 2: Routing Compliance ---
486
- const { lines: compLines } = buildComplianceSection(records, rateMap);
456
+ const { lines: compLines } = buildComplianceSection(records);
487
457
  output.push(...compLines);
488
458
  output.push(boxBlank());
489
459