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,349 @@
1
+ import { measuredOrBetter } from "../domain/bars.ts";
2
+ import { calibrate, DIM_SOURCES, type FieldRef } from "../domain/calibration.ts";
3
+ import {
4
+ DIMS,
5
+ type Dim,
6
+ type Family,
7
+ type FamilyFacts,
8
+ type ModelsFile,
9
+ outranks,
10
+ type Score,
11
+ type ScoresFile,
12
+ } from "../domain/catalog.ts";
13
+ import {
14
+ defaultEffortOf,
15
+ type Derived,
16
+ familyEfforts,
17
+ type FitRow,
18
+ idMapper,
19
+ type IdMapper,
20
+ nearestEffort,
21
+ type SourceId,
22
+ type SourcesFile,
23
+ splitSourceRung,
24
+ } from "../domain/sources.ts";
25
+ import { type AaAnswer, parseArtificialAnalysis } from "../infra/sources/artificial-analysis.ts";
26
+ import { parseArena } from "../infra/sources/arena.ts";
27
+ import { parseEpoch } from "../infra/sources/epoch.ts";
28
+ import { LITELLM_EFFORTS, parseLiteLlm } from "../infra/sources/litellm.ts";
29
+ import { modelsDevFacts } from "../infra/sources/models-dev.ts";
30
+ import { parseOpenRouterEndpoints } from "../infra/sources/openrouter-endpoints.ts";
31
+ import { type OpenRouterModel, parseOpenRouterModels } from "../infra/sources/openrouter-models.ts";
32
+ import type { SourceRow } from "../infra/sources/rows.ts";
33
+ import { parseVectara } from "../infra/sources/vectara.ts";
34
+
35
+ /** Each source's cached answer (its `data`) with its fetch time; a source never fetched is absent. */
36
+ export type RawAnswers = Partial<Record<SourceId, { fetchedAt: string; data: unknown }>>;
37
+
38
+ export interface DeriveContext {
39
+ models: ModelsFile;
40
+ scores: ScoresFile;
41
+ sources: SourcesFile;
42
+ now: number;
43
+ }
44
+
45
+ /** Spec 1.2 §3.5: a price that differs by more than this is a sync warning. */
46
+ const PRICE_TOLERANCE = 0.1;
47
+ /** Spec 1.2 §3.5: the models.dev provider that serves a backend key its own catalog. */
48
+ const OPENCODE_KEYS = ["opencode", "opencode-go"] as const;
49
+
50
+ interface Keyed {
51
+ key: string;
52
+ family: Family | null;
53
+ effort: string;
54
+ assumed: boolean;
55
+ row: SourceRow;
56
+ }
57
+
58
+ /** The score sources' rows, by source (spec 1.2 §3.3). */
59
+ function scoreRows(raw: RawAnswers): [SourceId, SourceRow[]][] {
60
+ const out: [SourceId, SourceRow[]][] = [];
61
+ const { arena, vectara, epoch } = raw;
62
+ const aa = raw["artificial-analysis"];
63
+ if (arena) out.push(["arena", parseArena(arena.data as Record<string, unknown>, arena.fetchedAt)]);
64
+ if (vectara) out.push(["vectara", parseVectara(String(vectara.data), vectara.fetchedAt)]);
65
+ if (epoch) out.push(["epoch", parseEpoch(epoch.data as Record<string, string>, epoch.fetchedAt)]);
66
+ if (aa) out.push(["artificial-analysis", parseArtificialAnalysis(aa.data as AaAnswer, aa.fetchedAt)]);
67
+ return out;
68
+ }
69
+
70
+ const round = (v: number) => Math.round(v * 1e4) / 1e4;
71
+
72
+ /**
73
+ * Spec 1.2 §3.4–§4.3: what the cached answers say. Each score source's ids are mapped onto the catalog's
74
+ * families (unmatched ones listed, never guessed), each dimension's anchor gives `measured` values, every
75
+ * other source is fitted onto the anchor and gives `calibrated` values when the fit may be used, and an
76
+ * effort no source covers takes the nearest covered effort's value as `adjacent`. models.dev gives the facts;
77
+ * OpenRouter and LiteLLM only cross-check them.
78
+ */
79
+ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
80
+ const map = idMapper(ctx.models.families, ctx.sources.aliases);
81
+ const unmatched: Record<string, Set<string>> = {};
82
+ const table = new Map<string, Map<string, Keyed>>();
83
+ for (const [source, rows] of scoreRows(raw))
84
+ for (const row of rows) {
85
+ const { id, effort } = splitSourceRung(row.rung);
86
+ const family = map.family(id);
87
+ if (!family) (unmatched[source] ??= new Set()).add(id);
88
+ const e = effort ?? (family ? defaultEffortOf(ctx.sources, family) : "");
89
+ const key = `${map.key(family?.id ?? id)}#${e}`;
90
+ const field = `${source}.${row.field}`;
91
+ const at = table.get(field) ?? new Map<string, Keyed>();
92
+ table.set(field, at);
93
+ const had = at.get(key);
94
+ if (!had || row.value > had.row.value)
95
+ at.set(key, { key, family, effort: e, assumed: effort === null && family !== null, row });
96
+ }
97
+ const valuesOf = (f: FieldRef) =>
98
+ new Map([...(table.get(`${f.source}.${f.field}`)?.values() ?? [])].map((k) => [k.key, k.row.value]));
99
+
100
+ const scores: Score[] = [];
101
+ const fits: FitRow[] = [];
102
+ const push = (dim: Dim, f: FieldRef, k: Keyed, value: number, extra: Partial<Score>) => {
103
+ if (!k.family) return;
104
+ scores.push({
105
+ rung: `${k.family.id}#${k.effort}`,
106
+ dim,
107
+ value: round(value),
108
+ benchmark: f.benchmark,
109
+ version: k.row.date,
110
+ url: k.row.url,
111
+ date: k.row.date,
112
+ confidence: "measured",
113
+ source: f.source,
114
+ ...(k.assumed ? { effortAssumed: true } : {}),
115
+ ...extra,
116
+ });
117
+ };
118
+ for (const dim of DIMS) {
119
+ const spec = DIM_SOURCES[dim];
120
+ let anchor: Map<string, number>;
121
+ if (spec.anchor === "shipped") {
122
+ anchor = new Map();
123
+ // the hand-typed values published for the rung; never the shipped keyless ones (they carry a source)
124
+ for (const s of ctx.scores.scores)
125
+ if (s.dim === dim && s.source === undefined && measuredOrBetter(s)) {
126
+ const { id, effort } = splitSourceRung(s.rung);
127
+ anchor.set(`${map.key(id)}#${effort}`, s.value);
128
+ }
129
+ } else {
130
+ const ref = spec.anchor;
131
+ anchor = valuesOf(ref);
132
+ for (const k of table.get(`${ref.source}.${ref.field}`)?.values() ?? [])
133
+ push(dim, ref, k, k.row.value, {});
134
+ }
135
+ for (const other of spec.others) {
136
+ const values = valuesOf(other);
137
+ if (values.size === 0) continue;
138
+ const r = calibrate(anchor, values);
139
+ fits.push({
140
+ dim,
141
+ source: other.source,
142
+ field: other.field,
143
+ n: r.n,
144
+ a: r.fit?.a ?? null,
145
+ b: r.fit?.b ?? null,
146
+ r2: r.fit?.r2 ?? null,
147
+ used: r.used,
148
+ ...(r.why ? { why: r.why } : {}),
149
+ });
150
+ const fit = r.used ? r.fit : null;
151
+ if (!fit) continue;
152
+ for (const k of table.get(`${other.source}.${other.field}`)?.values() ?? [])
153
+ push(dim, other, k, fit.a * k.row.value + fit.b, {
154
+ confidence: "calibrated",
155
+ fit: { source: other.source, field: other.field, a: fit.a, b: fit.b, r2: fit.r2, n: fit.n },
156
+ });
157
+ }
158
+ }
159
+ const shipped = new Set<string>();
160
+ for (const s of ctx.scores.scores)
161
+ if (s.confidence !== "inferred") {
162
+ const { id, effort } = splitSourceRung(s.rung);
163
+ const family = map.family(id);
164
+ if (family && effort !== null) shipped.add(`${family.id}#${effort}|${s.dim}`);
165
+ }
166
+ scores.push(...adjacent(ctx.models.families, scores, shipped, ctx.now));
167
+
168
+ const { facts, warnings } = factsOf(raw, ctx, map);
169
+ return {
170
+ schema: 1,
171
+ builtAt: new Date(ctx.now).toISOString(),
172
+ scores,
173
+ facts,
174
+ fits,
175
+ unmatched: Object.fromEntries(Object.entries(unmatched).map(([s, ids]) => [s, [...ids].sort()])),
176
+ warnings,
177
+ features: aaFeatures(table),
178
+ };
179
+ }
180
+
181
+ /** Spec 1.2 §6.3: the Artificial Analysis numbers a stand-in is ranked on, as AA names them. */
182
+ export const AA_FEATURES = [
183
+ "artificial_analysis_intelligence_index",
184
+ "hle",
185
+ "scicode",
186
+ "lcr",
187
+ "cost_per_task",
188
+ "median_output_tokens_per_second",
189
+ ] as const;
190
+
191
+ /** Each catalog rung's AA stand-in features, from the rows `derive` keyed (never shipped: AA is keyed). */
192
+ function aaFeatures(table: Map<string, Map<string, Keyed>>): Derived["features"] {
193
+ const out: Derived["features"] = {};
194
+ for (const field of AA_FEATURES)
195
+ for (const k of table.get(`artificial-analysis.${field}`)?.values() ?? []) {
196
+ if (!k.family) continue;
197
+ (out[`${k.family.id}#${k.effort}`] ??= {})[field] = k.row.value;
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * Spec 1.2 §4.3 `adjacent`: for each family effort a dimension has no value at (neither one in `direct` nor
204
+ * one `shipped` names, as `<family>#<effort>|<dim>`), the best value in `direct` at the nearest effort that
205
+ * has one (the weaker on a tie). A sync spreads only its own values, and never onto a value the shipped file
206
+ * carries; `rebuildShipped` spreads the shipped file's published values, with an empty `shipped`.
207
+ */
208
+ export function adjacent(
209
+ families: Family[],
210
+ direct: Score[],
211
+ shipped: ReadonlySet<string>,
212
+ now: number,
213
+ ): Score[] {
214
+ const out: Score[] = [];
215
+ for (const f of families)
216
+ for (const dim of DIMS) {
217
+ const byEffort = new Map<string, Score>();
218
+ for (const s of direct) {
219
+ const { id, effort } = splitSourceRung(s.rung);
220
+ if (id !== f.id || s.dim !== dim || effort === null) continue;
221
+ const had = byEffort.get(effort);
222
+ if (!had || outranks(s, had, now)) byEffort.set(effort, s);
223
+ }
224
+ if (byEffort.size === 0) continue;
225
+ for (const e of familyEfforts(f)) {
226
+ if (byEffort.has(e) || shipped.has(`${f.id}#${e}|${dim}`)) continue;
227
+ const near = nearestEffort(e, [...byEffort.keys()]);
228
+ const from = near ? byEffort.get(near) : undefined;
229
+ if (!near || !from) continue;
230
+ out.push({
231
+ ...from,
232
+ rung: `${f.id}#${e}`,
233
+ confidence: "adjacent",
234
+ note: `${from.source ?? from.benchmark} has it at ${near}; carried to this effort`,
235
+ });
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+
241
+ const differs = (a: number, b: number) => b !== 0 && Math.abs(a - b) / b > PRICE_TOLERANCE;
242
+ const money = (n: number) => `$${n}/M`;
243
+
244
+ /** Spec 1.2 §3.5: models.dev's facts per family, OpenRouter's speed facts, and the cross-check warnings. */
245
+ function factsOf(
246
+ raw: RawAnswers,
247
+ ctx: DeriveContext,
248
+ map: IdMapper,
249
+ ): { facts: Record<string, FamilyFacts>; warnings: string[] } {
250
+ const md = raw["models-dev"]?.data;
251
+ const orModels = raw["openrouter-models"] ? parseOpenRouterModels(raw["openrouter-models"].data) : [];
252
+ const liteLlm = raw.litellm ? parseLiteLlm(raw.litellm.data) : [];
253
+ const endpoints = raw["openrouter-endpoints"];
254
+ const speedRows = endpoints
255
+ ? parseOpenRouterEndpoints(endpoints.data as Record<string, unknown>, endpoints.fetchedAt)
256
+ : [];
257
+ const aa = raw["artificial-analysis"];
258
+ const aaRows = aa ? parseArtificialAnalysis(aa.data as AaAnswer, aa.fetchedAt) : [];
259
+ const facts: Record<string, FamilyFacts> = {};
260
+ const warnings: string[] = [];
261
+ for (const f of ctx.models.families) {
262
+ const x: FamilyFacts = { on: {}, speed: {} };
263
+ const vendor = (f as { vendor?: unknown }).vendor;
264
+ const own = md && typeof vendor === "string" ? modelsDevFacts(md, vendor, f.id) : null;
265
+ if (own?.price) x.price = own.price;
266
+ if (own && own.toolUse !== null && own.imageIn !== null && own.reasoning !== null)
267
+ x.capabilities = { toolUse: own.toolUse, imageIn: own.imageIn, reasoning: own.reasoning };
268
+ if (own?.releaseDate) x.releaseDate = own.releaseDate;
269
+ for (const key of OPENCODE_KEYS) {
270
+ const on = f.on[key];
271
+ const got = md && on ? modelsDevFacts(md, key, on.id.slice(on.id.indexOf("/") + 1)) : null;
272
+ if (got)
273
+ x.on[key] = {
274
+ ...(got.efforts ? { efforts: got.efforts } : {}),
275
+ ...(got.context ? { context: got.context } : {}),
276
+ };
277
+ }
278
+ for (const r of speedRows) if (map.family(r.rung) === f) x.speed[`openrouter.${r.field}`] = r.value;
279
+ const defaultRung = `${map.key(f.id)}#${defaultEffortOf(ctx.sources, f)}`;
280
+ for (const r of aaRows) {
281
+ const { id, effort } = splitSourceRung(r.rung);
282
+ if (map.family(id) !== f || `${map.key(f.id)}#${effort}` !== defaultRung) continue;
283
+ if (/^median_|^cost_per_task$/.test(r.field)) x.speed[`artificial-analysis.${r.field}`] = r.value;
284
+ }
285
+ if (x.price || x.capabilities || x.releaseDate || Object.keys(x.on).length || Object.keys(x.speed).length)
286
+ facts[f.id] = x;
287
+ if (own?.price) warnings.push(...priceWarnings(f, own.price, orModels, liteLlm, map));
288
+ if (own?.efforts) warnings.push(...effortWarnings(f, own.efforts, liteLlm, map));
289
+ }
290
+ return { facts, warnings };
291
+ }
292
+
293
+ /** The first entry of `list` whose id maps to `f` (OpenRouter's `:batch` variants never do). */
294
+ const entryFor = <T extends { id: string }>(list: T[], f: Family, map: IdMapper): T | undefined =>
295
+ list.find((m) => map.family(m.id) === f);
296
+
297
+ function priceWarnings(
298
+ f: Family,
299
+ price: { input: number; output: number },
300
+ orModels: OpenRouterModel[],
301
+ liteLlm: ReturnType<typeof parseLiteLlm>,
302
+ map: IdMapper,
303
+ ): string[] {
304
+ const out: string[] = [];
305
+ for (const [name, other] of [
306
+ ["OpenRouter", entryFor(orModels, f, map)?.price],
307
+ ["LiteLLM", entryFor(liteLlm, f, map)?.price],
308
+ ] as const) {
309
+ if (!other) continue;
310
+ for (const side of ["input", "output"] as const)
311
+ if (differs(other[side], price[side]))
312
+ out.push(
313
+ `${f.id}: ${side} price ${money(price[side])} on models.dev, ${money(other[side])} on ${name} (more than 10 % apart)`,
314
+ );
315
+ }
316
+ return out;
317
+ }
318
+
319
+ function effortWarnings(
320
+ f: Family,
321
+ efforts: string[],
322
+ liteLlm: ReturnType<typeof parseLiteLlm>,
323
+ map: IdMapper,
324
+ ): string[] {
325
+ const entry = entryFor(liteLlm, f, map);
326
+ if (!entry) return [];
327
+ const out: string[] = [];
328
+ for (const e of LITELLM_EFFORTS) {
329
+ const says = entry.efforts[e];
330
+ if (says === undefined || says === efforts.includes(e)) continue;
331
+ out.push(
332
+ `${f.id}: LiteLLM says effort ${e} is ${says ? "" : "not "}supported; models.dev ${says ? "does not list" : "lists"} it`,
333
+ );
334
+ }
335
+ return out;
336
+ }
337
+
338
+ /** Plan 13 R-H: the OpenRouter id of each catalog family OpenRouter lists, one each, for the endpoints fetch. */
339
+ export function openRouterIds(
340
+ orModelsData: unknown,
341
+ ctx: Pick<DeriveContext, "models" | "sources">,
342
+ ): string[] {
343
+ const map = idMapper(ctx.models.families, ctx.sources.aliases);
344
+ const models = parseOpenRouterModels(orModelsData);
345
+ return ctx.models.families.flatMap((f) => {
346
+ const m = entryFor(models, f, map);
347
+ return m ? [m.id] : [];
348
+ });
349
+ }
@@ -0,0 +1,305 @@
1
+ import { buildCatalog, type Catalog, DIMS } from "../domain/catalog.ts";
2
+ import { CatherdError, errorMessage } from "../domain/errors.ts";
3
+ import {
4
+ type Derived,
5
+ SOURCE_IDS,
6
+ type SourceId,
7
+ type SourcesFile,
8
+ SourcesFileSchema,
9
+ } from "../domain/sources.ts";
10
+ import { assetPath } from "../infra/assets.ts";
11
+ import { log } from "../infra/log.ts";
12
+ import { type AaAnswer, fetchArtificialAnalysis } from "../infra/sources/artificial-analysis.ts";
13
+ import { fetchArena } from "../infra/sources/arena.ts";
14
+ import {
15
+ readCached,
16
+ readDerived,
17
+ readSyncState,
18
+ tryLockSync,
19
+ writeCached,
20
+ writeDerived,
21
+ writeSyncState,
22
+ } from "../infra/sources/cache.ts";
23
+ import { fetchEpoch } from "../infra/sources/epoch.ts";
24
+ import { rateLimitRemaining, SourceError, type SourceTransport } from "../infra/sources/http.ts";
25
+ import { fetchLiteLlm } from "../infra/sources/litellm.ts";
26
+ import { fetchModelsDev } from "../infra/sources/models-dev.ts";
27
+ import { fetchOpenRouterEndpoints } from "../infra/sources/openrouter-endpoints.ts";
28
+ import { fetchOpenRouterModels } from "../infra/sources/openrouter-models.ts";
29
+ import { fetchVectara } from "../infra/sources/vectara.ts";
30
+ import { readVersioned } from "../infra/store.ts";
31
+ import { readOverride, shippedModels, shippedScores } from "./catalog-service.ts";
32
+ import { aaKey } from "./credentials.ts";
33
+ import { derive, openRouterIds, type RawAnswers } from "./source-derive.ts";
34
+
35
+ /** Spec 1.2 §3.2: one TTL for every source. */
36
+ export const TTL_MS = 12 * 3_600_000;
37
+ /** A source whose last attempt failed waits this long before a background sync tries it again. */
38
+ const BACKOFF_MS = 3_600_000;
39
+ /** How long a foreground sync waits for one already running. */
40
+ const LOCK_WAIT_MS = 120_000;
41
+
42
+ let sourcesFile: SourcesFile | null = null;
43
+ /** `catalog/sources.json`: the sources, their licenses and attribution lines, the aliases. */
44
+ export const shippedSources = (): SourcesFile =>
45
+ (sourcesFile ??= readVersioned(assetPath("catalog/sources.json"), SourcesFileSchema, 1));
46
+
47
+ export interface SyncOptions {
48
+ /** ignore the TTL (and a failing source's backoff) */
49
+ force?: boolean;
50
+ /** the MCP server's boot sync: skips when another sync runs, and gives a failing source an hour's rest */
51
+ background?: boolean;
52
+ /** how every source is fetched; tests replace it */
53
+ transport?: SourceTransport;
54
+ /** the Artificial Analysis key; default: the env's, else the saved one */
55
+ aaKey?: string | null;
56
+ now?: () => number;
57
+ }
58
+
59
+ export interface SourceOutcome {
60
+ source: SourceId;
61
+ /** fetched now; fresh (within the TTL); failed (the last good answer kept); skipped (no key, or backing off) */
62
+ state: "fetched" | "fresh" | "failed" | "skipped";
63
+ /** when the answer in the cache was fetched; null when there is none */
64
+ fetchedAt: string | null;
65
+ error?: string;
66
+ detail?: string;
67
+ }
68
+
69
+ export interface SyncReport {
70
+ /** another sync held the lock (a background sync never waits): nothing was done */
71
+ busy: boolean;
72
+ sources: SourceOutcome[];
73
+ /** canonical rungs with no value of their own before this sync and at least one after */
74
+ newlyScored: string[];
75
+ /** the user's treat-likes whose rung now has its own value on every dimension the stand-in lent */
76
+ noLongerNeeded: { rung: string; like: string }[];
77
+ failed: { source: SourceId; error: string }[];
78
+ /** the cross-checks that failed (spec 1.2 §3.5) */
79
+ warnings: string[];
80
+ /** each source's ids no family matched (spec 1.2 §3.4) */
81
+ unmatched: Record<string, string[]>;
82
+ }
83
+
84
+ const iso = (ms: number) => new Date(ms).toISOString();
85
+
86
+ /** The catalog as routing would read it with `derived` as the synced layer. */
87
+ const catalogWith = (derived: Derived | null, now: number): Catalog =>
88
+ buildCatalog({
89
+ models: shippedModels(),
90
+ scores: shippedScores(),
91
+ synced: derived?.scores,
92
+ facts: derived?.facts,
93
+ override: readOverride(),
94
+ now,
95
+ });
96
+
97
+ const scored = (c: Catalog): Set<string> =>
98
+ new Set(Object.entries(c.scores).flatMap(([rung, dims]) => (Object.keys(dims).length ? [rung] : [])));
99
+
100
+ async function takeLock(background: boolean): Promise<(() => void) | null> {
101
+ const deadline = Date.now() + LOCK_WAIT_MS;
102
+ for (;;) {
103
+ const release = tryLockSync();
104
+ if (release || background) return release;
105
+ if (Date.now() > deadline)
106
+ throw new CatherdError("E_IO_LOCK", "another catalog sync is still running", {
107
+ fix: "wait for it to finish, then run catherd catalog sync again",
108
+ });
109
+ await Bun.sleep(200);
110
+ }
111
+ }
112
+
113
+ /** Every cached answer, as `derive` reads them (and `scripts/catalog-refresh.ts` rebuilds the shipped file). */
114
+ export function cachedAnswers(): RawAnswers {
115
+ const raw: RawAnswers = {};
116
+ for (const id of SOURCE_IDS) {
117
+ const c = readCached(id);
118
+ if (c) raw[id] = { fetchedAt: c.fetchedAt, data: c.data };
119
+ }
120
+ return raw;
121
+ }
122
+
123
+ /**
124
+ * Spec 1.2 §3.2, §3.3: fetches every source whose cached answer is older than the TTL (every source with
125
+ * `force`), all in parallel, and writes each answer atomically with its fetch time. A source that fails keeps
126
+ * its last good answer; the sync records the error and goes on. Then it derives the synced values from every
127
+ * cached answer. One sync runs at a time: a background sync skips when another holds the lock, a foreground
128
+ * one waits for it.
129
+ */
130
+ export async function syncSources(o: SyncOptions = {}): Promise<SyncReport> {
131
+ const now = o.now ?? Date.now;
132
+ const release = await takeLock(o.background === true);
133
+ if (!release)
134
+ return {
135
+ busy: true,
136
+ sources: [],
137
+ newlyScored: [],
138
+ noLongerNeeded: [],
139
+ failed: [],
140
+ warnings: [],
141
+ unmatched: {},
142
+ };
143
+ try {
144
+ const before = catalogWith(readDerived(), now());
145
+ const state = readSyncState();
146
+ const key = o.aaKey === undefined ? aaKey() : o.aaKey;
147
+ const t = o.transport ?? {};
148
+ const outcomes = new Map<SourceId, SourceOutcome>();
149
+ const fetchedAt = (id: SourceId) => state.sources[id]?.fetchedAt ?? null;
150
+ const fresh = (id: SourceId) => {
151
+ const at = fetchedAt(id);
152
+ // a cache file that no longer reads (corrupt, or an older schema) is fetched again
153
+ return !o.force && at !== null && now() - Date.parse(at) < TTL_MS && readCached(id) !== null;
154
+ };
155
+ const resting = (id: SourceId) => {
156
+ const s = state.sources[id];
157
+ return o.background && !o.force && s?.error && now() - Date.parse(s.lastAttemptAt) < BACKOFF_MS;
158
+ };
159
+
160
+ const run = async (id: SourceId, fetcher: () => Promise<unknown>): Promise<void> => {
161
+ if (fresh(id)) return void outcomes.set(id, { source: id, state: "fresh", fetchedAt: fetchedAt(id) });
162
+ if (resting(id))
163
+ return void outcomes.set(id, {
164
+ source: id,
165
+ state: "skipped",
166
+ fetchedAt: fetchedAt(id),
167
+ detail: "failed within the hour; the next sync tries again",
168
+ });
169
+ const at = now();
170
+ const prev = state.sources[id];
171
+ try {
172
+ const data = await fetcher();
173
+ writeCached(id, data, at);
174
+ const limit = id === "artificial-analysis" ? (data as AaAnswer).rateLimitRemaining : null;
175
+ state.sources[id] = {
176
+ fetchedAt: iso(at),
177
+ lastAttemptAt: iso(at),
178
+ error: null,
179
+ rateLimitRemaining: limit,
180
+ };
181
+ outcomes.set(id, { source: id, state: "fetched", fetchedAt: iso(at) });
182
+ } catch (e) {
183
+ const error = errorMessage(e);
184
+ const limit = e instanceof SourceError ? rateLimitRemaining(e.headers) : null;
185
+ state.sources[id] = {
186
+ fetchedAt: prev?.fetchedAt ?? null,
187
+ lastAttemptAt: iso(at),
188
+ error,
189
+ rateLimitRemaining: limit ?? prev?.rateLimitRemaining ?? null,
190
+ };
191
+ outcomes.set(id, { source: id, state: "failed", fetchedAt: prev?.fetchedAt ?? null, error });
192
+ log(o.background ? "debug" : "warn", "sources", { source: id, error });
193
+ }
194
+ };
195
+
196
+ const orModels = run("openrouter-models", () => fetchOpenRouterModels(t));
197
+ const jobs: Promise<void>[] = [
198
+ orModels,
199
+ run("models-dev", () => fetchModelsDev(t)),
200
+ run("litellm", () => fetchLiteLlm(t)),
201
+ run("arena", () => fetchArena(t)),
202
+ run("vectara", () => fetchVectara(t)),
203
+ run("epoch", () => fetchEpoch(t)),
204
+ // plan 13 R-H: one request per catalog family OpenRouter lists, so it waits for OpenRouter's model list
205
+ orModels.then(() =>
206
+ run("openrouter-endpoints", async () => {
207
+ const list = readCached("openrouter-models");
208
+ if (!list) throw new Error("no OpenRouter model list to take the ids from");
209
+ const ids = openRouterIds(list.data, { models: shippedModels(), sources: shippedSources() });
210
+ return fetchOpenRouterEndpoints(ids, t);
211
+ }),
212
+ ),
213
+ ];
214
+ if (key) jobs.push(run("artificial-analysis", () => fetchArtificialAnalysis(key, t)));
215
+ else
216
+ outcomes.set("artificial-analysis", {
217
+ source: "artificial-analysis",
218
+ state: "skipped",
219
+ fetchedAt: fetchedAt("artificial-analysis"),
220
+ detail: "no key: catherd init, or export ARTIFICIAL_ANALYSIS_API_KEY",
221
+ });
222
+ await Promise.all(jobs);
223
+ writeSyncState(state);
224
+
225
+ const changed = [...outcomes.values()].some((x) => x.state === "fetched");
226
+ const raw = cachedAnswers();
227
+ if ((changed || readDerived() === null) && Object.keys(raw).length > 0)
228
+ writeDerived(
229
+ derive(raw, {
230
+ models: shippedModels(),
231
+ scores: shippedScores(),
232
+ sources: shippedSources(),
233
+ now: now(),
234
+ }),
235
+ );
236
+ const derived = readDerived();
237
+ const after = catalogWith(derived, now());
238
+ const had = scored(before);
239
+ const noLongerNeeded = Object.entries(after.treatLike).flatMap(([rung, t]) => {
240
+ if (t.source !== "user") return [];
241
+ const lends = DIMS.filter((d) => after.scores[t.like]?.[d]);
242
+ const covered = (c: Catalog) => lends.length > 0 && lends.every((d) => c.scores[rung]?.[d]);
243
+ return covered(after) && !covered(before) ? [{ rung, like: t.like }] : [];
244
+ });
245
+ const sources = SOURCE_IDS.map((id) => outcomes.get(id)).filter(
246
+ (x): x is SourceOutcome => x !== undefined,
247
+ );
248
+ return {
249
+ busy: false,
250
+ sources,
251
+ newlyScored: [...scored(after)].filter((r) => !had.has(r)).sort(),
252
+ noLongerNeeded,
253
+ failed: sources.flatMap((s) =>
254
+ s.state === "failed" ? [{ source: s.source, error: s.error ?? "" }] : [],
255
+ ),
256
+ warnings: derived?.warnings ?? [],
257
+ unmatched: derived?.unmatched ?? {},
258
+ };
259
+ } finally {
260
+ release();
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Spec 1.2 §3.2: the MCP server's boot sync, started without awaiting it. It never throws (a failure is
266
+ * logged at debug) and never waits for another sync. `CATHERD_NO_SYNC=1` turns it off (tests, air-gapped
267
+ * machines), as it does `init`'s sync; `catherd catalog sync` still runs.
268
+ */
269
+ export function backgroundSync(o: SyncOptions = {}): Promise<SyncReport | null> {
270
+ if (process.env.CATHERD_NO_SYNC === "1") return Promise.resolve(null);
271
+ return syncSources({ ...o, background: true }).catch((e: unknown) => {
272
+ log("debug", "sources", { error: errorMessage(e) });
273
+ return null;
274
+ });
275
+ }
276
+
277
+ export interface SourceStatus {
278
+ source: SourceId;
279
+ name: string;
280
+ /** when its cached answer was fetched; null when never */
281
+ fetchedAt: string | null;
282
+ error: string | null;
283
+ }
284
+
285
+ /** Spec 1.2 §9 doctor: each source's last fetch and error, the AA key, and AA's requests left. */
286
+ export function sourcesStatus(): {
287
+ sources: SourceStatus[];
288
+ aaKey: boolean;
289
+ rateLimitRemaining: number | null;
290
+ rateLimitAt: string | null;
291
+ } {
292
+ const state = readSyncState();
293
+ const aa = state.sources["artificial-analysis"];
294
+ return {
295
+ sources: shippedSources().sources.map((s) => ({
296
+ source: s.id,
297
+ name: s.name,
298
+ fetchedAt: state.sources[s.id]?.fetchedAt ?? null,
299
+ error: state.sources[s.id]?.error ?? null,
300
+ })),
301
+ aaKey: aaKey() !== null,
302
+ rateLimitRemaining: aa?.rateLimitRemaining ?? null,
303
+ rateLimitAt: aa?.rateLimitRemaining === null || aa === undefined ? null : aa.lastAttemptAt,
304
+ };
305
+ }