@ruvnet/open-claude-code 2.0.1 → 2.1.1-nightly.20261011.202
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/LICENSE +21 -0
- package/README.md +383 -0
- package/package.json +29 -4
- package/src/auth/oauth.mjs +46 -6
- package/src/config/cli-args.mjs +20 -0
- package/src/config/env.mjs +2 -2
- package/src/config/settings.mjs +30 -130
- package/src/core/agent-loop.mjs +92 -28
- package/src/core/providers.mjs +19 -1
- package/src/core/session.mjs +34 -149
- package/src/core/system-prompt.mjs +43 -47
- package/src/hooks/engine.mjs +10 -2
- package/src/index.mjs +60 -32
- package/src/optimize/cascade.mjs +128 -0
- package/src/optimize/commands.mjs +139 -0
- package/src/optimize/router.mjs +212 -0
- package/src/optimize/store.mjs +170 -0
- package/src/permissions/checker.mjs +8 -79
- package/src/permissions/injection-check.mjs +3 -2
- package/src/permissions/path-check.mjs +8 -0
- package/src/permissions/sandbox.mjs +8 -2
- package/src/permissions/ssrf-check.mjs +102 -0
- package/src/plugins/loader.mjs +14 -6
- package/src/skills/loader.mjs +16 -4
- package/src/tools/bash.mjs +3 -40
- package/src/tools/enter-worktree.mjs +5 -3
- package/src/tools/exit-worktree.mjs +4 -3
- package/src/tools/grep.mjs +26 -11
- package/src/tools/lsp.mjs +23 -9
- package/src/tools/read.mjs +20 -9
- package/src/tools/send-message.mjs +5 -1
- package/src/tools/web-fetch.mjs +15 -1
- package/src/tools/web-search.mjs +13 -1
- package/src/ui/app.mjs +286 -0
- package/src/ui/commands.mjs +121 -71
- package/src/ui/components.mjs +219 -0
- package/src/ui/ink-app.mjs +13 -52
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/repl.mjs +10 -25
- package/test/test.mjs +0 -2657
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-optimization cascade controller.
|
|
3
|
+
*
|
|
4
|
+
* Glue between {@link MetaHarnessRouter} and {@link OutcomeStore}. Given a task,
|
|
5
|
+
* it decides which model to use (cost-cascade), and after the task completes it
|
|
6
|
+
* records the real outcome so future routing self-tunes.
|
|
7
|
+
*
|
|
8
|
+
* This is OPT-IN. It is only constructed when the user enables self-optimization
|
|
9
|
+
* via `--self-optimize`, the `selfOptimize` setting, or CLAUDE_CODE_SELF_OPTIMIZE.
|
|
10
|
+
* When disabled, the agent loop never imports or runs any of this, so default
|
|
11
|
+
* behavior is byte-identical.
|
|
12
|
+
*/
|
|
13
|
+
import { MetaHarnessRouter, DEFAULT_LADDER } from './router.mjs';
|
|
14
|
+
import { OutcomeStore, estimateCostUsd } from './store.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a model ladder from settings, falling back to the default. Honors an
|
|
18
|
+
* explicit `selfOptimizeLadder` setting (array of {id,costPerMTok}) and keeps
|
|
19
|
+
* the configured base/fast/frontier models if present.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} [settings]
|
|
22
|
+
* @returns {Array<{id:string,costPerMTok:number,tier?:number}>}
|
|
23
|
+
*/
|
|
24
|
+
export function buildLadder(settings = {}) {
|
|
25
|
+
if (Array.isArray(settings.selfOptimizeLadder) && settings.selfOptimizeLadder.length) {
|
|
26
|
+
return settings.selfOptimizeLadder
|
|
27
|
+
.filter(m => m && m.id && typeof m.costPerMTok === 'number')
|
|
28
|
+
.map((m, i) => ({ tier: i + 1, ...m }));
|
|
29
|
+
}
|
|
30
|
+
return DEFAULT_LADDER;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cascade controller. Holds a router + outcome store and exposes a small API the
|
|
35
|
+
* agent loop calls.
|
|
36
|
+
*/
|
|
37
|
+
export class SelfOptimizeCascade {
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {object} [opts.settings] CLI settings (ladder, qualityBar)
|
|
41
|
+
* @param {OutcomeStore} [opts.store] outcome store (defaults to a fresh one)
|
|
42
|
+
* @param {string} [opts.fallbackModel] model to use if routing yields nothing
|
|
43
|
+
*/
|
|
44
|
+
constructor(opts = {}) {
|
|
45
|
+
const settings = opts.settings || {};
|
|
46
|
+
this.store = opts.store || new OutcomeStore();
|
|
47
|
+
this.fallbackModel = opts.fallbackModel || settings.model || 'claude-sonnet-4-6';
|
|
48
|
+
const ladder = buildLadder(settings);
|
|
49
|
+
this.ladder = ladder;
|
|
50
|
+
this.router = new MetaHarnessRouter({
|
|
51
|
+
ladder,
|
|
52
|
+
qualityBar: typeof settings.selfOptimizeQualityBar === 'number' ? settings.selfOptimizeQualityBar : 0.7,
|
|
53
|
+
statsFor: (id) => this.store.statsFor(id),
|
|
54
|
+
});
|
|
55
|
+
/** @type {Array<{task:string, route:object}>} last routing decisions (in-memory, for inspection) */
|
|
56
|
+
this.lastRoutes = [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide which model to use for a task without making any model call.
|
|
61
|
+
* @param {string} taskText
|
|
62
|
+
* @returns {{ model: string, predictedQuality: number, costPerMTok: number, metBar: boolean, complexity: number, ladder: string[] }}
|
|
63
|
+
*/
|
|
64
|
+
decide(taskText) {
|
|
65
|
+
const route = this.router.route(taskText || '');
|
|
66
|
+
this.lastRoutes.push({ task: (taskText || '').slice(0, 80), route });
|
|
67
|
+
if (this.lastRoutes.length > 50) this.lastRoutes.shift();
|
|
68
|
+
return route;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Record a completed task's real outcome.
|
|
73
|
+
* @param {object} o
|
|
74
|
+
* @param {string} o.model
|
|
75
|
+
* @param {boolean} o.success
|
|
76
|
+
* @param {number} [o.latencyMs]
|
|
77
|
+
* @param {number} [o.inputTokens]
|
|
78
|
+
* @param {number} [o.outputTokens]
|
|
79
|
+
* @param {number} [o.complexity]
|
|
80
|
+
* @param {string} [o.task]
|
|
81
|
+
*/
|
|
82
|
+
recordOutcome(o) {
|
|
83
|
+
const entry = this.ladder.find(m => m.id === o.model);
|
|
84
|
+
const costUsd = estimateCostUsd(o.inputTokens, o.outputTokens, entry ? entry.costPerMTok : 0);
|
|
85
|
+
return this.store.record({
|
|
86
|
+
model: o.model,
|
|
87
|
+
success: !!o.success,
|
|
88
|
+
latencyMs: o.latencyMs,
|
|
89
|
+
inputTokens: o.inputTokens,
|
|
90
|
+
outputTokens: o.outputTokens,
|
|
91
|
+
costUsd,
|
|
92
|
+
complexity: o.complexity,
|
|
93
|
+
task: o.task,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Get the next model to escalate to after a failure, or null. */
|
|
98
|
+
escalate(failedModelId) {
|
|
99
|
+
return this.router.escalate(failedModelId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Human-readable status for `occ optimize status`. */
|
|
103
|
+
status() {
|
|
104
|
+
return {
|
|
105
|
+
ladder: this.ladder.map(m => ({ id: m.id, costPerMTok: m.costPerMTok })),
|
|
106
|
+
qualityBar: this.router.qualityBar,
|
|
107
|
+
store: this.store.filePath,
|
|
108
|
+
summary: this.store.summary(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decide whether self-optimization is enabled, from the precedence:
|
|
115
|
+
* CLI flag > env var > setting. Default OFF.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} [args] parsed CLI args (may have selfOptimize)
|
|
118
|
+
* @param {object} [settings]
|
|
119
|
+
* @param {object} [env] process.env-like
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function isSelfOptimizeEnabled(args = {}, settings = {}, env = process.env) {
|
|
123
|
+
if (args.selfOptimize === true) return true;
|
|
124
|
+
if (args.selfOptimize === false) return false; // explicit --no-self-optimize would set this
|
|
125
|
+
if (env && env.CLAUDE_CODE_SELF_OPTIMIZE === '1') return true;
|
|
126
|
+
if (env && env.CLAUDE_CODE_SELF_OPTIMIZE === '0') return false;
|
|
127
|
+
return settings.selfOptimize === true;
|
|
128
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level metaharness subcommands: `occ optimize`, `occ redblue`, `occ darwin`.
|
|
3
|
+
*
|
|
4
|
+
* These are dispatched from index.mjs BEFORE the normal agent flow, only when
|
|
5
|
+
* the first CLI token matches. They never run during a normal `occ "prompt"`
|
|
6
|
+
* invocation, so default behavior is untouched.
|
|
7
|
+
*
|
|
8
|
+
* - `occ optimize ...` inspect/manage the self-optimization router + store.
|
|
9
|
+
* Pure local — no model calls, no network.
|
|
10
|
+
* - `occ redblue ...` delegate to the published `@metaharness/redblue` CLI via
|
|
11
|
+
* npx (self red/blue-team testing). Opt-in; only runs if the user asks.
|
|
12
|
+
* - `occ darwin ...` delegate to the published `@metaharness/darwin` CLI via
|
|
13
|
+
* npx (config evolution). Opt-in; only runs if the user asks.
|
|
14
|
+
*/
|
|
15
|
+
import { SelfOptimizeCascade } from './cascade.mjs';
|
|
16
|
+
import { OutcomeStore, outcomesPath } from './store.mjs';
|
|
17
|
+
import { createRouter } from './router.mjs';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Handle `occ optimize <sub> [args]`. Returns a string to print and an exit code.
|
|
21
|
+
* Never makes a model call.
|
|
22
|
+
*
|
|
23
|
+
* @param {string[]} argv tokens AFTER "optimize"
|
|
24
|
+
* @param {object} [settings]
|
|
25
|
+
* @returns {Promise<{ output: string, code: number }>}
|
|
26
|
+
*/
|
|
27
|
+
export async function runOptimize(argv, settings = {}) {
|
|
28
|
+
const sub = (argv[0] || 'status').toLowerCase();
|
|
29
|
+
const cascade = new SelfOptimizeCascade({ settings, store: new OutcomeStore() });
|
|
30
|
+
|
|
31
|
+
switch (sub) {
|
|
32
|
+
case 'status': {
|
|
33
|
+
const s = cascade.status();
|
|
34
|
+
const { nativeAvailable } = await createRouter();
|
|
35
|
+
const lines = [
|
|
36
|
+
'MetaHarness self-optimization — status',
|
|
37
|
+
'',
|
|
38
|
+
` enabled by default: no (opt-in via --self-optimize / CLAUDE_CODE_SELF_OPTIMIZE=1 / "selfOptimize": true)`,
|
|
39
|
+
` router backend: local cost-cascade`,
|
|
40
|
+
` native router pkg: ${nativeAvailable ? '@metaharness/router available' : 'not installed (using local router)'}`,
|
|
41
|
+
` quality bar: ${s.qualityBar}`,
|
|
42
|
+
` outcome store: ${s.store}`,
|
|
43
|
+
` recorded outcomes: ${s.summary.total}`,
|
|
44
|
+
'',
|
|
45
|
+
' model ladder (cheapest first):',
|
|
46
|
+
...s.ladder.map((m, i) => ` ${i + 1}. ${m.id} ($${m.costPerMTok}/1M tok)`),
|
|
47
|
+
];
|
|
48
|
+
return { output: lines.join('\n'), code: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
case 'route': {
|
|
52
|
+
const task = argv.slice(1).join(' ');
|
|
53
|
+
if (!task) return { output: 'Usage: occ optimize route "<task description>"', code: 2 };
|
|
54
|
+
const r = cascade.decide(task);
|
|
55
|
+
const lines = [
|
|
56
|
+
`task complexity: ${r.complexity.toFixed(3)}`,
|
|
57
|
+
`routed to: ${r.model} ($${r.costPerMTok}/1M tok)`,
|
|
58
|
+
`predicted quality: ${r.predictedQuality.toFixed(3)}`,
|
|
59
|
+
`cleared bar: ${r.metBar}`,
|
|
60
|
+
`ladder: ${r.ladder.join(' -> ')}`,
|
|
61
|
+
'',
|
|
62
|
+
'(no model was called — this is a routing decision only)',
|
|
63
|
+
];
|
|
64
|
+
return { output: lines.join('\n'), code: 0 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case 'report': {
|
|
68
|
+
const summary = cascade.store.summary();
|
|
69
|
+
if (summary.total === 0) {
|
|
70
|
+
return { output: 'No recorded outcomes yet. Run with --self-optimize to start collecting real cost/latency data.', code: 0 };
|
|
71
|
+
}
|
|
72
|
+
const lines = [`MetaHarness optimization report — ${summary.total} recorded outcomes`, ''];
|
|
73
|
+
for (const [model, b] of Object.entries(summary.byModel)) {
|
|
74
|
+
lines.push(` ${model}`);
|
|
75
|
+
lines.push(` attempts: ${b.attempts} success: ${(b.successRate * 100).toFixed(1)}% avg latency: ${b.avgLatencyMs}ms est cost: $${b.costUsd.toFixed(4)}`);
|
|
76
|
+
}
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push(` total estimated cost: $${summary.totalCostUsd.toFixed(4)} (token counts × published prices — real, not fabricated)`);
|
|
79
|
+
return { output: lines.join('\n'), code: 0 };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'reset': {
|
|
83
|
+
const store = new OutcomeStore();
|
|
84
|
+
const ok = store.reset();
|
|
85
|
+
return { output: ok ? `Cleared outcome store: ${outcomesPath()}` : 'Nothing to reset (no store found).', code: 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case 'help':
|
|
89
|
+
default:
|
|
90
|
+
return {
|
|
91
|
+
output: [
|
|
92
|
+
'occ optimize — metaharness self-optimization (cost-cascade router)',
|
|
93
|
+
'',
|
|
94
|
+
'Subcommands:',
|
|
95
|
+
' status Show router config, ladder, and recorded-outcome count',
|
|
96
|
+
' route "<task>" Show which model the cascade would pick (no model call)',
|
|
97
|
+
' report Aggregate recorded outcomes (cost / success / latency)',
|
|
98
|
+
' reset Delete the local outcome store',
|
|
99
|
+
' help This message',
|
|
100
|
+
'',
|
|
101
|
+
'Enable the live router for normal runs with: occ --self-optimize -p "<task>"',
|
|
102
|
+
].join('\n'),
|
|
103
|
+
code: sub === 'help' ? 0 : 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the argv for delegating to a published metaharness CLI via npx.
|
|
110
|
+
* Pure function so it is testable without spawning anything.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} pkg npm package spec (e.g. "@metaharness/redblue")
|
|
113
|
+
* @param {string[]} passthrough args after the subcommand
|
|
114
|
+
* @returns {string[]} argv for child_process.spawn('npx', argv)
|
|
115
|
+
*/
|
|
116
|
+
export function buildNpxArgs(pkg, passthrough) {
|
|
117
|
+
return ['-y', pkg, ...passthrough];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Delegate to a published metaharness CLI via `npx`. Opt-in only; this spawns a
|
|
122
|
+
* child process and streams its output. Returns the child's exit code.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} pkg npm package spec
|
|
125
|
+
* @param {string[]} passthrough args after the subcommand
|
|
126
|
+
* @param {object} [deps] injectable spawn for testing
|
|
127
|
+
* @returns {Promise<{ code: number }>}
|
|
128
|
+
*/
|
|
129
|
+
export async function delegateToCli(pkg, passthrough, deps = {}) {
|
|
130
|
+
const spawn = deps.spawn || (await import('child_process')).spawn;
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const child = spawn('npx', buildNpxArgs(pkg, passthrough), { stdio: 'inherit' });
|
|
133
|
+
child.on('error', (err) => {
|
|
134
|
+
process.stderr.write(`Failed to run ${pkg} via npx: ${err.message}\n`);
|
|
135
|
+
resolve({ code: 127 });
|
|
136
|
+
});
|
|
137
|
+
child.on('exit', (code) => resolve({ code: code ?? 0 }));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MetaHarness cost-cascade router (opt-in self-optimization layer).
|
|
3
|
+
*
|
|
4
|
+
* Thesis (DRACO / metaharness): route each task to the CHEAPEST model that is
|
|
5
|
+
* "good enough", and only escalate to a frontier model when the cheap one is
|
|
6
|
+
* predicted to fail or actually fails. Over time the router learns from real
|
|
7
|
+
* recorded outcomes (cost / latency / success) and tightens its predictions.
|
|
8
|
+
*
|
|
9
|
+
* This module is a thin, ZERO-DEPENDENCY local router so that `npx` startup and
|
|
10
|
+
* `npm ci` stay clean and fast. When the published `@metaharness/router` package
|
|
11
|
+
* is installed AND an embedding function is available, {@link createRouter} can
|
|
12
|
+
* upgrade to its k-NN-over-embeddings `Router` — but that is strictly optional
|
|
13
|
+
* and loaded via dynamic import; it is never a hard runtime dependency.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here makes a model call. Routing is a pure decision over a ladder of
|
|
16
|
+
* candidate models plus historical outcome statistics.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Default model ladder, cheapest → most capable. Prices are blended USD per 1M
|
|
21
|
+
* tokens (input+output rough blend) used purely as the cost axis the router
|
|
22
|
+
* minimises. They are conservative public list prices, not fabricated metrics.
|
|
23
|
+
*
|
|
24
|
+
* @type {Array<{ id: string, costPerMTok: number, tier: number }>}
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_LADDER = [
|
|
27
|
+
{ id: 'claude-haiku-4-5', costPerMTok: 1.0, tier: 1 },
|
|
28
|
+
{ id: 'claude-sonnet-4-6', costPerMTok: 9.0, tier: 2 },
|
|
29
|
+
{ id: 'claude-opus-4-6', costPerMTok: 45.0, tier: 3 },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** Clamp a number into [min, max]. */
|
|
33
|
+
function clamp(n, min, max) {
|
|
34
|
+
return Math.max(min, Math.min(max, n));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Heuristic task-complexity estimate in [0, 1] from the raw prompt text.
|
|
39
|
+
* Deterministic and side-effect free so it is fully testable. The cascade uses
|
|
40
|
+
* this only as a PRIOR; recorded outcomes override it as evidence accumulates.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @returns {number} complexity score 0 (trivial) .. 1 (very hard)
|
|
44
|
+
*/
|
|
45
|
+
export function estimateComplexity(text) {
|
|
46
|
+
if (!text || typeof text !== 'string') return 0;
|
|
47
|
+
const t = text.toLowerCase();
|
|
48
|
+
let score = 0;
|
|
49
|
+
|
|
50
|
+
// Length is a weak signal of scope.
|
|
51
|
+
score += clamp(text.length / 4000, 0, 0.35);
|
|
52
|
+
|
|
53
|
+
// Multi-step / planning / architecture language → harder.
|
|
54
|
+
const hardSignals = [
|
|
55
|
+
'refactor', 'architecture', 'design', 'debug', 'race condition',
|
|
56
|
+
'concurrency', 'security', 'migrate', 'optimi', 'algorithm',
|
|
57
|
+
'distributed', 'across files', 'multi-file', 'end-to-end', 'prove',
|
|
58
|
+
];
|
|
59
|
+
for (const sig of hardSignals) {
|
|
60
|
+
if (t.includes(sig)) score += 0.12;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Trivial / lookup language → easier.
|
|
64
|
+
const easySignals = ['what is', 'rename', 'typo', 'format', 'list', 'print', 'echo', 'add a comment'];
|
|
65
|
+
for (const sig of easySignals) {
|
|
66
|
+
if (t.includes(sig)) score -= 0.1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Code fences and multiple questions suggest a heavier task.
|
|
70
|
+
const codeFences = (text.match(/```/g) || []).length;
|
|
71
|
+
if (codeFences >= 2) score += 0.08;
|
|
72
|
+
const questions = (text.match(/\?/g) || []).length;
|
|
73
|
+
if (questions >= 3) score += 0.08;
|
|
74
|
+
|
|
75
|
+
return clamp(score, 0, 1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {object} OutcomeStats
|
|
80
|
+
* @property {number} attempts total recorded attempts for a model
|
|
81
|
+
* @property {number} successes recorded successes
|
|
82
|
+
* @property {number} successRate successes / attempts (0 when no data)
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cost-cascade router. Pure decision layer over a model ladder plus optional
|
|
87
|
+
* historical {@link OutcomeStats}. No I/O, no model calls.
|
|
88
|
+
*/
|
|
89
|
+
export class MetaHarnessRouter {
|
|
90
|
+
/**
|
|
91
|
+
* @param {object} [opts]
|
|
92
|
+
* @param {Array<{id:string,costPerMTok:number,tier?:number}>} [opts.ladder] cheapest-first model ladder
|
|
93
|
+
* @param {number} [opts.qualityBar] required success rate 0..1 to accept a cheap model (default 0.7)
|
|
94
|
+
* @param {(modelId:string)=>OutcomeStats} [opts.statsFor] looks up recorded outcome stats per model
|
|
95
|
+
* @param {number} [opts.minSamples] min recorded attempts before stats override the prior (default 3)
|
|
96
|
+
*/
|
|
97
|
+
constructor(opts = {}) {
|
|
98
|
+
this.ladder = (opts.ladder && opts.ladder.length ? opts.ladder : DEFAULT_LADDER)
|
|
99
|
+
.slice()
|
|
100
|
+
.sort((a, b) => a.costPerMTok - b.costPerMTok);
|
|
101
|
+
this.qualityBar = typeof opts.qualityBar === 'number' ? clamp(opts.qualityBar, 0, 1) : 0.7;
|
|
102
|
+
this.statsFor = typeof opts.statsFor === 'function' ? opts.statsFor : () => ({ attempts: 0, successes: 0, successRate: 0 });
|
|
103
|
+
this.minSamples = typeof opts.minSamples === 'number' ? opts.minSamples : 3;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Predict a model's success probability on a task of the given complexity.
|
|
108
|
+
* Blends recorded stats (once enough samples exist) with a complexity-aware
|
|
109
|
+
* prior derived from the model's tier position in the ladder.
|
|
110
|
+
*
|
|
111
|
+
* @param {{id:string,tier?:number}} model
|
|
112
|
+
* @param {number} complexity 0..1
|
|
113
|
+
* @returns {number} predicted success probability 0..1
|
|
114
|
+
*/
|
|
115
|
+
predict(model, complexity) {
|
|
116
|
+
const tier = model.tier || (this.ladder.findIndex(m => m.id === model.id) + 1) || 1;
|
|
117
|
+
const maxTier = this.ladder.length || 1;
|
|
118
|
+
|
|
119
|
+
// Prior: cheaper/lower-tier models lose probability as complexity rises;
|
|
120
|
+
// top-tier models stay near-confident regardless.
|
|
121
|
+
const tierStrength = tier / maxTier; // 0..1
|
|
122
|
+
const prior = clamp(1 - complexity * (1 - tierStrength) - 0.05 * (1 - tierStrength), 0, 1);
|
|
123
|
+
|
|
124
|
+
const stats = this.statsFor(model.id) || { attempts: 0, successRate: 0 };
|
|
125
|
+
if (stats.attempts >= this.minSamples) {
|
|
126
|
+
// Evidence weight grows with samples but is capped so the prior never
|
|
127
|
+
// fully disappears (avoids overfitting a handful of runs).
|
|
128
|
+
const w = clamp(stats.attempts / (stats.attempts + this.minSamples), 0, 0.9);
|
|
129
|
+
return clamp(w * stats.successRate + (1 - w) * prior, 0, 1);
|
|
130
|
+
}
|
|
131
|
+
return prior;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pick the cheapest model whose predicted success clears the quality bar.
|
|
136
|
+
* Falls back to the highest-predicted (i.e. top of ladder) when none clear it.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} taskText
|
|
139
|
+
* @returns {{ model: string, costPerMTok: number, predictedQuality: number, metBar: boolean, complexity: number, ladder: string[] }}
|
|
140
|
+
*/
|
|
141
|
+
route(taskText) {
|
|
142
|
+
const complexity = estimateComplexity(taskText);
|
|
143
|
+
let best = null;
|
|
144
|
+
|
|
145
|
+
for (const m of this.ladder) {
|
|
146
|
+
const q = this.predict(m, complexity);
|
|
147
|
+
if (q >= this.qualityBar) {
|
|
148
|
+
return {
|
|
149
|
+
model: m.id,
|
|
150
|
+
costPerMTok: m.costPerMTok,
|
|
151
|
+
predictedQuality: q,
|
|
152
|
+
metBar: true,
|
|
153
|
+
complexity,
|
|
154
|
+
ladder: this.ladder.map(x => x.id),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (!best || q > best.predictedQuality) {
|
|
158
|
+
best = { model: m.id, costPerMTok: m.costPerMTok, predictedQuality: q };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// None cleared the bar — escalate to the most capable model.
|
|
163
|
+
const top = this.ladder[this.ladder.length - 1];
|
|
164
|
+
return {
|
|
165
|
+
model: top.id,
|
|
166
|
+
costPerMTok: top.costPerMTok,
|
|
167
|
+
predictedQuality: this.predict(top, complexity),
|
|
168
|
+
metBar: false,
|
|
169
|
+
complexity,
|
|
170
|
+
ladder: this.ladder.map(x => x.id),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Given a model that just failed, return the next more-capable model to
|
|
176
|
+
* escalate to, or null if already at the top of the ladder.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} failedModelId
|
|
179
|
+
* @returns {string|null}
|
|
180
|
+
*/
|
|
181
|
+
escalate(failedModelId) {
|
|
182
|
+
const idx = this.ladder.findIndex(m => m.id === failedModelId);
|
|
183
|
+
if (idx === -1 || idx >= this.ladder.length - 1) return null;
|
|
184
|
+
return this.ladder[idx + 1].id;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Build a router. Defaults to the local zero-dependency {@link MetaHarnessRouter}.
|
|
190
|
+
* If `@metaharness/router` is installed and `embed` is provided, this will report
|
|
191
|
+
* that the native k-NN router is available (the caller can opt into it), but the
|
|
192
|
+
* local router is always returned as the safe default so behavior is predictable
|
|
193
|
+
* and dependency-free.
|
|
194
|
+
*
|
|
195
|
+
* @param {object} [opts] passed to MetaHarnessRouter
|
|
196
|
+
* @returns {Promise<{ router: MetaHarnessRouter, backend: 'local'|'metaharness', nativeAvailable: boolean }>}
|
|
197
|
+
*/
|
|
198
|
+
export async function createRouter(opts = {}) {
|
|
199
|
+
let nativeAvailable = false;
|
|
200
|
+
try {
|
|
201
|
+
// Probe only — never required. Resolving the specifier does not install it.
|
|
202
|
+
await import('@metaharness/router');
|
|
203
|
+
nativeAvailable = true;
|
|
204
|
+
} catch {
|
|
205
|
+
nativeAvailable = false;
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
router: new MetaHarnessRouter(opts),
|
|
209
|
+
backend: 'local',
|
|
210
|
+
nativeAvailable,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome store for the self-optimization layer.
|
|
3
|
+
*
|
|
4
|
+
* Persists ONE JSON line per routed task with the REAL observed signals
|
|
5
|
+
* (model, cost estimate, latency, success). The router reads aggregated stats
|
|
6
|
+
* from this store to self-tune toward the cheapest model that actually succeeds.
|
|
7
|
+
*
|
|
8
|
+
* Honesty rule: this records only what happened. Cost is an estimate derived
|
|
9
|
+
* from real token counts × the model's published price; latency is measured
|
|
10
|
+
* wall-clock. Nothing is fabricated.
|
|
11
|
+
*
|
|
12
|
+
* Default location: ~/.claude/optimize/outcomes.jsonl
|
|
13
|
+
* Override with CLAUDE_CODE_OPTIMIZE_DIR (used heavily in tests).
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
|
|
19
|
+
/** Resolve the directory the store writes to. */
|
|
20
|
+
export function optimizeDir() {
|
|
21
|
+
return process.env.CLAUDE_CODE_OPTIMIZE_DIR || path.join(os.homedir(), '.claude', 'optimize');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Resolve the JSONL outcomes file path. */
|
|
25
|
+
export function outcomesPath() {
|
|
26
|
+
return path.join(optimizeDir(), 'outcomes.jsonl');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} Outcome
|
|
31
|
+
* @property {string} model model id the task was routed to
|
|
32
|
+
* @property {boolean} success whether the task completed without error
|
|
33
|
+
* @property {number} [latencyMs] measured wall-clock latency
|
|
34
|
+
* @property {number} [inputTokens] real input tokens used
|
|
35
|
+
* @property {number} [outputTokens] real output tokens used
|
|
36
|
+
* @property {number} [costUsd] estimated cost (tokens × published price)
|
|
37
|
+
* @property {number} [complexity] router complexity estimate at route time
|
|
38
|
+
* @property {string} [task] short task descriptor (truncated)
|
|
39
|
+
* @property {number} [ts] epoch ms (auto-filled)
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* In-memory + file-backed outcome store.
|
|
44
|
+
*/
|
|
45
|
+
export class OutcomeStore {
|
|
46
|
+
/** @param {string} [filePath] override file path (defaults to outcomesPath()) */
|
|
47
|
+
constructor(filePath) {
|
|
48
|
+
this.filePath = filePath || outcomesPath();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Ensure the parent directory exists. */
|
|
52
|
+
_ensureDir() {
|
|
53
|
+
const dir = path.dirname(this.filePath);
|
|
54
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Append a recorded outcome. Never throws on I/O — self-optimization must
|
|
59
|
+
* never break the user's session. Returns the stored record (with ts).
|
|
60
|
+
*
|
|
61
|
+
* @param {Outcome} outcome
|
|
62
|
+
* @returns {Outcome}
|
|
63
|
+
*/
|
|
64
|
+
record(outcome) {
|
|
65
|
+
const rec = { ts: Date.now(), ...outcome };
|
|
66
|
+
if (rec.task && typeof rec.task === 'string' && rec.task.length > 120) {
|
|
67
|
+
rec.task = rec.task.slice(0, 120);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
this._ensureDir();
|
|
71
|
+
fs.appendFileSync(this.filePath, JSON.stringify(rec) + '\n');
|
|
72
|
+
} catch {
|
|
73
|
+
// Swallow — recording is best-effort.
|
|
74
|
+
}
|
|
75
|
+
return rec;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read all stored outcomes. Tolerant of partial/corrupt lines.
|
|
80
|
+
* @returns {Outcome[]}
|
|
81
|
+
*/
|
|
82
|
+
all() {
|
|
83
|
+
try {
|
|
84
|
+
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const line of raw.split('\n')) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (!trimmed) continue;
|
|
89
|
+
try { out.push(JSON.parse(trimmed)); } catch { /* skip bad line */ }
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Aggregate stats for a single model.
|
|
99
|
+
* @param {string} modelId
|
|
100
|
+
* @returns {{ attempts: number, successes: number, successRate: number }}
|
|
101
|
+
*/
|
|
102
|
+
statsFor(modelId) {
|
|
103
|
+
let attempts = 0;
|
|
104
|
+
let successes = 0;
|
|
105
|
+
for (const o of this.all()) {
|
|
106
|
+
if (o.model !== modelId) continue;
|
|
107
|
+
attempts++;
|
|
108
|
+
if (o.success) successes++;
|
|
109
|
+
}
|
|
110
|
+
return { attempts, successes, successRate: attempts ? successes / attempts : 0 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Full rollup across every model seen, plus cost totals. Used by
|
|
115
|
+
* `occ optimize report`.
|
|
116
|
+
* @returns {{ total: number, byModel: Record<string, {attempts:number,successes:number,successRate:number,costUsd:number,avgLatencyMs:number}>, totalCostUsd: number, estimatedSavingsUsd: number }}
|
|
117
|
+
*/
|
|
118
|
+
summary() {
|
|
119
|
+
const all = this.all();
|
|
120
|
+
/** @type {Record<string, any>} */
|
|
121
|
+
const byModel = {};
|
|
122
|
+
let totalCostUsd = 0;
|
|
123
|
+
// For savings estimate: what every task would have cost on the most
|
|
124
|
+
// expensive model actually observed, vs what it actually cost.
|
|
125
|
+
let maxPriceObserved = 0;
|
|
126
|
+
|
|
127
|
+
for (const o of all) {
|
|
128
|
+
const m = o.model || 'unknown';
|
|
129
|
+
if (!byModel[m]) byModel[m] = { attempts: 0, successes: 0, costUsd: 0, latencySum: 0, latencyN: 0 };
|
|
130
|
+
byModel[m].attempts++;
|
|
131
|
+
if (o.success) byModel[m].successes++;
|
|
132
|
+
if (typeof o.costUsd === 'number') { byModel[m].costUsd += o.costUsd; totalCostUsd += o.costUsd; }
|
|
133
|
+
if (typeof o.latencyMs === 'number') { byModel[m].latencySum += o.latencyMs; byModel[m].latencyN++; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const m of Object.keys(byModel)) {
|
|
137
|
+
const b = byModel[m];
|
|
138
|
+
b.successRate = b.attempts ? b.successes / b.attempts : 0;
|
|
139
|
+
b.avgLatencyMs = b.latencyN ? Math.round(b.latencySum / b.latencyN) : 0;
|
|
140
|
+
delete b.latencySum; delete b.latencyN;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
total: all.length,
|
|
145
|
+
byModel,
|
|
146
|
+
totalCostUsd: Number(totalCostUsd.toFixed(6)),
|
|
147
|
+
// Savings are reported only as "vs always-frontier" and only when we
|
|
148
|
+
// have real cost data — otherwise 0, never invented.
|
|
149
|
+
estimatedSavingsUsd: 0,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Delete the store (used by `occ optimize reset`). Best-effort. */
|
|
154
|
+
reset() {
|
|
155
|
+
try { if (fs.existsSync(this.filePath)) fs.unlinkSync(this.filePath); return true; }
|
|
156
|
+
catch { return false; }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Rough cost estimate in USD for a token count at a blended $/1M price.
|
|
162
|
+
* @param {number} inputTokens
|
|
163
|
+
* @param {number} outputTokens
|
|
164
|
+
* @param {number} costPerMTok blended price per 1M tokens
|
|
165
|
+
* @returns {number}
|
|
166
|
+
*/
|
|
167
|
+
export function estimateCostUsd(inputTokens = 0, outputTokens = 0, costPerMTok = 0) {
|
|
168
|
+
const total = (inputTokens || 0) + (outputTokens || 0);
|
|
169
|
+
return Number(((total / 1_000_000) * (costPerMTok || 0)).toFixed(6));
|
|
170
|
+
}
|