orcacompass 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/NOTICE +51 -0
- package/README.md +171 -3
- package/data/modelindex-snapshot.json +1537 -0
- package/data/models-snapshot.json +1356 -0
- package/dist/cli.js +906 -0
- package/dist/config.js +272 -0
- package/dist/install.js +56 -0
- package/dist/paths.js +30 -0
- package/dist/placements/agents/antigravity.js +87 -0
- package/dist/placements/agents/claude.js +72 -0
- package/dist/placements/agents/codex.js +66 -0
- package/dist/placements/agents/cursor.js +102 -0
- package/dist/placements/agents/deps.js +19 -0
- package/dist/placements/agents/grok.js +92 -0
- package/dist/placements/agents/list-cache.js +85 -0
- package/dist/placements/agents/muse.js +7 -0
- package/dist/placements/agents/opencode.js +77 -0
- package/dist/placements/agents/orca-catalog.js +34 -0
- package/dist/placements/benchmark.js +70 -0
- package/dist/placements/build.js +94 -0
- package/dist/placements/score.js +70 -0
- package/dist/placements/sources/artificialanalysis.js +133 -0
- package/dist/placements/sources/modelindex.js +84 -0
- package/dist/placements/sources/modelsdev.js +113 -0
- package/dist/placements/table.js +94 -0
- package/dist/placements/tiers.js +136 -0
- package/dist/routing/effort.js +31 -0
- package/dist/routing/environment.js +51 -0
- package/dist/routing/orca-settings.js +111 -0
- package/dist/routing/orca-usage.js +85 -0
- package/dist/routing/pace.js +52 -0
- package/dist/routing/rejection.js +58 -0
- package/dist/routing/rejections.js +18 -0
- package/dist/routing/router.js +552 -0
- package/dist/setup.js +358 -0
- package/dist/system/run.js +128 -0
- package/dist/types.js +8 -0
- package/dist/verify.js +106 -0
- package/dist/version.js +3 -0
- package/package.json +60 -6
- package/skills/orcacompass/SKILL.md +278 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { EFFORT_LADDER } from './routing/effort.js';
|
|
4
|
+
import { EFFORT_POLICIES, TIERS } from './types.js';
|
|
5
|
+
import { configPath } from './paths.js';
|
|
6
|
+
export const DEFAULT_CONFIG = {
|
|
7
|
+
denyAgents: [],
|
|
8
|
+
pins: {},
|
|
9
|
+
tierOverrides: {},
|
|
10
|
+
thresholds: { longPercent: 95, sessionPercent: 90 },
|
|
11
|
+
defaultEffort: { light: 'low', daily: 'medium', advanced: 'medium', frontier: 'high' },
|
|
12
|
+
effortPolicy: 'balanced',
|
|
13
|
+
scoreWeight: 0.4,
|
|
14
|
+
modelsDevTtlHours: 24,
|
|
15
|
+
placementsTtlHours: 5,
|
|
16
|
+
usageStaleMinutes: 30,
|
|
17
|
+
rejectionTtlHours: 168,
|
|
18
|
+
excludeNoZdr: false,
|
|
19
|
+
cursorAuto: true,
|
|
20
|
+
preferRiskLimit: 100,
|
|
21
|
+
banModels: [],
|
|
22
|
+
lastResortAgents: ['opencode'],
|
|
23
|
+
};
|
|
24
|
+
/** Message always starts with the config file path. */
|
|
25
|
+
export class ConfigError extends Error {
|
|
26
|
+
constructor(path, message) {
|
|
27
|
+
super(`${path}: ${message}`);
|
|
28
|
+
this.name = 'ConfigError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Missing file → defaults. Otherwise the file is deep-merged over the defaults; unknown keys are ignored. */
|
|
32
|
+
export function loadConfig(path = configPath()) {
|
|
33
|
+
let text;
|
|
34
|
+
try {
|
|
35
|
+
text = readFileSync(path, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e.code === 'ENOENT')
|
|
39
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
40
|
+
throw new ConfigError(path, `cannot read: ${e.message}`);
|
|
41
|
+
}
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = JSON.parse(text);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
throw new ConfigError(path, `invalid JSON: ${e.message}`);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return merge(raw);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e instanceof Invalid)
|
|
54
|
+
throw new ConfigError(path, e.message);
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
class Invalid extends Error {
|
|
59
|
+
}
|
|
60
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
61
|
+
function obj(v, key) {
|
|
62
|
+
if (!isObj(v))
|
|
63
|
+
throw new Invalid(`${key} must be an object`);
|
|
64
|
+
return v;
|
|
65
|
+
}
|
|
66
|
+
function strings(v, key) {
|
|
67
|
+
if (!Array.isArray(v) || !v.every((s) => typeof s === 'string'))
|
|
68
|
+
throw new Invalid(`${key} must be an array of strings`);
|
|
69
|
+
return [...v];
|
|
70
|
+
}
|
|
71
|
+
function num(v, key) {
|
|
72
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0)
|
|
73
|
+
throw new Invalid(`${key} must be a non-negative number`);
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
function fraction(v, key) {
|
|
77
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1)
|
|
78
|
+
throw new Invalid(`${key} must be a number from 0 to 1`);
|
|
79
|
+
return v;
|
|
80
|
+
}
|
|
81
|
+
function tier(v, key) {
|
|
82
|
+
if (!TIERS.includes(v))
|
|
83
|
+
throw new Invalid(`${key} must be one of ${TIERS.join(', ')} (got ${JSON.stringify(v)})`);
|
|
84
|
+
return v;
|
|
85
|
+
}
|
|
86
|
+
function effort(v, key) {
|
|
87
|
+
if (!EFFORT_LADDER.includes(v)) {
|
|
88
|
+
throw new Invalid(`${key} must be one of ${EFFORT_LADDER.join(', ')} (got ${JSON.stringify(v)})`);
|
|
89
|
+
}
|
|
90
|
+
return v;
|
|
91
|
+
}
|
|
92
|
+
function tierKey(k, key) {
|
|
93
|
+
return tier(k, `${key} key`);
|
|
94
|
+
}
|
|
95
|
+
function merge(raw) {
|
|
96
|
+
const r = obj(raw, 'config');
|
|
97
|
+
const c = structuredClone(DEFAULT_CONFIG);
|
|
98
|
+
if (r.allowAgents !== undefined)
|
|
99
|
+
c.allowAgents = strings(r.allowAgents, 'allowAgents');
|
|
100
|
+
if (r.denyAgents !== undefined)
|
|
101
|
+
c.denyAgents = strings(r.denyAgents, 'denyAgents');
|
|
102
|
+
if (r.pins !== undefined) {
|
|
103
|
+
for (const [k, v] of Object.entries(obj(r.pins, 'pins'))) {
|
|
104
|
+
const pin = obj(v, `pins.${k}`);
|
|
105
|
+
if (typeof pin.agent !== 'string' || (pin.model !== undefined && typeof pin.model !== 'string')) {
|
|
106
|
+
throw new Invalid(`pins.${k} must have a string "agent" and an optional string "model"`);
|
|
107
|
+
}
|
|
108
|
+
c.pins[tierKey(k, 'pins')] = { agent: pin.agent, ...(pin.model !== undefined ? { model: pin.model } : {}) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (r.tierOverrides !== undefined) {
|
|
112
|
+
for (const [k, v] of Object.entries(obj(r.tierOverrides, 'tierOverrides')))
|
|
113
|
+
c.tierOverrides[k] = tier(v, `tierOverrides.${k}`);
|
|
114
|
+
}
|
|
115
|
+
if (r.thresholds !== undefined) {
|
|
116
|
+
const t = obj(r.thresholds, 'thresholds');
|
|
117
|
+
for (const k of ['longPercent', 'sessionPercent']) {
|
|
118
|
+
if (t[k] !== undefined)
|
|
119
|
+
c.thresholds[k] = num(t[k], `thresholds.${k}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (r.defaultEffort !== undefined) {
|
|
123
|
+
for (const [k, v] of Object.entries(obj(r.defaultEffort, 'defaultEffort'))) {
|
|
124
|
+
c.defaultEffort[tierKey(k, 'defaultEffort')] = effort(v, `defaultEffort.${k}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (r.effortPolicy !== undefined) {
|
|
128
|
+
if (!EFFORT_POLICIES.includes(r.effortPolicy))
|
|
129
|
+
throw new Invalid(`effortPolicy must be one of ${EFFORT_POLICIES.join(', ')}`);
|
|
130
|
+
c.effortPolicy = r.effortPolicy;
|
|
131
|
+
}
|
|
132
|
+
if (r.scoreWeight !== undefined)
|
|
133
|
+
c.scoreWeight = fraction(r.scoreWeight, 'scoreWeight');
|
|
134
|
+
if (r.modelsDevTtlHours !== undefined)
|
|
135
|
+
c.modelsDevTtlHours = num(r.modelsDevTtlHours, 'modelsDevTtlHours');
|
|
136
|
+
if (r.placementsTtlHours !== undefined)
|
|
137
|
+
c.placementsTtlHours = num(r.placementsTtlHours, 'placementsTtlHours');
|
|
138
|
+
if (r.usageStaleMinutes !== undefined)
|
|
139
|
+
c.usageStaleMinutes = num(r.usageStaleMinutes, 'usageStaleMinutes');
|
|
140
|
+
if (r.rejectionTtlHours !== undefined)
|
|
141
|
+
c.rejectionTtlHours = num(r.rejectionTtlHours, 'rejectionTtlHours');
|
|
142
|
+
if (r.excludeNoZdr !== undefined) {
|
|
143
|
+
if (typeof r.excludeNoZdr !== 'boolean')
|
|
144
|
+
throw new Invalid('excludeNoZdr must be a boolean');
|
|
145
|
+
c.excludeNoZdr = r.excludeNoZdr;
|
|
146
|
+
}
|
|
147
|
+
if (r.cursorAuto !== undefined) {
|
|
148
|
+
if (typeof r.cursorAuto !== 'boolean')
|
|
149
|
+
throw new Invalid('cursorAuto must be a boolean');
|
|
150
|
+
c.cursorAuto = r.cursorAuto;
|
|
151
|
+
}
|
|
152
|
+
if (r.preferRiskLimit !== undefined)
|
|
153
|
+
c.preferRiskLimit = num(r.preferRiskLimit, 'preferRiskLimit');
|
|
154
|
+
if (r.banModels !== undefined)
|
|
155
|
+
c.banModels = strings(r.banModels, 'banModels');
|
|
156
|
+
if (r.lastResortAgents !== undefined)
|
|
157
|
+
c.lastResortAgents = strings(r.lastResortAgents, 'lastResortAgents');
|
|
158
|
+
return c;
|
|
159
|
+
}
|
|
160
|
+
const unique = (ids) => [...new Set(ids)];
|
|
161
|
+
/** The allow/deny lists after `orcacompass agents <action> <ids>`. */
|
|
162
|
+
export function applyAgentChoice(choice, action, ids) {
|
|
163
|
+
const deny = choice.denyAgents.filter((a) => !ids.includes(a));
|
|
164
|
+
switch (action) {
|
|
165
|
+
case 'disable':
|
|
166
|
+
return {
|
|
167
|
+
...(choice.allowAgents ? { allowAgents: choice.allowAgents.filter((a) => !ids.includes(a)) } : {}),
|
|
168
|
+
denyAgents: unique([...choice.denyAgents, ...ids]),
|
|
169
|
+
};
|
|
170
|
+
case 'enable':
|
|
171
|
+
return { ...(choice.allowAgents ? { allowAgents: unique([...choice.allowAgents, ...ids]) } : {}), denyAgents: deny };
|
|
172
|
+
case 'only':
|
|
173
|
+
return { allowAgents: unique(ids), denyAgents: deny };
|
|
174
|
+
case 'reset':
|
|
175
|
+
return { denyAgents: [] };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Whether the user's choice lets OrcaCompass use the agent. */
|
|
179
|
+
export const isChosen = (agent, choice) => !choice.denyAgents.includes(agent) && (!choice.allowAgents || choice.allowAgents.includes(agent));
|
|
180
|
+
/** Writes allowAgents/denyAgents; an empty denyAgents or an absent allowAgents removes the key. */
|
|
181
|
+
export function writeAgentChoice(choice, path = configPath()) {
|
|
182
|
+
updateConfigFile((raw) => setAgentChoice(raw, choice), path);
|
|
183
|
+
}
|
|
184
|
+
// Edits of the raw config JSON, shared by the commands and `setup` so both write the same keys the same way.
|
|
185
|
+
export function setAgentChoice(raw, choice) {
|
|
186
|
+
if (choice.allowAgents)
|
|
187
|
+
raw.allowAgents = choice.allowAgents;
|
|
188
|
+
else
|
|
189
|
+
delete raw.allowAgents;
|
|
190
|
+
if (choice.denyAgents.length > 0)
|
|
191
|
+
raw.denyAgents = choice.denyAgents;
|
|
192
|
+
else
|
|
193
|
+
delete raw.denyAgents;
|
|
194
|
+
}
|
|
195
|
+
/** Sets the preferred route of one tier, or removes it without a preference; no preferences removes `pins`. */
|
|
196
|
+
export function setPin(raw, tier, pin) {
|
|
197
|
+
const pins = isObj(raw.pins) ? { ...raw.pins } : {};
|
|
198
|
+
if (pin)
|
|
199
|
+
pins[tier] = pin;
|
|
200
|
+
else
|
|
201
|
+
delete pins[tier];
|
|
202
|
+
if (Object.keys(pins).length > 0)
|
|
203
|
+
raw.pins = pins;
|
|
204
|
+
else
|
|
205
|
+
delete raw.pins;
|
|
206
|
+
}
|
|
207
|
+
export function setBans(raw, banModels) {
|
|
208
|
+
if (banModels.length > 0)
|
|
209
|
+
raw.banModels = banModels;
|
|
210
|
+
else
|
|
211
|
+
delete raw.banModels;
|
|
212
|
+
}
|
|
213
|
+
/** A percent such as "120" or "120%" for `preferRiskLimit`, or undefined when it is not a non-negative number. */
|
|
214
|
+
export function parsePercent(text) {
|
|
215
|
+
const t = text.trim().replace(/%$/, '');
|
|
216
|
+
const n = Number(t);
|
|
217
|
+
return t !== '' && Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
218
|
+
}
|
|
219
|
+
/** The default limit removes the key. */
|
|
220
|
+
export function setPreferRiskLimit(raw, percent) {
|
|
221
|
+
if (percent === DEFAULT_CONFIG.preferRiskLimit)
|
|
222
|
+
delete raw.preferRiskLimit;
|
|
223
|
+
else
|
|
224
|
+
raw.preferRiskLimit = percent;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Applies `change` to the config file's JSON, creating the file if needed. Every other key keeps its
|
|
228
|
+
* value and position, and the file keeps its indentation and line endings. A file that is not a valid
|
|
229
|
+
* config, or a change that would make it invalid, leaves the file untouched.
|
|
230
|
+
*/
|
|
231
|
+
export function updateConfigFile(change, path = configPath()) {
|
|
232
|
+
let text;
|
|
233
|
+
try {
|
|
234
|
+
text = readFileSync(path, 'utf8');
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
if (e.code !== 'ENOENT')
|
|
238
|
+
throw new ConfigError(path, `cannot read: ${e.message}`);
|
|
239
|
+
}
|
|
240
|
+
let raw = {};
|
|
241
|
+
if (text !== undefined) {
|
|
242
|
+
try {
|
|
243
|
+
raw = obj(JSON.parse(text), 'config');
|
|
244
|
+
merge(raw);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
if (e instanceof SyntaxError)
|
|
248
|
+
throw new ConfigError(path, `invalid JSON: ${e.message}`);
|
|
249
|
+
if (e instanceof Invalid)
|
|
250
|
+
throw new ConfigError(path, e.message);
|
|
251
|
+
throw e;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
change(raw);
|
|
255
|
+
try {
|
|
256
|
+
merge(raw);
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
if (e instanceof Invalid)
|
|
260
|
+
throw new ConfigError(path, e.message);
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
263
|
+
const indent = (text && /^[ \t]+(?=")/m.exec(text)?.[0]) || ' ';
|
|
264
|
+
const eol = text?.includes('\r\n') ? '\r\n' : '\n';
|
|
265
|
+
const trailing = text === undefined || /\n$/.test(text) ? eol : '';
|
|
266
|
+
const out = JSON.stringify(raw, null, indent).replace(/\n/g, eol) + trailing;
|
|
267
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
268
|
+
// Replace in one step so a crash never leaves half a config.
|
|
269
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
270
|
+
writeFileSync(tmp, out);
|
|
271
|
+
renameSync(tmp, path);
|
|
272
|
+
}
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { delimiter, join, sep } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { findExecutable, runInteractive } from './system/run.js';
|
|
5
|
+
/** The `skills` installer (vercel-labs/skills) knows each agent's skills folder; the major version is pinned. */
|
|
6
|
+
export const SKILLS_CLI = 'skills@1';
|
|
7
|
+
// A folder URL converts to a path ending in a separator; drop it so the path is a plain argument.
|
|
8
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)).replace(/[\\/]+$/, '');
|
|
9
|
+
// npx runs this package from its cache and puts that cache on PATH, so a copy found there
|
|
10
|
+
// disappears after the run and does not count as installed.
|
|
11
|
+
function findInstalledCli() {
|
|
12
|
+
const marker = `${sep}_npx${sep}`;
|
|
13
|
+
const path = (process.env.PATH ?? '').split(delimiter).filter((d) => !d.includes(marker)).join(delimiter);
|
|
14
|
+
return findExecutable('orcacompass', [], path);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Installs the Skill through the `skills` installer, pointed at this package's own copy, so the
|
|
18
|
+
* Skill always matches this CLI version. Then installs the CLI globally if it is not on PATH yet.
|
|
19
|
+
* Returns the exit code.
|
|
20
|
+
*/
|
|
21
|
+
export async function install(opts, deps = {}) {
|
|
22
|
+
const interactive = deps.interactive ?? runInteractive;
|
|
23
|
+
const root = deps.packageRoot ?? PACKAGE_ROOT;
|
|
24
|
+
const log = deps.log ?? (() => { });
|
|
25
|
+
const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
26
|
+
const skillArgs = ['-y', SKILLS_CLI, 'add', root];
|
|
27
|
+
if (opts.global)
|
|
28
|
+
skillArgs.push('--global');
|
|
29
|
+
if (opts.yes)
|
|
30
|
+
skillArgs.push('--yes');
|
|
31
|
+
for (const agent of opts.agents ?? [])
|
|
32
|
+
skillArgs.push('--agent', agent);
|
|
33
|
+
log(`Installing the OrcaCompass Skill ${version} with npx ${SKILLS_CLI}...`);
|
|
34
|
+
const skillCode = await interactive('npx', skillArgs);
|
|
35
|
+
if (skillCode !== 0) {
|
|
36
|
+
log(`The Skill was not installed (npx ${SKILLS_CLI} exited with ${skillCode ?? 'no code'}).`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
if (opts.skillOnly) {
|
|
40
|
+
log('Skill installed. The CLI was left alone (--skill-only).');
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
const existing = (deps.findCli ?? findInstalledCli)();
|
|
44
|
+
if (existing) {
|
|
45
|
+
log(`Skill installed. The CLI is already installed: ${existing}`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
log(`Installing the CLI: npm install -g orcacompass@${version}`);
|
|
49
|
+
const cliCode = await interactive('npm', ['install', '-g', `orcacompass@${version}`]);
|
|
50
|
+
if (cliCode !== 0) {
|
|
51
|
+
log(`The Skill is installed, but the CLI is not (npm exited with ${cliCode ?? 'no code'}). Run: npm install -g orcacompass`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
log('Skill and CLI installed. Ask your orchestrator to set up OrcaCompass, or run: orcacompass setup');
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { homedir, userInfo } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// An empty location variable counts as unset throughout: joining onto "" gives a path relative to the
|
|
4
|
+
// working directory, so the config would change with the folder a command runs in.
|
|
5
|
+
/**
|
|
6
|
+
* The user's home folder, where agents keep their files. On Windows they follow USERPROFILE, not a HOME
|
|
7
|
+
* set by Git Bash or other tools. os.homedir() returns an empty HOME as is, hence the account's own record.
|
|
8
|
+
*/
|
|
9
|
+
export function homeDir(env = process.env, platform = process.platform) {
|
|
10
|
+
return (platform === 'win32' ? env.USERPROFILE || env.HOME : env.HOME) || homedir() || userInfo().homedir;
|
|
11
|
+
}
|
|
12
|
+
// ORCACOMPASS_HOME holds both config and cache when set (used by tests).
|
|
13
|
+
export function configPath() {
|
|
14
|
+
const home = process.env.ORCACOMPASS_HOME;
|
|
15
|
+
if (home)
|
|
16
|
+
return join(home, 'config.json');
|
|
17
|
+
if (process.platform === 'win32') {
|
|
18
|
+
return join(process.env.APPDATA || join(homeDir(), 'AppData', 'Roaming'), 'orcacompass', 'config.json');
|
|
19
|
+
}
|
|
20
|
+
return join(process.env.XDG_CONFIG_HOME || join(homeDir(), '.config'), 'orcacompass', 'config.json');
|
|
21
|
+
}
|
|
22
|
+
export function cacheDir() {
|
|
23
|
+
const home = process.env.ORCACOMPASS_HOME;
|
|
24
|
+
if (home)
|
|
25
|
+
return join(home, 'cache');
|
|
26
|
+
if (process.platform === 'win32') {
|
|
27
|
+
return join(process.env.LOCALAPPDATA || join(homeDir(), 'AppData', 'Local'), 'orcacompass', 'cache');
|
|
28
|
+
}
|
|
29
|
+
return join(process.env.XDG_CACHE_HOME || join(homeDir(), '.cache'), 'orcacompass');
|
|
30
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { homeDir } from '../../paths.js';
|
|
3
|
+
import { EFFORT_LADDER } from '../../routing/effort.js';
|
|
4
|
+
import { listModels, savedNote } from './list-cache.js';
|
|
5
|
+
// Orca's effort choices for every Antigravity model (it passes them on as agy --effort).
|
|
6
|
+
const EFFORTS = ['low', 'medium', 'high'];
|
|
7
|
+
const EFFORT_SUFFIX = /-(low|medium|high)$/;
|
|
8
|
+
// agy bills each quota group separately (Orca's bucket groups); these are the labels agy 1.4 reports.
|
|
9
|
+
const GEMINI_GROUP = 'Gemini Models';
|
|
10
|
+
const OTHER_GROUP = 'Claude and GPT models';
|
|
11
|
+
/** A group's own pools, then Orca's overall windows, which it reports only when there are no groups. */
|
|
12
|
+
const poolsOf = (id) => {
|
|
13
|
+
const group = id.startsWith('gemini') ? GEMINI_GROUP : OTHER_GROUP;
|
|
14
|
+
return [`bucket:${group} 5h`, `bucket:${group}`, 'session', 'weekly'];
|
|
15
|
+
};
|
|
16
|
+
/** `agy models` prints `id<TAB>label` rows; older agy versions print display names only, which are not launch ids. */
|
|
17
|
+
export function parseAgyModels(text) {
|
|
18
|
+
const ids = [];
|
|
19
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
20
|
+
const line = raw.trim();
|
|
21
|
+
const tab = line.indexOf('\t');
|
|
22
|
+
if (tab < 0)
|
|
23
|
+
continue;
|
|
24
|
+
const id = line.slice(0, tab).trim();
|
|
25
|
+
if (!id || /\s/.test(id) || /^(id|model)$/i.test(id) || !line.slice(tab + 1).trim() || ids.includes(id))
|
|
26
|
+
continue;
|
|
27
|
+
ids.push(id);
|
|
28
|
+
}
|
|
29
|
+
return ids;
|
|
30
|
+
}
|
|
31
|
+
/** agy bakes effort into Gemini ids (gemini-3.8-flash-high); other ids take Orca's --effort. */
|
|
32
|
+
function toLaunchModels(ids, deps) {
|
|
33
|
+
const groups = new Map();
|
|
34
|
+
for (const id of ids) {
|
|
35
|
+
const m = EFFORT_SUFFIX.exec(id);
|
|
36
|
+
const base = m ? id.slice(0, m.index) : id;
|
|
37
|
+
const g = groups.get(base) ?? { ids: {} };
|
|
38
|
+
groups.set(base, g);
|
|
39
|
+
if (m)
|
|
40
|
+
g.ids[m[1]] = id;
|
|
41
|
+
else
|
|
42
|
+
g.plain = id;
|
|
43
|
+
}
|
|
44
|
+
return [...groups].map(([base, g]) => {
|
|
45
|
+
// agy lists some Claude models as claude-opus-4-6-thinking; the catalog knows them without the suffix.
|
|
46
|
+
const plain = base.replace(/-thinking$/, '');
|
|
47
|
+
const modelId = deps.index[base] || !deps.index[plain] ? base : plain;
|
|
48
|
+
const common = {
|
|
49
|
+
agent: 'antigravity',
|
|
50
|
+
modelId,
|
|
51
|
+
...(deps.index[modelId] ? { meta: deps.index[modelId] } : {}),
|
|
52
|
+
poolNames: poolsOf(base),
|
|
53
|
+
};
|
|
54
|
+
// A plain id takes Orca's effort menu; agy does not say which efforts it honors.
|
|
55
|
+
if (Object.keys(g.ids).length === 0)
|
|
56
|
+
return { ...common, efforts: EFFORTS, effortSource: 'orca', launch: { kind: 'flag', model: g.plain } };
|
|
57
|
+
// An unsuffixed sibling is agy's default, so it stands in for a missing medium.
|
|
58
|
+
if (g.plain && !g.ids.medium)
|
|
59
|
+
g.ids.medium = g.plain;
|
|
60
|
+
return {
|
|
61
|
+
...common,
|
|
62
|
+
efforts: EFFORT_LADDER.filter((e) => g.ids[e]),
|
|
63
|
+
effortSource: 'provider',
|
|
64
|
+
launch: { kind: 'variant', ids: g.ids, ...(g.plain ? { defaultId: g.plain } : {}) },
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// agy otherwise forks a detached updater every 15 minutes, and on Windows its console-less child opens a visible terminal. agy accepts only the exact value "true".
|
|
69
|
+
const NO_AUTO_UPDATE = { AGY_CLI_DISABLE_AUTO_UPDATE: 'true' };
|
|
70
|
+
// The agy installer's default locations; a process started before the install misses them on PATH.
|
|
71
|
+
export function locateAntigravity(deps) {
|
|
72
|
+
const home = homeDir(deps.env);
|
|
73
|
+
const localAppData = deps.env.LOCALAPPDATA || join(home, 'AppData', 'Local');
|
|
74
|
+
return deps.find('agy', [join(localAppData, 'agy', 'bin'), join(home, '.local', 'bin')]);
|
|
75
|
+
}
|
|
76
|
+
export async function discoverAntigravity(deps) {
|
|
77
|
+
const exe = locateAntigravity(deps);
|
|
78
|
+
if (!exe)
|
|
79
|
+
return { agent: 'antigravity', installed: false, models: [], note: 'agy not found' };
|
|
80
|
+
const listing = await listModels(deps, exe, ['models'], 'antigravity', (text) => parseAgyModels(text).map((id) => ({ id })), NO_AUTO_UPDATE);
|
|
81
|
+
if (!listing.items) {
|
|
82
|
+
const note = listing.signedOut ? `not signed in: ${listing.error}` : `agy models gave no model ids: ${listing.error}`;
|
|
83
|
+
return { agent: 'antigravity', installed: true, models: [], note, ...(listing.signedOut ? { signedOut: true } : {}) };
|
|
84
|
+
}
|
|
85
|
+
const note = savedNote('agy models', listing, deps.now);
|
|
86
|
+
return { agent: 'antigravity', installed: true, models: toLaunchModels(listing.items.map((m) => m.id), deps), ...(note ? { note } : {}) };
|
|
87
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { parseEffort } from '../../routing/effort.js';
|
|
3
|
+
import { homeDir } from '../../paths.js';
|
|
4
|
+
import { description, fromOrcaCatalog, orcaCatalogPath, parseJson } from './orca-catalog.js';
|
|
5
|
+
const DEFAULT_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
6
|
+
const isClaude = (id) => id.startsWith('claude-');
|
|
7
|
+
const poolNames = (id) => (id.startsWith('claude-fable') ? ['session', 'weekly', 'fableWeekly'] : ['session', 'weekly']);
|
|
8
|
+
/** Claude Code's cached account catalog, <account>-<hash>-cc.json; several files can exist after an account change. */
|
|
9
|
+
function fromClaudeCache(deps, dir) {
|
|
10
|
+
const newest = deps.listDir(dir)
|
|
11
|
+
.filter((name) => name.endsWith('-cc.json'))
|
|
12
|
+
.map((name) => {
|
|
13
|
+
const path = join(dir, name);
|
|
14
|
+
const doc = parseJson(deps.readFile(path));
|
|
15
|
+
return { path, fetchedAt: Number(doc?.fetchedAt) || 0, models: doc?.catalog?.config?.models };
|
|
16
|
+
})
|
|
17
|
+
.filter((c) => Array.isArray(c.models))
|
|
18
|
+
.sort((a, b) => b.fetchedAt - a.fetchedAt)[0];
|
|
19
|
+
if (!newest)
|
|
20
|
+
return undefined;
|
|
21
|
+
const models = newest.models
|
|
22
|
+
.filter((m) => typeof m?.id === 'string')
|
|
23
|
+
.map((m) => ({
|
|
24
|
+
id: m.id,
|
|
25
|
+
efforts: (m.thinking?.effort_options ?? []).map((o) => String(o?.id)),
|
|
26
|
+
...description(m.description),
|
|
27
|
+
}));
|
|
28
|
+
return { path: newest.path, models };
|
|
29
|
+
}
|
|
30
|
+
function toLaunch({ id, efforts, description: lineup }, deps, effortSource) {
|
|
31
|
+
return {
|
|
32
|
+
agent: 'claude',
|
|
33
|
+
modelId: id,
|
|
34
|
+
...(deps.index[id] ? { meta: deps.index[id] } : {}),
|
|
35
|
+
efforts: [...new Set(efforts.map(parseEffort).filter((e) => e !== undefined))],
|
|
36
|
+
effortSource,
|
|
37
|
+
launch: { kind: 'flag', model: id },
|
|
38
|
+
poolNames: poolNames(id),
|
|
39
|
+
...(lineup ? { lineup } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Claude Code has no model list command: its account catalog, then Orca's probe, then the Models.dev catalog. */
|
|
43
|
+
export const locateClaude = (deps) => deps.find('claude');
|
|
44
|
+
export async function discoverClaude(deps) {
|
|
45
|
+
if (!locateClaude(deps))
|
|
46
|
+
return { agent: 'claude', installed: false, models: [], note: 'claude not found on PATH' };
|
|
47
|
+
const configDir = deps.env.CLAUDE_CONFIG_DIR || join(homeDir(deps.env), '.claude');
|
|
48
|
+
const cache = fromClaudeCache(deps, join(configDir, 'cache', 'model-catalog'));
|
|
49
|
+
const orcaPath = orcaCatalogPath(deps.env);
|
|
50
|
+
const sources = [
|
|
51
|
+
...(cache ? [[cache.path, cache.models, 'provider']] : []),
|
|
52
|
+
[orcaPath, fromOrcaCatalog(deps.readFile(orcaPath), 'claude'), 'orca'],
|
|
53
|
+
];
|
|
54
|
+
for (const [path, raw, effortSource] of sources) {
|
|
55
|
+
const unique = new Map(raw.filter((m) => isClaude(m.id)).map((m) => [m.id, m]));
|
|
56
|
+
if (unique.size === 0)
|
|
57
|
+
continue;
|
|
58
|
+
return { agent: 'claude', installed: true, models: [...unique.values()].map((m) => toLaunch(m, deps, effortSource)), note: `models from ${path}` };
|
|
59
|
+
}
|
|
60
|
+
const models = Object.values(deps.index)
|
|
61
|
+
.filter((m) => m.provider === 'anthropic' && m.family?.startsWith('claude-'))
|
|
62
|
+
.map((meta) => ({
|
|
63
|
+
agent: 'claude',
|
|
64
|
+
modelId: meta.id,
|
|
65
|
+
meta,
|
|
66
|
+
efforts: meta.efforts?.length ? meta.efforts : DEFAULT_EFFORTS,
|
|
67
|
+
effortSource: meta.efforts?.length ? 'catalog' : 'assumed',
|
|
68
|
+
launch: { kind: 'flag', model: meta.id },
|
|
69
|
+
poolNames: poolNames(meta.id),
|
|
70
|
+
}));
|
|
71
|
+
return { agent: 'claude', installed: true, models };
|
|
72
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { parseEffort } from '../../routing/effort.js';
|
|
3
|
+
import { FAMILY_TIERS } from '../tiers.js';
|
|
4
|
+
const DEFAULT_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
5
|
+
import { homeDir } from '../../paths.js';
|
|
6
|
+
import { description, fromOrcaCatalog, orcaCatalogPath, parseJson } from './orca-catalog.js';
|
|
7
|
+
/** Codex CLI's own cache: models[] with visibility and supported_reasoning_levels. */
|
|
8
|
+
function fromCodexCache(text) {
|
|
9
|
+
const models = parseJson(text)?.models;
|
|
10
|
+
if (!Array.isArray(models))
|
|
11
|
+
return [];
|
|
12
|
+
return models
|
|
13
|
+
.filter((m) => m?.visibility === 'list' && typeof m.slug === 'string')
|
|
14
|
+
.map((m) => ({
|
|
15
|
+
id: m.slug,
|
|
16
|
+
efforts: (m.supported_reasoning_levels ?? []).map((l) => String(l?.effort)),
|
|
17
|
+
...(typeof m.default_reasoning_level === 'string' ? { defaultEffort: m.default_reasoning_level } : {}),
|
|
18
|
+
...description(m.description),
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
export const locateCodex = (deps) => deps.find('codex');
|
|
22
|
+
export async function discoverCodex(deps) {
|
|
23
|
+
if (!locateCodex(deps))
|
|
24
|
+
return { agent: 'codex', installed: false, models: [], note: 'codex not found on PATH' };
|
|
25
|
+
const sources = [
|
|
26
|
+
[join(deps.env.CODEX_HOME || join(homeDir(deps.env), '.codex'), 'models_cache.json'), fromCodexCache, 'provider'],
|
|
27
|
+
[orcaCatalogPath(deps.env), (text) => fromOrcaCatalog(text, 'codex'), 'orca'],
|
|
28
|
+
];
|
|
29
|
+
for (const [path, parse, effortSource] of sources) {
|
|
30
|
+
const raw = parse(deps.readFile(path));
|
|
31
|
+
if (raw.length === 0)
|
|
32
|
+
continue;
|
|
33
|
+
const models = raw.map(({ id, efforts, defaultEffort, description: lineup }) => {
|
|
34
|
+
const native = defaultEffort === undefined ? undefined : parseEffort(defaultEffort);
|
|
35
|
+
return {
|
|
36
|
+
agent: 'codex',
|
|
37
|
+
modelId: id,
|
|
38
|
+
...(deps.index[id] ? { meta: deps.index[id] } : {}),
|
|
39
|
+
efforts: [...new Set(efforts.map(parseEffort).filter((e) => e !== undefined))],
|
|
40
|
+
effortSource,
|
|
41
|
+
...(native ? { defaultEffort: native } : {}),
|
|
42
|
+
launch: { kind: 'flag', model: id },
|
|
43
|
+
poolNames: ['session', 'weekly'],
|
|
44
|
+
...(lineup ? { lineup } : {}),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
return { agent: 'codex', installed: true, models, note: `models from ${path}` };
|
|
48
|
+
}
|
|
49
|
+
// Models.dev lists every OpenAI model; only the Codex lineup families are launchable here, and
|
|
50
|
+
// "-latest" ids are moving aliases.
|
|
51
|
+
const families = Object.keys(FAMILY_TIERS).filter((f) => f.startsWith('gpt-'));
|
|
52
|
+
const models = Object.values(deps.index)
|
|
53
|
+
.filter((m) => m.provider === 'openai' && m.family !== undefined && families.includes(m.family) && !m.id.endsWith('-latest'))
|
|
54
|
+
.map((meta) => ({
|
|
55
|
+
agent: 'codex',
|
|
56
|
+
modelId: meta.id,
|
|
57
|
+
meta,
|
|
58
|
+
efforts: meta.efforts?.length ? meta.efforts : DEFAULT_EFFORTS,
|
|
59
|
+
effortSource: meta.efforts?.length ? 'catalog' : 'assumed',
|
|
60
|
+
launch: { kind: 'flag', model: meta.id },
|
|
61
|
+
poolNames: ['session', 'weekly'],
|
|
62
|
+
}));
|
|
63
|
+
if (models.length > 0)
|
|
64
|
+
return { agent: 'codex', installed: true, models, note: 'models from Models.dev (Codex has not cached its model list yet)' };
|
|
65
|
+
return { agent: 'codex', installed: true, models: [], note: 'no Codex model list found' };
|
|
66
|
+
}
|