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.
Files changed (42) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +51 -0
  3. package/README.md +171 -3
  4. package/data/modelindex-snapshot.json +1537 -0
  5. package/data/models-snapshot.json +1356 -0
  6. package/dist/cli.js +906 -0
  7. package/dist/config.js +272 -0
  8. package/dist/install.js +56 -0
  9. package/dist/paths.js +30 -0
  10. package/dist/placements/agents/antigravity.js +87 -0
  11. package/dist/placements/agents/claude.js +72 -0
  12. package/dist/placements/agents/codex.js +66 -0
  13. package/dist/placements/agents/cursor.js +102 -0
  14. package/dist/placements/agents/deps.js +19 -0
  15. package/dist/placements/agents/grok.js +92 -0
  16. package/dist/placements/agents/list-cache.js +85 -0
  17. package/dist/placements/agents/muse.js +7 -0
  18. package/dist/placements/agents/opencode.js +77 -0
  19. package/dist/placements/agents/orca-catalog.js +34 -0
  20. package/dist/placements/benchmark.js +70 -0
  21. package/dist/placements/build.js +94 -0
  22. package/dist/placements/score.js +70 -0
  23. package/dist/placements/sources/artificialanalysis.js +133 -0
  24. package/dist/placements/sources/modelindex.js +84 -0
  25. package/dist/placements/sources/modelsdev.js +113 -0
  26. package/dist/placements/table.js +94 -0
  27. package/dist/placements/tiers.js +136 -0
  28. package/dist/routing/effort.js +31 -0
  29. package/dist/routing/environment.js +51 -0
  30. package/dist/routing/orca-settings.js +111 -0
  31. package/dist/routing/orca-usage.js +85 -0
  32. package/dist/routing/pace.js +52 -0
  33. package/dist/routing/rejection.js +58 -0
  34. package/dist/routing/rejections.js +18 -0
  35. package/dist/routing/router.js +552 -0
  36. package/dist/setup.js +358 -0
  37. package/dist/system/run.js +128 -0
  38. package/dist/types.js +8 -0
  39. package/dist/verify.js +106 -0
  40. package/dist/version.js +3 -0
  41. package/package.json +60 -6
  42. package/skills/orcacompass/SKILL.md +278 -0
@@ -0,0 +1,111 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homeDir } from '../paths.js';
4
+ import { readTextFile } from '../placements/agents/deps.js';
5
+ // Orca's own rule for profile ids (profile-state-active-location).
6
+ const PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
7
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
8
+ /** Orca's userData folder, resolved as the Orca CLI does. */
9
+ export function orcaUserDataPath(env, platform, home) {
10
+ if (env.ORCA_USER_DATA_PATH)
11
+ return env.ORCA_USER_DATA_PATH;
12
+ if (platform === 'darwin')
13
+ return join(home, 'Library', 'Application Support', 'orca');
14
+ if (platform === 'win32')
15
+ return env.APPDATA ? join(env.APPDATA, 'orca') : undefined;
16
+ return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'orca');
17
+ }
18
+ function activeProfile(deps, userData) {
19
+ const index = join(userData, 'orca-profile-index.json');
20
+ const texts = [index, `${index}.bak`].map(deps.readFile).filter((t) => t !== undefined);
21
+ if (texts.length === 0)
22
+ return { known: false, reason: `Orca profile index not found (${index})` };
23
+ for (const text of texts) {
24
+ try {
25
+ const data = JSON.parse(text);
26
+ if (!isObj(data) || !Array.isArray(data.profiles))
27
+ continue;
28
+ const id = data.activeProfileId;
29
+ if (typeof id === 'string' && PROFILE_ID.test(id) && data.profiles.some((p) => isObj(p) && p.id === id))
30
+ return id;
31
+ }
32
+ catch {
33
+ // try the backup copy
34
+ }
35
+ }
36
+ return { known: false, reason: `cannot resolve the active Orca profile from ${index}` };
37
+ }
38
+ function fromSettings(settings, source) {
39
+ if (!isObj(settings) || !Array.isArray(settings.disabledTuiAgents)) {
40
+ return { known: false, reason: `Orca settings in ${source} have no disabledTuiAgents list` };
41
+ }
42
+ return { known: true, disabled: settings.disabledTuiAgents.filter((a) => typeof a === 'string'), source };
43
+ }
44
+ export async function readOrcaAgents(deps = defaultDeps()) {
45
+ const userData = orcaUserDataPath(deps.env, deps.platform, deps.home);
46
+ if (!userData)
47
+ return { known: false, reason: 'APPDATA is not set, so the Orca folder is unknown' };
48
+ const profile = activeProfile(deps, userData);
49
+ if (typeof profile !== 'string')
50
+ return profile;
51
+ const dir = join(userData, 'profiles', profile);
52
+ const db = join(dir, 'profile-state.db');
53
+ const jsonFile = join(dir, 'orca-data.json');
54
+ try {
55
+ // Once the SQLite state exists Orca treats it as the authority; orca-data.json may be stale.
56
+ if (deps.exists(db)) {
57
+ const payload = await deps.readSettingsPayload(db);
58
+ if (payload === undefined)
59
+ return { known: false, reason: `no settings in ${db}` };
60
+ return fromSettings(JSON.parse(payload), db);
61
+ }
62
+ const text = deps.readFile(jsonFile);
63
+ if (text === undefined)
64
+ return { known: false, reason: `Orca settings not found in ${dir}` };
65
+ const data = JSON.parse(text);
66
+ return fromSettings(isObj(data) ? data.settings : undefined, jsonFile);
67
+ }
68
+ catch (e) {
69
+ return { known: false, reason: `cannot read Orca settings: ${e instanceof Error ? e.message : String(e)}` };
70
+ }
71
+ }
72
+ /** Opens profile-state.db read-only, reads the settings row and closes it at once. Needs node:sqlite (Node 22.5+). */
73
+ export async function readSettingsPayload(dbPath) {
74
+ const { DatabaseSync } = await importSqlite();
75
+ const db = new DatabaseSync(dbPath, { readOnly: true });
76
+ try {
77
+ const row = db.prepare('SELECT payload FROM profile_state_documents WHERE domain = ?').get('settings');
78
+ return typeof row?.payload === 'string' ? row.payload : undefined;
79
+ }
80
+ finally {
81
+ db.close();
82
+ }
83
+ }
84
+ /** node:sqlite without its one-time ExperimentalWarning on stderr. */
85
+ async function importSqlite() {
86
+ const emitWarning = process.emitWarning;
87
+ process.emitWarning = ((warning, ...rest) => {
88
+ if (String(warning instanceof Error ? warning.message : warning).includes('SQLite'))
89
+ return;
90
+ emitWarning.call(process, warning, ...rest);
91
+ });
92
+ try {
93
+ return await import('node:sqlite');
94
+ }
95
+ catch {
96
+ throw new Error(`this Node.js (${process.version}) has no node:sqlite; Node 22.5 or later is needed`);
97
+ }
98
+ finally {
99
+ process.emitWarning = emitWarning;
100
+ }
101
+ }
102
+ function defaultDeps() {
103
+ return {
104
+ env: process.env,
105
+ platform: process.platform,
106
+ home: homeDir(),
107
+ readFile: readTextFile,
108
+ exists: existsSync,
109
+ readSettingsPayload,
110
+ };
111
+ }
@@ -0,0 +1,85 @@
1
+ // Orca has no usage entry for Muse; listing it reports that instead of "not reachable".
2
+ const agents = ['claude', 'codex', 'cursor', 'grok', 'antigravity', 'muse'];
3
+ const isNumber = (x) => typeof x === 'number' && Number.isFinite(x);
4
+ export async function readOrcaUsage(run) {
5
+ try {
6
+ const result = await run('orca', ['account', 'list', '--json'], { timeoutMs: 20_000 });
7
+ if (result.code !== 0 || result.error) {
8
+ throw new Error(result.error || result.stderr.trim() || `command exited with code ${result.code}`);
9
+ }
10
+ const data = JSON.parse(result.stdout);
11
+ if (data?.ok !== true) {
12
+ const error = data?.error;
13
+ throw new Error(typeof error === 'string' ? error : error?.message || 'account list failed');
14
+ }
15
+ const rateLimits = data.result?.rateLimits;
16
+ if (!rateLimits || typeof rateLimits !== 'object' || Array.isArray(rateLimits)) {
17
+ throw new Error('account list response has no rate limits');
18
+ }
19
+ return Object.fromEntries(agents.map((agent) => {
20
+ const source = rateLimits[agent] ?? {};
21
+ const pools = {};
22
+ // A pool with a missing number is skipped rather than read as 0 % used.
23
+ const addPool = (name, pool, kind = name === 'session' ? 'session' : 'long') => {
24
+ if (pool == null)
25
+ return;
26
+ const { usedPercent, windowMinutes, resetsAt } = pool;
27
+ if (!isNumber(usedPercent) || !isNumber(windowMinutes) || !isNumber(resetsAt))
28
+ return;
29
+ // Two buckets under one name (an agy group with two weekly limits) keep the tighter one.
30
+ if (pools[name] && pools[name].usedPercent >= usedPercent)
31
+ return;
32
+ pools[name] = { name, kind, usedPercent, windowMinutes, resetsAt };
33
+ };
34
+ if (agent === 'cursor') {
35
+ addPool('monthly', source.monthly);
36
+ for (const bucket of source.buckets ?? []) {
37
+ if (typeof bucket?.name === 'string')
38
+ addPool(`bucket:${bucket.name}`, bucket);
39
+ }
40
+ }
41
+ else if (agent === 'grok') {
42
+ addPool('weekly', source.weekly);
43
+ addPool('monthly', source.monthly);
44
+ }
45
+ else if (agent === 'antigravity' && (source.buckets ?? []).some((b) => typeof b?.group === 'string')) {
46
+ // agy bills each quota group ("Gemini Models", "Claude and GPT models") separately, so each group gets
47
+ // its own pools; Orca's session and weekly are only the tightest across groups.
48
+ for (const bucket of source.buckets ?? []) {
49
+ if (typeof bucket?.group !== 'string')
50
+ continue;
51
+ if (bucket.windowMinutes === 300)
52
+ addPool(`bucket:${bucket.group} 5h`, bucket, 'session');
53
+ else if (isNumber(bucket.windowMinutes) && bucket.windowMinutes > 0)
54
+ addPool(`bucket:${bucket.group}`, bucket, 'long');
55
+ }
56
+ }
57
+ else {
58
+ // Without quota groups (older Orca), Antigravity falls back to Orca's tightest session and weekly.
59
+ addPool('session', source.session);
60
+ addPool('weekly', source.weekly);
61
+ if (agent === 'claude')
62
+ addPool('fableWeekly', source.fableWeekly);
63
+ }
64
+ // Only an explicit 'unavailable' (signed out / CLI missing) excludes the agent;
65
+ // a transient or unrecognised status keeps it as a low-confidence candidate.
66
+ const status = source.status === 'ok' || source.status === 'unavailable' ? source.status : 'unknown';
67
+ const usage = {
68
+ agent,
69
+ status,
70
+ pools,
71
+ ...(source.updatedAt !== undefined ? { updatedAt: source.updatedAt } : {}),
72
+ ...(status !== 'ok'
73
+ ? { error: source.error || (rateLimits[agent] ? `Orca usage ${source.status ?? 'status missing'}` : `Orca reports no usage for ${agent}`) }
74
+ : {}),
75
+ };
76
+ return [agent, usage];
77
+ }));
78
+ }
79
+ catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ return Object.fromEntries(agents.map((agent) => [agent, {
82
+ agent, status: 'unknown', pools: {}, error: `Orca not reachable: ${message}`,
83
+ }]));
84
+ }
85
+ }
@@ -0,0 +1,52 @@
1
+ /** Below this elapsed fraction a projection is mostly noise, so usage is not extrapolated. */
2
+ export const MIN_ELAPSED_TO_PROJECT = 0.1;
3
+ /**
4
+ * Weight, as a fraction of the window, of the even-pace assumption in the headroom estimate. Early in a
5
+ * window there is little history, so the window is assumed to be used up evenly; the observed pace takes
6
+ * over as the window progresses.
7
+ */
8
+ export const EVEN_PACE_WEIGHT = 0.1;
9
+ /**
10
+ * Expected used fraction at reset: what is used so far plus the rest of the window at the estimated pace.
11
+ * The pace (fraction of the window's capacity per window) blends the observed pace with an even pace of 1
12
+ * weighted as EVEN_PACE_WEIGHT of a window of history. The observed pace is measured over at least that
13
+ * much of the window, so a burst right after a reset (some windows start on first use) is not extrapolated.
14
+ */
15
+ export function expectedUse(used, elapsed) {
16
+ const observed = used / Math.max(elapsed, EVEN_PACE_WEIGHT);
17
+ const weight = elapsed / (elapsed + EVEN_PACE_WEIGHT);
18
+ const pace = weight * observed + (1 - weight);
19
+ return used + pace * (1 - elapsed);
20
+ }
21
+ /**
22
+ * Spare capacity counts in full only when it resets within this horizon. Spare capacity that resets later
23
+ * is less urgent to use (and its forecast less certain), so it counts in proportion: half at two weeks.
24
+ */
25
+ export const URGENCY_HORIZON_MS = 7 * 86_400_000;
26
+ /** The headroom a route ranks by: positive headroom scaled down when its reset is beyond the urgency horizon. */
27
+ export function rankHeadroom(headroom, resetsAt, now) {
28
+ const remaining = resetsAt - now;
29
+ return headroom > 0 && remaining > URGENCY_HORIZON_MS ? headroom * (URGENCY_HORIZON_MS / remaining) : headroom;
30
+ }
31
+ export function poolState(pool, now, t) {
32
+ const expired = pool.resetsAt <= now;
33
+ const windowMs = pool.windowMinutes * 60_000;
34
+ const elapsed = windowMs > 0 ? Math.min(1, Math.max(0, 1 - (pool.resetsAt - now) / windowMs)) : 0;
35
+ const headroom = 1 - expectedUse(pool.usedPercent / 100, elapsed);
36
+ const limit = pool.kind === 'session' ? t.sessionPercent : t.longPercent;
37
+ return { expired, blocked: !expired && pool.usedPercent >= limit, headroom, elapsed };
38
+ }
39
+ /** Usage at the window's reset if the current pace holds: used% / elapsed fraction. */
40
+ export function projectPool(pool, now) {
41
+ const { elapsed } = poolState(pool, now, { longPercent: Infinity, sessionPercent: Infinity });
42
+ const tooEarly = elapsed < MIN_ELAPSED_TO_PROJECT;
43
+ const projected = tooEarly ? pool.usedPercent : pool.usedPercent / elapsed;
44
+ return {
45
+ pool: pool.name,
46
+ usedPercent: pool.usedPercent,
47
+ elapsedPercent: Math.round(elapsed * 100),
48
+ projectedPercent: Math.round(projected * 10) / 10,
49
+ tooEarly,
50
+ resetsAt: pool.resetsAt,
51
+ };
52
+ }
@@ -0,0 +1,58 @@
1
+ // Each agent CLI's words for "cannot use this model", as printed when launched with a model it lacks:
2
+ // codex The 'claude-opus-5-5' model is not supported when using Codex with a ChatGPT account.
3
+ // claude There's an issue with the selected model (gpt-6-terra). It may not exist or you may not have access to it.
4
+ // cursor Cannot use this model: claude-opus-9-9. Available models: auto, ...
5
+ // grok Couldn't set model 'grok-9-fake': Invalid params: "unknown model id".
6
+ // antigravity invalid model selection (--model "gemini-9-fake" ...): model gemini-9-fake is not recognized as a known model ...
7
+ // opencode Error: Model unavailable: openrouter/fake/nonexistent-9
8
+ const REFUSAL = /model is not supported|issue with the selected model|cannot use this model|couldn'?t set model|unknown model id|invalid model selection|not recognized as a known model|model unavailable/i;
9
+ /**
10
+ * The agent's line refusing `model`, if the output has one. The line must name the model, so a worker's
11
+ * own output about other models never counts. Long lines wrap, so each line is also tried joined with the next.
12
+ */
13
+ export function findRejection(model, lines) {
14
+ const id = model.toLowerCase();
15
+ const rows = lines.map((l) => l.trim());
16
+ const refuses = (text) => text.toLowerCase().includes(id) && REFUSAL.test(text);
17
+ const single = rows.find(refuses);
18
+ if (single)
19
+ return clean(single);
20
+ for (let i = 0; i + 1 < rows.length; i++) {
21
+ for (const text of [`${rows[i]}${rows[i + 1]}`, `${rows[i]} ${rows[i + 1]}`])
22
+ if (refuses(text))
23
+ return clean(text);
24
+ }
25
+ return undefined;
26
+ }
27
+ /** Drops the agent's decoration ("■ ", "ERROR: "), Cursor's list of available models and a sentence cut off by wrapping. */
28
+ function clean(line) {
29
+ const text = line
30
+ .replace(/^[^\p{L}\p{N}]+/u, '')
31
+ .replace(/^(error|warning):\s*/i, '')
32
+ .split(/\s*Available models:/i)[0]
33
+ .trim()
34
+ .replace(/([.!?]["')\]]?)\s+[^.!?]*$/, '$1');
35
+ return text.length > 240 ? `${text.slice(0, 239)}…` : text;
36
+ }
37
+ /** Rejections seen less than `ttlHours` ago. */
38
+ export function activeRejections(list, now, ttlHours) {
39
+ return list.filter((r) => now - r.at < ttlHours * 3_600_000);
40
+ }
41
+ /** Parses the saved rejections file, dropping malformed entries; empty when missing or invalid. */
42
+ export function parseRejections(text) {
43
+ let data;
44
+ try {
45
+ data = text === undefined ? undefined : JSON.parse(text);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ const list = data?.rejections;
51
+ if (!Array.isArray(list))
52
+ return [];
53
+ return list.filter((r) => typeof r?.agent === 'string' && typeof r?.model === 'string' && typeof r?.message === 'string' && Number.isFinite(r?.at));
54
+ }
55
+ /** The list with `r` added, replacing an older entry for the same agent and model. */
56
+ export function withRejection(list, r) {
57
+ return [...list.filter((x) => x.agent !== r.agent || x.model !== r.model), r];
58
+ }
@@ -0,0 +1,18 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readTextFile } from '../placements/agents/deps.js';
4
+ import { activeRejections, parseRejections, withRejection } from './rejection.js';
5
+ import { cacheDir } from '../paths.js';
6
+ // Models an agent refused at launch, kept in the cache folder: it is observed state, safe to delete.
7
+ const FILE = 'rejected-models.json';
8
+ export function readRejections(readFile = readTextFile, dir = cacheDir()) {
9
+ return parseRejections(readFile(join(dir, FILE)));
10
+ }
11
+ export function writeRejections(list, dir = cacheDir()) {
12
+ mkdirSync(dir, { recursive: true });
13
+ writeFileSync(join(dir, FILE), `${JSON.stringify({ rejections: list }, null, 2)}\n`);
14
+ }
15
+ /** Saves `r`, replacing an older entry for the same agent and model and dropping expired ones. */
16
+ export function recordRejection(r, ttlHours, dir = cacheDir()) {
17
+ writeRejections(activeRejections(withRejection(readRejections(readTextFile, dir), r), r.at, ttlHours), dir);
18
+ }