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.
@@ -9,22 +9,50 @@
9
9
  *
10
10
  * Reads:
11
11
  * .claude/hooks/usage.jsonl — tool call log written by cost-logger.mjs
12
- *
13
- * Reports token-weighted activity scores (0-100), not dollar estimates.
12
+ * .claude/orchestrator.json — cost rates per model
14
13
  */
15
14
 
16
15
  import { readFileSync, existsSync, readdirSync } from "fs";
17
16
  import { dirname, join } from "path";
18
17
  import { fileURLToPath } from "url";
19
- import { execSync } from "child_process";
18
+ import { spawnSync } from "child_process";
20
19
 
21
20
  // ---------------------------------------------------------------------------
22
21
  // Paths
23
22
  // ---------------------------------------------------------------------------
24
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
24
  const WORKSPACE = join(__dirname, "..", ".."); // workspace root
26
- // Config and rate maps removed — cost-report no longer estimates dollar costs.
27
- // Activity scores are computed from token counts directly.
25
+ const CONFIG_FILE = join(__dirname, "..", "orchestrator.json");
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Load orchestrator config
29
+ // ---------------------------------------------------------------------------
30
+ function loadConfig() {
31
+ try {
32
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Build a flat map: { "haiku": { input_per_mtok, output_per_mtok, tier }, … }
40
+ * from orchestrator.json's subscriptions block.
41
+ */
42
+ function buildRateMap(config) {
43
+ const rates = {};
44
+ if (!config?.subscriptions) return rates;
45
+ for (const provider of Object.values(config.subscriptions)) {
46
+ for (const [modelKey, data] of Object.entries(provider.models || {})) {
47
+ rates[modelKey] = {
48
+ tier: data.tier,
49
+ input_per_mtok: data.input_per_mtok,
50
+ output_per_mtok: data.output_per_mtok,
51
+ };
52
+ }
53
+ }
54
+ return rates;
55
+ }
28
56
 
29
57
  // ---------------------------------------------------------------------------
30
58
  // Load & parse usage log
@@ -55,20 +83,41 @@ function loadUsage() {
55
83
  // Cost estimation
56
84
  // ---------------------------------------------------------------------------
57
85
 
58
- // Tier-based fallback weights when actual token counts are unavailable (legacy entries).
59
- // These are unitless activity weights, NOT dollar costs.
60
- const TIER_ACTIVITY_WEIGHTS = { search: 3, execute: 10, think: 25 };
61
-
62
- // Activity formula: (input_tokens * 1) + (output_tokens * 3)
63
- // SESSION_ACTIVITY_CEILING is the raw token-weighted value that maps to score 100.
64
- const SESSION_ACTIVITY_CEILING = 5_000_000;
86
+ /**
87
+ * Very rough token estimate per tool call.
88
+ * Without actual token counts from the session files, we use a conservative
89
+ * heuristic based on typical Claude Code usage patterns.
90
+ */
91
+ const TOKEN_HEURISTICS = {
92
+ // { input_tok, output_tok }
93
+ search: { input: 2_000, output: 500 },
94
+ execute: { input: 4_000, output: 1_500 },
95
+ think: { input: 8_000, output: 3_000 },
96
+ };
65
97
 
66
- function computeActivity(tier, record = {}) {
98
+ function estimateCost(tier, model, rateMap, record = {}) {
99
+ const heuristic = TOKEN_HEURISTICS[tier] || TOKEN_HEURISTICS.execute;
100
+ // Use actual tokens if logged, otherwise fall back to heuristics
67
101
  const hasActual = record.input_tokens != null && record.output_tokens != null;
68
- if (hasActual) {
69
- return { raw: (record.input_tokens * 1) + (record.output_tokens * 3), basis: 'actual' };
102
+ const inputTok = hasActual ? record.input_tokens : heuristic.input;
103
+ const outputTok = hasActual ? record.output_tokens : heuristic.output;
104
+ const rate = rateMap[model] || rateMap["main-session"];
105
+ if (!rate) {
106
+ // Fallback: use tier-matched rate from whatever model we know about
107
+ // "main-session" and "unknown" map to think-tier (Opus) since that's the session model
108
+ const fallbackTier = (model === "main-session" || model === "unknown") ? "think" : tier;
109
+ const tierRate = Object.values(rateMap).find((r) => r.tier === fallbackTier)
110
+ || Object.values(rateMap).find((r) => r.tier === tier);
111
+ if (!tierRate) return 0;
112
+ return (
113
+ (inputTok / 1_000_000) * tierRate.input_per_mtok +
114
+ (outputTok / 1_000_000) * tierRate.output_per_mtok
115
+ );
70
116
  }
71
- return { raw: TIER_ACTIVITY_WEIGHTS[tier] || TIER_ACTIVITY_WEIGHTS.execute, basis: 'estimated' };
117
+ return (
118
+ (inputTok / 1_000_000) * rate.input_per_mtok +
119
+ (outputTok / 1_000_000) * rate.output_per_mtok
120
+ );
72
121
  }
73
122
 
74
123
  // ---------------------------------------------------------------------------
@@ -77,10 +126,17 @@ function computeActivity(tier, record = {}) {
77
126
  function gitFallbackSummary() {
78
127
  try {
79
128
  const today = new Date().toISOString().slice(0, 10);
80
- const log = execSync(
81
- `git -C "${WORKSPACE}" log --oneline --since="${today} 00:00" --until="${today} 23:59"`,
82
- { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
83
- ).trim();
129
+ const proc = spawnSync("git", [
130
+ "-C", WORKSPACE,
131
+ "log", "--oneline",
132
+ `--since=${today} 00:00`,
133
+ `--until=${today} 23:59`,
134
+ ], {
135
+ encoding: "utf8",
136
+ stdio: ["pipe", "pipe", "pipe"],
137
+ timeout: 10_000,
138
+ });
139
+ const log = proc.status === 0 ? (proc.stdout || "").trim() : "";
84
140
  const commits = log ? log.split("\n").length : 0;
85
141
  return commits;
86
142
  } catch {
@@ -97,35 +153,29 @@ function todayPrefix() {
97
153
  }
98
154
 
99
155
  /**
100
- * Aggregate records into { [tier]: { model, calls, activityRaw, actualCount } }
156
+ * Aggregate records into { [tier]: { model, calls, cost } }
101
157
  * where model is the most-seen model for that tier.
102
158
  */
103
- function aggregate(records, datePrefix = null) {
159
+ function aggregate(records, rateMap, datePrefix = null) {
104
160
  const filtered = datePrefix
105
161
  ? records.filter((r) => r.timestamp?.startsWith(datePrefix))
106
162
  : records;
107
163
 
108
- // tier → { calls, activityRaw, actualCount, modelCounts }
164
+ // tier → { calls: number, costSum: number, modelCounts: { model: count } }
109
165
  const buckets = {};
110
166
 
111
167
  for (const record of filtered) {
112
168
  const tier = record.tier || "execute";
113
169
  const model = record.model || "unknown";
114
170
  if (!buckets[tier]) {
115
- buckets[tier] = { calls: 0, activityRaw: 0, actualCount: 0, modelCounts: {} };
171
+ buckets[tier] = { calls: 0, costSum: 0, modelCounts: {} };
116
172
  }
117
173
  buckets[tier].calls += 1;
118
- const { raw } = computeActivity(tier, record);
119
- buckets[tier].activityRaw += raw;
174
+ buckets[tier].costSum += estimateCost(tier, model, rateMap, record);
120
175
  buckets[tier].modelCounts[model] = (buckets[tier].modelCounts[model] || 0) + 1;
121
- if (record.input_tokens != null && record.output_tokens != null) {
122
- buckets[tier].actualCount += 1;
123
- }
176
+ if (record.input_tokens != null && record.output_tokens != null) buckets[tier].actualCount = (buckets[tier].actualCount || 0) + 1;
124
177
  }
125
178
 
126
- // Compute total raw for percentage breakdown
127
- const totalRaw = Object.values(buckets).reduce((s, b) => s + b.activityRaw, 0);
128
-
129
179
  // Resolve dominant model per tier
130
180
  const result = {};
131
181
  for (const [tier, data] of Object.entries(buckets)) {
@@ -133,23 +183,24 @@ function aggregate(records, datePrefix = null) {
133
183
  result[tier] = {
134
184
  model: dominantModel,
135
185
  calls: data.calls,
136
- activityRaw: data.activityRaw,
137
- activityPct: totalRaw > 0 ? Math.round((data.activityRaw / totalRaw) * 100) : 0,
138
- actualCount: data.actualCount,
186
+ cost: data.costSum,
187
+ actualCount: data.actualCount || 0,
139
188
  };
140
189
  }
141
190
  return result;
142
191
  }
143
192
 
144
- /**
145
- * Classify overall activity level from score.
146
- */
147
- function activityLabel(score) {
148
- if (score <= 10) return 'minimal';
149
- if (score <= 30) return 'light';
150
- if (score <= 60) return 'moderate';
151
- if (score <= 85) return 'heavy';
152
- return 'intense';
193
+ // ---------------------------------------------------------------------------
194
+ // Opus all-in cost (for savings calculation)
195
+ // ---------------------------------------------------------------------------
196
+ function allOpusCost(records, rateMap, datePrefix = null) {
197
+ const filtered = datePrefix
198
+ ? records.filter((r) => r.timestamp?.startsWith(datePrefix))
199
+ : records;
200
+
201
+ return filtered.reduce((sum, record) => {
202
+ return sum + estimateCost("think", "opus", rateMap, record);
203
+ }, 0);
153
204
  }
154
205
 
155
206
  // ---------------------------------------------------------------------------
@@ -166,6 +217,10 @@ const TIER_LABELS = {
166
217
  think: "Think ",
167
218
  };
168
219
 
220
+ function fmt$(n) {
221
+ return "$" + n.toFixed(2);
222
+ }
223
+
169
224
  function pad(str, len, align = "left") {
170
225
  str = String(str);
171
226
  if (str.length >= len) return str.slice(0, len);
@@ -173,37 +228,39 @@ function pad(str, len, align = "left") {
173
228
  return align === "right" ? spaces + str : str + spaces;
174
229
  }
175
230
 
176
- function renderTable(title, aggregated, records = []) {
177
- const totalRaw = Object.values(aggregated).reduce((s, v) => s + v.activityRaw, 0);
178
- const totalScore = Math.min(100, Math.round((totalRaw / SESSION_ACTIVITY_CEILING) * 100));
179
- const label = activityLabel(totalScore);
231
+ function renderTable(title, aggregated, allOpus, records = []) {
232
+ const totalCost = Object.values(aggregated).reduce((s, v) => s + v.cost, 0);
233
+ const savings = allOpus - totalCost;
234
+ const savingsPct = allOpus > 0 ? Math.round((savings / allOpus) * 100) : 0;
180
235
 
181
236
  const line = (s) => `║ ${pad(s, W - 2)} ║`;
182
- const border = (l, r) => l + "═".repeat(W) + r;
237
+ const border = (l, r, m) => l + "═".repeat(W) + r;
183
238
  const sep = () => "╠" + "═".repeat(W) + "╣";
184
239
 
185
240
  const rows = TIER_ORDER
186
241
  .filter((t) => aggregated[t])
187
242
  .map((t) => {
188
- const { model, calls, activityPct } = aggregated[t];
243
+ const { model, calls, cost } = aggregated[t];
189
244
  const tierLbl = pad(TIER_LABELS[t] || t, 8);
190
245
  const modelLbl = pad(model, 10);
191
246
  const callsLbl = pad(String(calls), 5, "right");
192
- const pctLbl = pad(`${activityPct}%`, 10, "right");
193
- return line(`${tierLbl} │ ${modelLbl} │ ${callsLbl} │ ${pctLbl}`);
247
+ const costLbl = pad(fmt$(cost), 12, "right");
248
+ return line(`${tierLbl} │ ${modelLbl} │ ${callsLbl} │ ${costLbl}`);
194
249
  });
195
250
 
196
- const header = line(`Tier │ Model │ Calls │ Activity % `);
251
+ const header = line(`Tier │ Model │ Calls │ Est. Cost `);
197
252
  const hline = line(`─────────┼────────────┼───────┼────────────`);
198
253
 
199
254
  const totalCalls = Object.values(aggregated).reduce((s, v) => s + v.calls, 0);
200
255
  const actualCalls = Object.values(aggregated).reduce((s, v) => s + (v.actualCount || 0), 0);
201
- const basis = actualCalls === 0 ? 'estimated (no token data)' :
202
- actualCalls === totalCalls ? 'actual token counts' :
203
- `mixed (${Math.round(actualCalls/totalCalls*100)}% actual)`;
256
+ const confidence = actualCalls === 0 ? 'low (heuristic only)' :
257
+ actualCalls === totalCalls ? 'high (actual tokens)' :
258
+ `medium (${Math.round(actualCalls/totalCalls*100)}% actual)`;
204
259
 
205
260
  // Data quality stats
261
+ const totalRecords = Object.values(aggregated).reduce((s, v) => s + v.calls, 0);
206
262
  const unknownModels = records.filter(r => !r.model || r.model === 'unknown').length;
263
+ const v2Records = records.filter(r => r.schema_version >= 2).length;
207
264
  const errorRecords = records.filter(r => r.status === 'error').length;
208
265
 
209
266
  const lines = [
@@ -214,15 +271,15 @@ function renderTable(title, aggregated, records = []) {
214
271
  hline,
215
272
  ...rows,
216
273
  sep(),
217
- line(`Session activity: ${totalScore}/100 (${label})`),
218
- line(`Basis: ${basis}`),
219
- line(`Activity score based on token usage, not billing`),
274
+ line(`Total estimated: ${fmt$(totalCost)}`),
275
+ line(`Savings vs all-Opus: ~${fmt$(Math.max(0, savings))} (${savingsPct}%)`),
276
+ line(`Confidence: ${confidence}`),
220
277
  border("╚", "╝"),
221
278
  ];
222
279
 
223
280
  if (unknownModels > 0 || errorRecords > 0) {
224
281
  lines.splice(-1, 0,
225
- line(`Unknown models: ${unknownModels}/${totalCalls} entries`),
282
+ line(`Unknown models: ${unknownModels}/${totalRecords} entries`),
226
283
  line(`Errors: ${errorRecords} tool calls failed`),
227
284
  );
228
285
  }
@@ -235,7 +292,7 @@ function renderEmpty() {
235
292
  const ln = (s) => `║ ${pad(s, W - 2)} ║`;
236
293
  return [
237
294
  border("╔", "╗"),
238
- ln("Session Activity Report"),
295
+ ln("Activity & Cost Estimate"),
239
296
  border("╠", "╣"),
240
297
  ln("No usage data yet."),
241
298
  ln(""),
@@ -253,6 +310,8 @@ function main() {
253
310
  const args = process.argv.slice(2);
254
311
  const showAll = args.includes("--all");
255
312
 
313
+ const config = loadConfig();
314
+ const rateMap = buildRateMap(config);
256
315
  const records = loadUsage();
257
316
 
258
317
  if (records.length === 0) {
@@ -269,12 +328,13 @@ function main() {
269
328
 
270
329
  if (!showAll) {
271
330
  // Today's report
272
- const todayAgg = aggregate(records, today);
331
+ const todayAgg = aggregate(records, rateMap, today);
332
+ const todayOpus = allOpusCost(records, rateMap, today);
273
333
  const todayRecords = records.filter(r => r.timestamp?.startsWith(today));
274
334
  const hasTodayData = Object.keys(todayAgg).length > 0;
275
335
 
276
336
  if (hasTodayData) {
277
- console.log(renderTable("Session Activity — Today", todayAgg, todayRecords));
337
+ console.log(renderTable("Activity & Cost Estimate — Today", todayAgg, todayOpus, todayRecords));
278
338
  } else {
279
339
  console.log(" No activity recorded for today yet.");
280
340
  }
@@ -283,8 +343,9 @@ function main() {
283
343
  }
284
344
 
285
345
  // All-time report
286
- const allAgg = aggregate(records);
287
- console.log(renderTable("Session Activity All Time", allAgg, records));
346
+ const allAgg = aggregate(records, rateMap);
347
+ const allOpus = allOpusCost(records, rateMap);
348
+ console.log(renderTable("Activity & Cost Estimate — All Time", allAgg, allOpus, records));
288
349
  }
289
350
 
290
351
  main();
@@ -23,7 +23,6 @@ import { appendFileSync, existsSync, readFileSync } from 'fs';
23
23
  import { dirname, join } from 'path';
24
24
  import { fileURLToPath } from 'url';
25
25
  import { randomBytes } from 'crypto';
26
- import { logHookError } from './error-channel.mjs';
27
26
 
28
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
28
  const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
@@ -63,7 +62,7 @@ function recordDecision(decision = {}) {
63
62
 
64
63
  try {
65
64
  appendFileSync(LEDGER_FILE, entry + '\n');
66
- } catch (e) { logHookError('decision-ledger', 'recordDecision append', e); }
65
+ } catch {}
67
66
 
68
67
  return id;
69
68
  }
@@ -98,7 +97,7 @@ function recordOutcome(decisionId, outcome = {}) {
98
97
 
99
98
  try {
100
99
  appendFileSync(LEDGER_FILE, entry + '\n');
101
- } catch (e) { logHookError('decision-ledger', 'recordOutcome append', e); }
100
+ } catch {}
102
101
  }
103
102
 
104
103
  function loadLedger() {
@@ -205,55 +204,6 @@ function getInsights(opts = {}) {
205
204
  };
206
205
  }
207
206
 
208
- /**
209
- * getOutcomeStats — lightweight aggregation for the routing hot path.
210
- *
211
- * Returns success rates by tier and provider over the last 24 hours,
212
- * plus flags for any tier with < 50% success (with ≥ 5 outcomes).
213
- */
214
- function getOutcomeStats() {
215
- const { decisions, outcomes } = loadLedger();
216
- const merged = mergeDecisionsWithOutcomes(decisions, outcomes);
217
-
218
- const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
219
- const recent = merged.filter(d => d.outcome && d.timestamp >= cutoff);
220
-
221
- const byTier = {};
222
- const byProvider = {};
223
-
224
- for (const d of recent) {
225
- // Tier stats
226
- const t = d.tier || 'execute';
227
- if (!byTier[t]) byTier[t] = { total: 0, success: 0 };
228
- byTier[t].total++;
229
- if (d.outcome.success) byTier[t].success++;
230
-
231
- // Provider stats
232
- const p = d.provider || 'claude';
233
- if (!byProvider[p]) byProvider[p] = { total: 0, success: 0 };
234
- byProvider[p].total++;
235
- if (d.outcome.success) byProvider[p].success++;
236
- }
237
-
238
- // Flag underperforming tiers (< 50% success with ≥ 5 outcomes)
239
- const underperforming = [];
240
- for (const [tier, stats] of Object.entries(byTier)) {
241
- if (stats.total >= 5) {
242
- const rate = Math.round((stats.success / stats.total) * 100);
243
- if (rate < 50) {
244
- underperforming.push({ tier, rate, total: stats.total });
245
- }
246
- }
247
- }
248
-
249
- return {
250
- by_tier: byTier,
251
- by_provider: byProvider,
252
- total_outcomes: recent.length,
253
- underperforming,
254
- };
255
- }
256
-
257
207
  // ─── CLI ────────────────────────────────────────────────────────────────────
258
208
 
259
209
  function printInsights() {
@@ -346,4 +296,4 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
346
296
  }
347
297
  }
348
298
 
349
- export { recordDecision, recordOutcome, getInsights, getOutcomeStats, loadLedger };
299
+ export { recordDecision, recordOutcome, getInsights, loadLedger };