catherd-cli 1.1.1 → 1.2.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 +40 -14
- package/catalog/ATTRIBUTION.md +36 -0
- package/catalog/models.json +10 -0
- package/catalog/scores.json +3006 -194
- package/catalog/sources.json +86 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +2 -2
- package/src/domain/bars.ts +119 -0
- package/src/domain/calibration.ts +125 -0
- package/src/domain/catalog.ts +210 -23
- package/src/domain/failover.ts +14 -1
- package/src/domain/profile-rules.ts +108 -18
- package/src/domain/sources.ts +144 -0
- package/src/entry/catalog-command.ts +163 -8
- package/src/entry/init-command.ts +72 -2
- package/src/entry/mcp/lane-tools.ts +1 -1
- package/src/entry/mcp/server.ts +13 -3
- package/src/entry/mcp/setup-tools.ts +24 -2
- package/src/entry/profile-command.ts +10 -4
- package/src/entry/tui/commands.ts +20 -2
- package/src/entry/tui/effects.ts +50 -1
- package/src/entry/tui/fixtures.ts +31 -3
- package/src/entry/tui/profile-edits.ts +17 -9
- package/src/entry/tui/profile-tree.ts +2 -2
- package/src/entry/tui/state.ts +4 -0
- package/src/entry/tui/views/profile-actions.ts +9 -1
- package/src/entry/tui/views/profiles.tsx +81 -14
- package/src/entry/tui/views/save-dialog.tsx +12 -4
- package/src/infra/env.ts +2 -0
- package/src/infra/jev-client.ts +88 -40
- package/src/infra/paths.ts +2 -0
- package/src/infra/sources/arena.ts +87 -0
- package/src/infra/sources/artificial-analysis.ts +112 -0
- package/src/infra/sources/cache.ts +124 -0
- package/src/infra/sources/csv.ts +38 -0
- package/src/infra/sources/epoch.ts +82 -0
- package/src/infra/sources/http.ts +75 -0
- package/src/infra/sources/litellm.ts +58 -0
- package/src/infra/sources/models-dev.ts +59 -0
- package/src/infra/sources/openrouter-endpoints.ts +52 -0
- package/src/infra/sources/openrouter-models.ts +60 -0
- package/src/infra/sources/rows.ts +38 -0
- package/src/infra/sources/vectara.ts +69 -0
- package/src/infra/sources/zip.ts +48 -0
- package/src/services/catalog-refresh.ts +200 -0
- package/src/services/catalog-service.ts +83 -23
- package/src/services/credentials.ts +69 -0
- package/src/services/doctor-sources.ts +76 -0
- package/src/services/doctor.ts +10 -0
- package/src/services/jev-service.ts +14 -41
- package/src/services/ports.ts +6 -0
- package/src/services/profile-service.ts +8 -6
- package/src/services/provenance.ts +115 -0
- package/src/services/routing-service.ts +7 -2
- package/src/services/run-evidence.ts +86 -0
- package/src/services/source-derive.ts +349 -0
- package/src/services/source-sync.ts +305 -0
- package/src/services/standins.ts +204 -0
- package/src/services/treat-likes.ts +84 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DIMS, type Family, FamilyFactsSchema, ScoreSchema } from "./catalog.ts";
|
|
3
|
+
|
|
4
|
+
/** Spec 1.2 §3.1: the sources `catherd catalog sync` reads, one cache file each in `<data>/sources/`. */
|
|
5
|
+
export const SOURCE_IDS = [
|
|
6
|
+
"models-dev",
|
|
7
|
+
"openrouter-models",
|
|
8
|
+
"openrouter-endpoints",
|
|
9
|
+
"litellm",
|
|
10
|
+
"arena",
|
|
11
|
+
"vectara",
|
|
12
|
+
"epoch",
|
|
13
|
+
"artificial-analysis",
|
|
14
|
+
] as const;
|
|
15
|
+
export type SourceId = (typeof SOURCE_IDS)[number];
|
|
16
|
+
|
|
17
|
+
/** `catalog/sources.json`: each source with its license and attribution line, the id aliases, default efforts. */
|
|
18
|
+
export const SourcesFileSchema = z.looseObject({
|
|
19
|
+
schema: z.literal(1),
|
|
20
|
+
version: z.string(),
|
|
21
|
+
sources: z.array(
|
|
22
|
+
z.object({
|
|
23
|
+
id: z.enum(SOURCE_IDS),
|
|
24
|
+
name: z.string(),
|
|
25
|
+
url: z.url(),
|
|
26
|
+
license: z.string(),
|
|
27
|
+
attribution: z.string(),
|
|
28
|
+
keyed: z.boolean(),
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
/** a source's irregular model id → the catalog's id (spec 1.2 §3.4) */
|
|
32
|
+
aliases: z.record(z.string(), z.string()),
|
|
33
|
+
/** family id → the effort a source that names none means; a family not listed means `high` */
|
|
34
|
+
defaultEffort: z.record(z.string(), z.string()),
|
|
35
|
+
});
|
|
36
|
+
export type SourcesFile = z.infer<typeof SourcesFileSchema>;
|
|
37
|
+
|
|
38
|
+
/** Effort words as catherd writes them, weakest first; a source's spelling (`xHigh`, `MAX`) is folded onto these. */
|
|
39
|
+
export const EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"] as const;
|
|
40
|
+
|
|
41
|
+
/** `s` as a catherd effort word (`xHigh` → `xhigh`), or null when it is not one. */
|
|
42
|
+
export function effortWord(s: string): string | null {
|
|
43
|
+
const w = s.trim().toLowerCase();
|
|
44
|
+
return (EFFORT_ORDER as readonly string[]).includes(w) ? w : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Spec 1.2 §3.4: a source's model id in catherd's form: without a `vendor/` prefix, lower case, and each run
|
|
49
|
+
* of dots and blanks as one `-` (`openai/gpt-5.6-sol` and `GPT 5.6 Sol` → `gpt-5-6-sol`).
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeId(id: string): string {
|
|
52
|
+
return id
|
|
53
|
+
.slice(id.lastIndexOf("/") + 1)
|
|
54
|
+
.trim()
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[\s.]+/g, "-")
|
|
57
|
+
.replace(/-+/g, "-");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A parser's `rung`: `<source's model id>#<effort>`, or the bare id when the source names no effort. */
|
|
61
|
+
export function splitSourceRung(rung: string): { id: string; effort: string | null } {
|
|
62
|
+
const hash = rung.lastIndexOf("#");
|
|
63
|
+
return hash < 0 ? { id: rung, effort: null } : { id: rung.slice(0, hash), effort: rung.slice(hash + 1) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface IdMapper {
|
|
67
|
+
/** the id pairs are keyed by: normalized, then through the alias table */
|
|
68
|
+
key(id: string): string;
|
|
69
|
+
/** the catalog family a source id names, directly, by a backend's own id or through an alias; never guessed */
|
|
70
|
+
family(id: string): Family | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Spec 1.2 §3.4: matches a source's ids to the catalog's families. */
|
|
74
|
+
export function idMapper(families: Family[], aliases: Record<string, string>): IdMapper {
|
|
75
|
+
const alias = new Map(Object.entries(aliases).map(([from, to]) => [normalizeId(from), normalizeId(to)]));
|
|
76
|
+
const byKey = new Map<string, Family>();
|
|
77
|
+
for (const f of families) {
|
|
78
|
+
for (const on of Object.values(f.on)) if (on) byKey.set(normalizeId(on.id), f);
|
|
79
|
+
byKey.set(normalizeId(f.id), f);
|
|
80
|
+
}
|
|
81
|
+
const key = (id: string) => {
|
|
82
|
+
const n = normalizeId(id);
|
|
83
|
+
return alias.get(n) ?? n;
|
|
84
|
+
};
|
|
85
|
+
return { key, family: (id) => byKey.get(key(id)) ?? null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Spec 1.2 §3.4: the effort a source that names none means for `family`. */
|
|
89
|
+
export const defaultEffortOf = (sources: SourcesFile, family: Family): string =>
|
|
90
|
+
sources.defaultEffort[family.id] ?? "high";
|
|
91
|
+
|
|
92
|
+
const order = (e: string) => (EFFORT_ORDER as readonly string[]).indexOf(e);
|
|
93
|
+
|
|
94
|
+
/** Every effort the family offers on any backend, weakest first (spec 1.2 §4.3 `adjacent`). */
|
|
95
|
+
export function familyEfforts(f: Family): string[] {
|
|
96
|
+
const all = new Set<string>();
|
|
97
|
+
for (const on of Object.values(f.on)) for (const e of on?.efforts ?? []) all.add(e);
|
|
98
|
+
return [...all].filter((e) => order(e) >= 0).sort((a, b) => order(a) - order(b));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Spec 1.2 §4.2: one source's fit onto a dimension's anchor, used or not, and why not. */
|
|
102
|
+
export const FitRowSchema = z.object({
|
|
103
|
+
dim: z.enum(DIMS),
|
|
104
|
+
source: z.string(),
|
|
105
|
+
field: z.string(),
|
|
106
|
+
n: z.number().int(),
|
|
107
|
+
a: z.number().nullable(),
|
|
108
|
+
b: z.number().nullable(),
|
|
109
|
+
r2: z.number().nullable(),
|
|
110
|
+
used: z.boolean(),
|
|
111
|
+
why: z.string().optional(),
|
|
112
|
+
});
|
|
113
|
+
export type FitRow = z.infer<typeof FitRowSchema>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `<data>/sources/derived.json`: what a sync made of the cached answers. `scores` are the synced values the
|
|
117
|
+
* catalog layers over the shipped ones (`measured`, `calibrated`, `adjacent`), `facts` each family's facts,
|
|
118
|
+
* `unmatched` each source's ids no family matched (never guessed), `warnings` the cross-checks that failed.
|
|
119
|
+
*/
|
|
120
|
+
export const DerivedSchema = z.looseObject({
|
|
121
|
+
schema: z.literal(1),
|
|
122
|
+
builtAt: z.iso.datetime(),
|
|
123
|
+
scores: z.array(ScoreSchema),
|
|
124
|
+
facts: z.record(z.string(), FamilyFactsSchema),
|
|
125
|
+
fits: z.array(FitRowSchema),
|
|
126
|
+
unmatched: z.record(z.string(), z.array(z.string())),
|
|
127
|
+
warnings: z.array(z.string()),
|
|
128
|
+
/** canonical rung → the Artificial Analysis stand-in features (spec 1.2 §6.3); a file before plan 14 has none */
|
|
129
|
+
features: z.record(z.string(), z.record(z.string(), z.number())).default({}),
|
|
130
|
+
});
|
|
131
|
+
export type Derived = z.infer<typeof DerivedSchema>;
|
|
132
|
+
|
|
133
|
+
/** The effort of `have` nearest `target` (the weaker one on a tie); null when `have` holds no effort word. */
|
|
134
|
+
export function nearestEffort(target: string, have: string[]): string | null {
|
|
135
|
+
const t = order(target);
|
|
136
|
+
let best: string | null = null;
|
|
137
|
+
for (const e of have) {
|
|
138
|
+
const i = order(e);
|
|
139
|
+
if (i < 0 || t < 0) continue;
|
|
140
|
+
const b = best === null ? Number.POSITIVE_INFINITY : Math.abs(order(best) - t);
|
|
141
|
+
if (Math.abs(i - t) < b || (Math.abs(i - t) === b && i < order(best as string))) best = e;
|
|
142
|
+
}
|
|
143
|
+
return best;
|
|
144
|
+
}
|
|
@@ -9,8 +9,67 @@ import {
|
|
|
9
9
|
refreshDiscovery,
|
|
10
10
|
saveTreatLike,
|
|
11
11
|
} from "../services/catalog-service.ts";
|
|
12
|
+
import { readDerived } from "../infra/sources/cache.ts";
|
|
13
|
+
import { type SyncReport, syncSources } from "../services/source-sync.ts";
|
|
14
|
+
import { clearTreatLike, type LeftOnStandIn, resetTreatLikes, suggestFor } from "../services/treat-likes.ts";
|
|
12
15
|
import { exitCodeOf, JSON_ARG, mark, printError } from "./cli-kit.ts";
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Spec 1.2 §9: a sync, one line per source (fetched, fresh, skipped, or failed with the answer it kept),
|
|
19
|
+
* then the rungs newly scored, the user's stand-ins no longer needed and the cross-check warnings.
|
|
20
|
+
*/
|
|
21
|
+
export function syncLines(r: SyncReport, plain = false): string[] {
|
|
22
|
+
if (r.busy) return ["- sources: another sync is running; its results apply when it ends"];
|
|
23
|
+
const out: string[] = [];
|
|
24
|
+
for (const s of r.sources) {
|
|
25
|
+
if (s.state === "fetched") out.push(`${mark("ok", plain)} ${s.source}: fetched`);
|
|
26
|
+
else if (s.state === "fresh") out.push(`- ${s.source}: fresh (fetched ${s.fetchedAt})`);
|
|
27
|
+
else if (s.state === "skipped") out.push(`- ${s.source}: ${s.detail}`);
|
|
28
|
+
else
|
|
29
|
+
out.push(
|
|
30
|
+
`${mark("warn", plain)} ${s.source}: ${s.error}; ${s.fetchedAt ? `keeps the answer fetched ${s.fetchedAt}` : "no earlier answer"}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (r.newlyScored.length) out.push(`newly scored: ${r.newlyScored.join(", ")}`);
|
|
34
|
+
for (const n of r.noLongerNeeded)
|
|
35
|
+
out.push(`stand-in no longer needed: ${n.rung} has values of its own for what ${n.like} lent it`);
|
|
36
|
+
for (const w of r.warnings) out.push(`${mark("warn", plain)} ${w}`);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sync = defineCommand({
|
|
41
|
+
meta: {
|
|
42
|
+
name: "sync",
|
|
43
|
+
description:
|
|
44
|
+
"Fetch the public model facts and scores now (each source at most every 12 hours unless --force) and show what changed",
|
|
45
|
+
},
|
|
46
|
+
args: {
|
|
47
|
+
force: { type: "boolean", description: "fetch every source, however fresh" },
|
|
48
|
+
unmatched: {
|
|
49
|
+
type: "boolean",
|
|
50
|
+
description: "also list each source's ids that match no model in the catalog",
|
|
51
|
+
},
|
|
52
|
+
...JSON_ARG,
|
|
53
|
+
},
|
|
54
|
+
async run({ args }) {
|
|
55
|
+
try {
|
|
56
|
+
const r = await syncSources({ force: args.force === true });
|
|
57
|
+
if (args.json) console.log(JSON.stringify(r, null, 2));
|
|
58
|
+
else for (const l of syncLines(r)) console.log(l);
|
|
59
|
+
if (args.unmatched && !args.json) {
|
|
60
|
+
const unmatched = readDerived()?.unmatched ?? {};
|
|
61
|
+
if (Object.keys(unmatched).length === 0) console.log("unmatched: none");
|
|
62
|
+
for (const [source, ids] of Object.entries(unmatched))
|
|
63
|
+
console.log(`unmatched in ${source}: ${ids.join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
const got = r.sources.some((s) => s.state === "fetched" || s.state === "fresh");
|
|
66
|
+
process.exitCode = r.failed.length && !got ? 1 : 0;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
fail(e);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
14
73
|
/** One backend's refresh: its model count, else why it listed none, then its fix on a line of its own. */
|
|
15
74
|
export function formatRefreshed(r: Refreshed, plain = false): string {
|
|
16
75
|
if (!r.error) return `${mark("ok", plain)} ${r.backend}: ${r.models} models`;
|
|
@@ -20,10 +79,32 @@ export function formatRefreshed(r: Refreshed, plain = false): string {
|
|
|
20
79
|
].join("\n");
|
|
21
80
|
}
|
|
22
81
|
|
|
82
|
+
/**
|
|
83
|
+
* One model: its rungs scored and roles, then (spec 1.2 §4.1, §5.3, §8) its price and speed facts, and each
|
|
84
|
+
* rung's values with their confidence and source, and the runs catherd has made on it.
|
|
85
|
+
*/
|
|
23
86
|
export function formatModel(m: CatalogModel): string {
|
|
24
87
|
const scored = m.rungs.filter((r) => r.enabled).length;
|
|
25
88
|
const listed = m.listed === false ? " not offered by this account" : "";
|
|
26
|
-
|
|
89
|
+
const lines = [
|
|
90
|
+
`${m.backend}:${m.model} ${scored}/${m.rungs.length} rungs scored roles ${m.roles.join(",") || "none"}${listed}`,
|
|
91
|
+
];
|
|
92
|
+
const facts = [
|
|
93
|
+
...(m.price ? [`$${m.price.input}/$${m.price.output} per M tokens in/out`] : []),
|
|
94
|
+
...Object.entries(m.speed).map(([k, v]) => `${k} ${Number(v.toPrecision(4))}`),
|
|
95
|
+
];
|
|
96
|
+
if (facts.length) lines.push(` ${facts.join(" · ")}`);
|
|
97
|
+
for (const r of m.rungs) {
|
|
98
|
+
// spec 1.2 §5.3: each value with its confidence and source, as `route` reports it; a guess names its rung
|
|
99
|
+
const values = Object.entries(r.scores).map(
|
|
100
|
+
([dim, v]) =>
|
|
101
|
+
`${dim} ${Number(v.value.toPrecision(4))} (${v.from ? `inferred from ${v.from}` : v.confidence}, ${v.source})`,
|
|
102
|
+
);
|
|
103
|
+
if (values.length === 0 && !r.evidence) continue;
|
|
104
|
+
lines.push(` #${r.rung.slice(r.rung.lastIndexOf("#") + 1)} ${values.join(" · ") || "unscored"}`);
|
|
105
|
+
if (r.evidence) lines.push(` runs: ${r.evidence}`);
|
|
106
|
+
}
|
|
107
|
+
return lines.join("\n");
|
|
27
108
|
}
|
|
28
109
|
|
|
29
110
|
/** The repository the command runs in, for per-repository listings; none outside one (global). */
|
|
@@ -79,28 +160,102 @@ const list = defineCommand({
|
|
|
79
160
|
},
|
|
80
161
|
});
|
|
81
162
|
|
|
163
|
+
/** Spec 1.2 §6.4: the profile rungs a removal leaves on an inferred stand-in or unscored, one line each. */
|
|
164
|
+
export function leftLines(left: LeftOnStandIn[], plain = false): string[] {
|
|
165
|
+
return left.map(
|
|
166
|
+
(l) =>
|
|
167
|
+
`${mark("warn", plain)} ${l.profile}: ${l.rung} is left ${l.unscored ? "unscored: routing skips it" : `on an inferred stand-in for ${l.dims.join(", ")}`}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Spec 1.2 §6.4 `--suggest`: the three nearest stand-ins, each with its distance and the features it rests on. */
|
|
172
|
+
export function suggestLines(r: ReturnType<typeof suggestFor>, rung: string): string[] {
|
|
173
|
+
const head = r.lacking.length
|
|
174
|
+
? `${r.canonical} has no ${r.lacking.join(", ")} value of its own; the nearest stand-ins:`
|
|
175
|
+
: `${r.canonical} has every value the bars use; the nearest scored rungs:`;
|
|
176
|
+
if (r.suggestions.length === 0)
|
|
177
|
+
return [
|
|
178
|
+
`${r.canonical}: no scored rung shares 3 features with it; map it by hand: catherd catalog treat-like ${rung} <rung>`,
|
|
179
|
+
];
|
|
180
|
+
return [
|
|
181
|
+
head,
|
|
182
|
+
...r.suggestions.map(
|
|
183
|
+
(s, i) =>
|
|
184
|
+
` ${i + 1}. ${s.like} distance ${s.distance.toFixed(2)} lends ${s.lends.join(", ")} on ${s.features.join(", ")}`,
|
|
185
|
+
),
|
|
186
|
+
`map one: catherd catalog treat-like ${rung} <rung>`,
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
|
|
82
190
|
const treatLike = defineCommand({
|
|
83
|
-
meta: {
|
|
191
|
+
meta: {
|
|
192
|
+
name: "treat-like",
|
|
193
|
+
description:
|
|
194
|
+
"Score an unscored rung as a scored one; --suggest ranks stand-ins, --clear and --reset remove your mappings",
|
|
195
|
+
},
|
|
84
196
|
args: {
|
|
85
197
|
rung: {
|
|
86
198
|
type: "positional",
|
|
87
|
-
required:
|
|
199
|
+
required: false,
|
|
88
200
|
description: "the unscored rung, backend:model#effort or model#effort",
|
|
89
201
|
},
|
|
90
|
-
like: { type: "positional", required:
|
|
202
|
+
like: { type: "positional", required: false, description: "the scored rung whose scores it borrows" },
|
|
203
|
+
suggest: { type: "string", description: "list the 3 nearest stand-ins for this rung" },
|
|
204
|
+
clear: { type: "string", description: "remove your treat-like for this rung" },
|
|
205
|
+
reset: { type: "boolean", description: "remove every treat-like of yours" },
|
|
206
|
+
...JSON_ARG,
|
|
91
207
|
},
|
|
92
208
|
async run({ args }) {
|
|
93
209
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
210
|
+
const modes = [
|
|
211
|
+
args.suggest !== undefined,
|
|
212
|
+
args.clear !== undefined,
|
|
213
|
+
args.reset === true,
|
|
214
|
+
args.rung !== undefined,
|
|
215
|
+
];
|
|
216
|
+
if (modes.filter(Boolean).length !== 1 || (args.rung !== undefined && args.like === undefined))
|
|
217
|
+
throw new CatherdError(
|
|
218
|
+
"E_INPUT_INVALID",
|
|
219
|
+
"treat-like takes <rung> <like>, or one of --suggest, --clear, --reset",
|
|
220
|
+
{
|
|
221
|
+
fix: "catherd catalog treat-like <rung> <like> | --suggest <rung> | --clear <rung> | --reset",
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
if (args.suggest !== undefined) {
|
|
225
|
+
const r = suggestFor(args.suggest);
|
|
226
|
+
if (args.json) console.log(JSON.stringify(r, null, 2));
|
|
227
|
+
else for (const l of suggestLines(r, args.suggest)) console.log(l);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (args.clear !== undefined) {
|
|
231
|
+
const r = await clearTreatLike(args.clear);
|
|
232
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
233
|
+
for (const l of leftLines(r.left)) console.log(l);
|
|
234
|
+
console.log(`${mark("ok")} ${r.rung} is no longer treated like ${r.like}`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (args.reset) {
|
|
238
|
+
const r = await resetTreatLikes();
|
|
239
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
240
|
+
for (const l of leftLines(r.left)) console.log(l);
|
|
241
|
+
console.log(
|
|
242
|
+
r.removed.length
|
|
243
|
+
? `${mark("ok")} removed ${r.removed.length} treat-like${r.removed.length === 1 ? "" : "s"}: ${r.removed.map(([a, b]) => `${a} → ${b}`).join(", ")}`
|
|
244
|
+
: "no treat-like of yours to remove",
|
|
245
|
+
);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const r = await saveTreatLike(args.rung as string, args.like as string);
|
|
249
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
250
|
+
console.log(`${mark("ok")} ${r.rung} is treated like ${r.like}`);
|
|
96
251
|
} catch (e) {
|
|
97
252
|
fail(e);
|
|
98
253
|
}
|
|
99
254
|
},
|
|
100
255
|
});
|
|
101
256
|
|
|
102
|
-
/** Spec §8: `catherd catalog refresh|list|treat-like <rung> <like
|
|
257
|
+
/** Spec §8 and 1.2 §9: `catherd catalog refresh|list|sync|treat-like <rung> <like> | --suggest|--clear|--reset`. */
|
|
103
258
|
export const catalogCommand = defineCommand({
|
|
104
259
|
meta: { name: "catalog", description: "The model catalog" },
|
|
105
|
-
subCommands: { refresh, list, "treat-like": treatLike },
|
|
260
|
+
subCommands: { refresh, list, sync, "treat-like": treatLike },
|
|
106
261
|
});
|
|
@@ -4,11 +4,14 @@ import { errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
|
4
4
|
import { configDir } from "../infra/paths.ts";
|
|
5
5
|
import { VERSION } from "../infra/version.ts";
|
|
6
6
|
import { doctor } from "../services/doctor.ts";
|
|
7
|
+
import { testAaKey } from "../infra/sources/artificial-analysis.ts";
|
|
8
|
+
import { aaKey, saveAaKey } from "../services/credentials.ts";
|
|
7
9
|
import { jevKey, saveJevKey, testJevKey } from "../services/jev-service.ts";
|
|
10
|
+
import { type SyncReport, syncSources } from "../services/source-sync.ts";
|
|
8
11
|
import { reinstallCommand } from "../services/doctor-checks.ts";
|
|
9
12
|
import { ensureGlobal, type GlobalInstallDeps, realGlobalInstall } from "../services/global-install.ts";
|
|
10
13
|
import { hasProfileFile, type InitResult, initSetup, moveLegacy } from "../services/setup.ts";
|
|
11
|
-
import { formatRefreshed } from "./catalog-command.ts";
|
|
14
|
+
import { formatRefreshed, syncLines } from "./catalog-command.ts";
|
|
12
15
|
import { mark as markOf } from "./cli-kit.ts";
|
|
13
16
|
import { formatReport } from "./doctor-command.ts";
|
|
14
17
|
import { mcpHandshake } from "./mcp/handshake.ts";
|
|
@@ -65,6 +68,71 @@ export async function jevStep(
|
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Spec 1.2 §9: the Artificial Analysis key step, after Jev's. ARTIFICIAL_ANALYSIS_API_KEY wins, else the saved
|
|
73
|
+
* key, else a prompt (Enter skips). A typed key is tested with one request: a 200 saves it, a 401 does not,
|
|
74
|
+
* and a key that cannot be checked (no network) is not saved either. It never stops `init`.
|
|
75
|
+
*/
|
|
76
|
+
export async function aaStep(
|
|
77
|
+
ask: Prompter | null,
|
|
78
|
+
d: {
|
|
79
|
+
testAaKey?: (key: string) => Promise<{ result: "ok" | "refused" | "unchecked"; error?: string }>;
|
|
80
|
+
saveAaKey?: (key: string) => void;
|
|
81
|
+
} = {},
|
|
82
|
+
plain = false,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const mark = (state: State) => markOf(state, plain);
|
|
85
|
+
if (process.env.ARTIFICIAL_ANALYSIS_API_KEY?.trim()) {
|
|
86
|
+
ask?.skip?.();
|
|
87
|
+
return console.log(`${mark("ok")} Artificial Analysis: using ARTIFICIAL_ANALYSIS_API_KEY`);
|
|
88
|
+
}
|
|
89
|
+
if (aaKey()) {
|
|
90
|
+
ask?.skip?.();
|
|
91
|
+
return console.log(`${mark("ok")} Artificial Analysis: using the saved key`);
|
|
92
|
+
}
|
|
93
|
+
const key = ask ? await ask.secret("Artificial Analysis API key (optional; Enter skips): ") : "";
|
|
94
|
+
if (!key)
|
|
95
|
+
return console.log(
|
|
96
|
+
"- Artificial Analysis: no key; scores come from the keyless sources (add one later with catherd init)",
|
|
97
|
+
);
|
|
98
|
+
const t = await (d.testAaKey ?? testAaKey)(key).catch((e: unknown) => ({
|
|
99
|
+
result: "unchecked" as const,
|
|
100
|
+
error: errorMessage(e),
|
|
101
|
+
}));
|
|
102
|
+
if (t.result === "refused")
|
|
103
|
+
return console.log(`${mark("warn")} Artificial Analysis: the key was refused (401), so it was not saved`);
|
|
104
|
+
if (t.result === "unchecked")
|
|
105
|
+
return console.log(
|
|
106
|
+
`${mark("warn")} Artificial Analysis: the key could not be checked (${t.error ?? "no answer"}), so it was not saved; run catherd init again to retry`,
|
|
107
|
+
);
|
|
108
|
+
try {
|
|
109
|
+
(d.saveAaKey ?? saveAaKey)(key);
|
|
110
|
+
console.log(`${mark("ok")} Artificial Analysis: the key answers; saved with mode 600`);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.log(
|
|
113
|
+
`${mark("warn")} Artificial Analysis: could not save the key: ${errorMessage(e).split("\n").join(" ")}`,
|
|
114
|
+
);
|
|
115
|
+
if (isCatherdError(e) && e.fix) console.log(` fix: ${e.fix}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Spec 1.2 §9: `init` syncs the public sources in the foreground, after the keys. It never stops `init`;
|
|
121
|
+
* CATHERD_NO_SYNC=1 skips it.
|
|
122
|
+
*/
|
|
123
|
+
export async function syncStep(o: { plain?: boolean; sync?: () => Promise<SyncReport> } = {}): Promise<void> {
|
|
124
|
+
const plain = o.plain === true;
|
|
125
|
+
if (process.env.CATHERD_NO_SYNC === "1")
|
|
126
|
+
return console.log("- sources: not synced (CATHERD_NO_SYNC=1); catherd catalog sync fetches them");
|
|
127
|
+
console.log("syncing the public model sources…");
|
|
128
|
+
try {
|
|
129
|
+
for (const l of syncLines(await (o.sync ?? (() => syncSources()))(), plain)) console.log(l);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.log(`${markOf("warn", plain)} sources: ${errorMessage(e).split("\n").join(" ")}`);
|
|
132
|
+
console.log(" fix: catherd catalog sync");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
68
136
|
/**
|
|
69
137
|
* Spec 1.1 §12: installs the global CLI at this version (the plugin's launcher then needs no bunx), and
|
|
70
138
|
* says "installing catherd…" before the resolve. It never stops `init`: a failure is a `!` line and its fix.
|
|
@@ -124,7 +192,7 @@ export const initCommand = defineCommand({
|
|
|
124
192
|
meta: {
|
|
125
193
|
name: "init",
|
|
126
194
|
description:
|
|
127
|
-
"First run: the global catherd command, the Jev key, the default profile, its agents, and a readiness report. Piped, it reads the answers from stdin one per line, a line per question even when this machine skips it (the Jev key, the profile, whether to replace it), and waits for stdin to close; --no-input asks nothing",
|
|
195
|
+
"First run: the global catherd command, the Jev key, the Artificial Analysis key, a sync of the public model sources, the default profile, its agents, and a readiness report. Piped, it reads the answers from stdin one per line, a line per question even when this machine skips it (the Jev key, the Artificial Analysis key, the profile, whether to replace it), and waits for stdin to close; --no-input asks nothing",
|
|
128
196
|
},
|
|
129
197
|
args: {
|
|
130
198
|
// citty reads --no-input as input: false, whatever the flag is named; naming it no-input shows it as is
|
|
@@ -148,6 +216,8 @@ export const initCommand = defineCommand({
|
|
|
148
216
|
console.log(`catherd ${VERSION}: setting up in ${configDir()}`);
|
|
149
217
|
await globalStep(VERSION, { skip: (args as { global?: boolean }).global === false, plain });
|
|
150
218
|
await jevStep(ask, {}, plain);
|
|
219
|
+
await aaStep(ask, {}, plain);
|
|
220
|
+
await syncStep({ plain });
|
|
151
221
|
if (args.profile !== undefined) ask?.skip?.();
|
|
152
222
|
const name = assertProfileName(
|
|
153
223
|
args.profile ?? (ask ? (await ask.ask("Profile to set up [default]: ")) || "default" : "default"),
|
|
@@ -13,7 +13,7 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
|
|
|
13
13
|
"route",
|
|
14
14
|
{
|
|
15
15
|
description:
|
|
16
|
-
"The rung for a lane (from Jev, else the lane file's Kind/Difficulty lines, else the profile default) or, without a lane file, a role's default rung, with the ladder above it. Rungs are backend:model#effort; a claude: rung comes with the agent to run it as. A lane whose Kind: or Difficulty: the catalog does not know is refused with E_LANE_INVALID.",
|
|
16
|
+
"The rung for a lane (from Jev, else the lane file's Kind/Difficulty lines, else the profile default) or, without a lane file, a role's default rung, with the ladder above it. Rungs are backend:model#effort; a claude: rung comes with the agent to run it as. `provenance` says why: each threshold of the lane's bar with the value used against it, that value's confidence, source and date (`inferred: true` when a treat-like or a stand-in lent it), the rung's speed and cost facts, and catherd's own run evidence for it (shown, never used to route). A lane whose Kind: or Difficulty: the catalog does not know is refused with E_LANE_INVALID.",
|
|
17
17
|
inputSchema: {
|
|
18
18
|
run: z.string(),
|
|
19
19
|
lane_file: z.string().optional(),
|
package/src/entry/mcp/server.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
3
4
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
4
5
|
import { errorMessage } from "../../domain/errors.ts";
|
|
5
6
|
import { log } from "../../infra/log.ts";
|
|
6
7
|
import { startNotifier } from "../../services/notifier.ts";
|
|
7
8
|
import type { Deps } from "../../services/ports.ts";
|
|
8
9
|
import { reconcileAll } from "../../services/reconcile.ts";
|
|
10
|
+
import { backgroundSync } from "../../services/source-sync.ts";
|
|
9
11
|
import { defaultDeps } from "../deps.ts";
|
|
10
12
|
import { registerDispatchTools } from "./dispatch-tools.ts";
|
|
11
13
|
import { registerLaneTools } from "./lane-tools.ts";
|
|
@@ -60,9 +62,13 @@ export function buildServer(deps: Deps = defaultDeps()): McpServer {
|
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
64
|
* Spec §4.7: connect first, so the client never waits on a scan; then reconcile every run. Spec §3.4: the notifier
|
|
63
|
-
* starts before reconcile, so what reconcile settles is announced, and scans for the rest once it is done.
|
|
65
|
+
* starts before reconcile, so what reconcile settles is announced, and scans for the rest once it is done. Spec
|
|
66
|
+
* 1.2 §3.2: the source sync starts once connected and is never awaited, so neither the handshake nor a tool call
|
|
67
|
+
* waits on the network. Tests pass their own transport and sync.
|
|
64
68
|
*/
|
|
65
|
-
export async function startMcpServer(
|
|
69
|
+
export async function startMcpServer(
|
|
70
|
+
o: { transport?: Transport; sync?: () => Promise<unknown> } = {},
|
|
71
|
+
): Promise<void> {
|
|
66
72
|
const deps = defaultDeps();
|
|
67
73
|
// which of the session's variables this server got (never their values): the live check of spec §3.9
|
|
68
74
|
log("info", "session", {
|
|
@@ -72,7 +78,11 @@ export async function startMcpServer(): Promise<void> {
|
|
|
72
78
|
token: Boolean(deps.session?.token),
|
|
73
79
|
});
|
|
74
80
|
const notifier = startNotifier(deps);
|
|
75
|
-
await buildServer(deps).connect(new StdioServerTransport());
|
|
81
|
+
await buildServer(deps).connect(o.transport ?? new StdioServerTransport());
|
|
82
|
+
const sync = o.sync ?? (() => backgroundSync());
|
|
83
|
+
void Promise.resolve()
|
|
84
|
+
.then(sync)
|
|
85
|
+
.catch((e: unknown) => log("debug", "sources", { error: errorMessage(e) }));
|
|
76
86
|
try {
|
|
77
87
|
const r = await reconcileAll(deps);
|
|
78
88
|
const shown = r.warnings.length;
|
|
@@ -5,6 +5,7 @@ import { PROFILE_NAME, ProfilePatchSchema } from "../../domain/profile.ts";
|
|
|
5
5
|
import { ROLES } from "../../domain/roles.ts";
|
|
6
6
|
import { gitToplevel } from "../../infra/git.ts";
|
|
7
7
|
import type { Deps } from "../../services/ports.ts";
|
|
8
|
+
import { syncSources } from "../../services/source-sync.ts";
|
|
8
9
|
import { handle } from "./result.ts";
|
|
9
10
|
|
|
10
11
|
const PROFILE = z.string().regex(PROFILE_NAME).optional();
|
|
@@ -28,7 +29,7 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
|
|
|
28
29
|
"catalog_query",
|
|
29
30
|
{
|
|
30
31
|
description:
|
|
31
|
-
"Models catherd can place, with capabilities, the roles they can fill, their scored rungs (backend:model#effort), any 'treat like', their cost under the billing of `repo`'s profile, and whether this account's last listing offers them (`listed: false`: it does not); `enabled: false` rungs are unscored. Scored models first. opencode's models are as listed in `repo` (default: this server's directory), as route sees them there.",
|
|
32
|
+
"Models catherd can place, with capabilities, the roles they can fill, their scored rungs (backend:model#effort), any 'treat like', their cost under the billing of `repo`'s profile, and whether this account's last listing offers them (`listed: false`: it does not); `enabled: false` rungs are unscored. Each score has its confidence, source and date (`from` names the rung a lent value belongs to); each model its price and speed facts, each rung catherd's own run evidence. Scored models first. opencode's models are as listed in `repo` (default: this server's directory), as route sees them there.",
|
|
32
33
|
inputSchema: {
|
|
33
34
|
repo: z.string().min(1).optional(),
|
|
34
35
|
role: z.enum(ROLES).optional(),
|
|
@@ -49,6 +50,27 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
|
|
|
49
50
|
}),
|
|
50
51
|
);
|
|
51
52
|
|
|
53
|
+
server.registerTool(
|
|
54
|
+
"catalog_sync",
|
|
55
|
+
{
|
|
56
|
+
description:
|
|
57
|
+
"Fetch the public model facts and scores now (models.dev, OpenRouter, LiteLLM, Arena, Vectara, Epoch AI, and Artificial Analysis with the user's key): each source at most every 12 hours unless `force`. Returns the rungs newly scored, the user's treat-likes no longer needed (their rung now has values of its own), the sources that failed (each keeps its last good answer) and each source's state. route and catalog_query read the result at once.",
|
|
58
|
+
inputSchema: { force: z.boolean().default(false) },
|
|
59
|
+
},
|
|
60
|
+
(a) =>
|
|
61
|
+
handle(async () => {
|
|
62
|
+
const r = await (deps.sync ?? ((o) => syncSources(o)))({ force: a.force });
|
|
63
|
+
return {
|
|
64
|
+
newlyScored: r.newlyScored,
|
|
65
|
+
standInsNoLongerNeeded: r.noLongerNeeded,
|
|
66
|
+
failed: r.failed,
|
|
67
|
+
sources: r.sources,
|
|
68
|
+
warnings: r.warnings,
|
|
69
|
+
...(r.busy ? { busy: "another sync was running; call catalog_sync again" } : {}),
|
|
70
|
+
};
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
52
74
|
server.registerTool(
|
|
53
75
|
"profile_get",
|
|
54
76
|
{
|
|
@@ -73,7 +95,7 @@ export function registerSetupTools(server: McpServer, deps: Deps): void {
|
|
|
73
95
|
"profile_set",
|
|
74
96
|
{
|
|
75
97
|
description:
|
|
76
|
-
"Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, network, rungs, defaultRung), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid
|
|
98
|
+
"Apply a patch to a profile (without a name: the profile this repo runs on, as in profile_get; a new name starts from the default profile): objective, jev.use, billing, roles (enabled, access, network, rungs, defaultRung), harness isolation per backend, failover, budget, timeouts, preflight.confirm, lock.heavy and notify. Lists replace, maps merge, and null removes a key. An unknown key is refused. It validates first and writes nothing when invalid, unless the patch repairs an invalid profile: it fixes at least one of its errors and adds none, and then it saves with `errors` listing those still open. A save rewrites the agent files and relinks them. Returns saved, the errors and warnings, the diff, and newSessionNeededFor: the agents that apply from the next Claude Code session.",
|
|
77
99
|
inputSchema: { name: PROFILE, repo: REPO, patch: ProfilePatchSchema },
|
|
78
100
|
},
|
|
79
101
|
(a) => handle(async () => deps.profiles.set(a.name, a.patch, await toplevel(a.repo))),
|
|
@@ -38,6 +38,8 @@ export interface StandIn {
|
|
|
38
38
|
to: string;
|
|
39
39
|
inferred: boolean;
|
|
40
40
|
via: string | null;
|
|
41
|
+
/** what the treat-like lends, as `profile show` says it */
|
|
42
|
+
note: string | null;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/** Spec §7.2: each stand-in; `via` names the rung whose scores it borrows (spec 1.1 §11). */
|
|
@@ -46,7 +48,7 @@ export function standIns(p: Profile, c: Catalog): StandIn[] {
|
|
|
46
48
|
try {
|
|
47
49
|
return { from, to, ...inferredScores(c, rungInfo(c, to)) };
|
|
48
50
|
} catch {
|
|
49
|
-
return { from, to, inferred: false, via: null };
|
|
51
|
+
return { from, to, inferred: false, via: null, note: null };
|
|
50
52
|
}
|
|
51
53
|
});
|
|
52
54
|
}
|
|
@@ -93,8 +95,7 @@ export function formatProfile(
|
|
|
93
95
|
const harness = Object.entries(p.harness).filter(runs);
|
|
94
96
|
lines.push(`harness ${harness.map(([k, h]) => `${k} ${h.isolated ? "isolated" : "native"}`).join(" · ")}`);
|
|
95
97
|
lines.push(o.standIns.length ? "failover" : "failover none");
|
|
96
|
-
for (const s of o.standIns)
|
|
97
|
-
lines.push(` ${s.from} → ${s.to}${s.via ? ` (scores borrowed from ${s.via})` : ""}`);
|
|
98
|
+
for (const s of o.standIns) lines.push(` ${s.from} → ${s.to}${s.note ? ` (${s.note})` : ""}`);
|
|
98
99
|
const budget = Object.entries(p.budget).map(([k, v]) => (k === "usd" ? `$${v}` : `${v} ${k}`));
|
|
99
100
|
lines.push(`budget ${budget.join(" · ") || "no cap"}`);
|
|
100
101
|
lines.push(`timeouts idle ${p.timeouts.idleMin} min · wall ${p.timeouts.wallMin} min`);
|
|
@@ -265,7 +266,12 @@ const set = defineCommand({
|
|
|
265
266
|
if (!r.saved) throw refused(r.errors);
|
|
266
267
|
if (r.diff.length === 0) console.log("no change");
|
|
267
268
|
for (const c of r.diff) console.log(`${mark("ok")} ${formatChange(c)}`);
|
|
268
|
-
|
|
269
|
+
// spec 1.2 §6.2: a save that repairs part of an invalid profile goes through; it lists what is still open
|
|
270
|
+
if (r.errors.length)
|
|
271
|
+
console.log(
|
|
272
|
+
`${mark("warn")} saved; ${r.errors.length} ${r.errors.length === 1 ? "error is" : "errors are"} still open:`,
|
|
273
|
+
);
|
|
274
|
+
printIssues(r.errors, r.warnings);
|
|
269
275
|
printSynced(r);
|
|
270
276
|
},
|
|
271
277
|
});
|
|
@@ -211,12 +211,12 @@ export const COMMANDS = [
|
|
|
211
211
|
},
|
|
212
212
|
{
|
|
213
213
|
id: "catalog.refresh",
|
|
214
|
-
title: "
|
|
214
|
+
title: "Sync the public sources and refresh the model catalog",
|
|
215
215
|
group: "Profiles",
|
|
216
216
|
scope: "tab.profiles",
|
|
217
217
|
keys: ["r"],
|
|
218
218
|
palette: true,
|
|
219
|
-
cli: "catherd catalog
|
|
219
|
+
cli: "catherd catalog sync",
|
|
220
220
|
},
|
|
221
221
|
{
|
|
222
222
|
id: "edit.undo",
|
|
@@ -305,6 +305,24 @@ export const COMMANDS = [
|
|
|
305
305
|
short: "change",
|
|
306
306
|
},
|
|
307
307
|
{ id: "tree.expand", title: "Expand", group: "Profiles", scope: "row.profiles", keys: ["right", "l"] },
|
|
308
|
+
{
|
|
309
|
+
id: "tree.detail",
|
|
310
|
+
title: "Show the rung's values, sources and runs",
|
|
311
|
+
group: "Profiles",
|
|
312
|
+
scope: "row.profiles",
|
|
313
|
+
keys: ["i"],
|
|
314
|
+
palette: true,
|
|
315
|
+
cli: "catherd catalog list --json",
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
id: "tree.treatLike",
|
|
319
|
+
title: "Treat the rung like another…",
|
|
320
|
+
group: "Profiles",
|
|
321
|
+
scope: "row.profiles",
|
|
322
|
+
keys: ["t"],
|
|
323
|
+
palette: true,
|
|
324
|
+
cli: "catherd catalog treat-like --suggest <rung>",
|
|
325
|
+
},
|
|
308
326
|
{
|
|
309
327
|
id: "tree.collapse",
|
|
310
328
|
title: "Collapse, or go to the parent",
|