catherd-cli 0.2.1 → 1.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/README.md +114 -30
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +227 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +316 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +58 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -26
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +33 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +133 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +196 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { DiscoveredModel } from "../adapters/backend.ts";
|
|
4
|
+
import { discovered, listingRepo, readDiscovery, writeDiscovery } from "../adapters/discovery.ts";
|
|
5
|
+
import { adapterFor } from "../adapters/registry.ts";
|
|
6
|
+
import "../adapters/all.ts";
|
|
7
|
+
import {
|
|
8
|
+
buildCatalog,
|
|
9
|
+
type Catalog,
|
|
10
|
+
CanonicalRung,
|
|
11
|
+
capableFor,
|
|
12
|
+
DIMS,
|
|
13
|
+
type Family,
|
|
14
|
+
type ModelsFile,
|
|
15
|
+
ModelsFileSchema,
|
|
16
|
+
type Override,
|
|
17
|
+
OverrideSchema,
|
|
18
|
+
rungInfo,
|
|
19
|
+
type ScoresFile,
|
|
20
|
+
ScoresFileSchema,
|
|
21
|
+
scoresOf,
|
|
22
|
+
} from "../domain/catalog.ts";
|
|
23
|
+
import { costOf, DEFAULT_BILLING, type BillingMode } from "../domain/cost.ts";
|
|
24
|
+
import { CatherdError, errorMessage } from "../domain/errors.ts";
|
|
25
|
+
import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
|
|
26
|
+
import type { Kind } from "../domain/lane.ts";
|
|
27
|
+
import { routeAt } from "../domain/route.ts";
|
|
28
|
+
import { ROLES, type Role } from "../domain/roles.ts";
|
|
29
|
+
import { median } from "../domain/util.ts";
|
|
30
|
+
import { assetPath } from "../infra/assets.ts";
|
|
31
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
32
|
+
import { log } from "../infra/log.ts";
|
|
33
|
+
import { configDir } from "../infra/paths.ts";
|
|
34
|
+
import { ensurePrivateDir, readVersioned, writeJsonAtomic } from "../infra/store.ts";
|
|
35
|
+
import type { CatalogFilter } from "./ports.ts";
|
|
36
|
+
import { listRuns, readAgentRuns, readRecords, readRoutes } from "./run-store.ts";
|
|
37
|
+
|
|
38
|
+
const DAY_MS = 24 * 3_600_000;
|
|
39
|
+
/** Spec §5.2: `secs_per_task` counts once a rung has this many of the user's own runs. */
|
|
40
|
+
const MIN_SAMPLES = 5;
|
|
41
|
+
|
|
42
|
+
let models: ModelsFile | null = null;
|
|
43
|
+
let scores: ScoresFile | null = null;
|
|
44
|
+
const shippedModels = (): ModelsFile =>
|
|
45
|
+
(models ??= readVersioned(assetPath("catalog/models.json"), ModelsFileSchema, 1));
|
|
46
|
+
const shippedScores = (): ScoresFile =>
|
|
47
|
+
(scores ??= readVersioned(assetPath("catalog/scores.json"), ScoresFileSchema, 1));
|
|
48
|
+
|
|
49
|
+
export const overridePath = (): string => join(configDir(), "catalog.override.json");
|
|
50
|
+
|
|
51
|
+
function readOverride(): Override {
|
|
52
|
+
const file = overridePath();
|
|
53
|
+
return existsSync(file) ? readVersioned(file, OverrideSchema, 1) : OverrideSchema.parse({});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every backend's last listing (spec §3.5 `<data>/discovery/<backend>.json`); for a backend listed per
|
|
58
|
+
* repository, its listing in `repo` (none without one).
|
|
59
|
+
*/
|
|
60
|
+
function listedModels(repo?: string): Catalog["listed"] {
|
|
61
|
+
const out: Catalog["listed"] = {};
|
|
62
|
+
for (const id of ADAPTER_IDS) {
|
|
63
|
+
const d = readDiscovery(id, listingRepo(id, repo));
|
|
64
|
+
if (d) out[id] = { fetchedAt: d.fetchedAt, models: d.models };
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Spec §5.2: the median seconds of the user's own successful runs per canonical rung and lane kind
|
|
71
|
+
* (`|*` over every kind), kept only with at least MIN_SAMPLES runs. A run counts under the kind of its
|
|
72
|
+
* lane's route in force when it started; a run with none counts only under `|*`. Native `claude:`
|
|
73
|
+
* subagent runs (agents.jsonl, from record_agent_run) count too; one that names no lane counts only
|
|
74
|
+
* under `|*`.
|
|
75
|
+
*/
|
|
76
|
+
export function measuredSecs(base: Catalog): Catalog["secs"] {
|
|
77
|
+
const groups = new Map<string, number[]>();
|
|
78
|
+
const add = (k: string, secs: number) => groups.set(k, [...(groups.get(k) ?? []), secs]);
|
|
79
|
+
const count = (
|
|
80
|
+
routes: ReturnType<typeof readRoutes>,
|
|
81
|
+
rung: string,
|
|
82
|
+
secs: number,
|
|
83
|
+
lane: string | null,
|
|
84
|
+
startedAt: string,
|
|
85
|
+
) => {
|
|
86
|
+
let canonical: string;
|
|
87
|
+
try {
|
|
88
|
+
canonical = rungInfo(base, rung).canonical;
|
|
89
|
+
} catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// the kind the lane was routed as when this run started, not its newest route
|
|
93
|
+
const kind: Kind | null = lane ? (routeAt(routes, lane, startedAt)?.kind ?? null) : null;
|
|
94
|
+
add(`${canonical}|*`, secs);
|
|
95
|
+
if (kind) add(`${canonical}|${kind}`, secs);
|
|
96
|
+
};
|
|
97
|
+
for (const run of listRuns().runs) {
|
|
98
|
+
const routes = readRoutes(run);
|
|
99
|
+
for (const r of readRecords(run).records)
|
|
100
|
+
if (r.status === "ok") count(routes, r.rung, r.secs, r.lane, r.startedAt);
|
|
101
|
+
for (const a of readAgentRuns(run)) {
|
|
102
|
+
if (a.status !== "ok" || a.secs === null) continue;
|
|
103
|
+
// a row is written when the subagent ends; it started `secs` earlier
|
|
104
|
+
const lane = typeof a.lane === "string" ? a.lane : null;
|
|
105
|
+
const end = Date.parse(a.at);
|
|
106
|
+
// a duration past the Date range (record_agent_run takes any) keeps the end, not a RangeError
|
|
107
|
+
const start = new Date(end - a.secs * 1000);
|
|
108
|
+
const startedAt = Number.isNaN(start.getTime()) ? a.at : start.toISOString();
|
|
109
|
+
count(routes, a.rung, a.secs, lane, startedAt);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const secs: Catalog["secs"] = {};
|
|
113
|
+
for (const [k, xs] of groups) if (xs.length >= MIN_SAMPLES) secs[k] = median(xs) as number;
|
|
114
|
+
return secs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The shipped catalog with every listing (in `repo` for a backend listed per repository), the user's
|
|
119
|
+
* override and, unless `timings: false`, own timings.
|
|
120
|
+
*/
|
|
121
|
+
export function loadCatalog(o: { timings?: boolean; repo?: string } = {}): Catalog {
|
|
122
|
+
const base = buildCatalog({
|
|
123
|
+
models: shippedModels(),
|
|
124
|
+
scores: shippedScores(),
|
|
125
|
+
override: readOverride(),
|
|
126
|
+
listed: listedModels(o.repo),
|
|
127
|
+
});
|
|
128
|
+
return o.timings === false ? base : { ...base, secs: measuredSecs(base) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface Refreshed {
|
|
132
|
+
backend: string;
|
|
133
|
+
models: number;
|
|
134
|
+
fetchedAt: string | null;
|
|
135
|
+
error?: string;
|
|
136
|
+
/** the command that fixes `error`, when the backend's probe names one */
|
|
137
|
+
fix?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Spec §5.2: `init`, `doctor` and `catherd catalog refresh` list every backend's models now. */
|
|
141
|
+
export async function refreshDiscovery(
|
|
142
|
+
o: { backends?: string[]; now?: number; repo?: string } = {},
|
|
143
|
+
): Promise<Refreshed[]> {
|
|
144
|
+
const out: Refreshed[] = [];
|
|
145
|
+
for (const id of o.backends ?? ADAPTER_IDS) {
|
|
146
|
+
const adapter = adapterFor(id);
|
|
147
|
+
if (!adapter) continue;
|
|
148
|
+
const repo = listingRepo(id, o.repo);
|
|
149
|
+
let listed: DiscoveredModel[] = [];
|
|
150
|
+
try {
|
|
151
|
+
listed = await adapter.listModels(repo);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
out.push({
|
|
154
|
+
backend: id,
|
|
155
|
+
models: 0,
|
|
156
|
+
fetchedAt: readDiscovery(id, repo)?.fetchedAt ?? null,
|
|
157
|
+
error: String(e),
|
|
158
|
+
});
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (listed.length === 0) {
|
|
162
|
+
// a CLI that is missing (or not ready) lists nothing: say why, and keep only a listing that exists
|
|
163
|
+
const problem = (await adapter.probe().catch(() => null))?.problems[0];
|
|
164
|
+
const fetchedAt = readDiscovery(id, repo)?.fetchedAt ?? null;
|
|
165
|
+
out.push({
|
|
166
|
+
backend: id,
|
|
167
|
+
models: 0,
|
|
168
|
+
fetchedAt,
|
|
169
|
+
error: `${problem?.message ?? "listed no models"}${fetchedAt === null ? "" : "; the previous listing is kept"}`,
|
|
170
|
+
...(problem?.fix ? { fix: problem.fix } : {}),
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const f = writeDiscovery(id, listed, o.now, repo);
|
|
175
|
+
out.push({ backend: id, models: f.models.length, fetchedAt: f.fetchedAt });
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const tried = new Map<string, number>();
|
|
181
|
+
/** Forget which backends (and repositories) this process already tried to list (tests). */
|
|
182
|
+
export const resetFreshen = (): void => tried.clear();
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Spec §5.2: `route` lists a backend again at most daily. A backend this process failed to list is not
|
|
186
|
+
* tried again for an hour, so a missing or wedged CLI never slows every route. Due backends are listed
|
|
187
|
+
* in parallel, so the slowest listing bounds the wait, not their sum. A backend listed per repository is
|
|
188
|
+
* listed, cached and backed off in `repo`.
|
|
189
|
+
*/
|
|
190
|
+
export async function freshenDiscovery(rungs: string[], now = Date.now(), repo?: string): Promise<void> {
|
|
191
|
+
const backends = new Set<string>();
|
|
192
|
+
for (const r of rungs) {
|
|
193
|
+
const b = tryParseRung(r)?.backend;
|
|
194
|
+
if (b) backends.add(b === "claude" ? "claude-code" : b); // validate reports a bad rung
|
|
195
|
+
}
|
|
196
|
+
const due: { backend: string; listing: Promise<unknown> }[] = [];
|
|
197
|
+
for (const b of backends) {
|
|
198
|
+
const adapter = adapterFor(b);
|
|
199
|
+
const where = listingRepo(b, repo);
|
|
200
|
+
const key = where === undefined ? b : `${b} ${where}`;
|
|
201
|
+
if (!adapter || now - (tried.get(key) ?? Number.NEGATIVE_INFINITY) < 3_600_000) continue;
|
|
202
|
+
const cached = readDiscovery(b, where);
|
|
203
|
+
if (cached && now - Date.parse(cached.fetchedAt) < DAY_MS) continue;
|
|
204
|
+
tried.set(key, now);
|
|
205
|
+
due.push({
|
|
206
|
+
backend: b,
|
|
207
|
+
listing: discovered(b, () => adapter.listModels(where), { maxAgeMs: DAY_MS, now, repo: where }),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// a failed listing keeps the last one (and the hour's backoff above); the log says why it failed
|
|
211
|
+
const settled = await Promise.allSettled(due.map((d) => d.listing));
|
|
212
|
+
settled.forEach((r, i) => {
|
|
213
|
+
if (r.status === "rejected")
|
|
214
|
+
log("debug", "discovery", { backend: due[i]?.backend, error: errorMessage(r.reason) });
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A rung given as `backend:model#effort` or as a canonical `model#effort`, in canonical form. */
|
|
219
|
+
function canonicalOf(c: Catalog, rung: string): string {
|
|
220
|
+
let canonical = rung;
|
|
221
|
+
try {
|
|
222
|
+
if (rung.includes(":")) canonical = rungInfo(c, rung).canonical;
|
|
223
|
+
} catch {
|
|
224
|
+
// a rung the catalog cannot resolve stays as given; the canonical check below refuses it
|
|
225
|
+
}
|
|
226
|
+
// a backend's own model id without its backend: the family that backend runs it for names it
|
|
227
|
+
const hash = canonical.lastIndexOf("#");
|
|
228
|
+
const model = canonical.slice(0, hash);
|
|
229
|
+
if (hash > 0 && !c.families.some((f) => f.id === model)) {
|
|
230
|
+
const family = c.families.find((f) => Object.values(f.on).some((m) => m?.id === model));
|
|
231
|
+
if (family) canonical = `${family.id}${canonical.slice(hash)}`;
|
|
232
|
+
}
|
|
233
|
+
if (!CanonicalRung.safeParse(canonical).success)
|
|
234
|
+
throw new CatherdError("E_INPUT_INVALID", `"${rung}" is not a rung`, {
|
|
235
|
+
fix: "name it as backend:model#effort or model#effort, e.g. codex:gpt-6-sol#high",
|
|
236
|
+
});
|
|
237
|
+
return canonical;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Spec §5.2 "treat like": the user maps an unscored rung onto a scored one in catalog.override.json. */
|
|
241
|
+
export async function saveTreatLike(rung: string, like: string): Promise<{ rung: string; like: string }> {
|
|
242
|
+
const c = loadCatalog({ timings: false });
|
|
243
|
+
const from = canonicalOf(c, rung);
|
|
244
|
+
const to = canonicalOf(c, like);
|
|
245
|
+
if (!c.scores[to])
|
|
246
|
+
throw new CatherdError("E_CONFIG_INVALID", `${like} has no scores of its own to lend`, {
|
|
247
|
+
fix: "treat it like a scored rung; catalog_query lists them",
|
|
248
|
+
});
|
|
249
|
+
if (from === to)
|
|
250
|
+
throw new CatherdError("E_CONFIG_INVALID", `${rung} cannot be treated like itself`, {
|
|
251
|
+
fix: "name a different, scored rung",
|
|
252
|
+
});
|
|
253
|
+
if (c.scores[from])
|
|
254
|
+
throw new CatherdError(
|
|
255
|
+
"E_CONFIG_INVALID",
|
|
256
|
+
`${from} has scores of its own; a treat-like would not change it`,
|
|
257
|
+
{
|
|
258
|
+
fix: "treat-like maps only unscored rungs; catalog_query shows each rung's scores",
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
ensurePrivateDir(dirname(overridePath()));
|
|
262
|
+
await withFileLock(overridePath(), () => {
|
|
263
|
+
const cur = readOverride();
|
|
264
|
+
const next = { ...cur, schema: 1, treatLike: { ...cur.treatLike, [from]: to } };
|
|
265
|
+
const checked = OverrideSchema.safeParse(next);
|
|
266
|
+
if (!checked.success)
|
|
267
|
+
throw new CatherdError(
|
|
268
|
+
"E_CONFIG_INVALID",
|
|
269
|
+
`the treat-like ${from} → ${to} would not be a valid override`,
|
|
270
|
+
{
|
|
271
|
+
fix: checked.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
writeJsonAtomic(overridePath(), next);
|
|
275
|
+
});
|
|
276
|
+
return { rung: from, like: to };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The backend a billing or harness key bills or isolates: `opencode-go` is a provider of the opencode backend. */
|
|
280
|
+
export const backendOfKey = (key: string) => (key === "opencode-go" ? "opencode" : key);
|
|
281
|
+
|
|
282
|
+
function rungRows(
|
|
283
|
+
c: Catalog,
|
|
284
|
+
backend: string,
|
|
285
|
+
model: string,
|
|
286
|
+
efforts: string[],
|
|
287
|
+
billing: Partial<Record<string, BillingMode>>,
|
|
288
|
+
) {
|
|
289
|
+
return (efforts.length ? efforts : ["default"]).map((effort) => {
|
|
290
|
+
const rung = `${backend}:${model}#${effort}`;
|
|
291
|
+
const info = rungInfo(c, rung);
|
|
292
|
+
const s = scoresOf(c, info.canonical);
|
|
293
|
+
const scored: Record<string, { value: number; benchmark: string; confidence: string }> = {};
|
|
294
|
+
for (const d of DIMS) {
|
|
295
|
+
const r = s?.records[d];
|
|
296
|
+
if (r)
|
|
297
|
+
scored[d] = {
|
|
298
|
+
value: r.value,
|
|
299
|
+
benchmark: `${r.benchmark} ${r.version}`,
|
|
300
|
+
confidence: s?.via ? "inferred" : r.confidence,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const like = c.treatLike[info.canonical] ?? null;
|
|
304
|
+
return {
|
|
305
|
+
rung,
|
|
306
|
+
enabled: s !== null,
|
|
307
|
+
...(s === null ? { why: "unscored: map it with catherd catalog treat-like <rung> <scored rung>" } : {}),
|
|
308
|
+
scores: scored,
|
|
309
|
+
treatLike: s?.via ? like : null,
|
|
310
|
+
cost: costOf(info.family, effort, billing[info.key] ?? DEFAULT_BILLING[info.key]),
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface CatalogModel {
|
|
316
|
+
id: string;
|
|
317
|
+
name: string | null;
|
|
318
|
+
backend: string;
|
|
319
|
+
model: string;
|
|
320
|
+
billing: string;
|
|
321
|
+
efforts: string[];
|
|
322
|
+
context: number | null;
|
|
323
|
+
capabilities: Family["capabilities"] | null;
|
|
324
|
+
roles: Role[];
|
|
325
|
+
listed: boolean | null;
|
|
326
|
+
notes: Record<string, string>;
|
|
327
|
+
rungs: ReturnType<typeof rungRows>;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Spec §4.8 `catalog_query`: shipped families on each backend, plus listed models catherd cannot score;
|
|
332
|
+
* a backend listed per repository as it is listed in `f.repo` (a git toplevel), as `route` reads it.
|
|
333
|
+
*/
|
|
334
|
+
export function catalogQuery(
|
|
335
|
+
f: CatalogFilter,
|
|
336
|
+
billing: Partial<Record<string, BillingMode>> = {},
|
|
337
|
+
): { total: number; models: CatalogModel[] } {
|
|
338
|
+
const c = loadCatalog({ timings: false, repo: f.repo });
|
|
339
|
+
const rows: CatalogModel[] = [];
|
|
340
|
+
const known = new Set<string>();
|
|
341
|
+
const entry = (backend: string, model: string, fam: Family | null): CatalogModel => {
|
|
342
|
+
const probe = rungInfo(c, `${backend}:${model}#default`);
|
|
343
|
+
return {
|
|
344
|
+
id: fam?.id ?? model,
|
|
345
|
+
name: fam?.name ?? null,
|
|
346
|
+
backend,
|
|
347
|
+
model,
|
|
348
|
+
billing: probe.key,
|
|
349
|
+
efforts: probe.efforts,
|
|
350
|
+
context: probe.context,
|
|
351
|
+
capabilities: fam?.capabilities ?? null,
|
|
352
|
+
roles: ROLES.filter((r) => capableFor(c, probe, r)),
|
|
353
|
+
listed: probe.listed,
|
|
354
|
+
notes: fam?.notes ?? {},
|
|
355
|
+
rungs: rungRows(c, backend, model, probe.efforts, billing),
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
for (const fam of c.families)
|
|
359
|
+
for (const [key, on] of Object.entries(fam.on)) {
|
|
360
|
+
const backends = key === "claude-code" ? ["claude", "claude-code"] : [backendOfKey(key)];
|
|
361
|
+
for (const b of backends) rows.push(entry(b, on.id, fam));
|
|
362
|
+
known.add(`${backendOfKey(key)}:${on.id}`);
|
|
363
|
+
}
|
|
364
|
+
for (const [backend, l] of Object.entries(c.listed))
|
|
365
|
+
for (const m of l.models) if (!known.has(`${backend}:${m.id}`)) rows.push(entry(backend, m.id, null));
|
|
366
|
+
const needle = f.text?.toLowerCase();
|
|
367
|
+
const kept = rows
|
|
368
|
+
.filter(
|
|
369
|
+
(m) =>
|
|
370
|
+
(!f.role || m.roles.includes(f.role)) &&
|
|
371
|
+
(!f.backend || m.backend === f.backend || m.billing === f.backend) &&
|
|
372
|
+
(!needle || `${m.id} ${m.model} ${m.name ?? ""}`.toLowerCase().includes(needle)) &&
|
|
373
|
+
(!f.scoredOnly || m.rungs.some((r) => r.enabled)),
|
|
374
|
+
)
|
|
375
|
+
.sort(
|
|
376
|
+
(a, b) =>
|
|
377
|
+
Number(b.rungs.some((r) => r.enabled)) - Number(a.rungs.some((r) => r.enabled)) ||
|
|
378
|
+
a.id.localeCompare(b.id) ||
|
|
379
|
+
a.backend.localeCompare(b.backend),
|
|
380
|
+
);
|
|
381
|
+
return { total: kept.length, models: kept.slice(0, f.limit) };
|
|
382
|
+
}
|