catherd-cli 1.4.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.
- package/README.md +188 -169
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -239
- package/package.json +6 -8
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -356
- package/plugin/skills/catherd-setup/SKILL.md +21 -20
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +10 -4
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +27 -27
- package/src/domain/agents.ts +3 -1
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +106 -0
- package/src/domain/catalog.ts +69 -313
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +19 -29
- package/src/domain/errors.ts +6 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -75
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +53 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +118 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +32 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +175 -138
- package/src/domain/profile.ts +140 -63
- package/src/domain/record.ts +68 -3
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +182 -56
- package/src/domain/role-scope.ts +76 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +90 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +229 -72
- package/src/domain/slopalytics.ts +72 -0
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +24 -165
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +18 -2
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +20 -69
- package/src/entry/knowledge-command.ts +121 -5
- package/src/entry/lock-command.ts +103 -8
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +134 -0
- package/src/entry/mcp/server.ts +85 -21
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +33 -18
- package/src/entry/role-cli-command.ts +159 -0
- package/src/entry/runs-command.ts +81 -27
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -3
- package/src/infra/git.ts +70 -0
- package/src/infra/heavy-lock.ts +133 -6
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/launch.ts +63 -7
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +37 -2
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +133 -31
- package/src/services/backends.ts +4 -6
- package/src/services/budget.ts +26 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -254
- package/src/services/credentials.ts +15 -26
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +209 -17
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +72 -15
- package/src/services/end-push.ts +157 -0
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +24 -2
- package/src/services/gate-env.ts +217 -0
- package/src/services/gate-service.ts +231 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +291 -55
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +167 -29
- package/src/services/notifier.ts +124 -4
- package/src/services/notify-service.ts +26 -0
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +60 -5
- package/src/services/preflight.ts +232 -24
- package/src/services/profile-service.ts +31 -3
- package/src/services/profile-store.ts +18 -6
- package/src/services/protocol.ts +123 -25
- package/src/services/provenance.ts +34 -89
- package/src/services/questions.ts +24 -0
- package/src/services/reconcile.ts +33 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +161 -42
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +204 -23
- package/src/services/run-store.ts +159 -18
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +76 -12
- package/src/services/tree-path.ts +27 -0
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +259 -0
- package/src/services/workspace-service.ts +259 -0
- package/src/services/workspace-store.ts +95 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -36
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -320
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -132
- package/src/adapters/grok/index.ts +0 -339
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -125
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -84
- package/src/entry/mcp/protocol-tools.ts +0 -57
- package/src/entry/mcp/run-tools.ts +0 -148
- package/src/entry/mcp/setup-tools.ts +0 -108
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -381
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -373
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -573
- package/src/entry/tui/views/save-dialog.tsx +0 -340
- package/src/entry/tui/views/status.tsx +0 -209
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -87
- package/src/infra/sources/artificial-analysis.ts +0 -112
- package/src/infra/sources/cache.ts +0 -124
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -255
- package/src/services/source-derive.ts +0 -374
- package/src/services/source-sync.ts +0 -305
- package/src/services/standins.ts +0 -204
- package/src/services/treat-likes.ts +0 -97
package/src/services/capture.ts
CHANGED
|
@@ -37,11 +37,6 @@ const HAIKU = "claude-code:claude-haiku-4-5-20251001#default";
|
|
|
37
37
|
/** spec 1.3 §4.6: `auto` has no family; only the live kit and the capture run it */
|
|
38
38
|
const AUTO = "cursor:auto#default";
|
|
39
39
|
const BUNNY = "opencode:opencode/space-bunny-free#default";
|
|
40
|
-
/** spec 1.3 §5.6: grok 1.0.44's default model (research §3.4) at its cheapest effort */
|
|
41
|
-
const GROK = "grok:grok-4.6#low";
|
|
42
|
-
/** spec 1.3 §6.6: the cheapest Gemini effort; capture runs isolated, on GEMINI_API_KEY */
|
|
43
|
-
const FLASH_LOW = "antigravity:gemini-3.8-flash#low";
|
|
44
|
-
|
|
45
40
|
/** Spec §11.7–8: one cheap run per backend, plus a read-only role trying to write where enforcement is advisory. */
|
|
46
41
|
const CAPTURE_CASES: CaptureCase[] = [
|
|
47
42
|
{ backend: "codex", name: "ok", rung: "codex:gpt-6-luna#low", access: "read-only", brief: SAY_HELLO },
|
|
@@ -53,22 +48,6 @@ const CAPTURE_CASES: CaptureCase[] = [
|
|
|
53
48
|
{ backend: "cursor", name: "ok", rung: AUTO, access: "workspace-write", brief: WORK },
|
|
54
49
|
{ backend: "cursor", name: "resume", rung: AUTO, access: "read-only", brief: SAY_HELLO, resume: RECALL },
|
|
55
50
|
{ backend: "cursor", name: "read-only-write", rung: AUTO, access: "read-only", brief: TRY_WRITE },
|
|
56
|
-
// spec 1.3 §5.6: the same three on grok; its read-only is the kernel profile, or the read tools on a Mac
|
|
57
|
-
// with a symlinked docker socket
|
|
58
|
-
{ backend: "grok", name: "ok", rung: GROK, access: "workspace-write", brief: WORK },
|
|
59
|
-
{ backend: "grok", name: "resume", rung: GROK, access: "read-only", brief: SAY_HELLO, resume: RECALL },
|
|
60
|
-
{ backend: "grok", name: "read-only-write", rung: GROK, access: "read-only", brief: TRY_WRITE },
|
|
61
|
-
// spec 1.3 §6.6: the same three on agy; read-only runs under catherd's deny rules (research §8 agy 2, 5, 6)
|
|
62
|
-
{ backend: "antigravity", name: "ok", rung: FLASH_LOW, access: "workspace-write", brief: WORK },
|
|
63
|
-
{
|
|
64
|
-
backend: "antigravity",
|
|
65
|
-
name: "resume",
|
|
66
|
-
rung: FLASH_LOW,
|
|
67
|
-
access: "read-only",
|
|
68
|
-
brief: SAY_HELLO,
|
|
69
|
-
resume: RECALL,
|
|
70
|
-
},
|
|
71
|
-
{ backend: "antigravity", name: "read-only-write", rung: FLASH_LOW, access: "read-only", brief: TRY_WRITE },
|
|
72
51
|
];
|
|
73
52
|
export const CAPTURE_BACKENDS = [...new Set(CAPTURE_CASES.map((c) => c.backend))];
|
|
74
53
|
|
|
@@ -122,7 +101,7 @@ async function runOnce(
|
|
|
122
101
|
const p = Bun.spawn([plan.cmd, ...plan.args], {
|
|
123
102
|
cwd: plan.cwd,
|
|
124
103
|
env: workerEnv(process.env, plan.env, plan.cwd),
|
|
125
|
-
// spec 1.3 §3.3: the brief goes on stdin only to a CLI whose plan reads it there (
|
|
104
|
+
// spec 1.3 §3.3: the brief goes on stdin only to a CLI whose plan reads it there (one reads a file)
|
|
126
105
|
stdin: plan.stdinPath ? Bun.file(plan.stdinPath) : "ignore",
|
|
127
106
|
stdout: "pipe",
|
|
128
107
|
stderr: "pipe",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import type { DiscoveredModel } from "../adapters/backend.ts";
|
|
4
4
|
import { discovered, listingRepo, readDiscovery, writeDiscovery } from "../adapters/discovery.ts";
|
|
@@ -7,55 +7,32 @@ import "../adapters/all.ts";
|
|
|
7
7
|
import {
|
|
8
8
|
buildCatalog,
|
|
9
9
|
type Catalog,
|
|
10
|
-
CanonicalRung,
|
|
11
10
|
capableFor,
|
|
12
|
-
DIMS,
|
|
13
11
|
type Family,
|
|
14
12
|
type ModelsFile,
|
|
15
13
|
ModelsFileSchema,
|
|
16
|
-
type
|
|
17
|
-
OverrideSchema,
|
|
14
|
+
type Pair,
|
|
18
15
|
rungInfo,
|
|
19
|
-
type ScoresFile,
|
|
20
|
-
ScoresFileSchema,
|
|
21
|
-
scoresOf,
|
|
22
16
|
} from "../domain/catalog.ts";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
17
|
+
import { type BillingMode, type Cost, costOf, DEFAULT_BILLING } from "../domain/cost.ts";
|
|
18
|
+
import { errorMessage } from "../domain/errors.ts";
|
|
25
19
|
import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
|
|
26
|
-
import type { Kind } from "../domain/lane.ts";
|
|
27
|
-
import { routeAt } from "../domain/route.ts";
|
|
28
20
|
import { ROLES, type Role } from "../domain/roles.ts";
|
|
29
|
-
import { median } from "../domain/util.ts";
|
|
30
21
|
import { assetPath } from "../infra/assets.ts";
|
|
31
|
-
import { withFileLock } from "../infra/filelock.ts";
|
|
32
22
|
import { log } from "../infra/log.ts";
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
23
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
24
|
+
import { indexPath } from "../infra/paths.ts";
|
|
25
|
+
import { type IndexFile, IndexFileSchema } from "../domain/slopalytics.ts";
|
|
26
|
+
import { fetchProxy, readIndexFile, writeIndexFile } from "../infra/slopalytics.ts";
|
|
27
|
+
import { PRIVATE_DIR, readVersioned } from "../infra/store.ts";
|
|
36
28
|
import type { CatalogFilter } from "./ports.ts";
|
|
37
|
-
import { listRuns, readAgentRuns, readRecords, readRoutes } from "./run-store.ts";
|
|
38
|
-
import { valuesUsed } from "./provenance.ts";
|
|
39
29
|
import { type EvidenceTable, evidenceLine, evidenceOf, runEvidence } from "./run-evidence.ts";
|
|
40
|
-
import { withStandIns } from "./standins.ts";
|
|
41
30
|
|
|
42
31
|
const DAY_MS = 24 * 3_600_000;
|
|
43
|
-
/** Spec §5.2: `secs_per_task` counts once a rung has this many of the user's own runs. */
|
|
44
|
-
const MIN_SAMPLES = 5;
|
|
45
32
|
|
|
46
33
|
let models: ModelsFile | null = null;
|
|
47
|
-
let scores: ScoresFile | null = null;
|
|
48
34
|
export const shippedModels = (): ModelsFile =>
|
|
49
35
|
(models ??= readVersioned(assetPath("catalog/models.json"), ModelsFileSchema, 1));
|
|
50
|
-
export const shippedScores = (): ScoresFile =>
|
|
51
|
-
(scores ??= readVersioned(assetPath("catalog/scores.json"), ScoresFileSchema, 1));
|
|
52
|
-
|
|
53
|
-
export const overridePath = (): string => join(configDir(), "catalog.override.json");
|
|
54
|
-
|
|
55
|
-
export function readOverride(): Override {
|
|
56
|
-
const file = overridePath();
|
|
57
|
-
return existsSync(file) ? readVersioned(file, OverrideSchema, 1) : OverrideSchema.parse({});
|
|
58
|
-
}
|
|
59
36
|
|
|
60
37
|
/**
|
|
61
38
|
* Every backend's last listing (spec §3.5 `<data>/discovery/<backend>.json`); for a backend listed per
|
|
@@ -70,75 +47,36 @@ function listedModels(repo?: string): Catalog["listed"] {
|
|
|
70
47
|
return out;
|
|
71
48
|
}
|
|
72
49
|
|
|
50
|
+
const RUN_INDEX = "slopalytics.json";
|
|
51
|
+
|
|
73
52
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* lane's route in force when it started; a run with none counts only under `|*`. Native `claude:`
|
|
77
|
-
* subagent runs (agents.jsonl, from record_agent_run) count too; one that names no lane counts only
|
|
78
|
-
* under `|*`.
|
|
53
|
+
* The shipped families on the Slopalytics index (the run's own pin when `runDir` holds one, else the machine's),
|
|
54
|
+
* with every listing (in `repo` for a per-repo backend).
|
|
79
55
|
*/
|
|
80
|
-
export function
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
) => {
|
|
90
|
-
let canonical: string;
|
|
91
|
-
try {
|
|
92
|
-
canonical = rungInfo(base, rung).canonical;
|
|
93
|
-
} catch {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
// the kind the lane was routed as when this run started, not its newest route
|
|
97
|
-
const kind: Kind | null = lane ? (routeAt(routes, lane, startedAt)?.kind ?? null) : null;
|
|
98
|
-
add(`${canonical}|*`, secs);
|
|
99
|
-
if (kind) add(`${canonical}|${kind}`, secs);
|
|
100
|
-
};
|
|
101
|
-
for (const run of listRuns().runs) {
|
|
102
|
-
const routes = readRoutes(run);
|
|
103
|
-
for (const r of readRecords(run).records)
|
|
104
|
-
if (r.status === "ok") count(routes, r.rung, r.secs, r.lane, r.startedAt);
|
|
105
|
-
for (const a of readAgentRuns(run)) {
|
|
106
|
-
if (a.status !== "ok" || a.secs === null) continue;
|
|
107
|
-
// a row is written when the subagent ends; it started `secs` earlier
|
|
108
|
-
const lane = typeof a.lane === "string" ? a.lane : null;
|
|
109
|
-
const end = Date.parse(a.at);
|
|
110
|
-
// a duration past the Date range (record_agent_run takes any) keeps the end, not a RangeError
|
|
111
|
-
const start = new Date(end - a.secs * 1000);
|
|
112
|
-
const startedAt = Number.isNaN(start.getTime()) ? a.at : start.toISOString();
|
|
113
|
-
count(routes, a.rung, a.secs, lane, startedAt);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
const secs: Catalog["secs"] = {};
|
|
117
|
-
for (const [k, xs] of groups) if (xs.length >= MIN_SAMPLES) secs[k] = median(xs) as number;
|
|
118
|
-
return secs;
|
|
56
|
+
export function loadCatalog(o: { repo?: string; runDir?: string } = {}): Catalog {
|
|
57
|
+
const pin = o.runDir === undefined ? null : join(o.runDir, RUN_INDEX);
|
|
58
|
+
// a missing pin falls back to the machine's index; one that exists but cannot be read is an error, not a fallback
|
|
59
|
+
const pinned = pin !== null && existsSync(pin) ? readVersioned(pin, IndexFileSchema, 1) : null;
|
|
60
|
+
return buildCatalog({
|
|
61
|
+
models: shippedModels(),
|
|
62
|
+
index: pinned ?? readIndexFile(indexPath()),
|
|
63
|
+
listed: listedModels(o.repo),
|
|
64
|
+
});
|
|
119
65
|
}
|
|
120
66
|
|
|
121
67
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* timings.
|
|
68
|
+
* What a run starts on: the index refreshed if it is due, read once, with the catalog built on exactly that
|
|
69
|
+
* read, so the start check and the pin written after it cannot disagree.
|
|
125
70
|
*/
|
|
126
|
-
export function
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
facts: synced?.facts,
|
|
136
|
-
features: synced?.features,
|
|
137
|
-
override: o.override ?? readOverride(),
|
|
138
|
-
listed: listedModels(o.repo),
|
|
139
|
-
}),
|
|
140
|
-
);
|
|
141
|
-
return o.timings === false ? base : { ...base, secs: measuredSecs(base) };
|
|
71
|
+
export async function startIndex(repo: string): Promise<{ catalog: Catalog; index: IndexFile | null }> {
|
|
72
|
+
await refreshIndex({ auto: true });
|
|
73
|
+
const index = readIndexFile(indexPath());
|
|
74
|
+
return { catalog: buildCatalog({ models: shippedModels(), index, listed: listedModels(repo) }), index };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Pins `index` into the run: later routes of the run read it, whatever the machine's index becomes. */
|
|
78
|
+
export function writeRunIndex(runDir: string, index: IndexFile | null): void {
|
|
79
|
+
if (index) writeIndexFile(join(runDir, RUN_INDEX), index);
|
|
142
80
|
}
|
|
143
81
|
|
|
144
82
|
export interface Refreshed {
|
|
@@ -228,142 +166,19 @@ export async function freshenDiscovery(rungs: string[], now = Date.now(), repo?:
|
|
|
228
166
|
});
|
|
229
167
|
}
|
|
230
168
|
|
|
231
|
-
/** A rung given as `backend:model#effort` or as a canonical `model#effort`, in canonical form. */
|
|
232
|
-
function canonicalOf(c: Catalog, rung: string): string {
|
|
233
|
-
let canonical = rung;
|
|
234
|
-
try {
|
|
235
|
-
if (rung.includes(":")) canonical = rungInfo(c, rung).canonical;
|
|
236
|
-
} catch {
|
|
237
|
-
// a rung the catalog cannot resolve stays as given; the canonical check below refuses it
|
|
238
|
-
}
|
|
239
|
-
// a backend's own model id without its backend: the family that backend runs it for names it
|
|
240
|
-
const hash = canonical.lastIndexOf("#");
|
|
241
|
-
const model = canonical.slice(0, hash);
|
|
242
|
-
if (hash > 0 && !c.families.some((f) => f.id === model)) {
|
|
243
|
-
const family = c.families.find((f) => Object.values(f.on).some((m) => m?.id === model));
|
|
244
|
-
if (family) canonical = `${family.id}${canonical.slice(hash)}`;
|
|
245
|
-
}
|
|
246
|
-
if (!CanonicalRung.safeParse(canonical).success)
|
|
247
|
-
throw new CatherdError("E_INPUT_INVALID", `"${rung}" is not a rung`, {
|
|
248
|
-
fix: "name it as backend:model#effort or model#effort, e.g. codex:gpt-6-sol#high",
|
|
249
|
-
});
|
|
250
|
-
return canonical;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Spec §5.2 "treat like": the user maps an unscored rung onto a scored one in catalog.override.json. */
|
|
254
|
-
export async function saveTreatLike(rung: string, like: string): Promise<{ rung: string; like: string }> {
|
|
255
|
-
const c = loadCatalog({ timings: false });
|
|
256
|
-
const from = canonicalOf(c, rung);
|
|
257
|
-
const to = canonicalOf(c, like);
|
|
258
|
-
if (!c.scores[to])
|
|
259
|
-
throw new CatherdError("E_CONFIG_INVALID", `${like} has no scores of its own to lend`, {
|
|
260
|
-
fix: "treat it like a scored rung; catalog_query lists them",
|
|
261
|
-
});
|
|
262
|
-
if (from === to)
|
|
263
|
-
throw new CatherdError("E_CONFIG_INVALID", `${rung} cannot be treated like itself`, {
|
|
264
|
-
fix: "name a different, scored rung",
|
|
265
|
-
});
|
|
266
|
-
// a rung a sync scored on some dimensions may still borrow the others; one with every value `to` lends may not
|
|
267
|
-
const own = c.scores[from] ?? {};
|
|
268
|
-
if (DIMS.filter((d) => c.scores[to]?.[d]).every((d) => own[d]))
|
|
269
|
-
throw new CatherdError(
|
|
270
|
-
"E_CONFIG_INVALID",
|
|
271
|
-
`${from} has scores of its own; a treat-like would not change it`,
|
|
272
|
-
{
|
|
273
|
-
fix: "treat-like maps only unscored rungs; catalog_query shows each rung's scores",
|
|
274
|
-
},
|
|
275
|
-
);
|
|
276
|
-
ensurePrivateDir(dirname(overridePath()));
|
|
277
|
-
await withFileLock(overridePath(), () => {
|
|
278
|
-
const cur = readOverride();
|
|
279
|
-
const next = { ...cur, schema: 1, treatLike: { ...cur.treatLike, [from]: to } };
|
|
280
|
-
const checked = OverrideSchema.safeParse(next);
|
|
281
|
-
if (!checked.success)
|
|
282
|
-
throw new CatherdError(
|
|
283
|
-
"E_CONFIG_INVALID",
|
|
284
|
-
`the treat-like ${from} → ${to} would not be a valid override`,
|
|
285
|
-
{
|
|
286
|
-
fix: checked.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
|
|
287
|
-
},
|
|
288
|
-
);
|
|
289
|
-
writeJsonAtomic(overridePath(), next);
|
|
290
|
-
});
|
|
291
|
-
return { rung: from, like: to };
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** A rung given as `backend:model#effort` or `model#effort`, as the canonical rung the override keys it by. */
|
|
295
|
-
export const canonicalRung = (rung: string): string => canonicalOf(loadCatalog({ timings: false }), rung);
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Spec 1.2 §6.4 `treat-like --clear|--reset`: removes the user's treat-likes for `rungs` (canonical), or every
|
|
299
|
-
* one with `"all"`, from catalog.override.json; the shipped ones are not the user's to remove. Returns the
|
|
300
|
-
* mappings removed. Leaves the override's other fields as they are.
|
|
301
|
-
*/
|
|
302
|
-
export async function removeTreatLikes(rungs: string[] | "all"): Promise<[string, string][]> {
|
|
303
|
-
if (!existsSync(overridePath())) return [];
|
|
304
|
-
let removed: [string, string][] = [];
|
|
305
|
-
await withFileLock(overridePath(), () => {
|
|
306
|
-
const cur = readOverride();
|
|
307
|
-
const gone = Object.entries(cur.treatLike).filter(([r]) => rungs === "all" || rungs.includes(r));
|
|
308
|
-
removed = gone;
|
|
309
|
-
if (gone.length === 0) return;
|
|
310
|
-
const treatLike = Object.fromEntries(
|
|
311
|
-
Object.entries(cur.treatLike).filter(([r]) => !gone.some(([g]) => g === r)),
|
|
312
|
-
);
|
|
313
|
-
writeJsonAtomic(overridePath(), { ...cur, schema: 1, treatLike });
|
|
314
|
-
});
|
|
315
|
-
return removed;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
169
|
/** The backend a billing or harness key bills or isolates: `opencode-go` is a provider of the opencode backend. */
|
|
319
170
|
export const backendOfKey = (key: string) => (key === "opencode-go" ? "opencode" : key);
|
|
320
171
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const info = rungInfo(c, rung);
|
|
332
|
-
const s = scoresOf(c, info.canonical);
|
|
333
|
-
const scored: Record<
|
|
334
|
-
string,
|
|
335
|
-
{
|
|
336
|
-
value: number;
|
|
337
|
-
benchmark: string;
|
|
338
|
-
confidence: string;
|
|
339
|
-
source: string;
|
|
340
|
-
date: string;
|
|
341
|
-
/** spec 1.2 §5.3: the rung a borrowed or inferred value belongs to */
|
|
342
|
-
from?: string;
|
|
343
|
-
}
|
|
344
|
-
> = {};
|
|
345
|
-
for (const v of valuesUsed(c, info.canonical))
|
|
346
|
-
scored[v.dim] = {
|
|
347
|
-
value: v.value,
|
|
348
|
-
benchmark: v.benchmark,
|
|
349
|
-
// a value lent by a treat-like or a stand-in is catherd's guess for this rung, whatever its own confidence
|
|
350
|
-
confidence: v.inferred ? "inferred" : v.confidence,
|
|
351
|
-
source: v.source,
|
|
352
|
-
date: v.date,
|
|
353
|
-
...(v.from ? { from: v.from } : {}),
|
|
354
|
-
};
|
|
355
|
-
const like = c.treatLike[info.canonical] ?? null;
|
|
356
|
-
return {
|
|
357
|
-
rung,
|
|
358
|
-
enabled: s !== null,
|
|
359
|
-
...(s === null ? { why: "unscored: map it with catherd catalog treat-like <rung> <scored rung>" } : {}),
|
|
360
|
-
scores: scored,
|
|
361
|
-
treatLike: s?.via ? like : null,
|
|
362
|
-
cost: costOf(info.family, effort, billing[info.key] ?? DEFAULT_BILLING[info.key]),
|
|
363
|
-
/** spec 1.2 §8: catherd's own runs on it, over every kind; never used for routing */
|
|
364
|
-
evidence: evidenceLine(evidenceOf(evidence, info.canonical)),
|
|
365
|
-
};
|
|
366
|
-
});
|
|
172
|
+
/** One indexed pair as `catalog_query` and `catalog list` show it. */
|
|
173
|
+
export interface IndexedPair {
|
|
174
|
+
rung: string;
|
|
175
|
+
intelligence: number;
|
|
176
|
+
price: Pair["price"];
|
|
177
|
+
speed: number | null;
|
|
178
|
+
context: number | null;
|
|
179
|
+
cost: Cost;
|
|
180
|
+
/** spec 1.2 §8: catherd's own runs on it, over every kind; never used for routing */
|
|
181
|
+
evidence: string | null;
|
|
367
182
|
}
|
|
368
183
|
|
|
369
184
|
export interface CatalogModel {
|
|
@@ -373,70 +188,156 @@ export interface CatalogModel {
|
|
|
373
188
|
model: string;
|
|
374
189
|
billing: string;
|
|
375
190
|
efforts: string[];
|
|
376
|
-
context: number | null;
|
|
377
191
|
capabilities: Family["capabilities"] | null;
|
|
378
192
|
roles: Role[];
|
|
379
193
|
listed: boolean | null;
|
|
380
194
|
notes: Record<string, string>;
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
195
|
+
rungs: IndexedPair[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function pairsOf(
|
|
199
|
+
c: Catalog,
|
|
200
|
+
backend: string,
|
|
201
|
+
model: string,
|
|
202
|
+
billing: Partial<Record<string, BillingMode>>,
|
|
203
|
+
evidence: EvidenceTable,
|
|
204
|
+
): IndexedPair[] {
|
|
205
|
+
const probe = rungInfo(c, `${backend}:${model}#default`);
|
|
206
|
+
const efforts = [...new Set([...probe.efforts, "default"])];
|
|
207
|
+
return efforts
|
|
208
|
+
.flatMap((effort) => {
|
|
209
|
+
const rung = `${backend}:${model}#${effort}`;
|
|
210
|
+
const info = rungInfo(c, rung);
|
|
211
|
+
const pair = info.pair;
|
|
212
|
+
if (!pair) return [];
|
|
213
|
+
return [
|
|
214
|
+
{
|
|
215
|
+
rung,
|
|
216
|
+
intelligence: pair.intelligence,
|
|
217
|
+
price: pair.price,
|
|
218
|
+
speed: pair.speed,
|
|
219
|
+
context: info.context,
|
|
220
|
+
cost: costOf(info.family, pair, effort, billing[info.key] ?? DEFAULT_BILLING[info.key]),
|
|
221
|
+
evidence: evidenceLine(evidenceOf(evidence, info.canonical)),
|
|
222
|
+
},
|
|
223
|
+
];
|
|
224
|
+
})
|
|
225
|
+
.sort((a, b) => a.intelligence - b.intelligence);
|
|
385
226
|
}
|
|
386
227
|
|
|
387
228
|
/**
|
|
388
|
-
* Spec §4.8 `catalog_query`: shipped families on each backend,
|
|
389
|
-
* a
|
|
229
|
+
* Spec §4.8 `catalog_query`: the indexed pairs of the shipped families on each backend, and of the listed models
|
|
230
|
+
* catherd has no family for; a model with no indexed pair is not listed. A backend listed per repository as it
|
|
231
|
+
* is listed in `f.repo` (a git toplevel), as `route` reads it.
|
|
390
232
|
*/
|
|
391
233
|
export function catalogQuery(
|
|
392
234
|
f: CatalogFilter,
|
|
393
235
|
billing: Partial<Record<string, BillingMode>> = {},
|
|
394
236
|
): { total: number; models: CatalogModel[] } {
|
|
395
|
-
const c = loadCatalog({
|
|
237
|
+
const c = loadCatalog({ repo: f.repo });
|
|
396
238
|
const evidence = runEvidence(c);
|
|
397
239
|
const rows: CatalogModel[] = [];
|
|
398
240
|
const known = new Set<string>();
|
|
399
|
-
const entry = (backend: string, model: string, fam: Family | null)
|
|
241
|
+
const entry = (backend: string, model: string, fam: Family | null) => {
|
|
242
|
+
const rungs = pairsOf(c, backend, model, billing, evidence);
|
|
243
|
+
if (rungs.length === 0) return;
|
|
400
244
|
const probe = rungInfo(c, `${backend}:${model}#default`);
|
|
401
|
-
|
|
245
|
+
rows.push({
|
|
402
246
|
id: fam?.id ?? model,
|
|
403
247
|
name: fam?.name ?? null,
|
|
404
248
|
backend,
|
|
405
249
|
model,
|
|
406
250
|
billing: probe.key,
|
|
407
|
-
efforts:
|
|
408
|
-
context: probe.context,
|
|
251
|
+
efforts: rungs.map((r) => r.rung.slice(r.rung.lastIndexOf("#") + 1)),
|
|
409
252
|
capabilities: fam?.capabilities ?? null,
|
|
410
253
|
roles: ROLES.filter((r) => capableFor(c, probe, r)),
|
|
411
254
|
listed: probe.listed,
|
|
412
255
|
notes: fam?.notes ?? {},
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
rungs: rungRows(c, backend, model, probe.efforts, billing, evidence),
|
|
416
|
-
};
|
|
256
|
+
rungs,
|
|
257
|
+
});
|
|
417
258
|
};
|
|
418
259
|
for (const fam of c.families)
|
|
419
260
|
for (const [key, on] of Object.entries(fam.on)) {
|
|
420
261
|
const backends = key === "claude-code" ? ["claude", "claude-code"] : [backendOfKey(key)];
|
|
421
|
-
for (const b of backends)
|
|
262
|
+
for (const b of backends) entry(b, on.id, fam);
|
|
422
263
|
known.add(`${backendOfKey(key)}:${on.id}`);
|
|
423
264
|
}
|
|
424
265
|
for (const [backend, l] of Object.entries(c.listed))
|
|
425
|
-
for (const m of l.models) if (!known.has(`${backend}:${m.id}`))
|
|
266
|
+
for (const m of l.models) if (!known.has(`${backend}:${m.id}`)) entry(backend, m.id, null);
|
|
426
267
|
const needle = f.text?.toLowerCase();
|
|
427
268
|
const kept = rows
|
|
428
269
|
.filter(
|
|
429
270
|
(m) =>
|
|
430
271
|
(!f.role || m.roles.includes(f.role)) &&
|
|
431
272
|
(!f.backend || m.backend === f.backend || m.billing === f.backend) &&
|
|
432
|
-
(!needle || `${m.id} ${m.model} ${m.name ?? ""}`.toLowerCase().includes(needle))
|
|
433
|
-
(!f.scoredOnly || m.rungs.some((r) => r.enabled)),
|
|
273
|
+
(!needle || `${m.id} ${m.model} ${m.name ?? ""}`.toLowerCase().includes(needle)),
|
|
434
274
|
)
|
|
435
|
-
.sort(
|
|
436
|
-
(a, b) =>
|
|
437
|
-
Number(b.rungs.some((r) => r.enabled)) - Number(a.rungs.some((r) => r.enabled)) ||
|
|
438
|
-
a.id.localeCompare(b.id) ||
|
|
439
|
-
a.backend.localeCompare(b.backend),
|
|
440
|
-
);
|
|
275
|
+
.sort((a, b) => a.id.localeCompare(b.id) || a.backend.localeCompare(b.backend));
|
|
441
276
|
return { total: kept.length, models: kept.slice(0, f.limit) };
|
|
442
277
|
}
|
|
278
|
+
|
|
279
|
+
export interface IndexStatus {
|
|
280
|
+
fetchedAt: string | null;
|
|
281
|
+
checkedAt: string | null;
|
|
282
|
+
pairs: number;
|
|
283
|
+
error: string | null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** How long a fetched index is trusted before the next automatic refresh. */
|
|
287
|
+
export const INDEX_TTL_MS = 3_600_000;
|
|
288
|
+
|
|
289
|
+
export function indexStatus(): IndexStatus {
|
|
290
|
+
const f = readIndexFile(indexPath());
|
|
291
|
+
return {
|
|
292
|
+
fetchedAt: f?.fetchedAt ?? null,
|
|
293
|
+
checkedAt: f?.checkedAt ?? null,
|
|
294
|
+
pairs: f?.models.length ?? 0,
|
|
295
|
+
error: f?.error?.message ?? null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Keeps `<data>/slopalytics.json` fresh: fetched when its last check is over an hour old (or `force`), the last
|
|
301
|
+
* good answer kept when the proxy fails and the error recorded beside it. Never throws. An `auto` refresh
|
|
302
|
+
* (MCP boot, `init`, run start) fetches nothing under `CATHERD_NO_SYNC=1`.
|
|
303
|
+
*/
|
|
304
|
+
export async function refreshIndex(
|
|
305
|
+
o: { force?: boolean; auto?: boolean; fetch?: typeof fetch; now?: number } = {},
|
|
306
|
+
): Promise<IndexStatus> {
|
|
307
|
+
if (o.auto && process.env.CATHERD_NO_SYNC === "1") return indexStatus();
|
|
308
|
+
const now = o.now ?? Date.now();
|
|
309
|
+
// read, fetch and write run under one lock: a refresh that fails reads the file as the one before it left it
|
|
310
|
+
try {
|
|
311
|
+
mkdirSync(dirname(indexPath()), { recursive: true, mode: PRIVATE_DIR });
|
|
312
|
+
await withFileLock(indexPath(), () => refreshLocked(o, now), { timeoutMs: 30_000 });
|
|
313
|
+
} catch (e) {
|
|
314
|
+
log("debug", "catalog", { index: errorMessage(e) });
|
|
315
|
+
}
|
|
316
|
+
return indexStatus();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function refreshLocked(o: { force?: boolean; fetch?: typeof fetch }, now: number): Promise<void> {
|
|
320
|
+
const prev = readIndexFile(indexPath());
|
|
321
|
+
if (!o.force && prev && now - Date.parse(prev.checkedAt) < INDEX_TTL_MS) return;
|
|
322
|
+
const at = new Date(now).toISOString();
|
|
323
|
+
try {
|
|
324
|
+
const got = await fetchProxy({ fetch: o.fetch });
|
|
325
|
+
writeIndexFile(indexPath(), {
|
|
326
|
+
schema: 1,
|
|
327
|
+
checkedAt: at,
|
|
328
|
+
fetchedAt: got.fetchedAt,
|
|
329
|
+
error: null,
|
|
330
|
+
models: got.models,
|
|
331
|
+
});
|
|
332
|
+
} catch (e) {
|
|
333
|
+
const message = errorMessage(e);
|
|
334
|
+
log("debug", "catalog", { index: message });
|
|
335
|
+
writeIndexFile(indexPath(), {
|
|
336
|
+
schema: 1,
|
|
337
|
+
checkedAt: at,
|
|
338
|
+
fetchedAt: prev?.fetchedAt ?? null,
|
|
339
|
+
error: { at, message },
|
|
340
|
+
models: prev?.models ?? [],
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { CatherdError, isCatherdError } from "../domain/errors.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { withFileLockSync } from "../infra/filelock.ts";
|
|
6
6
|
import { configDir } from "../infra/paths.ts";
|
|
7
7
|
import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
|
|
8
8
|
|
|
9
|
-
// Spec §10.4
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// Spec §10.4: `<config>/credentials.json`, mode 600, holds catherd's own key, Jev's (`typesafeApiKey`). The
|
|
10
|
+
// schema is a loose object, so a key one catherd does not know (a 1.5 file's `artificialAnalysisApiKey`)
|
|
11
|
+
// survives another's rewrite.
|
|
12
12
|
|
|
13
13
|
export const credentialsPath = (): string => join(configDir(), "credentials.json");
|
|
14
14
|
|
|
15
15
|
const CredentialsSchema = z.looseObject({
|
|
16
16
|
schema: z.literal(1).default(1),
|
|
17
17
|
typesafeApiKey: z.string().optional(),
|
|
18
|
-
artificialAnalysisApiKey: z.string().optional(),
|
|
19
18
|
});
|
|
20
|
-
export type CredentialField = "typesafeApiKey"
|
|
19
|
+
export type CredentialField = "typesafeApiKey";
|
|
21
20
|
|
|
22
21
|
/** How to repair a credentials file catherd cannot read: `init` alone refuses to overwrite it. */
|
|
23
22
|
const credentialsFix = (): string =>
|
|
@@ -45,25 +44,15 @@ export function savedCredential(field: CredentialField): {
|
|
|
45
44
|
|
|
46
45
|
/**
|
|
47
46
|
* Keeps every other credential, and the file at mode 600 (spec §10.4). A missing file starts empty; an
|
|
48
|
-
* unreadable or newer-schema one is refused (it throws) rather than overwritten.
|
|
47
|
+
* unreadable or newer-schema one is refused (it throws) rather than overwritten. The read and the write hold
|
|
48
|
+
* the file's lock (1.2 minor), so two concurrent `init`s never lose a key.
|
|
49
49
|
*/
|
|
50
50
|
export function saveCredential(field: CredentialField, key: string): void {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
:
|
|
54
|
-
|
|
51
|
+
mkdirSync(configDir(), { recursive: true });
|
|
52
|
+
withFileLockSync(credentialsPath(), () => {
|
|
53
|
+
const cur: z.infer<typeof CredentialsSchema> = existsSync(credentialsPath())
|
|
54
|
+
? readCredentials()
|
|
55
|
+
: { schema: 1 };
|
|
56
|
+
writeJsonAtomic(credentialsPath(), { ...cur, schema: 1, [field]: key.trim() }, { mode: 0o600 });
|
|
57
|
+
});
|
|
55
58
|
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Spec 1.2 §9: `ARTIFICIAL_ANALYSIS_API_KEY`, else the saved key; null when neither has one. The key is
|
|
59
|
-
* optional, so a credentials file that cannot be read means no key here; it is logged.
|
|
60
|
-
*/
|
|
61
|
-
export function aaKey(env: Record<string, string | undefined> = process.env): string | null {
|
|
62
|
-
const fromEnv = env.ARTIFICIAL_ANALYSIS_API_KEY?.trim();
|
|
63
|
-
if (fromEnv) return fromEnv;
|
|
64
|
-
const { key, problem } = savedCredential("artificialAnalysisApiKey");
|
|
65
|
-
if (problem) log("warn", "sources", { error: `no Artificial Analysis key: ${problem.message}` });
|
|
66
|
-
return key;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export const saveAaKey = (key: string): void => saveCredential("artificialAnalysisApiKey", key);
|