catherd-cli 1.5.0 → 2.0.0-beta.20261022113309

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 (275) hide show
  1. package/README.md +164 -208
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -247
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -421
  11. package/plugin/skills/catherd-setup/SKILL.md +20 -19
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +1 -2
  14. package/src/adapters/cursor/home.ts +1 -1
  15. package/src/adapters/cursor/index.ts +4 -4
  16. package/src/cli.ts +22 -27
  17. package/src/domain/bars.ts +33 -107
  18. package/src/domain/blocked.ts +72 -0
  19. package/src/domain/branch.ts +51 -0
  20. package/src/domain/brief.ts +21 -6
  21. package/src/domain/catalog.ts +69 -324
  22. package/src/domain/cost.ts +15 -30
  23. package/src/domain/errors.ts +2 -2
  24. package/src/domain/fail-lines.ts +26 -0
  25. package/src/domain/failover.ts +37 -87
  26. package/src/domain/findings.ts +47 -0
  27. package/src/domain/gate-brief.ts +1 -0
  28. package/src/domain/ids.ts +13 -2
  29. package/src/domain/jev.ts +47 -2
  30. package/src/domain/judge.ts +124 -0
  31. package/src/domain/lane.ts +22 -3
  32. package/src/domain/milestone-memory.ts +53 -0
  33. package/src/domain/notice.ts +25 -8
  34. package/src/domain/notify.ts +32 -0
  35. package/src/domain/plan-doc.ts +198 -0
  36. package/src/domain/plan-lint.ts +141 -0
  37. package/src/domain/profile-rules.ts +101 -160
  38. package/src/domain/profile.ts +116 -63
  39. package/src/domain/record.ts +53 -2
  40. package/src/domain/role-prompts.ts +131 -45
  41. package/src/domain/role-scope.ts +4 -34
  42. package/src/domain/role-tools.ts +2 -2
  43. package/src/domain/roles.ts +2 -0
  44. package/src/domain/route.ts +17 -0
  45. package/src/domain/rules.ts +33 -0
  46. package/src/domain/run-events.ts +298 -0
  47. package/src/domain/run-machine.ts +668 -0
  48. package/src/domain/run-state.ts +1310 -0
  49. package/src/domain/scorecard.ts +197 -0
  50. package/src/domain/select.ts +151 -146
  51. package/src/domain/slopalytics.ts +72 -0
  52. package/src/entry/answer-command.ts +39 -0
  53. package/src/entry/catalog-command.ts +25 -174
  54. package/src/entry/check-bin.ts +17 -0
  55. package/src/entry/cli-kit.ts +1 -1
  56. package/src/entry/daemon-bin.ts +22 -0
  57. package/src/entry/glyphs.ts +15 -2
  58. package/src/entry/init-command.ts +18 -68
  59. package/src/entry/knowledge-command.ts +45 -4
  60. package/src/entry/lock-command.ts +6 -7
  61. package/src/entry/mcp/role-server.ts +18 -0
  62. package/src/entry/mcp/server.ts +13 -40
  63. package/src/entry/mcp/session-tools.ts +149 -0
  64. package/src/entry/panel/openapi.ts +189 -0
  65. package/src/entry/panel/server.ts +410 -0
  66. package/src/entry/panel-command.ts +68 -0
  67. package/src/entry/profile-command.ts +10 -14
  68. package/src/entry/role-cli-command.ts +47 -3
  69. package/src/entry/runs-command.ts +3 -19
  70. package/src/entry/scorecard-command.ts +187 -0
  71. package/src/entry/supervise-bin.ts +1 -1
  72. package/src/infra/env.ts +0 -2
  73. package/src/infra/git.ts +64 -0
  74. package/src/infra/heavy-lock.ts +45 -7
  75. package/src/infra/launch.ts +63 -7
  76. package/src/infra/memory.ts +45 -0
  77. package/src/infra/ntfy.ts +40 -0
  78. package/src/infra/open-url.ts +42 -0
  79. package/src/infra/paths.ts +2 -2
  80. package/src/infra/slopalytics.ts +30 -0
  81. package/src/infra/store.ts +6 -1
  82. package/src/services/admission.ts +24 -9
  83. package/src/services/backends.ts +4 -6
  84. package/src/services/capture.ts +1 -22
  85. package/src/services/catalog-service.ts +155 -262
  86. package/src/services/credentials.ts +4 -20
  87. package/src/services/daemon.ts +181 -0
  88. package/src/services/dispatch-service.ts +5 -1
  89. package/src/services/doctor-backends.ts +1 -1
  90. package/src/services/doctor-checks.ts +47 -0
  91. package/src/services/doctor.ts +4 -9
  92. package/src/services/failover-rungs.ts +109 -0
  93. package/src/services/finalize.ts +12 -0
  94. package/src/services/gate-env.ts +1 -2
  95. package/src/services/gate-service.ts +21 -6
  96. package/src/services/jev-service.ts +2 -4
  97. package/src/services/lane-service.ts +34 -3
  98. package/src/services/milestone-check.ts +433 -0
  99. package/src/services/milestone-tree.ts +170 -0
  100. package/src/services/milestones.ts +35 -8
  101. package/src/services/notifier.ts +69 -2
  102. package/src/services/notify-service.ts +26 -0
  103. package/src/services/panel-setup.ts +116 -0
  104. package/src/services/plan-input.ts +151 -0
  105. package/src/services/ports.ts +30 -4
  106. package/src/services/preflight.ts +82 -8
  107. package/src/services/profile-service.ts +30 -3
  108. package/src/services/profile-store.ts +8 -3
  109. package/src/services/protocol.ts +4 -0
  110. package/src/services/provenance.ts +31 -90
  111. package/src/services/questions.ts +9 -0
  112. package/src/services/reconcile.ts +11 -0
  113. package/src/services/routing-service.ts +26 -15
  114. package/src/services/run-briefs.ts +477 -0
  115. package/src/services/run-client.ts +217 -0
  116. package/src/services/run-effects.ts +837 -0
  117. package/src/services/run-events-store.ts +174 -0
  118. package/src/services/run-evidence.ts +1 -1
  119. package/src/services/run-read.ts +221 -0
  120. package/src/services/run-report.ts +176 -0
  121. package/src/services/run-service.ts +55 -6
  122. package/src/services/run-store.ts +54 -7
  123. package/src/services/scorecard-metrics.ts +86 -0
  124. package/src/services/scorecard.ts +382 -0
  125. package/src/services/summary.ts +27 -3
  126. package/src/services/tree-path.ts +27 -0
  127. package/src/services/workspace-admission.ts +5 -13
  128. package/src/services/workspace-service.ts +5 -0
  129. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  130. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  131. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  132. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  133. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  134. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  135. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  136. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  137. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  138. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  139. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  140. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  141. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  142. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  143. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  144. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  145. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  146. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  147. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  148. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  149. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  150. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  151. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  152. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  153. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  154. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  155. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  156. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  157. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  158. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  159. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  160. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  161. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  162. package/web/dist/assets/index-C5IbylvM.css +2 -0
  163. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  164. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  165. package/web/dist/assets/label-DFSYBc62.js +1 -0
  166. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  167. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  168. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  169. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  170. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  171. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  172. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  173. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  174. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  175. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  176. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  177. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  178. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  179. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  180. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  181. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  183. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  184. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  185. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  186. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  187. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  188. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  189. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  190. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  191. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  192. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  193. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  194. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  195. package/web/dist/assets/time--9kSQbbp.js +1 -0
  196. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  197. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  198. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  199. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  200. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  201. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  202. package/web/dist/index.html +15 -0
  203. package/catalog/ATTRIBUTION.md +0 -44
  204. package/catalog/scores.json +0 -3202
  205. package/catalog/sources.json +0 -97
  206. package/src/adapters/antigravity/events.ts +0 -131
  207. package/src/adapters/antigravity/home.ts +0 -46
  208. package/src/adapters/antigravity/index.ts +0 -335
  209. package/src/adapters/antigravity/models.ts +0 -37
  210. package/src/adapters/grok/events.ts +0 -112
  211. package/src/adapters/grok/home.ts +0 -187
  212. package/src/adapters/grok/index.ts +0 -358
  213. package/src/adapters/grok/models.ts +0 -48
  214. package/src/domain/calibration.ts +0 -132
  215. package/src/domain/sources.ts +0 -144
  216. package/src/entry/mcp/dispatch-tools.ts +0 -52
  217. package/src/entry/mcp/lane-tools.ts +0 -131
  218. package/src/entry/mcp/protocol-tools.ts +0 -63
  219. package/src/entry/mcp/push-tools.ts +0 -16
  220. package/src/entry/mcp/run-tools.ts +0 -164
  221. package/src/entry/mcp/setup-tools.ts +0 -109
  222. package/src/entry/mcp/workspace-tools.ts +0 -142
  223. package/src/entry/tui/commands.ts +0 -563
  224. package/src/entry/tui/effects.ts +0 -385
  225. package/src/entry/tui/fixtures.ts +0 -637
  226. package/src/entry/tui/profile-edits.ts +0 -146
  227. package/src/entry/tui/profile-tree.ts +0 -429
  228. package/src/entry/tui/providers/app.tsx +0 -162
  229. package/src/entry/tui/providers/boundary.tsx +0 -57
  230. package/src/entry/tui/providers/data.tsx +0 -160
  231. package/src/entry/tui/providers/keymap.tsx +0 -166
  232. package/src/entry/tui/providers/root.tsx +0 -45
  233. package/src/entry/tui/providers/theme.tsx +0 -17
  234. package/src/entry/tui/providers/toast.tsx +0 -77
  235. package/src/entry/tui/run.tsx +0 -114
  236. package/src/entry/tui/state.ts +0 -400
  237. package/src/entry/tui/stories.ts +0 -114
  238. package/src/entry/tui/text.ts +0 -130
  239. package/src/entry/tui/theme.ts +0 -140
  240. package/src/entry/tui/views/app.tsx +0 -208
  241. package/src/entry/tui/views/dialogs.tsx +0 -41
  242. package/src/entry/tui/views/profile-actions.ts +0 -331
  243. package/src/entry/tui/views/profiles.tsx +0 -520
  244. package/src/entry/tui/views/runs.tsx +0 -619
  245. package/src/entry/tui/views/save-dialog.tsx +0 -344
  246. package/src/entry/tui/views/status.tsx +0 -213
  247. package/src/entry/tui/widgets/chrome.tsx +0 -114
  248. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  249. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  250. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  251. package/src/entry/tui/widgets/dialog.tsx +0 -68
  252. package/src/entry/tui/widgets/line.tsx +0 -92
  253. package/src/entry/tui/widgets/list.tsx +0 -197
  254. package/src/entry/tui/widgets/spinner.tsx +0 -49
  255. package/src/entry/tui/widgets/toast.tsx +0 -62
  256. package/src/infra/sources/arena.ts +0 -95
  257. package/src/infra/sources/artificial-analysis.ts +0 -119
  258. package/src/infra/sources/cache.ts +0 -126
  259. package/src/infra/sources/csv.ts +0 -38
  260. package/src/infra/sources/epoch.ts +0 -82
  261. package/src/infra/sources/http.ts +0 -75
  262. package/src/infra/sources/litellm.ts +0 -58
  263. package/src/infra/sources/models-dev.ts +0 -59
  264. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  265. package/src/infra/sources/openrouter-models.ts +0 -60
  266. package/src/infra/sources/rows.ts +0 -38
  267. package/src/infra/sources/vectara.ts +0 -69
  268. package/src/infra/sources/zip.ts +0 -48
  269. package/src/services/catalog-refresh.ts +0 -201
  270. package/src/services/doctor-sources.ts +0 -76
  271. package/src/services/runs-page.ts +0 -290
  272. package/src/services/source-derive.ts +0 -397
  273. package/src/services/source-sync.ts +0 -312
  274. package/src/services/standins.ts +0 -238
  275. package/src/services/treat-likes.ts +0 -113
@@ -1,49 +0,0 @@
1
- // Adapted from anomalyco/opencode packages/tui/src/component/spinner.tsx and startup-loading.tsx
2
- // (MIT, © 2025 opencode); see THIRD_PARTY_NOTICES.md.
3
- import { useEffect, useState } from "react";
4
- import { useApp } from "../providers/app.tsx";
5
- import { useTone, useUi } from "../providers/theme.tsx";
6
- import { ascii } from "../text.ts";
7
- import { SPINNER } from "../theme.ts";
8
-
9
- /**
10
- * Spec §9.3 motion: true once `active` has lasted SPINNER.showAfterMs, and then for at least
11
- * SPINNER.minShowMs, so a quick check never flickers.
12
- */
13
- export function useDelayedPresence(active: boolean): boolean {
14
- const { clock } = useApp();
15
- const [shownAt, setShownAt] = useState<number | null>(null);
16
- useEffect(() => {
17
- if (active && shownAt === null) {
18
- const h = clock.setTimeout(() => setShownAt(clock.now()), SPINNER.showAfterMs);
19
- return () => clock.clearTimeout(h);
20
- }
21
- if (!active && shownAt !== null) {
22
- const left = SPINNER.minShowMs - (clock.now() - shownAt);
23
- if (left <= 0) {
24
- setShownAt(null);
25
- return;
26
- }
27
- const h = clock.setTimeout(() => setShownAt(null), left);
28
- return () => clock.clearTimeout(h);
29
- }
30
- return undefined;
31
- }, [active, shownAt, clock]);
32
- return shownAt !== null;
33
- }
34
-
35
- /** A braille spinner and what it waits on; a still `⋯` under reduced motion (spec §9.3). */
36
- export function Spinner(props: { label: string }) {
37
- const { clock } = useApp();
38
- const ui = useUi();
39
- const tone = useTone();
40
- const [frame, setFrame] = useState(0);
41
- useEffect(() => {
42
- if (ui.reducedMotion) return;
43
- const h = clock.setInterval(() => setFrame((f) => f + 1), SPINNER.ms);
44
- return () => clock.clearInterval(h);
45
- }, [clock, ui.reducedMotion]);
46
- const frames = ui.plain ? SPINNER.plainFrames : SPINNER.frames;
47
- const g = ui.reducedMotion ? SPINNER.still[ui.plain ? 1 : 0] : frames[frame % frames.length];
48
- return <text fg={tone("muted")}>{`${g} ${ui.plain ? ascii(props.label) : props.label}`}</text>;
49
- }
@@ -1,62 +0,0 @@
1
- // Adapted from anomalyco/opencode packages/tui/src/ui/toast.tsx (MIT, © 2025 opencode);
2
- // see THIRD_PARTY_NOTICES.md.
3
- import { useTerminalDimensions } from "@opentui/react";
4
- import { useToasts } from "../providers/toast.tsx";
5
- import { useUi } from "../providers/theme.tsx";
6
- import { truncateEnd, wrap } from "../text.ts";
7
- import { glyph, type Token } from "../theme.ts";
8
- import { Line } from "./line.tsx";
9
-
10
- const TONE: Record<string, Token> = { info: "info", success: "success", warning: "warning", error: "error" };
11
-
12
- /** Lines of a toast's message kept before the rest is cut; a fix command always shows in full. */
13
- const MESSAGE_LINES = 3;
14
-
15
- /**
16
- * Spec §9.3: the current toast at the top right, with a `┃` bar in its colour and the queue's length.
17
- * The message wraps to three lines; a fix command wraps in full on muted lines under it.
18
- */
19
- export function ToastHost() {
20
- const { current, queued } = useToasts();
21
- const ui = useUi();
22
- const dims = useTerminalDimensions();
23
- if (!current) return null;
24
- const bar = glyph("bar", ui.plain);
25
- const max = Math.max(1, Math.min(60, dims.width - 6) - 4);
26
- const said = wrap(`${current.message}${queued ? ` +${queued} more` : ""}`, max);
27
- const message = said.slice(0, MESSAGE_LINES);
28
- if (said.length > MESSAGE_LINES)
29
- message[MESSAGE_LINES - 1] = truncateEnd(
30
- `${message[MESSAGE_LINES - 1]} ${said[MESSAGE_LINES]}`,
31
- max,
32
- ui.plain,
33
- );
34
- const fix = current.fix ? wrap(current.fix, max) : [];
35
- const text = Math.max(...[...message, ...fix].map((l) => Bun.stringWidth(l)));
36
- const w = text + 4;
37
- const line = (t: string, muted: boolean, i: number) => (
38
- <Line
39
- key={i}
40
- width={w}
41
- opaque
42
- parts={[
43
- { text: `${bar} `, tone: TONE[current.variant] },
44
- { text: t.padEnd(text + t.length - Bun.stringWidth(t)), tone: muted ? "muted" : undefined },
45
- { text: ` ${bar}`, tone: TONE[current.variant] },
46
- ]}
47
- />
48
- );
49
- return (
50
- <box
51
- position="absolute"
52
- top={1}
53
- left={Math.max(0, dims.width - w - 2)}
54
- zIndex={4000}
55
- width={w}
56
- height={message.length + fix.length}
57
- flexDirection="column"
58
- >
59
- {[...message.map((t, i) => line(t, false, i)), ...fix.map((t, i) => line(t, true, message.length + i))]}
60
- </box>
61
- );
62
- }
@@ -1,95 +0,0 @@
1
- import { effortWord } from "../../domain/sources.ts";
2
- import { SourceError, type SourceTransport, sourceGet } from "./http.ts";
3
- import { isoDay, num, type SourceRow } from "./rows.ts";
4
-
5
- /** Spec 1.2 §3.1: the Agent Arena boards and WebDev (CC-BY-4.0). */
6
- export const ARENA_CONFIGS = [
7
- "agent",
8
- "agent_task_outcome_explicit",
9
- "agent_bash_recovery_steps",
10
- "agent_steerability",
11
- "agent_tool_hallucination",
12
- "webdev",
13
- ] as const;
14
- export type ArenaConfig = (typeof ARENA_CONFIGS)[number];
15
-
16
- export const ARENA_PAGE = "https://huggingface.co/datasets/lmarena-ai/leaderboard-dataset";
17
-
18
- export const arenaUrl = (config: ArenaConfig): string =>
19
- `https://datasets-server.huggingface.co/rows?dataset=lmarena-ai/leaderboard-dataset&config=${config}&split=latest&length=100`;
20
-
21
- /**
22
- * Every config, in parallel; one failure fails the source, which keeps its last answer. A config that answers
23
- * no rows is a failure too (1.2 minor): an answer of the wrong shape never replaces a good one.
24
- */
25
- export async function fetchArena(t: SourceTransport = {}): Promise<Record<ArenaConfig, unknown>> {
26
- const got = await Promise.all(
27
- ARENA_CONFIGS.map(async (c) => {
28
- const body = (await sourceGet(arenaUrl(c), t)).json() as { rows?: unknown } | null;
29
- if (!Array.isArray(body?.rows) || body.rows.length === 0)
30
- throw new SourceError(`Arena ${c} answered no rows`);
31
- return [c, body];
32
- }),
33
- );
34
- return Object.fromEntries(got) as Record<ArenaConfig, unknown>;
35
- }
36
-
37
- /**
38
- * Spec 1.2 §3.4: an Arena model name as `<name>#<effort>`: the effort from a `(Max)`/`(xHigh)`/`(High)`
39
- * suffix, or from a trailing `-max`/`-xhigh`/`-high` (WebDev's ids). A harness note such as
40
- * `(codex-harness)` is dropped; any other parenthesis stays part of the name.
41
- */
42
- export function arenaRung(name: string): string {
43
- let rest = name.replace(/\s*\([^()]*harness\)/gi, "").trim();
44
- let effort: string | null = null;
45
- rest = rest
46
- .replace(/\s*\(([^()]*)\)/g, (whole, inner: string) => {
47
- const e = effort === null ? effortWord(inner) : null;
48
- if (e === null) return whole;
49
- effort = e;
50
- return "";
51
- })
52
- .trim();
53
- if (effort === null) {
54
- const m = /^(.*)-([A-Za-z]+)$/.exec(rest);
55
- const e = m ? effortWord(m[2] as string) : null;
56
- if (m && e) {
57
- effort = e;
58
- rest = m[1] as string;
59
- }
60
- }
61
- return effort === null ? rest : `${rest}#${effort}`;
62
- }
63
-
64
- interface ArenaRow {
65
- model_name?: unknown;
66
- score?: unknown;
67
- rating?: unknown;
68
- leaderboard_publish_date?: unknown;
69
- }
70
-
71
- /**
72
- * One row per model and config: `field` is the config, `value` the agent boards' `score` (net improvement) or
73
- * WebDev's `rating`, dated by the board's publish date.
74
- */
75
- export function parseArena(raw: Record<string, unknown>, fetchedAt: string): SourceRow[] {
76
- const rows: SourceRow[] = [];
77
- for (const config of ARENA_CONFIGS) {
78
- const list = (raw[config] as { rows?: { row?: ArenaRow }[] } | undefined)?.rows;
79
- if (!Array.isArray(list)) continue;
80
- for (const { row } of list) {
81
- if (!row || typeof row.model_name !== "string") continue;
82
- const value = num(config === "webdev" ? row.rating : row.score);
83
- if (value === null) continue;
84
- const published = typeof row.leaderboard_publish_date === "string" ? row.leaderboard_publish_date : "";
85
- rows.push({
86
- rung: arenaRung(row.model_name),
87
- field: config,
88
- value,
89
- date: /^\d{4}-\d{2}-\d{2}$/.test(published) ? published : isoDay(fetchedAt),
90
- url: ARENA_PAGE,
91
- });
92
- }
93
- }
94
- return rows;
95
- }
@@ -1,119 +0,0 @@
1
- import { effortWord } from "../../domain/sources.ts";
2
- import { rateLimitRemaining, SourceError, type SourceTransport, sourceGet } from "./http.ts";
3
- import { isoDay, num, type SourceRow } from "./rows.ts";
4
-
5
- /** Spec 1.2 §3.1: the legacy v2 route a free key reads today, one row per effort. */
6
- export const AA_MODELS_URL = "https://artificialanalysis.ai/api/v2/data/llms/models";
7
- /** Spec 1.2 §3.1: the fallback when the first path answers 403 or 404, a page at a time. */
8
- export const aaFreeUrl = (page: number): string =>
9
- `https://artificialanalysis.ai/api/v2/language/models/free?page=${page}`;
10
- export const AA_PAGE = "https://artificialanalysis.ai";
11
- /** The free path had 4 pages on 2026-09-27; this bounds a runaway pagination. */
12
- const MAX_FREE_PAGES = 10;
13
-
14
- /** What catherd caches for Artificial Analysis: the path that answered, each page, the requests left. */
15
- export interface AaAnswer {
16
- path: "models" | "free";
17
- pages: unknown[];
18
- rateLimitRemaining: number | null;
19
- }
20
-
21
- /**
22
- * Spec 1.2 §3.1: the models path with the user's key; on a 403 or 404 there, the free path's pages until an
23
- * empty page or the last one. Throws a SourceError (carrying the last answer's headers) on any other failure.
24
- */
25
- export async function fetchArtificialAnalysis(key: string, t: SourceTransport = {}): Promise<AaAnswer> {
26
- const o = { ...t, headers: { "x-api-key": key } };
27
- try {
28
- const r = await sourceGet(AA_MODELS_URL, o);
29
- const body = r.json() as { data?: unknown } | null;
30
- // (1.2 minor) an answer of the wrong shape is a failure: the last good one stays
31
- if (!Array.isArray(body?.data) || body.data.length === 0)
32
- throw new SourceError("Artificial Analysis answered no models", r.status, r.headers);
33
- return { path: "models", pages: [body], rateLimitRemaining: rateLimitRemaining(r.headers) };
34
- } catch (e) {
35
- if (!(e instanceof SourceError) || (e.status !== 403 && e.status !== 404)) throw e;
36
- }
37
- const pages: unknown[] = [];
38
- let remaining: number | null = null;
39
- for (let page = 1; page <= MAX_FREE_PAGES; page++) {
40
- const r = await sourceGet(aaFreeUrl(page), o);
41
- remaining = rateLimitRemaining(r.headers) ?? remaining;
42
- const body = r.json() as { data?: unknown; pagination?: { total_pages?: unknown } } | null;
43
- if (!Array.isArray(body?.data) || body.data.length === 0) break;
44
- pages.push(body);
45
- const total = num(body.pagination?.total_pages);
46
- if (total !== null && page >= total) break;
47
- }
48
- if (pages.length === 0) throw new SourceError("Artificial Analysis answered an empty first page");
49
- return { path: "free", pages, rateLimitRemaining: remaining };
50
- }
51
-
52
- /**
53
- * Spec 1.2 §9: tests a key with one request to the free path's first page. `ok` on a 200, `refused` on a 401;
54
- * anything else (a network error, a 5xx) leaves the key `unchecked`.
55
- */
56
- export async function testAaKey(
57
- key: string,
58
- t: SourceTransport = {},
59
- ): Promise<{ result: "ok" | "refused" | "unchecked"; error?: string; rateLimitRemaining: number | null }> {
60
- try {
61
- // spec 1.2 §9: one request, never retried (a 429 or 5xx leaves the key unchecked)
62
- const r = await sourceGet(aaFreeUrl(1), {
63
- attemptMs: 10_000,
64
- deadlineMs: 20_000,
65
- ...t,
66
- retries: 0,
67
- headers: { "x-api-key": key },
68
- });
69
- return { result: "ok", rateLimitRemaining: rateLimitRemaining(r.headers) };
70
- } catch (e) {
71
- const err = e instanceof SourceError ? e : new SourceError(String(e));
72
- return {
73
- result: err.status === 401 ? "refused" : "unchecked",
74
- error: err.message,
75
- rateLimitRemaining: rateLimitRemaining(err.headers),
76
- };
77
- }
78
- }
79
-
80
- /** Spec 1.2 §3.4: an AA slug as `<id>#<effort>`: its effort suffix, and `max` for a bare slug. */
81
- export function aaRung(slug: string): string {
82
- const m = /^(.*)-([a-z]+)$/.exec(slug);
83
- const effort = m ? effortWord(m[2] as string) : null;
84
- return m && effort ? `${m[1]}#${effort}` : `${slug}#max`;
85
- }
86
-
87
- const TOP = [
88
- "median_output_tokens_per_second",
89
- "median_time_to_first_token_seconds",
90
- "cost_per_task",
91
- ] as const;
92
-
93
- /**
94
- * One row per slug and number: every evaluation (`livecodebench`, `terminalbench_v2_1`, the indexes, …), every
95
- * price, speed and `cost_per_task`, named as AA names them. Nulls are left out.
96
- */
97
- export function parseArtificialAnalysis(answer: AaAnswer, fetchedAt: string): SourceRow[] {
98
- const rows: SourceRow[] = [];
99
- const date = isoDay(fetchedAt);
100
- for (const page of answer.pages) {
101
- const data = (page as { data?: unknown } | null)?.data;
102
- if (!Array.isArray(data)) continue;
103
- for (const m of data as Record<string, unknown>[]) {
104
- if (typeof m?.slug !== "string") continue;
105
- const rung = aaRung(m.slug);
106
- const url = `${AA_PAGE}/models/${m.slug}`;
107
- const push = (field: string, v: unknown) => {
108
- const value = num(v);
109
- if (value !== null) rows.push({ rung, field, value, date, url });
110
- };
111
- for (const group of ["evaluations", "pricing"] as const) {
112
- const g = m[group];
113
- if (g && typeof g === "object") for (const [field, v] of Object.entries(g)) push(field, v);
114
- }
115
- for (const field of TOP) push(field, m[field]);
116
- }
117
- }
118
- return rows;
119
- }
@@ -1,126 +0,0 @@
1
- import { existsSync, statSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { z } from "zod";
4
- import { errorMessage } from "../../domain/errors.ts";
5
- import { type Derived, DerivedSchema, type SourceId } from "../../domain/sources.ts";
6
- import { tryLock } from "../filelock.ts";
7
- import { log } from "../log.ts";
8
- import { sourcesDir } from "../paths.ts";
9
- import { ensurePrivateDir, readVersioned, writeJsonAtomic, writeTextAtomic } from "../store.ts";
10
-
11
- // Spec 1.2 §3.3: `<data>/sources/<source>.json` holds a source's last good answer with its fetch time; a
12
- // failed fetch never touches it. `state.json` holds each source's last attempt and error.
13
-
14
- export const cachePath = (id: SourceId): string => join(sourcesDir(), `${id}.json`);
15
-
16
- const CachedSchema = z.looseObject({
17
- schema: z.literal(1),
18
- source: z.string(),
19
- fetchedAt: z.iso.datetime(),
20
- /** the answer as fetched (Epoch: the CSV tables catherd reads out of its zip) */
21
- data: z.unknown(),
22
- meta: z.record(z.string(), z.unknown()).default({}),
23
- });
24
- export type Cached = z.infer<typeof CachedSchema>;
25
-
26
- /** A source's last good answer; null when there is none, or when it cannot be read (the next sync rewrites it). */
27
- export function readCached(id: SourceId): Cached | null {
28
- const file = cachePath(id);
29
- if (!existsSync(file)) return null;
30
- try {
31
- return readVersioned(file, CachedSchema, 1);
32
- } catch (e) {
33
- log("debug", "sources", { source: id, error: errorMessage(e) });
34
- return null;
35
- }
36
- }
37
-
38
- /** Writes a source's answer atomically (mode 600), compact: models.dev alone is 5 MB. */
39
- export function writeCached(
40
- id: SourceId,
41
- data: unknown,
42
- fetchedAt: number,
43
- meta: Record<string, unknown> = {},
44
- ): Cached {
45
- const c: Cached = { schema: 1, source: id, fetchedAt: new Date(fetchedAt).toISOString(), data, meta };
46
- writeTextAtomic(cachePath(id), `${JSON.stringify(c)}\n`);
47
- return c;
48
- }
49
-
50
- const StateSchema = z.looseObject({
51
- schema: z.literal(1),
52
- sources: z
53
- .record(
54
- z.string(),
55
- z.looseObject({
56
- /** when the cached answer was fetched (its TTL runs from here); null before a first success */
57
- fetchedAt: z.iso.datetime().nullable().default(null),
58
- lastAttemptAt: z.iso.datetime(),
59
- error: z.string().nullable(),
60
- /** Artificial Analysis: `x-ratelimit-remaining` of its last answer */
61
- rateLimitRemaining: z.number().nullable().default(null),
62
- /** when that count was read (1.2 minor); absent in a state written before 1.5 */
63
- rateLimitAt: z.iso.datetime().nullable().optional(),
64
- }),
65
- )
66
- .default({}),
67
- });
68
- export type SyncState = z.infer<typeof StateSchema>;
69
-
70
- export const statePath = (): string => join(sourcesDir(), "state.json");
71
-
72
- /** Each source's last attempt; empty when never synced or unreadable. */
73
- export function readSyncState(): SyncState {
74
- const empty: SyncState = { schema: 1, sources: {} };
75
- if (!existsSync(statePath())) return empty;
76
- try {
77
- return readVersioned(statePath(), StateSchema, 1);
78
- } catch (e) {
79
- log("debug", "sources", { state: errorMessage(e) });
80
- return empty;
81
- }
82
- }
83
-
84
- export const writeSyncState = (s: SyncState): void => writeJsonAtomic(statePath(), s);
85
-
86
- /** Spec 1.2 §3.2: one sync at a time on this machine. Its release, or null while another live process syncs. */
87
- export function tryLockSync(): (() => void) | null {
88
- ensurePrivateDir(sourcesDir());
89
- return tryLock(join(sourcesDir(), "sync"));
90
- }
91
-
92
- export const derivedPath = (): string => join(sourcesDir(), "derived.json");
93
- /** Spec 1.2 §4.2: every fit with its R², used or not. */
94
- export const calibrationPath = (): string => join(sourcesDir(), "calibration.json");
95
-
96
- let memo: { mtimeMs: number; size: number; file: string; derived: Derived | null } | null = null;
97
-
98
- /**
99
- * What the last sync derived; null when never synced or unreadable (routing then reads the shipped values
100
- * alone). Read on every catalog load, so it is parsed again only when the file changes.
101
- */
102
- export function readDerived(): Derived | null {
103
- const file = derivedPath();
104
- let st: { mtimeMs: number; size: number };
105
- try {
106
- st = statSync(file);
107
- } catch {
108
- return null;
109
- }
110
- if (memo?.file === file && memo.mtimeMs === st.mtimeMs && memo.size === st.size) return memo.derived;
111
- let derived: Derived | null = null;
112
- try {
113
- derived = readVersioned(file, DerivedSchema, 1);
114
- } catch (e) {
115
- log("debug", "sources", { derived: errorMessage(e) });
116
- }
117
- memo = { file, mtimeMs: st.mtimeMs, size: st.size, derived };
118
- return derived;
119
- }
120
-
121
- /** Writes derived.json and calibration.json atomically. */
122
- export function writeDerived(d: Derived): void {
123
- memo = null;
124
- writeTextAtomic(derivedPath(), `${JSON.stringify(d)}\n`);
125
- writeJsonAtomic(calibrationPath(), { schema: 1, builtAt: d.builtAt, fits: d.fits });
126
- }
@@ -1,38 +0,0 @@
1
- /**
2
- * RFC 4180 CSV as one object per row, keyed by the header row: quoted fields may hold commas, `""` and line
3
- * breaks (Epoch's notes do); CRLF and LF both end a row; blank lines are skipped.
4
- */
5
- export function parseCsv(text: string): Record<string, string>[] {
6
- const records: string[][] = [];
7
- let row: string[] = [];
8
- let field = "";
9
- let quoted = false;
10
- for (let i = 0; i < text.length; i++) {
11
- const ch = text[i] as string;
12
- if (quoted) {
13
- if (ch === '"' && text[i + 1] === '"') {
14
- field += '"';
15
- i++;
16
- } else if (ch === '"') quoted = false;
17
- else field += ch;
18
- } else if (ch === '"') quoted = true;
19
- else if (ch === ",") {
20
- row.push(field);
21
- field = "";
22
- } else if (ch === "\n" || ch === "\r") {
23
- if (ch === "\r" && text[i + 1] === "\n") i++;
24
- row.push(field);
25
- records.push(row);
26
- row = [];
27
- field = "";
28
- } else field += ch;
29
- }
30
- if (field !== "" || row.length) {
31
- row.push(field);
32
- records.push(row);
33
- }
34
- const rows = records.filter((r) => r.some((c) => c !== ""));
35
- const [header, ...body] = rows;
36
- if (!header) return [];
37
- return body.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] ?? ""])));
38
- }
@@ -1,82 +0,0 @@
1
- import { effortWord } from "../../domain/sources.ts";
2
- import { parseCsv } from "./csv.ts";
3
- import { SourceError, type SourceTransport, sourceGet } from "./http.ts";
4
- import { isoDay, keepHighest, num, type SourceRow, urlOr } from "./rows.ts";
5
- import { unzip } from "./zip.ts";
6
-
7
- /** Spec 1.2 §3.1: one CSV per benchmark in a zip (CC-BY; external tables keep their own license). */
8
- export const EPOCH_URL = "https://epoch.ai/data/benchmark_data.zip";
9
- export const EPOCH_PAGE = "https://epoch.ai/benchmarks";
10
-
11
- /**
12
- * The tables catherd reads, by field: the file, its score column, whether that score is a fraction (read in
13
- * percent, as the shipped values are), and the column that dates a row, if any.
14
- */
15
- export const EPOCH_TABLES = {
16
- frontiercode: { file: "frontiercode_external.csv", score: "Main score", fraction: true, dated: null },
17
- terminalbench: {
18
- file: "terminalbench_external.csv",
19
- score: "Accuracy mean",
20
- fraction: true,
21
- dated: "Run date",
22
- },
23
- webdev: { file: "webdev_arena_external.csv", score: "Arena Score", fraction: false, dated: "Last updated" },
24
- } as const;
25
- type EpochField = keyof typeof EPOCH_TABLES;
26
- const FIELDS = Object.keys(EPOCH_TABLES) as EpochField[];
27
-
28
- /** The zip's tables catherd reads, by file name (the answer catherd caches); a missing one fails the source. */
29
- export async function fetchEpoch(t: SourceTransport = {}): Promise<Record<string, string>> {
30
- const r = await sourceGet(EPOCH_URL, t);
31
- const wanted = new Set<string>(FIELDS.map((f) => EPOCH_TABLES[f].file));
32
- let files: Map<string, Uint8Array>;
33
- try {
34
- files = unzip(r.bytes, (name) => wanted.has(name));
35
- } catch (e) {
36
- throw new SourceError(`the answer is not a readable zip: ${(e as Error).message}`, r.status);
37
- }
38
- const out: Record<string, string> = {};
39
- for (const name of wanted) {
40
- const data = files.get(name);
41
- if (!data) throw new SourceError(`the zip has no ${name}`, r.status);
42
- out[name] = new TextDecoder().decode(data);
43
- }
44
- return out;
45
- }
46
-
47
- /**
48
- * Spec 1.2 §3.4: Epoch's `Model version` as `<id>#<effort>`: the effort after the last `_` (`gpt-6-astra_max`),
49
- * else the row's `Reasoning effort` column; `_unknown` and suffixes that are not efforts (`_32K`) name none.
50
- */
51
- export function epochRung(version: string, effortColumn = ""): string {
52
- const cut = version.lastIndexOf("_");
53
- const id = cut < 0 ? version : version.slice(0, cut);
54
- const effort = (cut < 0 ? null : effortWord(version.slice(cut + 1))) ?? effortWord(effortColumn);
55
- return effort ? `${id}#${effort}` : id;
56
- }
57
-
58
- const DAY = /^\d{4}-\d{2}-\d{2}$/;
59
-
60
- /** One row per model and table, the highest where a model has several (Terminal-Bench: one per agent). */
61
- export function parseEpoch(tables: Record<string, string>, fetchedAt: string): SourceRow[] {
62
- const rows: SourceRow[] = [];
63
- for (const field of FIELDS) {
64
- const t = EPOCH_TABLES[field];
65
- const text = tables[t.file];
66
- if (typeof text !== "string") continue;
67
- for (const r of parseCsv(text)) {
68
- const version = (r["Model version"] ?? "").trim();
69
- const raw = num(r[t.score]);
70
- if (!version || raw === null) continue;
71
- const dated = t.dated ? (r[t.dated] ?? "").trim() : "";
72
- rows.push({
73
- rung: epochRung(version, r["Reasoning effort"] ?? ""),
74
- field,
75
- value: t.fraction ? Math.round(raw * 1e6) / 1e4 : raw,
76
- date: DAY.test(dated) ? dated : isoDay(fetchedAt),
77
- url: urlOr(r.Source, urlOr(r["Source Link"] ?? r["Source link (site from table)"], EPOCH_PAGE)),
78
- });
79
- }
80
- }
81
- return keepHighest(rows);
82
- }
@@ -1,75 +0,0 @@
1
- import { type JevTransport, retryingFetch } from "../jev-client.ts";
2
- import { VERSION } from "../version.ts";
3
-
4
- /** How a source is fetched; tests replace `fetchImpl` and the clock, so no test reaches the network. */
5
- export type SourceTransport = JevTransport;
6
-
7
- /** A source request that failed: the error, and the status and headers of the last answer when there was one. */
8
- export class SourceError extends Error {
9
- constructor(
10
- message: string,
11
- readonly status: number | null = null,
12
- readonly headers: Headers | null = null,
13
- ) {
14
- super(message);
15
- this.name = "SourceError";
16
- }
17
- }
18
-
19
- export interface SourceResponse {
20
- status: number;
21
- headers: Headers;
22
- bytes: Uint8Array;
23
- text(): string;
24
- /** the body as JSON; a SourceError when it is not */
25
- json(): unknown;
26
- }
27
-
28
- /** Spec 1.2 §3.3: one timeout per source; the big files (models.dev, LiteLLM, Epoch) need more than Jev's 10 s. */
29
- const ATTEMPT_MS = 30_000;
30
- const DEADLINE_MS = 60_000;
31
-
32
- /**
33
- * Spec 1.2 §3.3: GET `url` with the Jev client's retry policy and the source's own timeout (30 s an attempt,
34
- * 60 s in all, unless the transport says otherwise). Throws a SourceError on any failure.
35
- */
36
- export async function sourceGet(
37
- url: string,
38
- o: SourceTransport & { headers?: Record<string, string> } = {},
39
- ): Promise<SourceResponse> {
40
- const { headers = {}, ...t } = o;
41
- const r = await retryingFetch(
42
- url,
43
- {
44
- method: "GET",
45
- headers: () => ({
46
- "user-agent": `catherd-cli/${VERSION} (+https://github.com/47vigen/catherd)`,
47
- ...headers,
48
- }),
49
- },
50
- { attemptMs: ATTEMPT_MS, deadlineMs: DEADLINE_MS, ...t },
51
- );
52
- if (!r.ok) throw new SourceError(r.error, r.status, r.headers);
53
- const { bytes, status } = r;
54
- const text = () => new TextDecoder().decode(bytes);
55
- return {
56
- status,
57
- headers: r.headers,
58
- bytes,
59
- text,
60
- json() {
61
- try {
62
- return JSON.parse(text());
63
- } catch {
64
- throw new SourceError("unexpected response (not JSON)", status, r.headers);
65
- }
66
- },
67
- };
68
- }
69
-
70
- /** `x-ratelimit-remaining` of an answer, when it has one (Artificial Analysis, spec 1.2 §9). */
71
- export function rateLimitRemaining(h: Headers | null): number | null {
72
- const raw = h?.get("x-ratelimit-remaining")?.trim();
73
- const v = raw ? Number(raw) : Number.NaN;
74
- return Number.isFinite(v) ? v : null;
75
- }