catherd-cli 1.1.1 → 1.2.1

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 (62) hide show
  1. package/README.md +40 -14
  2. package/catalog/ATTRIBUTION.md +36 -0
  3. package/catalog/models.json +30 -0
  4. package/catalog/scores.json +3006 -194
  5. package/catalog/sources.json +87 -0
  6. package/package.json +1 -1
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +2 -2
  10. package/src/adapters/claude-code/models.ts +2 -1
  11. package/src/domain/bars.ts +119 -0
  12. package/src/domain/calibration.ts +125 -0
  13. package/src/domain/catalog.ts +210 -23
  14. package/src/domain/failover.ts +14 -1
  15. package/src/domain/profile-rules.ts +108 -18
  16. package/src/domain/sources.ts +144 -0
  17. package/src/entry/catalog-command.ts +163 -8
  18. package/src/entry/init-command.ts +72 -2
  19. package/src/entry/mcp/lane-tools.ts +1 -1
  20. package/src/entry/mcp/server.ts +13 -3
  21. package/src/entry/mcp/setup-tools.ts +24 -2
  22. package/src/entry/profile-command.ts +10 -4
  23. package/src/entry/tui/commands.ts +20 -2
  24. package/src/entry/tui/effects.ts +50 -1
  25. package/src/entry/tui/fixtures.ts +31 -3
  26. package/src/entry/tui/profile-edits.ts +17 -9
  27. package/src/entry/tui/profile-tree.ts +2 -2
  28. package/src/entry/tui/state.ts +4 -0
  29. package/src/entry/tui/views/profile-actions.ts +9 -1
  30. package/src/entry/tui/views/profiles.tsx +81 -14
  31. package/src/entry/tui/views/save-dialog.tsx +12 -4
  32. package/src/infra/env.ts +2 -0
  33. package/src/infra/jev-client.ts +88 -40
  34. package/src/infra/paths.ts +2 -0
  35. package/src/infra/sources/arena.ts +87 -0
  36. package/src/infra/sources/artificial-analysis.ts +112 -0
  37. package/src/infra/sources/cache.ts +124 -0
  38. package/src/infra/sources/csv.ts +38 -0
  39. package/src/infra/sources/epoch.ts +82 -0
  40. package/src/infra/sources/http.ts +75 -0
  41. package/src/infra/sources/litellm.ts +58 -0
  42. package/src/infra/sources/models-dev.ts +59 -0
  43. package/src/infra/sources/openrouter-endpoints.ts +52 -0
  44. package/src/infra/sources/openrouter-models.ts +60 -0
  45. package/src/infra/sources/rows.ts +38 -0
  46. package/src/infra/sources/vectara.ts +69 -0
  47. package/src/infra/sources/zip.ts +48 -0
  48. package/src/services/catalog-refresh.ts +200 -0
  49. package/src/services/catalog-service.ts +83 -23
  50. package/src/services/credentials.ts +69 -0
  51. package/src/services/doctor-sources.ts +76 -0
  52. package/src/services/doctor.ts +10 -0
  53. package/src/services/jev-service.ts +14 -41
  54. package/src/services/ports.ts +6 -0
  55. package/src/services/profile-service.ts +8 -6
  56. package/src/services/provenance.ts +115 -0
  57. package/src/services/routing-service.ts +7 -2
  58. package/src/services/run-evidence.ts +86 -0
  59. package/src/services/source-derive.ts +349 -0
  60. package/src/services/source-sync.ts +305 -0
  61. package/src/services/standins.ts +204 -0
  62. package/src/services/treat-likes.ts +84 -0
@@ -0,0 +1,204 @@
1
+ import {
2
+ type Catalog,
3
+ DIMS,
4
+ type Dim,
5
+ type Family,
6
+ type InferredStandIn,
7
+ rungInfo,
8
+ scoresOf,
9
+ } from "../domain/catalog.ts";
10
+ import { catalogRungs } from "../domain/failover.ts";
11
+ import { EFFORT_ORDER } from "../domain/sources.ts";
12
+
13
+ /**
14
+ * Spec 1.2 §6.3: the features a rung is compared on. Numbers are z-scored across the catalog; `vendor` and
15
+ * `family` are words, the same or not. Keyless: price, context, release date, vendor and family, the effort
16
+ * (plan 14 Ruling 6), and the rung's own values on each dimension (the Arena and Epoch values, with the
17
+ * shipped ones). With an Artificial Analysis key, its index, `hle`, `scicode`, `lcr`, cost per task and
18
+ * tokens/s join them (`Catalog.features`).
19
+ */
20
+ export type Features = Record<string, number | string>;
21
+
22
+ /** Spec 1.2 §6.3: a pair sharing fewer features than this is not suggested. */
23
+ export const MIN_SHARED_FEATURES = 3;
24
+
25
+ export interface Suggestion {
26
+ /** the canonical rung that would stand in */
27
+ like: string;
28
+ distance: number;
29
+ /** the features the distance rests on: those both rungs have */
30
+ features: string[];
31
+ /** the dimensions it would lend: those the rung lacks and it has */
32
+ lends: Dim[];
33
+ }
34
+
35
+ const DAY_MS = 86_400_000;
36
+ const family = (c: Catalog, canonical: string): Family | null =>
37
+ c.families.find((f) => f.id === canonical.slice(0, canonical.lastIndexOf("#"))) ?? null;
38
+
39
+ /** The rung's own value per dimension: a value it borrows, or catherd's guess (`inferred`), never counts. */
40
+ function ownValues(c: Catalog, canonical: string): Partial<Record<Dim, number>> {
41
+ const out: Partial<Record<Dim, number>> = {};
42
+ for (const d of DIMS) {
43
+ const s = c.scores[canonical]?.[d];
44
+ if (s && s.confidence !== "inferred") out[d] = s.value;
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /** Spec 1.2 §6.3: a canonical rung's features, from its family, its effort, its own values and AA's. */
50
+ export function featuresOf(c: Catalog, canonical: string): Features {
51
+ const out: Features = {};
52
+ const f = family(c, canonical);
53
+ if (f) {
54
+ out.price = Math.log10(f.price.input + f.price.output + 1e-6);
55
+ const contexts = Object.values(f.on).flatMap((m) => (m ? [m.context] : []));
56
+ if (contexts.length) out.context = Math.log10(Math.max(...contexts));
57
+ if (f.releaseDate) out.release = Date.parse(f.releaseDate) / DAY_MS;
58
+ const vendor = (f as { vendor?: unknown }).vendor;
59
+ if (typeof vendor === "string") out.vendor = vendor;
60
+ out.family = f.id;
61
+ }
62
+ const effort = (EFFORT_ORDER as readonly string[]).indexOf(canonical.slice(canonical.lastIndexOf("#") + 1));
63
+ if (effort >= 0) out.effort = effort;
64
+ for (const [d, v] of Object.entries(ownValues(c, canonical))) out[d] = v;
65
+ for (const [k, v] of Object.entries(c.features[canonical] ?? {})) out[`aa.${k}`] = v;
66
+ return out;
67
+ }
68
+
69
+ /** Every canonical rung the catalog can name, and every one it holds a value for. */
70
+ export function canonicalRungs(c: Catalog): string[] {
71
+ const out = new Set(Object.keys(c.scores));
72
+ for (const r of catalogRungs(c)) {
73
+ try {
74
+ out.add(rungInfo(c, r).canonical);
75
+ } catch {
76
+ // catalogRungs only names parseable rungs
77
+ }
78
+ }
79
+ return [...out].sort();
80
+ }
81
+
82
+ interface Scale {
83
+ mean: number;
84
+ sd: number;
85
+ }
86
+
87
+ /** Each numeric feature's mean and spread over `all`; a feature with no spread tells rungs apart by nothing. */
88
+ function scales(all: Features[]): Map<string, Scale> {
89
+ const values = new Map<string, number[]>();
90
+ for (const f of all)
91
+ for (const [k, v] of Object.entries(f))
92
+ if (typeof v === "number") values.set(k, [...(values.get(k) ?? []), v]);
93
+ const out = new Map<string, Scale>();
94
+ for (const [k, xs] of values) {
95
+ const mean = xs.reduce((s, x) => s + x, 0) / xs.length;
96
+ const sd = Math.sqrt(xs.reduce((s, x) => s + (x - mean) ** 2, 0) / xs.length);
97
+ if (sd > 0) out.set(k, { mean, sd });
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Spec 1.2 §6.3: the z-scored Euclidean distance over the features both have, as a root mean square so pairs
104
+ * sharing different numbers of features compare (plan 14 Ruling 6); a word counts 0 when equal, else 1. Null
105
+ * when they share fewer than MIN_SHARED_FEATURES.
106
+ */
107
+ function distance(a: Features, b: Features, z: Map<string, Scale>): { d: number; shared: string[] } | null {
108
+ const shared: string[] = [];
109
+ let sum = 0;
110
+ for (const [k, va] of Object.entries(a)) {
111
+ const vb = b[k];
112
+ if (vb === undefined) continue;
113
+ if (typeof va === "string" || typeof vb === "string") {
114
+ shared.push(k);
115
+ sum += va === vb ? 0 : 1;
116
+ continue;
117
+ }
118
+ const s = z.get(k);
119
+ if (!s) continue;
120
+ shared.push(k);
121
+ sum += ((va - vb) / s.sd) ** 2;
122
+ }
123
+ if (shared.length < MIN_SHARED_FEATURES) return null;
124
+ return { d: Math.sqrt(sum / shared.length), shared };
125
+ }
126
+
127
+ /** The dimensions the bars use that `canonical` has no value for, of its own or through a treat-like. */
128
+ export function missingDims(c: Catalog, canonical: string, dims: readonly Dim[] = barDimsIn(c)): Dim[] {
129
+ const own = c.scores[canonical] ?? {};
130
+ const like = c.treatLike[canonical]?.like;
131
+ const lent = like ? (c.scores[like] ?? {}) : {};
132
+ return dims.filter((d) => !own[d] && !lent[d]);
133
+ }
134
+
135
+ /** Every dimension some bar of the catalog (the defaults, with the user's override) has a threshold on. */
136
+ export function barDimsIn(c: Catalog): Dim[] {
137
+ const used = new Set<string>();
138
+ for (const kind of Object.values(c.bars))
139
+ for (const bar of Object.values(kind))
140
+ for (const [d, min] of Object.entries(bar)) if (min !== undefined) used.add(d);
141
+ return DIMS.filter((d) => used.has(d));
142
+ }
143
+
144
+ class Ranker {
145
+ private readonly all = new Map<string, Features>();
146
+ private readonly z: Map<string, Scale>;
147
+ constructor(private readonly c: Catalog) {
148
+ for (const r of canonicalRungs(c)) this.all.set(r, featuresOf(c, r));
149
+ this.z = scales([...this.all.values()]);
150
+ }
151
+ /** The rungs with an own value on any of `dims`, nearest first; each with what it would lend. */
152
+ rank(canonical: string, dims: readonly Dim[]): Suggestion[] {
153
+ const mine = this.all.get(canonical) ?? featuresOf(this.c, canonical);
154
+ const out: Suggestion[] = [];
155
+ for (const [other, f] of this.all) {
156
+ if (other === canonical) continue;
157
+ const own = ownValues(this.c, other);
158
+ const lends = dims.filter((d) => own[d] !== undefined);
159
+ if (lends.length === 0) continue;
160
+ const got = distance(mine, f, this.z);
161
+ if (got) out.push({ like: other, distance: Number(got.d.toFixed(4)), features: got.shared, lends });
162
+ }
163
+ return out.sort((a, b) => a.distance - b.distance || a.like.localeCompare(b.like));
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Spec 1.2 §6.4 `treat-like --suggest`: the `limit` nearest rungs that would lend `canonical` a value it lacks
169
+ * on a dimension the bars use (any scored rung when it lacks none), with their distance and features.
170
+ */
171
+ export function suggestStandIns(c: Catalog, canonical: string, limit = 3): Suggestion[] {
172
+ const lacking = missingDims(c, canonical);
173
+ return new Ranker(c).rank(canonical, lacking.length ? lacking : DIMS).slice(0, limit);
174
+ }
175
+
176
+ /**
177
+ * Spec 1.2 §6.1: for every rung the catalog can name, per dimension the bars use that it has no value for
178
+ * (of its own or through a treat-like), its nearest stand-in with a value there. A rung no rung is near
179
+ * enough to (fewer than MIN_SHARED_FEATURES shared) gets none on that dimension.
180
+ */
181
+ export function inferStandIns(c: Catalog): Catalog["inferred"] {
182
+ const dims = barDimsIn(c);
183
+ const ranker = new Ranker(c);
184
+ const out: Catalog["inferred"] = {};
185
+ for (const canonical of canonicalRungs(c)) {
186
+ const lacking = missingDims(c, canonical, dims);
187
+ for (const d of lacking) {
188
+ const best = ranker.rank(canonical, [d])[0];
189
+ if (!best) continue;
190
+ const entry: InferredStandIn = { like: best.like, distance: best.distance, features: best.features };
191
+ (out[canonical] ??= {})[d] = entry;
192
+ }
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /** The catalog with its inferred stand-ins filled in: what routing, validation and the surfaces read. */
198
+ export function withStandIns(c: Catalog): Catalog {
199
+ return { ...c, inferred: inferStandIns(c) };
200
+ }
201
+
202
+ /** Whether `canonical` leans on an inferred stand-in for any value (`scoresOf`'s `inferred`). */
203
+ export const leansOnStandIn = (c: Catalog, canonical: string): boolean =>
204
+ (scoresOf(c, canonical)?.inferred.length ?? 0) > 0;
@@ -0,0 +1,84 @@
1
+ import { type Dim, scoresOf, rungInfo } from "../domain/catalog.ts";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import { tryParseRung } from "../domain/ids.ts";
4
+ import { ROLES } from "../domain/roles.ts";
5
+ import { canonicalRung, loadCatalog, readOverride, removeTreatLikes } from "./catalog-service.ts";
6
+ import { getProfile, listProfiles } from "./profile-store.ts";
7
+ import { type Suggestion, suggestStandIns } from "./standins.ts";
8
+
9
+ /** A profile's rung that would lean on an inferred stand-in, or be left with no value at all (spec 1.2 §6.4). */
10
+ export interface LeftOnStandIn {
11
+ profile: string;
12
+ rung: string;
13
+ /** the dimensions an inferred stand-in would fill; empty when the rung is left unscored */
14
+ dims: Dim[];
15
+ /** no value of its own and no stand-in near enough: routing skips it */
16
+ unscored: boolean;
17
+ }
18
+
19
+ /**
20
+ * Spec 1.2 §6.4: the rungs of every profile (enabled roles' rungs and failover stand-ins) that would lean on an
21
+ * inferred stand-in, or be left with no value at all, once the user's treat-likes for `removed` (canonical
22
+ * rungs, or all of them) are gone. Never removed silently.
23
+ */
24
+ export function leftOnStandIns(removed: string[] | "all"): LeftOnStandIn[] {
25
+ const cur = readOverride();
26
+ const treatLike = Object.fromEntries(
27
+ Object.entries(cur.treatLike).filter(([r]) => removed !== "all" && !removed.includes(r)),
28
+ );
29
+ const gone = removed === "all" ? Object.keys(cur.treatLike) : removed;
30
+ const c = loadCatalog({ timings: false, override: { ...cur, treatLike } });
31
+ const out: LeftOnStandIn[] = [];
32
+ for (const profile of listProfiles()) {
33
+ const p = getProfile(profile);
34
+ const rungs = new Set([
35
+ ...ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs),
36
+ ...Object.values(p.failover),
37
+ ]);
38
+ for (const rung of rungs) {
39
+ if (!tryParseRung(rung)) continue;
40
+ const canonical = rungInfo(c, rung).canonical;
41
+ if (!gone.includes(canonical)) continue;
42
+ const s = scoresOf(c, canonical);
43
+ if (!s) out.push({ profile, rung, dims: [], unscored: true });
44
+ else if (s.inferred.length) out.push({ profile, rung, dims: s.inferred, unscored: false });
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** Spec 1.2 §6.4 `--suggest`: the rung, in canonical form, and its three nearest stand-ins. */
51
+ export function suggestFor(rung: string): { canonical: string; lacking: Dim[]; suggestions: Suggestion[] } {
52
+ const c = loadCatalog({ timings: false });
53
+ const canonical = canonicalRung(rung);
54
+ const s = scoresOf(c, canonical);
55
+ return {
56
+ canonical,
57
+ lacking: s ? s.inferred : [],
58
+ suggestions: suggestStandIns(c, canonical),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Spec 1.2 §6.4 `--clear`: removes the user's treat-like for `rung`, naming first the profile rungs it
64
+ * leaves on an inferred stand-in. A shipped treat-like is not the user's to remove.
65
+ */
66
+ export async function clearTreatLike(
67
+ rung: string,
68
+ ): Promise<{ rung: string; like: string; left: LeftOnStandIn[] }> {
69
+ const canonical = canonicalRung(rung);
70
+ const like = readOverride().treatLike[canonical];
71
+ if (!like)
72
+ throw new CatherdError("E_INPUT_INVALID", `${canonical} has no treat-like of yours to clear`, {
73
+ fix: "catherd catalog list shows each rung's treat-like; a shipped one is not yours to clear",
74
+ });
75
+ const left = leftOnStandIns([canonical]);
76
+ await removeTreatLikes([canonical]);
77
+ return { rung: canonical, like, left };
78
+ }
79
+
80
+ /** Spec 1.2 §6.4 `--reset`: removes every treat-like of the user's, naming first the rungs left on a stand-in. */
81
+ export async function resetTreatLikes(): Promise<{ removed: [string, string][]; left: LeftOnStandIn[] }> {
82
+ const left = leftOnStandIns("all");
83
+ return { removed: await removeTreatLikes("all"), left };
84
+ }