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,290 +0,0 @@
1
- import { sessionKey } from "../domain/host.ts";
2
- import { existsSync, readdirSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { budgetStatus } from "../domain/budget.ts";
5
- import { ID_PATTERN } from "../domain/ids.ts";
6
- import type { RunRecord } from "../domain/record.ts";
7
- import { dispatchPaths } from "../infra/dispatch-dir.ts";
8
- import { nonBlankLines } from "../infra/store.ts";
9
- import { spendOf } from "./budget.ts";
10
- import { dispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
11
- import { lastActivity } from "./finalize.ts";
12
- import type { Deps } from "./ports.ts";
13
- import { findRun, listRuns, readRecords, type Run, runPaths } from "./run-store.ts";
14
- import { groupRuns, type SessionGroup } from "./session-view.ts";
15
- import { orchestratorWait, type OrchestratorWait } from "./orchestrator-wait.ts";
16
-
17
- /**
18
- * Spec §4: what the Runs tab shows. The top level is the sessions; a session's screen holds its runs, each with its
19
- * milestones and every role (live ones first); a role's screen holds its brief, reply and record.
20
- */
21
-
22
- export interface SessionRow {
23
- /** the session id; null for "earlier runs" */
24
- key: string | null;
25
- name: string;
26
- live: boolean;
27
- runs: number;
28
- liveRoles: number;
29
- landed: number;
30
- /** ISO time of its newest activity */
31
- lastActivity: string;
32
- }
33
-
34
- export interface RoleRow {
35
- run: string;
36
- dispatchId: string;
37
- name: string;
38
- role: string;
39
- rung: string;
40
- /** running | starting while live; once finished, the record's status (`finished` until it has one) */
41
- status: string;
42
- live: boolean;
43
- /** a live role's admission time: the page ticks its elapsed time from it */
44
- since: string;
45
- /** a finished role's seconds, from its record */
46
- secs: number | null;
47
- /** a live role's last command, file edit or message line */
48
- lastEvent: string | null;
49
- replyStatus: string | null;
50
- }
51
-
52
- export interface Milestone {
53
- name: string;
54
- landed: boolean;
55
- /** the ledger's "what" once landed; before that, plan.md's line for the milestone ("" when it has none) */
56
- what: string;
57
- }
58
-
59
- export interface SessionRun {
60
- waiting?: OrchestratorWait | null;
61
- id: string;
62
- title: string;
63
- repo: string;
64
- createdAt: string;
65
- continued: "here" | "elsewhere" | null;
66
- continuedIn: string | null;
67
- /** the budget's spent fraction, null without a cap */
68
- budget: number | null;
69
- milestones: Milestone[];
70
- roles: RoleRow[];
71
- }
72
-
73
- export interface SessionDetail {
74
- session: SessionRow;
75
- runs: SessionRun[];
76
- /** the run folders to watch for changes */
77
- dirs: string[];
78
- }
79
-
80
- export interface RoleDetail {
81
- run: string;
82
- runTitle: string;
83
- dispatchId: string;
84
- name: string;
85
- role: string;
86
- rung: string;
87
- /** running | starting | finished */
88
- state: string;
89
- brief: string;
90
- reply: string;
91
- record: RunRecord | null;
92
- }
93
-
94
- export interface MilestoneDetail {
95
- run: string;
96
- runTitle: string;
97
- name: string;
98
- landed: boolean;
99
- what: string;
100
- /** R/digests/<name>.md; null when land has not written one (not landed, or landed by 1.0) or it is empty */
101
- digest: string | null;
102
- }
103
-
104
- const keyOf = (g: SessionGroup): string | null => (g.session ? sessionKey(g.session) : null);
105
-
106
- /** The runs that live on in a session now (it owns them): each run's live roles and landings count once. */
107
- const ownRuns = (g: SessionGroup): Run[] => g.runs.filter((x) => x.current).map((x) => x.run);
108
-
109
- function rowOf(deps: Deps, g: SessionGroup): SessionRow {
110
- const own = ownRuns(g);
111
- return {
112
- key: keyOf(g),
113
- name: g.session?.name ?? "earlier runs",
114
- live: g.session?.live ?? false,
115
- runs: g.runs.length,
116
- liveRoles: own.reduce((n, r) => n + liveDispatches(r, deps.now()).length, 0),
117
- landed: own.reduce((n, r) => n + Math.max(0, nonBlankLines(runPaths(r.dir).ledger).length - 1), 0),
118
- lastActivity: new Date(g.lastActivity).toISOString(),
119
- };
120
- }
121
-
122
- /** The Runs tab's top level: every session, newest activity first, "earlier runs" last. */
123
- export function sessionRows(deps: Deps): { rows: SessionRow[]; warnings: string[] } {
124
- const { runs, corrupt } = listRuns();
125
- return {
126
- rows: groupRuns(runs).map((g) => rowOf(deps, g)),
127
- warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`),
128
- };
129
- }
130
-
131
- /**
132
- * A milestone's line in plan.md: a heading, list item or plain line that opens with its id and a separator
133
- * (`## M1 — the jobs list`, `- **M2:** export`). The id stops at the separator, so `M1.L1 — …` is a lane's.
134
- */
135
- const PLAN_LINE =
136
- /^\s*(?:#{1,6}\s+|[-*]\s+|\d+[.)]\s+)?(?:\*\*)?([A-Za-z0-9_-]+)(?::\*\*|(?:\*\*)?\s*(?:—|–|:|-))\s*(.+?)\s*$/;
137
-
138
- /** The description plan.md gives each of `names`, from the first line that opens with it; none for the rest. */
139
- function planWhat(run: Run, names: string[]): Map<string, string> {
140
- const out = new Map<string, string>();
141
- const file = runPaths(run.dir).plan;
142
- if (names.length === 0 || !existsSync(file)) return out;
143
- for (const line of readFileSync(file, "utf8").split("\n")) {
144
- const m = PLAN_LINE.exec(line);
145
- const name = m?.[1];
146
- if (m && name && names.includes(name) && !out.has(name)) out.set(name, (m[2] ?? "").trim());
147
- }
148
- return out;
149
- }
150
-
151
- const byNumber = (a: string, b: string) => a.localeCompare(b, "en", { numeric: true });
152
-
153
- /**
154
- * The ledger's landed milestones, then each milestone a lane file names that has not landed, whatever its id:
155
- * lane `<milestone>.<lane>` belongs to the milestone before its first dot, as protocol.ts reads it. An open
156
- * milestone's description is plan.md's line for it, when there is one.
157
- */
158
- function milestonesOf(run: Run): Milestone[] {
159
- const landed = nonBlankLines(runPaths(run.dir).ledger)
160
- .slice(1)
161
- .map((l) => l.split(" | "))
162
- .map(([name, what]) => ({ name: (name ?? "").trim(), landed: true, what: (what ?? "").trim() }));
163
- const lanes = existsSync(runPaths(run.dir).lanes) ? readdirSync(runPaths(run.dir).lanes) : [];
164
- const names = [
165
- ...new Set(
166
- lanes
167
- .filter((f) => f.endsWith(".md") && f.slice(0, -".md".length).includes("."))
168
- .map((f) => f.split(".")[0] as string),
169
- ),
170
- ]
171
- .filter((m) => m !== "" && !landed.some((l) => l.name === m))
172
- .sort(byNumber);
173
- const what = planWhat(run, names);
174
- return [...landed, ...names.map((name) => ({ name, landed: false, what: what.get(name) ?? "" }))];
175
- }
176
-
177
- /** Each role's latest dispatch, live ones first, then the newest finished first. */
178
- function rolesOf(deps: Deps, run: Run): RoleRow[] {
179
- const now = deps.now();
180
- const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
181
- const latest = new Map<string, ReturnType<typeof listDispatches>[number]>();
182
- for (const d of listDispatches(run)) latest.set(d.admit.name, d);
183
- const rows = [...latest.values()].map((d): RoleRow => {
184
- const r = records.get(d.admit.dispatchId);
185
- const state = r ? "finished" : dispatchState(d, now);
186
- const live = state !== "finished";
187
- return {
188
- run: run.id,
189
- dispatchId: d.admit.dispatchId,
190
- name: d.admit.name,
191
- role: d.admit.role,
192
- rung: d.admit.rung,
193
- status: r?.status ?? state,
194
- live,
195
- since: d.admit.admittedAt,
196
- secs: r?.secs ?? null,
197
- lastEvent: live ? lastActivity(d) : null,
198
- replyStatus: r?.replyStatus ?? null,
199
- };
200
- });
201
- return rows.sort((a, b) =>
202
- a.live !== b.live ? (a.live ? -1 : 1) : b.dispatchId.localeCompare(a.dispatchId),
203
- );
204
- }
205
-
206
- function sessionRun(deps: Deps, x: SessionGroup["runs"][number]): SessionRun {
207
- const run = x.run;
208
- let budget: number | null = null;
209
- try {
210
- const b = budgetStatus(
211
- spendOf(run, readRecords(run).records, liveDispatches(run, deps.now()), deps.now()),
212
- deps.profiles.budgetFor?.(run.meta.repo) ?? deps.profiles.forRepo(run.meta.repo).budget,
213
- );
214
- budget = b?.fraction ?? null;
215
- } catch {
216
- // an unreadable profile: no bar, the rest of the page stands
217
- }
218
- return {
219
- id: run.id,
220
- waiting: orchestratorWait(run, deps.now()),
221
- title: run.meta.title,
222
- repo: run.meta.repo,
223
- createdAt: run.meta.createdAt,
224
- continued: x.continued,
225
- continuedIn: x.continuedIn,
226
- budget,
227
- milestones: milestonesOf(run),
228
- roles: rolesOf(deps, run),
229
- };
230
- }
231
-
232
- /** A session's screen: its runs, newest first, each with its milestones and roles. */
233
- export function sessionDetail(deps: Deps, key: string | null): SessionDetail {
234
- const g = groupRuns(listRuns().runs).find((x) => keyOf(x) === key);
235
- if (!g) throw new Error(key === null ? "no earlier runs" : `no session "${key}" in the runs`);
236
- return {
237
- session: rowOf(deps, g),
238
- runs: g.runs.map((x) => sessionRun(deps, x)),
239
- dirs: g.runs.map((x) => x.run.dir),
240
- };
241
- }
242
-
243
- const text = (file: string): string => {
244
- try {
245
- return readFileSync(file, "utf8");
246
- } catch {
247
- return "";
248
- }
249
- };
250
-
251
- /** A role's screen: its brief, its reply and its record. */
252
- export function roleDetail(deps: Deps, runId: string, dispatchId: string): RoleDetail {
253
- const run = findRun(runId);
254
- const d = listDispatches(run).find((x) => x.admit.dispatchId === dispatchId);
255
- if (!d) throw new Error(`no dispatch ${dispatchId} in run ${runId}`);
256
- const record = readRecords(run).records.find((r) => r.dispatchId === dispatchId) ?? null;
257
- const p = dispatchPaths(d.dir);
258
- return {
259
- run: run.id,
260
- runTitle: run.meta.title,
261
- dispatchId,
262
- name: d.admit.name,
263
- role: d.admit.role,
264
- rung: d.admit.rung,
265
- state: record ? "finished" : dispatchState(d, deps.now()),
266
- brief: text(p.brief),
267
- reply: text(p.reply),
268
- record,
269
- };
270
- }
271
-
272
- /** Spec 1.1 §10: a milestone's screen, its digest. */
273
- export function milestoneDetail(runId: string, name: string): MilestoneDetail {
274
- const run = findRun(runId);
275
- // the id rule land writes digests under (assertId): never a path out of the run folder
276
- const valid = ID_PATTERN.test(name) && !name.includes("..");
277
- const m = valid ? milestonesOf(run).find((x) => x.name === name) : undefined;
278
- if (!m) throw new Error(`no milestone "${name}" in run ${runId}`);
279
- const file = join(run.dir, "digests", `${name}.md`);
280
- const text = existsSync(file) ? readFileSync(file, "utf8") : "";
281
- return {
282
- run: run.id,
283
- runTitle: run.meta.title,
284
- name,
285
- landed: m.landed,
286
- what: m.what,
287
- // an empty file is no digest either: land writes the whole digest at once
288
- digest: text.trim() ? text : null,
289
- };
290
- }
@@ -1,397 +0,0 @@
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
- ScoreSchema,
12
- type ScoresFile,
13
- } from "../domain/catalog.ts";
14
- import {
15
- EFFORT_ORDER,
16
- defaultEffortOf,
17
- type Derived,
18
- familyEfforts,
19
- type FitRow,
20
- idMapper,
21
- type IdMapper,
22
- nearestEffort,
23
- type SourceId,
24
- type SourcesFile,
25
- splitSourceRung,
26
- } from "../domain/sources.ts";
27
- import { type AaAnswer, parseArtificialAnalysis } from "../infra/sources/artificial-analysis.ts";
28
- import { parseArena } from "../infra/sources/arena.ts";
29
- import { parseEpoch } from "../infra/sources/epoch.ts";
30
- import { LITELLM_EFFORTS, parseLiteLlm } from "../infra/sources/litellm.ts";
31
- import { modelsDevFacts } from "../infra/sources/models-dev.ts";
32
- import { parseOpenRouterEndpoints } from "../infra/sources/openrouter-endpoints.ts";
33
- import { type OpenRouterModel, parseOpenRouterModels } from "../infra/sources/openrouter-models.ts";
34
- import type { SourceRow } from "../infra/sources/rows.ts";
35
- import { parseVectara } from "../infra/sources/vectara.ts";
36
-
37
- /** Each source's cached answer (its `data`) with its fetch time; a source never fetched is absent. */
38
- export type RawAnswers = Partial<Record<SourceId, { fetchedAt: string; data: unknown }>>;
39
-
40
- export interface DeriveContext {
41
- models: ModelsFile;
42
- scores: ScoresFile;
43
- sources: SourcesFile;
44
- now: number;
45
- }
46
-
47
- /** Spec 1.2 §3.5: a price that differs by more than this is a sync warning. */
48
- const PRICE_TOLERANCE = 0.1;
49
- /** Spec 1.2 §3.5: the models.dev provider that serves a backend key its own catalog. */
50
- const OPENCODE_KEYS = ["opencode", "opencode-go"] as const;
51
-
52
- interface Keyed {
53
- key: string;
54
- family: Family | null;
55
- effort: string;
56
- assumed: boolean;
57
- row: SourceRow;
58
- }
59
-
60
- /**
61
- * (1.2 minor) the fields where less is better: when two of a source's rows land on one rung, the lower one
62
- * stands for it. Every other field keeps the higher.
63
- */
64
- export const LOWER_IS_BETTER: ReadonlySet<string> = new Set([
65
- "cost_per_task",
66
- "median_time_to_first_token_seconds",
67
- ]);
68
-
69
- /** Whether `a` should stand for a rung over `b` on `field`. */
70
- const better = (field: string, a: number, b: number): boolean => (LOWER_IS_BETTER.has(field) ? a < b : a > b);
71
-
72
- /** The score sources' rows, by source (spec 1.2 §3.3). */
73
- function scoreRows(raw: RawAnswers): [SourceId, SourceRow[]][] {
74
- const out: [SourceId, SourceRow[]][] = [];
75
- const { arena, vectara, epoch } = raw;
76
- const aa = raw["artificial-analysis"];
77
- if (arena) out.push(["arena", parseArena(arena.data as Record<string, unknown>, arena.fetchedAt)]);
78
- if (vectara) out.push(["vectara", parseVectara(String(vectara.data), vectara.fetchedAt)]);
79
- if (epoch) out.push(["epoch", parseEpoch(epoch.data as Record<string, string>, epoch.fetchedAt)]);
80
- if (aa) out.push(["artificial-analysis", parseArtificialAnalysis(aa.data as AaAnswer, aa.fetchedAt)]);
81
- return out;
82
- }
83
-
84
- const round = (v: number) => Math.round(v * 1e4) / 1e4;
85
-
86
- /**
87
- * Spec 1.2 §3.4–§4.3: what the cached answers say. Each score source's ids are mapped onto the catalog's
88
- * families (unmatched ones listed, never guessed), each dimension's anchor gives `measured` values, every
89
- * other source is fitted onto the anchor and gives `calibrated` values when the fit may be used, and an
90
- * effort no source covers takes the nearest covered effort's value as `adjacent`. models.dev gives the facts;
91
- * OpenRouter and LiteLLM only cross-check them.
92
- */
93
- export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
94
- const map = idMapper(ctx.models.families, ctx.sources.aliases);
95
- const unmatched: Record<string, Set<string>> = {};
96
- const table = new Map<string, Map<string, Keyed>>();
97
- for (const [source, rows] of scoreRows(raw))
98
- for (const row of rows) {
99
- const { id, effort } = splitSourceRung(row.rung);
100
- const family = map.family(id);
101
- if (!family) (unmatched[source] ??= new Set()).add(id);
102
- // spec 1.3 §7.1: a family with no effort (Composer) has one rung, #default, whatever effort a source names
103
- const effortless = family !== null && familyEfforts(family).length === 0;
104
- const e = effortless ? "default" : (effort ?? (family ? defaultEffortOf(ctx.sources, family) : ""));
105
- const key = `${map.key(family?.id ?? id)}#${e}`;
106
- const field = `${source}.${row.field}`;
107
- const at = table.get(field) ?? new Map<string, Keyed>();
108
- table.set(field, at);
109
- const had = at.get(key);
110
- if (!had || better(row.field, row.value, had.row.value))
111
- at.set(key, {
112
- key,
113
- family,
114
- effort: e,
115
- assumed: effort === null && family !== null && !effortless,
116
- row,
117
- });
118
- }
119
- const valuesOf = (f: FieldRef) =>
120
- new Map([...(table.get(`${f.source}.${f.field}`)?.values() ?? [])].map((k) => [k.key, k.row.value]));
121
-
122
- const scores: Score[] = [];
123
- const fits: FitRow[] = [];
124
- const push = (dim: Dim, f: FieldRef, k: Keyed, value: number, extra: Partial<Score>) => {
125
- if (!k.family) return;
126
- scores.push({
127
- rung: `${k.family.id}#${k.effort}`,
128
- dim,
129
- value: round(value),
130
- benchmark: f.benchmark,
131
- version: k.row.date,
132
- url: k.row.url,
133
- date: k.row.date,
134
- confidence: "measured",
135
- source: f.source,
136
- ...(k.assumed ? { effortAssumed: true } : {}),
137
- ...extra,
138
- });
139
- };
140
- for (const dim of DIMS) {
141
- const spec = DIM_SOURCES[dim];
142
- let anchor: Map<string, number>;
143
- if (spec.anchor === "shipped") {
144
- anchor = new Map();
145
- // the hand-typed values published for the rung; never the shipped keyless ones (they carry a source)
146
- for (const s of ctx.scores.scores)
147
- if (s.dim === dim && s.source === undefined && measuredOrBetter(s)) {
148
- const { id, effort } = splitSourceRung(s.rung);
149
- anchor.set(`${map.key(id)}#${effort}`, s.value);
150
- }
151
- } else {
152
- const ref = spec.anchor;
153
- anchor = valuesOf(ref);
154
- for (const k of table.get(`${ref.source}.${ref.field}`)?.values() ?? [])
155
- push(dim, ref, k, k.row.value, {});
156
- }
157
- for (const other of spec.others) {
158
- const values = valuesOf(other);
159
- if (values.size === 0) continue;
160
- const r = calibrate(anchor, values);
161
- fits.push({
162
- dim,
163
- source: other.source,
164
- field: other.field,
165
- n: r.n,
166
- a: r.fit?.a ?? null,
167
- b: r.fit?.b ?? null,
168
- r2: r.fit?.r2 ?? null,
169
- used: r.used,
170
- ...(r.why ? { why: r.why } : {}),
171
- });
172
- const fit = r.used ? r.fit : null;
173
- if (!fit) continue;
174
- for (const k of table.get(`${other.source}.${other.field}`)?.values() ?? [])
175
- push(dim, other, k, fit.a * k.row.value + fit.b, {
176
- confidence: "calibrated",
177
- fit: { source: other.source, field: other.field, a: fit.a, b: fit.b, r2: fit.r2, n: fit.n },
178
- });
179
- }
180
- }
181
- const shipped = new Set<string>();
182
- for (const s of ctx.scores.scores)
183
- if (s.confidence !== "inferred") {
184
- const { id, effort } = splitSourceRung(s.rung);
185
- const family = map.family(id);
186
- if (family && effort !== null) shipped.add(`${family.id}#${effort}|${s.dim}`);
187
- }
188
- // (1.2 minor) a sync carries a value only up to a stronger effort: a low effort never takes a high one's
189
- scores.push(
190
- ...adjacent(ctx.models.families, scores, shipped, ctx.now, (f) => defaultEffortOf(ctx.sources, f), {
191
- upwardOnly: true,
192
- }),
193
- );
194
-
195
- const { facts, warnings } = factsOf(raw, ctx, map);
196
- return {
197
- schema: 1,
198
- builtAt: new Date(ctx.now).toISOString(),
199
- // (1.2 minor) readDerived validates every score: one bad row would make the whole file unreadable
200
- scores: scores.filter((s) => ScoreSchema.safeParse(s).success),
201
- facts,
202
- fits,
203
- unmatched: Object.fromEntries(Object.entries(unmatched).map(([s, ids]) => [s, [...ids].sort()])),
204
- warnings,
205
- features: aaFeatures(table),
206
- };
207
- }
208
-
209
- /** Spec 1.2 §6.3: the Artificial Analysis numbers a stand-in is ranked on, as AA names them. */
210
- export const AA_FEATURES = [
211
- "artificial_analysis_intelligence_index",
212
- "hle",
213
- "scicode",
214
- "lcr",
215
- "cost_per_task",
216
- "median_output_tokens_per_second",
217
- ] as const;
218
-
219
- /** Each catalog rung's AA stand-in features, from the rows `derive` keyed (never shipped: AA is keyed). */
220
- function aaFeatures(table: Map<string, Map<string, Keyed>>): Derived["features"] {
221
- const out: Derived["features"] = {};
222
- for (const field of AA_FEATURES)
223
- for (const k of table.get(`artificial-analysis.${field}`)?.values() ?? []) {
224
- if (!k.family) continue;
225
- (out[`${k.family.id}#${k.effort}`] ??= {})[field] = k.row.value;
226
- }
227
- return out;
228
- }
229
-
230
- /**
231
- * Spec 1.2 §4.3 `adjacent`: for each family effort a dimension has no value at (neither one in `direct` nor
232
- * one `shipped` names, as `<family>#<effort>|<dim>`), the best value in `direct` at the nearest effort that
233
- * has one (the weaker on a tie); with `upwardOnly` (a sync's values, 1.2 minor), only a weaker effort's value
234
- * is carried, so Luna none never takes Luna max's. A sync spreads only its own values, and never onto a value the shipped file
235
- * carries; `rebuildShipped` spreads the shipped file's published values, with an empty `shipped`. A family that
236
- * one backend runs without an effort (Cursor's bare slug, spec 1.3 §7.1) also gets `#default`, carried from the
237
- * value nearest `defaultEffort(family)`.
238
- */
239
- export function adjacent(
240
- families: Family[],
241
- direct: Score[],
242
- shipped: ReadonlySet<string>,
243
- now: number,
244
- defaultEffort: (f: Family) => string = () => "high",
245
- o: { upwardOnly?: boolean } = {},
246
- ): Score[] {
247
- const rank = (e: string) => (EFFORT_ORDER as readonly string[]).indexOf(e);
248
- const out: Score[] = [];
249
- for (const f of families)
250
- for (const dim of DIMS) {
251
- const byEffort = new Map<string, Score>();
252
- for (const s of direct) {
253
- const { id, effort } = splitSourceRung(s.rung);
254
- if (id !== f.id || s.dim !== dim || effort === null) continue;
255
- const had = byEffort.get(effort);
256
- if (!had || outranks(s, had, now)) byEffort.set(effort, s);
257
- }
258
- if (byEffort.size === 0) continue;
259
- const efforts = familyEfforts(f);
260
- const bare = efforts.length > 0 && Object.values(f.on).some((o) => o && o.efforts.length === 0);
261
- for (const e of bare ? [...efforts, "default"] : efforts) {
262
- if (byEffort.has(e) || shipped.has(`${f.id}#${e}|${dim}`)) continue;
263
- const target = e === "default" ? defaultEffort(f) : e;
264
- const from_ = [...byEffort.keys()].filter((x) => !o.upwardOnly || rank(x) <= rank(target));
265
- const near = nearestEffort(target, from_);
266
- const from = near ? byEffort.get(near) : undefined;
267
- if (!near || !from) continue;
268
- out.push({
269
- ...from,
270
- rung: `${f.id}#${e}`,
271
- confidence: "adjacent",
272
- note: `${from.source ?? from.benchmark} has it at ${near}; carried to this effort`,
273
- });
274
- }
275
- }
276
- return out;
277
- }
278
-
279
- const differs = (a: number, b: number) => b !== 0 && Math.abs(a - b) / b > PRICE_TOLERANCE;
280
- const money = (n: number) => `$${n}/M`;
281
-
282
- /** Spec 1.2 §3.5: models.dev's facts per family, OpenRouter's speed facts, and the cross-check warnings. */
283
- function factsOf(
284
- raw: RawAnswers,
285
- ctx: DeriveContext,
286
- map: IdMapper,
287
- ): { facts: Record<string, FamilyFacts>; warnings: string[] } {
288
- const md = raw["models-dev"]?.data;
289
- const orModels = raw["openrouter-models"] ? parseOpenRouterModels(raw["openrouter-models"].data) : [];
290
- const liteLlm = raw.litellm ? parseLiteLlm(raw.litellm.data) : [];
291
- const endpoints = raw["openrouter-endpoints"];
292
- const speedRows = endpoints
293
- ? parseOpenRouterEndpoints(endpoints.data as Record<string, unknown>, endpoints.fetchedAt)
294
- : [];
295
- const aa = raw["artificial-analysis"];
296
- const aaRows = aa ? parseArtificialAnalysis(aa.data as AaAnswer, aa.fetchedAt) : [];
297
- const facts: Record<string, FamilyFacts> = {};
298
- const warnings: string[] = [];
299
- for (const f of ctx.models.families) {
300
- const x: FamilyFacts = { on: {}, speed: {} };
301
- const vendor = (f as { vendor?: unknown }).vendor;
302
- const own =
303
- md && typeof vendor === "string" ? modelsDevFacts(md, vendor, modelsDevId(md, vendor, f, map)) : null;
304
- if (own?.price) x.price = own.price;
305
- if (own && own.toolUse !== null && own.imageIn !== null && own.reasoning !== null)
306
- x.capabilities = { toolUse: own.toolUse, imageIn: own.imageIn, reasoning: own.reasoning };
307
- if (own?.releaseDate) x.releaseDate = own.releaseDate;
308
- for (const key of OPENCODE_KEYS) {
309
- const on = f.on[key];
310
- const got = md && on ? modelsDevFacts(md, key, on.id.slice(on.id.indexOf("/") + 1)) : null;
311
- if (got)
312
- x.on[key] = {
313
- ...(got.efforts ? { efforts: got.efforts } : {}),
314
- ...(got.context ? { context: got.context } : {}),
315
- };
316
- }
317
- for (const r of speedRows) if (map.family(r.rung) === f) x.speed[`openrouter.${r.field}`] = r.value;
318
- const defaultRung = `${map.key(f.id)}#${defaultEffortOf(ctx.sources, f)}`;
319
- for (const r of aaRows) {
320
- const { id, effort } = splitSourceRung(r.rung);
321
- if (map.family(id) !== f || `${map.key(f.id)}#${effort}` !== defaultRung) continue;
322
- if (/^median_|^cost_per_task$/.test(r.field)) x.speed[`artificial-analysis.${r.field}`] = r.value;
323
- }
324
- if (x.price || x.capabilities || x.releaseDate || Object.keys(x.on).length || Object.keys(x.speed).length)
325
- facts[f.id] = x;
326
- if (own?.price) warnings.push(...priceWarnings(f, own.price, orModels, liteLlm, map));
327
- if (own?.efforts) warnings.push(...effortWarnings(f, own.efforts, liteLlm, map));
328
- }
329
- return { facts, warnings };
330
- }
331
-
332
- /**
333
- * The id of `vendor`'s models.dev model that is `f`: its own id, else the first that maps to it (spec 1.3 §7.1:
334
- * `gemini-3-8-flash` is Google's `gemini-3.8-flash`, `gemini-3-1-pro` its aliased `gemini-3.1-pro-preview`).
335
- */
336
- function modelsDevId(md: unknown, vendor: string, f: Family, map: IdMapper): string {
337
- const ids = Object.keys((md as Record<string, { models?: object }>)[vendor]?.models ?? {});
338
- return ids.includes(f.id) ? f.id : (ids.find((id) => map.family(id) === f) ?? f.id);
339
- }
340
-
341
- /** The first entry of `list` whose id maps to `f` (OpenRouter's `:batch` variants never do). */
342
- const entryFor = <T extends { id: string }>(list: T[], f: Family, map: IdMapper): T | undefined =>
343
- list.find((m) => map.family(m.id) === f);
344
-
345
- function priceWarnings(
346
- f: Family,
347
- price: { input: number; output: number },
348
- orModels: OpenRouterModel[],
349
- liteLlm: ReturnType<typeof parseLiteLlm>,
350
- map: IdMapper,
351
- ): string[] {
352
- const out: string[] = [];
353
- for (const [name, other] of [
354
- ["OpenRouter", entryFor(orModels, f, map)?.price],
355
- ["LiteLLM", entryFor(liteLlm, f, map)?.price],
356
- ] as const) {
357
- if (!other) continue;
358
- for (const side of ["input", "output"] as const)
359
- if (differs(other[side], price[side]))
360
- out.push(
361
- `${f.id}: ${side} price ${money(price[side])} on models.dev, ${money(other[side])} on ${name} (more than 10 % apart)`,
362
- );
363
- }
364
- return out;
365
- }
366
-
367
- function effortWarnings(
368
- f: Family,
369
- efforts: string[],
370
- liteLlm: ReturnType<typeof parseLiteLlm>,
371
- map: IdMapper,
372
- ): string[] {
373
- const entry = entryFor(liteLlm, f, map);
374
- if (!entry) return [];
375
- const out: string[] = [];
376
- for (const e of LITELLM_EFFORTS) {
377
- const says = entry.efforts[e];
378
- if (says === undefined || says === efforts.includes(e)) continue;
379
- out.push(
380
- `${f.id}: LiteLLM says effort ${e} is ${says ? "" : "not "}supported; models.dev ${says ? "does not list" : "lists"} it`,
381
- );
382
- }
383
- return out;
384
- }
385
-
386
- /** Plan 13 R-H: the OpenRouter id of each catalog family OpenRouter lists, one each, for the endpoints fetch. */
387
- export function openRouterIds(
388
- orModelsData: unknown,
389
- ctx: Pick<DeriveContext, "models" | "sources">,
390
- ): string[] {
391
- const map = idMapper(ctx.models.families, ctx.sources.aliases);
392
- const models = parseOpenRouterModels(orModelsData);
393
- return ctx.models.families.flatMap((f) => {
394
- const m = entryFor(models, f, map);
395
- return m ? [m.id] : [];
396
- });
397
- }