dual-brain 2.0.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 +40 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/hookify.orchestrator-cost.local.md +16 -0
- package/hookify.orchestrator-gate.local.md +19 -0
- package/hookify.orchestrator-route.local.md +23 -0
- package/hooks/budget-balancer.mjs +463 -0
- package/hooks/cost-logger.mjs +250 -0
- package/hooks/cost-report.mjs +344 -0
- package/hooks/dual-brain-review.mjs +302 -0
- package/hooks/dual-brain-think.mjs +321 -0
- package/hooks/enforce-tier.mjs +282 -0
- package/hooks/gpt-work-dispatcher.mjs +254 -0
- package/hooks/health-check.mjs +390 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/quality-gate.mjs +283 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/test-orchestrator.mjs +316 -0
- package/install.mjs +153 -0
- package/orchestrator.json +215 -0
- package/package.json +38 -0
- package/review-rules.md +17 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Dual-Brain Orchestrator
|
|
2
|
+
|
|
3
|
+
This project uses dual-provider orchestration. Config: `.claude/orchestrator.json`.
|
|
4
|
+
|
|
5
|
+
## Tier Routing
|
|
6
|
+
|
|
7
|
+
Route subagents by task complexity:
|
|
8
|
+
|
|
9
|
+
- **Search** (`model: "haiku"`): Read-only lookups, grep, explore. Return: files found, line refs, confidence.
|
|
10
|
+
- **Execute** (`model: "sonnet"`): Edits, tests, git ops. Return: files changed, tests run, edge cases.
|
|
11
|
+
- **Think** (main session, Opus): Architecture, review, planning. Return: decision, alternatives, risks.
|
|
12
|
+
|
|
13
|
+
## GPT Lane
|
|
14
|
+
|
|
15
|
+
For isolated or parallel work, dispatch to GPT via Codex CLI:
|
|
16
|
+
|
|
17
|
+
- `node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --model gpt-5.4` — execution tasks
|
|
18
|
+
- `node .claude/hooks/dual-brain-think.mjs --question "..."` — dual-perspective decisions
|
|
19
|
+
|
|
20
|
+
## Routing Rules
|
|
21
|
+
|
|
22
|
+
1. Tasks under 3 min → Claude (Codex startup overhead not worth it)
|
|
23
|
+
2. Isolated tasks over 3 min → check balance: `node .claude/hooks/budget-balancer.mjs`
|
|
24
|
+
3. High-risk decisions → dual-brain think
|
|
25
|
+
4. When a task spans tiers: think > execute > search
|
|
26
|
+
|
|
27
|
+
## Quality Gate
|
|
28
|
+
|
|
29
|
+
Before ending a session with code changes:
|
|
30
|
+
1. Run `node .claude/hooks/session-report.mjs`
|
|
31
|
+
2. Run `node .claude/hooks/quality-gate.mjs`
|
|
32
|
+
|
|
33
|
+
Gate statuses: `pass` (safe to end), `issues_found` (fix first), `needs_human_review` (GPT unavailable).
|
|
34
|
+
|
|
35
|
+
## Available Tools
|
|
36
|
+
|
|
37
|
+
- `node .claude/hooks/cost-report.mjs` — activity and cost estimates
|
|
38
|
+
- `node .claude/hooks/health-check.mjs` — verify system health
|
|
39
|
+
- `node .claude/hooks/budget-balancer.mjs` — provider balance status
|
|
40
|
+
- `node .claude/hooks/test-orchestrator.mjs` — run self-tests
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 1xmint
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Dual-Brain Orchestrator
|
|
2
|
+
|
|
3
|
+
Dual-provider orchestration for Claude Code across Claude and OpenAI subscriptions. Routes search work to cheap models, execution to mid-tier, and reserves the most capable models for thinking. Dispatches isolated tasks to GPT via Codex CLI, with dual-brain analysis for high-risk decisions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx dual-brain init
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Then run the setup wizard:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
node .claude/hooks/setup-wizard.mjs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Restart your Claude Code session. The wizard configures `orchestrator.json` with the right models and cost rates for your subscription tier.
|
|
18
|
+
|
|
19
|
+
**What the installer does:**
|
|
20
|
+
- Copies 13 hook scripts to `.claude/hooks/`
|
|
21
|
+
- Copies orchestrator config and hookify rules to `.claude/`
|
|
22
|
+
- Registers `enforce-tier.mjs` (PreToolUse) and `cost-logger.mjs` (PostToolUse) in `.claude/settings.json`
|
|
23
|
+
- Creates a `review-rules.md` template for your project-specific GPT review rules
|
|
24
|
+
- Updates `.gitignore` to exclude usage logs and review artifacts
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
Two hooks are registered in `.claude/settings.json` and run automatically:
|
|
29
|
+
|
|
30
|
+
- **enforce-tier.mjs** (PreToolUse on Agent): Classifies agent tasks by keyword, advises the correct model tier, detects duplicates, and suggests cross-provider routing
|
|
31
|
+
- **cost-logger.mjs** (PostToolUse on all tools): Logs usage data to daily rotated files for cost tracking
|
|
32
|
+
|
|
33
|
+
Three hookify rules in `.claude/hookify.orchestrator-*.local.md` provide session-level guidance:
|
|
34
|
+
|
|
35
|
+
- **Route**: Reminds the session to delegate subagents at the right tier
|
|
36
|
+
- **Gate**: Catches code changes that weren't reviewed before the session ends
|
|
37
|
+
- **Cost**: Checks that dispatched subagents use the correct model tier
|
|
38
|
+
|
|
39
|
+
## Scripts
|
|
40
|
+
|
|
41
|
+
| Script | Purpose |
|
|
42
|
+
|--------|---------|
|
|
43
|
+
| `hooks/setup-wizard.mjs` | Interactive setup — configure your subscription and preferences |
|
|
44
|
+
| `hooks/cost-report.mjs` | Activity & cost estimates by model tier |
|
|
45
|
+
| `hooks/dual-brain-review.mjs` | Send current git diff to GPT for independent review |
|
|
46
|
+
| `hooks/dual-brain-think.mjs` | Dual-perspective analysis on architecture decisions |
|
|
47
|
+
| `hooks/quality-gate.mjs` | Config-driven quality gate with sensitivity scoring |
|
|
48
|
+
| `hooks/budget-balancer.mjs` | Show provider balance and routing recommendations |
|
|
49
|
+
| `hooks/gpt-work-dispatcher.mjs` | Dispatch execution tasks to GPT via Codex CLI |
|
|
50
|
+
| `hooks/session-report.mjs` | Session-end summary: activity, routing compliance, quality gate |
|
|
51
|
+
| `hooks/health-check.mjs` | Verify all hooks and dependencies are configured |
|
|
52
|
+
| `hooks/test-orchestrator.mjs` | Self-test harness — validates all hooks work correctly |
|
|
53
|
+
| `hooks/install-git-hooks.mjs` | Install a git pre-commit hook for the quality gate |
|
|
54
|
+
| `hooks/enforce-tier.mjs` | PreToolUse hook — enforces model tier routing (automatic) |
|
|
55
|
+
| `hooks/cost-logger.mjs` | PostToolUse hook — logs usage data (automatic) |
|
|
56
|
+
|
|
57
|
+
## Model Intelligence
|
|
58
|
+
|
|
59
|
+
The `model_intelligence` section in `orchestrator.json` provides per-model metadata: strengths, weaknesses, best-for/avoid-for task guidance, context windows, and Codex compatibility. The `enforce-tier.mjs` hook reads this to give context-aware routing advice.
|
|
60
|
+
|
|
61
|
+
## Customize
|
|
62
|
+
|
|
63
|
+
- `orchestrator.json` — subscriptions, tiers, quality gate, routing rules, budgets
|
|
64
|
+
- `.claude/review-rules.md` — project-specific rules injected into GPT review prompts
|
|
65
|
+
- `.claude/settings.json` — hook registrations (auto-generated by installer)
|
|
66
|
+
|
|
67
|
+
## Requirements
|
|
68
|
+
|
|
69
|
+
- Node 20+
|
|
70
|
+
- Codex CLI (optional) — for GPT-lane features: `npm i -g @openai/codex` then `codex login`. Falls back to `OPENAI_API_KEY` env var. Without Codex, Claude-lane features work normally.
|
|
71
|
+
|
|
72
|
+
## Works with any subscription
|
|
73
|
+
|
|
74
|
+
The setup wizard supports any combination:
|
|
75
|
+
- Claude only ($20 Pro / $100 Max / $200 Max / API)
|
|
76
|
+
- OpenAI only ($20 Plus / $100 Pro)
|
|
77
|
+
- Both providers (recommended for dual-brain features)
|
|
78
|
+
|
|
79
|
+
Without an OpenAI subscription, GPT-lane features gracefully degrade — all work routes through Claude.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: orchestrator-cost-check
|
|
3
|
+
enabled: true
|
|
4
|
+
event: all
|
|
5
|
+
tool_matcher: Agent
|
|
6
|
+
action: warn
|
|
7
|
+
conditions:
|
|
8
|
+
- field: tool_name
|
|
9
|
+
operator: equals
|
|
10
|
+
pattern: Agent
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
**[Cost]** Subagent dispatched. Verify you selected the right tier:
|
|
14
|
+
- `model: "haiku"` for search/exploration
|
|
15
|
+
- `model: "sonnet"` for execution/implementation
|
|
16
|
+
- No model param (inherit Opus) only for thinking tasks
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: orchestrator-quality-gate
|
|
3
|
+
enabled: true
|
|
4
|
+
event: stop
|
|
5
|
+
action: warn
|
|
6
|
+
conditions:
|
|
7
|
+
- field: transcript
|
|
8
|
+
operator: regex_match
|
|
9
|
+
pattern: (Edit|Write|MultiEdit).+\.(ts|tsx|js|jsx|py|rs|go|java|rb|swift|kt)
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
**[Quality Gate]** Before ending this session:
|
|
13
|
+
1. Run `node .claude/hooks/session-report.mjs` to see the session summary
|
|
14
|
+
2. Run `node .claude/hooks/quality-gate.mjs` and check the output:
|
|
15
|
+
- `gate: "pass"` — safe to end
|
|
16
|
+
- `gate: "issues_found"` — address flagged issues first
|
|
17
|
+
- `gate: "needs_human_review"` — GPT unavailable, review diff manually
|
|
18
|
+
- `gate: "disabled"` — gate is off in config
|
|
19
|
+
Do NOT skip these steps.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: orchestrator-routing
|
|
3
|
+
enabled: true
|
|
4
|
+
event: prompt
|
|
5
|
+
action: warn
|
|
6
|
+
conditions:
|
|
7
|
+
- field: user_prompt
|
|
8
|
+
operator: regex_match
|
|
9
|
+
pattern: .+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
**[Tier Router]** Route work across both providers (config: `.claude/orchestrator.json`):
|
|
13
|
+
|
|
14
|
+
**Claude lane** (fast, interactive):
|
|
15
|
+
- Search (`model: "haiku"`): Read-only lookups, grep, explore. Agent must return: files found, line refs, confidence.
|
|
16
|
+
- Execute (`model: "sonnet"`): Edits, tests, git ops. Agent must return: files changed, tests run, edge cases.
|
|
17
|
+
- Think (main session): Architecture, review, planning. Agent must return: decision, alternatives, risks.
|
|
18
|
+
|
|
19
|
+
**GPT lane** (parallel, isolated work):
|
|
20
|
+
- Use `node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --model gpt-5.4` for isolated execution
|
|
21
|
+
- Use `node .claude/hooks/dual-brain-think.mjs --question "..."` for dual-perspective decisions
|
|
22
|
+
|
|
23
|
+
**Routing:** Tasks <3min → Claude. Isolated tasks >3min → check balance first (`node .claude/hooks/budget-balancer.mjs`). High-risk → dual-brain. Think > execute > search when task spans tiers.
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* budget-balancer.mjs — Core budget balancing module for the Dual-Brain Orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Tracks rolling usage pressure across Claude and OpenAI providers and recommends
|
|
6
|
+
* which provider should handle incoming work.
|
|
7
|
+
*
|
|
8
|
+
* Exported API:
|
|
9
|
+
* getProviderStatus() → current pressure per provider/tier
|
|
10
|
+
* chooseProvider(taskProfile) → recommended provider + model + rationale
|
|
11
|
+
* recordUsageEvent(event) → append a usage event to today's log
|
|
12
|
+
*
|
|
13
|
+
* Also works as a standalone CLI: node .claude/hooks/budget-balancer.mjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
|
17
|
+
import { dirname, join } from "path";
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Paths
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const ORCHESTRATOR_CONFIG = join(__dirname, "..", "orchestrator.json");
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Constants
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Rolling window for pressure calculation (milliseconds) */
|
|
31
|
+
const WINDOW_MS = 5 * 60 * 60 * 1000; // 5 hours
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rough per-tier token budgets per 5-hour window.
|
|
35
|
+
* Based on $100/month Claude Max 5x and OpenAI Pro subscription estimates.
|
|
36
|
+
* These are approximations — the real limit is monthly, distributed evenly.
|
|
37
|
+
*/
|
|
38
|
+
const WINDOW_BUDGETS = {
|
|
39
|
+
claude: {
|
|
40
|
+
think: 500_000, // Opus — costly, use sparingly
|
|
41
|
+
execute: 2_000_000, // Sonnet — primary workhorse
|
|
42
|
+
search: 5_000_000, // Haiku — cheap, generous budget
|
|
43
|
+
},
|
|
44
|
+
openai: {
|
|
45
|
+
think: 500_000, // gpt-5.5
|
|
46
|
+
execute: 2_000_000, // gpt-5.4
|
|
47
|
+
search: 5_000_000, // gpt-4.1-mini
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Estimated tokens consumed per call, by tier */
|
|
52
|
+
const TOKENS_PER_CALL = {
|
|
53
|
+
search: 2_500,
|
|
54
|
+
execute: 5_500,
|
|
55
|
+
think: 11_000,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Default pressure thresholds (fraction 0–1) */
|
|
59
|
+
const DEFAULT_THRESHOLDS = {
|
|
60
|
+
warm: 0.65,
|
|
61
|
+
hot: 0.82,
|
|
62
|
+
throttled: 0.95,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Default model mapping when orchestrator.json is missing provider config */
|
|
66
|
+
const DEFAULT_MODELS = {
|
|
67
|
+
claude: { think: "opus", execute: "sonnet", search: "haiku" },
|
|
68
|
+
openai: { think: "gpt-5.5", execute: "gpt-5.4", search: "gpt-4.1-mini" },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Config helpers
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function loadConfig() {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(readFileSync(ORCHESTRATOR_CONFIG, "utf8"));
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getThresholds(config, provider) {
|
|
84
|
+
return (
|
|
85
|
+
config?.providers?.[provider]?.pressure_thresholds || DEFAULT_THRESHOLDS
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getProviderModels(config, provider) {
|
|
90
|
+
return config?.providers?.[provider]?.models || DEFAULT_MODELS[provider];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Provider / tier detection from model name
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Given a model string, return { provider, tier } or null if unrecognised.
|
|
99
|
+
*/
|
|
100
|
+
function classifyModel(model) {
|
|
101
|
+
if (!model) return null;
|
|
102
|
+
const m = String(model).toLowerCase();
|
|
103
|
+
|
|
104
|
+
if (m.includes("opus")) return { provider: "claude", tier: "think" };
|
|
105
|
+
if (m.includes("sonnet")) return { provider: "claude", tier: "execute" };
|
|
106
|
+
if (m.includes("haiku")) return { provider: "claude", tier: "search" };
|
|
107
|
+
if (m.includes("gpt-5.5") || m.includes("gpt4.5")) return { provider: "openai", tier: "think" };
|
|
108
|
+
if (m.includes("gpt-5.4") || (m.includes("gpt-4.1") && !m.includes("mini"))) return { provider: "openai", tier: "execute" };
|
|
109
|
+
if (m.includes("mini")) return { provider: "openai", tier: "search" };
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Usage log helpers
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function usageFilePath(date) {
|
|
119
|
+
const d = date || new Date().toISOString().slice(0, 10);
|
|
120
|
+
return join(__dirname, `usage-${d}.jsonl`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read all usage entries from the last `WINDOW_MS` milliseconds.
|
|
125
|
+
* Scans today's (and optionally yesterday's) log file.
|
|
126
|
+
*/
|
|
127
|
+
function readRecentEntries() {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const cutoff = now - WINDOW_MS;
|
|
130
|
+
|
|
131
|
+
const entries = [];
|
|
132
|
+
|
|
133
|
+
// Check today's and yesterday's files to cover the rolling window boundary
|
|
134
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
135
|
+
const yesterday = new Date(now - 86_400_000).toISOString().slice(0, 10);
|
|
136
|
+
|
|
137
|
+
for (const date of [yesterday, today]) {
|
|
138
|
+
const file = usageFilePath(date);
|
|
139
|
+
if (!existsSync(file)) continue;
|
|
140
|
+
let raw;
|
|
141
|
+
try {
|
|
142
|
+
raw = readFileSync(file, "utf8");
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const line of raw.split("\n")) {
|
|
147
|
+
if (!line.trim()) continue;
|
|
148
|
+
let record;
|
|
149
|
+
try {
|
|
150
|
+
record = JSON.parse(line);
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const ts = Date.parse(record.timestamp);
|
|
155
|
+
if (!isNaN(ts) && ts >= cutoff) {
|
|
156
|
+
entries.push(record);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return entries;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Exported: getProviderStatus()
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compute rolling 5-hour pressure for each provider/tier combination.
|
|
170
|
+
*
|
|
171
|
+
* @returns {object} Status keyed by provider → tier → { pressure, state, calls, estTokens }
|
|
172
|
+
*/
|
|
173
|
+
function getProviderStatus() {
|
|
174
|
+
const config = loadConfig();
|
|
175
|
+
|
|
176
|
+
const entries = readRecentEntries();
|
|
177
|
+
|
|
178
|
+
// Accumulate call counts per provider/tier
|
|
179
|
+
const counts = {
|
|
180
|
+
claude: { think: 0, execute: 0, search: 0 },
|
|
181
|
+
openai: { think: 0, execute: 0, search: 0 },
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
// Determine provider/tier either from stored `provider` field or by classifying model
|
|
186
|
+
let provider = entry.provider;
|
|
187
|
+
let tier = entry.tier;
|
|
188
|
+
|
|
189
|
+
if (!provider && entry.model) {
|
|
190
|
+
const classified = classifyModel(entry.model);
|
|
191
|
+
if (classified) {
|
|
192
|
+
provider = classified.provider;
|
|
193
|
+
tier = classified.tier;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (provider && tier && counts[provider] && counts[provider][tier] !== undefined) {
|
|
198
|
+
counts[provider][tier]++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Build status object
|
|
203
|
+
const status = {};
|
|
204
|
+
|
|
205
|
+
for (const provider of ["claude", "openai"]) {
|
|
206
|
+
const thresholds = getThresholds(config, provider);
|
|
207
|
+
status[provider] = {};
|
|
208
|
+
|
|
209
|
+
for (const tier of ["think", "execute", "search"]) {
|
|
210
|
+
const calls = counts[provider][tier];
|
|
211
|
+
const estTokens = calls * TOKENS_PER_CALL[tier];
|
|
212
|
+
const budget = WINDOW_BUDGETS[provider][tier];
|
|
213
|
+
const pressure = budget > 0 ? estTokens / budget : 0;
|
|
214
|
+
|
|
215
|
+
let state;
|
|
216
|
+
if (pressure >= (thresholds.throttled ?? DEFAULT_THRESHOLDS.throttled)) {
|
|
217
|
+
state = "throttled";
|
|
218
|
+
} else if (pressure >= (thresholds.hot ?? DEFAULT_THRESHOLDS.hot)) {
|
|
219
|
+
state = "hot";
|
|
220
|
+
} else if (pressure >= (thresholds.warm ?? DEFAULT_THRESHOLDS.warm)) {
|
|
221
|
+
state = "warm";
|
|
222
|
+
} else {
|
|
223
|
+
state = "healthy";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
status[provider][tier] = { pressure, state, calls, estTokens };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return status;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Exported: chooseProvider(taskProfile)
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Recommend a provider for an incoming task.
|
|
239
|
+
*
|
|
240
|
+
* @param {object} taskProfile
|
|
241
|
+
* @param {string} taskProfile.tier - search | execute | think
|
|
242
|
+
* @param {number} [taskProfile.estimatedDurationMs] - expected task duration
|
|
243
|
+
* @param {string} [taskProfile.contextCoupling] - low | medium | high
|
|
244
|
+
* @param {string} [taskProfile.isolation] - low | medium | high
|
|
245
|
+
* @returns {{ provider, model, reason, scores }}
|
|
246
|
+
*/
|
|
247
|
+
function chooseProvider(taskProfile = {}) {
|
|
248
|
+
const {
|
|
249
|
+
tier = "execute",
|
|
250
|
+
estimatedDurationMs = 0,
|
|
251
|
+
contextCoupling = "low",
|
|
252
|
+
isolation = "low",
|
|
253
|
+
} = taskProfile;
|
|
254
|
+
|
|
255
|
+
const config = loadConfig();
|
|
256
|
+
const status = getProviderStatus();
|
|
257
|
+
|
|
258
|
+
const PRESSURE_PENALTY = {
|
|
259
|
+
healthy: 0,
|
|
260
|
+
warm: 15,
|
|
261
|
+
hot: 40,
|
|
262
|
+
throttled: 100,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const scores = {};
|
|
266
|
+
|
|
267
|
+
for (const provider of ["claude", "openai"]) {
|
|
268
|
+
const tierStatus = status[provider]?.[tier] || { pressure: 0, state: "healthy" };
|
|
269
|
+
const otherProvider = provider === "claude" ? "openai" : "claude";
|
|
270
|
+
const otherTierStatus = status[otherProvider]?.[tier] || { pressure: 0, state: "healthy" };
|
|
271
|
+
|
|
272
|
+
// Base score
|
|
273
|
+
let score = 50;
|
|
274
|
+
|
|
275
|
+
// Task-fit score
|
|
276
|
+
if (provider === "claude") {
|
|
277
|
+
if (contextCoupling === "high") score += 20;
|
|
278
|
+
else if (contextCoupling === "medium") score += 10;
|
|
279
|
+
} else {
|
|
280
|
+
// openai
|
|
281
|
+
if (isolation === "high") score += 20;
|
|
282
|
+
else if (isolation === "medium") score += 10;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Pressure penalty
|
|
286
|
+
score -= PRESSURE_PENALTY[tierStatus.state] ?? 0;
|
|
287
|
+
|
|
288
|
+
// Latency penalty (OpenAI only — Codex has higher startup overhead)
|
|
289
|
+
if (provider === "openai") {
|
|
290
|
+
if (estimatedDurationMs < 180_000) {
|
|
291
|
+
score -= 25; // < 3 min: overhead not worth it
|
|
292
|
+
} else if (estimatedDurationMs < 600_000) {
|
|
293
|
+
score -= 10; // < 10 min: minor penalty
|
|
294
|
+
}
|
|
295
|
+
// >= 10 min: no penalty
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Underused bonus
|
|
299
|
+
if (
|
|
300
|
+
tierStatus.pressure < 0.3 &&
|
|
301
|
+
otherTierStatus.pressure > 0.5
|
|
302
|
+
) {
|
|
303
|
+
score += 20;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
scores[provider] = Math.round(score);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const winner = scores.claude >= scores.openai ? "claude" : "openai";
|
|
310
|
+
const loser = winner === "claude" ? "openai" : "claude";
|
|
311
|
+
|
|
312
|
+
// Resolve model name
|
|
313
|
+
const models = getProviderModels(config, winner);
|
|
314
|
+
const model = models?.[tier] || DEFAULT_MODELS[winner][tier];
|
|
315
|
+
|
|
316
|
+
// Build human reason string
|
|
317
|
+
const winnerPressure = (status[winner]?.[tier]?.pressure ?? 0).toFixed(2);
|
|
318
|
+
const loserPressure = (status[loser]?.[tier]?.pressure ?? 0).toFixed(2);
|
|
319
|
+
|
|
320
|
+
let reasonParts = [];
|
|
321
|
+
if (winner === "claude" && contextCoupling !== "low") {
|
|
322
|
+
reasonParts.push(`high session context coupling`);
|
|
323
|
+
}
|
|
324
|
+
if (winner === "openai" && isolation !== "low") {
|
|
325
|
+
reasonParts.push(`isolated task`);
|
|
326
|
+
}
|
|
327
|
+
if (parseFloat(winnerPressure) < parseFloat(loserPressure)) {
|
|
328
|
+
reasonParts.push(`${winner} pressure lower (${winnerPressure} vs ${loserPressure})`);
|
|
329
|
+
}
|
|
330
|
+
if (!reasonParts.length) {
|
|
331
|
+
reasonParts.push(`${winner} scored higher (${scores[winner]} vs ${scores[loser]})`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
provider: winner,
|
|
336
|
+
model,
|
|
337
|
+
reason: reasonParts.join(", "),
|
|
338
|
+
scores,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Exported: recordUsageEvent(event)
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Append a usage event to today's daily log file.
|
|
348
|
+
* Automatically adds `provider` field if not present.
|
|
349
|
+
*
|
|
350
|
+
* @param {object} event - Usage event (see cost-logger.mjs schema)
|
|
351
|
+
*/
|
|
352
|
+
function recordUsageEvent(event = {}) {
|
|
353
|
+
// Infer provider from model name if not supplied
|
|
354
|
+
let provider = event.provider;
|
|
355
|
+
if (!provider && event.model) {
|
|
356
|
+
const classified = classifyModel(event.model);
|
|
357
|
+
provider = classified?.provider || "claude";
|
|
358
|
+
}
|
|
359
|
+
if (!provider) provider = "claude";
|
|
360
|
+
|
|
361
|
+
const entry = JSON.stringify({
|
|
362
|
+
schema_version: 2,
|
|
363
|
+
timestamp: event.timestamp || new Date().toISOString(),
|
|
364
|
+
provider,
|
|
365
|
+
...event,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const file = usageFilePath();
|
|
369
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
appendFileSync(file, entry + "\n", { encoding: "utf8", flag: "a" });
|
|
373
|
+
} catch (err) {
|
|
374
|
+
// Non-fatal — log to stderr but don't crash callers
|
|
375
|
+
process.stderr.write(`[budget-balancer] Failed to write usage event: ${err.message}\n`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// CLI rendering helpers
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
function pressureBar(pressure, width = 10) {
|
|
384
|
+
const filled = Math.min(width, Math.round(pressure * width));
|
|
385
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function stateLabel(state) {
|
|
389
|
+
return state.padEnd(8);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function formatPercent(pressure) {
|
|
393
|
+
return String(Math.round(pressure * 100)).padStart(3) + "%";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function printStatusTable(status) {
|
|
397
|
+
const LINE_WIDTH = 50;
|
|
398
|
+
const border = "═".repeat(LINE_WIDTH - 2);
|
|
399
|
+
const blank = " ".repeat(LINE_WIDTH - 4);
|
|
400
|
+
|
|
401
|
+
const h = (text) => {
|
|
402
|
+
const padded = ` ${text}`.padEnd(LINE_WIDTH - 4);
|
|
403
|
+
return `║ ${padded} ║`;
|
|
404
|
+
};
|
|
405
|
+
const row = (label, tier) => {
|
|
406
|
+
const s = status[label]?.[tier] || { pressure: 0, state: "healthy" };
|
|
407
|
+
const bar = pressureBar(s.pressure);
|
|
408
|
+
const pct = formatPercent(s.pressure);
|
|
409
|
+
const lbl = stateLabel(s.state);
|
|
410
|
+
const line = ` ${tier.charAt(0).toUpperCase() + tier.slice(1).padEnd(7)}: ${bar} ${pct} ${lbl}`;
|
|
411
|
+
return h(line);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const config = loadConfig();
|
|
415
|
+
const claudePlan = config?.subscriptions?.claude?.plan ? `Claude Max ${config.subscriptions.claude.plan}` : "Claude Max $100";
|
|
416
|
+
const openaiPlan = config?.subscriptions?.openai?.plan ? `OpenAI Pro ${config.subscriptions.openai.plan}` : "OpenAI Pro $100";
|
|
417
|
+
|
|
418
|
+
// Recommendation
|
|
419
|
+
const rec = chooseProvider({ tier: "execute", estimatedDurationMs: 300_000, isolation: "high", contextCoupling: "low" });
|
|
420
|
+
const recText = `Route execution to ${rec.provider === "openai" ? "OpenAI" : "Claude"}`;
|
|
421
|
+
|
|
422
|
+
const lines = [
|
|
423
|
+
`╔${border}╗`,
|
|
424
|
+
h(" Provider Balance Status "),
|
|
425
|
+
`╠${border}╣`,
|
|
426
|
+
h(claudePlan),
|
|
427
|
+
row("claude", "think"),
|
|
428
|
+
row("claude", "execute"),
|
|
429
|
+
row("claude", "search"),
|
|
430
|
+
h(blank),
|
|
431
|
+
h(openaiPlan),
|
|
432
|
+
row("openai", "think"),
|
|
433
|
+
row("openai", "execute"),
|
|
434
|
+
row("openai", "search"),
|
|
435
|
+
`╠${border}╣`,
|
|
436
|
+
h(`Recommendation: ${recText}`),
|
|
437
|
+
`╚${border}╝`,
|
|
438
|
+
];
|
|
439
|
+
|
|
440
|
+
console.log(lines.join("\n"));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// CLI entry point
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
async function main() {
|
|
448
|
+
const status = getProviderStatus();
|
|
449
|
+
printStatusTable(status);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Run as CLI only when invoked directly
|
|
453
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
454
|
+
main().catch((err) => {
|
|
455
|
+
process.stderr.write(`[budget-balancer] ${err.message}\n`);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Exports
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
export { getProviderStatus, chooseProvider, recordUsageEvent };
|