dual-brain 4.6.0 → 4.7.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.
@@ -61,34 +61,56 @@ 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); }
64
65
 
65
66
  // ---------------------------------------------------------------------------
66
- // Load orchestrator config (used by drift section)
67
+ // Load orchestrator config
67
68
  // ---------------------------------------------------------------------------
68
69
  function loadConfig() {
69
70
  try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return null; }
70
71
  }
71
72
 
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
+
72
88
  // ---------------------------------------------------------------------------
73
- // Activity scoring (mirrors summary-checkpoint.mjs formula)
89
+ // Token heuristics (mirrors cost-report.mjs)
74
90
  // ---------------------------------------------------------------------------
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 = {}) {
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;
79
99
  const hasActual = record.input_tokens != null && record.output_tokens != null;
80
- if (hasActual) {
81
- return (record.input_tokens * 1) + (record.output_tokens * 3);
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;
82
111
  }
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';
112
+ return (inputTok / 1_000_000) * rate.input_per_mtok +
113
+ (outputTok / 1_000_000) * rate.output_per_mtok;
92
114
  }
93
115
 
94
116
  // ---------------------------------------------------------------------------
@@ -131,54 +153,52 @@ function loadTodayRecords() {
131
153
  const TIER_ORDER = ['search', 'execute', 'think'];
132
154
  const TIER_LABELS = { search: 'Search ', execute: 'Execute', think: 'Think ' };
133
155
 
134
- function buildActivitySection(records) {
156
+ function buildActivitySection(records, rateMap) {
135
157
  // Aggregate by tier — only non-recommendation records
136
158
  const activity = records.filter(r => r.type !== 'tier_recommendation');
137
159
 
138
160
  const buckets = {};
139
161
  for (const r of activity) {
140
162
  const tier = r.tier || 'execute';
141
- if (!buckets[tier]) buckets[tier] = { calls: 0, activityRaw: 0, actualCount: 0 };
163
+ const model = r.model || 'unknown';
164
+ if (!buckets[tier]) buckets[tier] = { calls: 0, cost: 0, actualCount: 0 };
142
165
  buckets[tier].calls += 1;
143
- buckets[tier].activityRaw += computeActivity(tier, r);
166
+ buckets[tier].cost += estimateCost(tier, model, rateMap, r);
144
167
  if (r.input_tokens != null && r.output_tokens != null) buckets[tier].actualCount += 1;
145
168
  }
146
169
 
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
-
150
170
  const lines = [];
151
171
  lines.push(boxLine('Activity Summary'));
152
172
  lines.push(boxLine('─'.repeat(INNER)));
153
173
 
154
- // Column widths: Tier(8) │ Calls(6) │ Activity %(10)
155
- const header = padR('Tier', 8) + ' │ ' + padL('Calls', 5) + ' │ ' + padL('Activity %', 10);
174
+ // Column widths: Tier(8) │ Calls(6) │ Est. Cost(10)
175
+ const header = padR('Tier', 8) + ' │ ' + padL('Calls', 5) + ' │ ' + padL('Est. Cost', 10);
156
176
  const divRow = '─'.repeat(8) + '─┼─' + '─'.repeat(5) + '─┼─' + '─'.repeat(10);
157
177
  lines.push(boxLine(header));
158
178
  lines.push(boxLine(divRow));
159
179
 
160
180
  let totalCalls = 0;
181
+ let totalCost = 0;
161
182
 
162
183
  for (const tier of TIER_ORDER) {
163
184
  const b = buckets[tier];
164
185
  if (!b) continue;
165
186
  const label = padR(TIER_LABELS[tier] || tier, 8);
166
187
  const calls = padL(String(b.calls), 5);
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}`));
188
+ const cost = padL(fmt$(b.cost), 10);
189
+ lines.push(boxLine(`${label} ${calls} │ ${cost}`));
170
190
  totalCalls += b.calls;
191
+ totalCost += b.cost;
171
192
  }
172
193
 
173
194
  lines.push(boxLine(divRow));
174
- lines.push(boxLine(padR('Total', 8) + ' │ ' + padL(String(totalCalls), 5) + ' │ ' + padL(`${totalScore}/100`, 10)));
175
- lines.push(boxLine(`Activity: ${totalScore}/100 (${activityLabel(totalScore)})`));
195
+ lines.push(boxLine(padR('Total', 8) + ' │ ' + padL(String(totalCalls), 5) + ' │ ' + padL(fmt$(totalCost), 10)));
176
196
 
177
197
  if (totalCalls === 0) {
178
198
  lines.push(boxLine(' (no usage data recorded today)'));
179
199
  }
180
200
 
181
- return { lines, totalCalls, totalScore, buckets };
201
+ return { lines, totalCalls, totalCost, buckets };
182
202
  }
183
203
 
184
204
  // ---------------------------------------------------------------------------
@@ -247,7 +267,7 @@ function buildProviderBalanceSection(records) {
247
267
  // ---------------------------------------------------------------------------
248
268
  // Section 2: Routing Compliance
249
269
  // ---------------------------------------------------------------------------
250
- function buildComplianceSection(records) {
270
+ function buildComplianceSection(records, rateMap) {
251
271
  const recs = records.filter(r => r.type === 'tier_recommendation');
252
272
 
253
273
  const total = recs.length;
@@ -256,15 +276,24 @@ function buildComplianceSection(records) {
256
276
  const followPct = total > 0 ? Math.round((followed / total) * 100) : 0;
257
277
  const ignorePct = total > 0 ? 100 - followPct : 0;
258
278
 
259
- // Activity waste: diff between actual-tier weight and recommended-tier weight
260
- let wastedActivity = 0;
279
+ // Overspend: for each ignored rec, diff between actual-tier cost and recommended-tier cost
280
+ let overspend = 0;
261
281
  for (const r of recs) {
262
282
  if (r.followed === true) continue;
263
283
  if (!r.recommended_tier || !r.actual_tier) continue;
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;
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;
268
297
  }
269
298
 
270
299
  const lines = [];
@@ -273,7 +302,7 @@ function buildComplianceSection(records) {
273
302
  lines.push(boxLine(`Recommendations: ${total}`));
274
303
  lines.push(boxLine(`Followed: ${followed} (${followPct}%)`));
275
304
  lines.push(boxLine(`Ignored: ${ignored} (${ignorePct}%)`));
276
- lines.push(boxLine(`Wasted activity: ${wastedActivity} units (from misrouted calls)`));
305
+ lines.push(boxLine(`Estimated overspend: ~${fmt$(overspend)}`));
277
306
 
278
307
  return { lines };
279
308
  }
@@ -434,6 +463,7 @@ function buildDriftSection(config) {
434
463
  // ---------------------------------------------------------------------------
435
464
  function main() {
436
465
  const config = loadConfig();
466
+ const rateMap = buildRateMap(config);
437
467
  const records = loadTodayRecords();
438
468
 
439
469
  const output = [];
@@ -443,7 +473,7 @@ function main() {
443
473
  output.push(boxDiv());
444
474
 
445
475
  // --- Section 1: Activity Summary ---
446
- const { lines: actLines } = buildActivitySection(records);
476
+ const { lines: actLines } = buildActivitySection(records, rateMap);
447
477
  output.push(...actLines);
448
478
  output.push(boxBlank());
449
479
 
@@ -453,7 +483,7 @@ function main() {
453
483
  output.push(boxBlank());
454
484
 
455
485
  // --- Section 2: Routing Compliance ---
456
- const { lines: compLines } = buildComplianceSection(records);
486
+ const { lines: compLines } = buildComplianceSection(records, rateMap);
457
487
  output.push(...compLines);
458
488
  output.push(boxBlank());
459
489
 
@@ -17,11 +17,9 @@
17
17
  */
18
18
 
19
19
  import { execSync as _execSync } from 'child_process';
20
- import { existsSync, readFileSync } from 'fs';
20
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
21
21
  import { dirname, join } from 'path';
22
22
  import { fileURLToPath } from 'url';
23
- import { atomicWriteJSON } from './atomic-write.mjs';
24
- import { logHookError } from './error-channel.mjs';
25
23
 
26
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
25
 
@@ -44,9 +42,7 @@ function emptySummary() {
44
42
 
45
43
  totals: {
46
44
  calls: 0,
47
- activity_score: 0, // token-weighted 0-100 scale, not dollars
48
- activity_raw: 0, // raw weighted token count before normalization
49
- activity_basis: 'none', // 'actual' | 'estimated' | 'mixed'
45
+ cost_estimate: 0,
50
46
  by_tier: {},
51
47
  by_provider: {},
52
48
  by_model: {},
@@ -82,18 +78,12 @@ function emptySummary() {
82
78
  };
83
79
  }
84
80
 
85
- // Tier-based fallback weights when actual token counts are unavailable (legacy entries).
86
- // These are unitless activity weights, NOT dollar costs.
87
- const TIER_ACTIVITY_WEIGHTS = { search: 3, execute: 10, think: 25 };
81
+ const COST_PER_CALL = { search: 0.003, execute: 0.012, think: 0.055 };
88
82
 
89
- // Activity formula: (input_tokens * 1) + (output_tokens * 3), normalized to 0-100 per session.
90
- // SESSION_ACTIVITY_CEILING is the raw token-weighted value that maps to score 100.
91
- // Calibrated to ~200 moderate tool calls in a session.
92
- const SESSION_ACTIVITY_CEILING = 5_000_000;
93
-
94
- /** @deprecated Use atomicWriteJSON directly. Kept as re-export for backward compat. */
95
83
  function atomicWrite(path, data) {
96
- atomicWriteJSON(path, data);
84
+ const tmp = path + '.tmp.' + process.pid;
85
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
86
+ renameSync(tmp, path);
97
87
  }
98
88
 
99
89
  function readSummary(date) {
@@ -137,27 +127,10 @@ function applyEntry(summary, entry) {
137
127
  const tier = entry.tier || 'execute';
138
128
  const provider = entry.provider || 'claude';
139
129
  const model = entry.model || 'unknown';
140
-
141
- // Compute activity from actual tokens when available, else use tier-based fallback
142
- const hasTokens = entry.input_tokens != null && entry.output_tokens != null;
143
- const rawActivity = hasTokens
144
- ? (entry.input_tokens * 1) + (entry.output_tokens * 3)
145
- : TIER_ACTIVITY_WEIGHTS[tier] || TIER_ACTIVITY_WEIGHTS.execute;
130
+ const cost = COST_PER_CALL[tier] || COST_PER_CALL.execute;
146
131
 
147
132
  summary.totals.calls++;
148
- summary.totals.activity_raw = (summary.totals.activity_raw || 0) + rawActivity;
149
- summary.totals.activity_score = Math.min(100,
150
- Math.round((summary.totals.activity_raw / SESSION_ACTIVITY_CEILING) * 100));
151
-
152
- // Track whether scores are based on actual tokens or estimates
153
- const prevBasis = summary.totals.activity_basis || 'none';
154
- if (prevBasis === 'none') {
155
- summary.totals.activity_basis = hasTokens ? 'actual' : 'estimated';
156
- } else if (prevBasis === 'actual' && !hasTokens) {
157
- summary.totals.activity_basis = 'mixed';
158
- } else if (prevBasis === 'estimated' && hasTokens) {
159
- summary.totals.activity_basis = 'mixed';
160
- }
133
+ summary.totals.cost_estimate += cost;
161
134
 
162
135
  summary.totals.by_tier[tier] = (summary.totals.by_tier[tier] || 0) + 1;
163
136
  summary.totals.by_provider[provider] = (summary.totals.by_provider[provider] || 0) + 1;