catherd-cli 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -14
- package/catalog/ATTRIBUTION.md +36 -0
- package/catalog/models.json +10 -0
- package/catalog/scores.json +3006 -194
- package/catalog/sources.json +86 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +2 -2
- package/src/adapters/access.ts +57 -2
- package/src/domain/bars.ts +119 -0
- package/src/domain/calibration.ts +125 -0
- package/src/domain/catalog.ts +210 -23
- package/src/domain/failover.ts +14 -1
- package/src/domain/profile-rules.ts +108 -18
- package/src/domain/sources.ts +144 -0
- package/src/entry/catalog-command.ts +163 -8
- package/src/entry/init-command.ts +72 -2
- package/src/entry/mcp/lane-tools.ts +1 -1
- package/src/entry/mcp/server.ts +13 -3
- package/src/entry/mcp/setup-tools.ts +24 -2
- package/src/entry/profile-command.ts +10 -4
- package/src/entry/tui/commands.ts +20 -2
- package/src/entry/tui/effects.ts +50 -1
- package/src/entry/tui/fixtures.ts +31 -3
- package/src/entry/tui/profile-edits.ts +17 -9
- package/src/entry/tui/profile-tree.ts +2 -2
- package/src/entry/tui/run.tsx +11 -1
- package/src/entry/tui/state.ts +4 -0
- package/src/entry/tui/views/profile-actions.ts +9 -1
- package/src/entry/tui/views/profiles.tsx +81 -14
- package/src/entry/tui/views/save-dialog.tsx +12 -4
- package/src/infra/env.ts +2 -0
- package/src/infra/jev-client.ts +88 -40
- package/src/infra/paths.ts +2 -0
- package/src/infra/sources/arena.ts +87 -0
- package/src/infra/sources/artificial-analysis.ts +112 -0
- package/src/infra/sources/cache.ts +124 -0
- package/src/infra/sources/csv.ts +38 -0
- package/src/infra/sources/epoch.ts +82 -0
- package/src/infra/sources/http.ts +75 -0
- package/src/infra/sources/litellm.ts +58 -0
- package/src/infra/sources/models-dev.ts +59 -0
- package/src/infra/sources/openrouter-endpoints.ts +52 -0
- package/src/infra/sources/openrouter-models.ts +60 -0
- package/src/infra/sources/rows.ts +38 -0
- package/src/infra/sources/vectara.ts +69 -0
- package/src/infra/sources/zip.ts +48 -0
- package/src/services/catalog-refresh.ts +200 -0
- package/src/services/catalog-service.ts +83 -23
- package/src/services/credentials.ts +69 -0
- package/src/services/doctor-sources.ts +76 -0
- package/src/services/doctor.ts +10 -0
- package/src/services/jev-service.ts +14 -41
- package/src/services/ports.ts +6 -0
- package/src/services/profile-service.ts +8 -6
- package/src/services/provenance.ts +115 -0
- package/src/services/routing-service.ts +7 -2
- package/src/services/run-evidence.ts +86 -0
- package/src/services/source-derive.ts +349 -0
- package/src/services/source-sync.ts +305 -0
- package/src/services/standins.ts +204 -0
- package/src/services/treat-likes.ts +84 -0
package/src/entry/tui/effects.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { realpathSync, statSync, watch } from "node:fs";
|
|
|
2
2
|
import { adapterFor } from "../../adapters/registry.ts";
|
|
3
3
|
import "../../adapters/all.ts";
|
|
4
4
|
import { agentFiles } from "../../domain/agents.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { type Catalog, rungInfo } from "../../domain/catalog.ts";
|
|
6
6
|
import { HARNESS_KEYS, type Profile, type ProfileDoc, type ProfilePatch } from "../../domain/profile.ts";
|
|
7
7
|
import { type Validation, validateProfile } from "../../domain/profile-rules.ts";
|
|
8
8
|
import type { Access } from "../../domain/record.ts";
|
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
} from "../../services/catalog-service.ts";
|
|
18
18
|
import { cancel } from "../../services/dispatch-service.ts";
|
|
19
19
|
import { type DoctorReport, doctor } from "../../services/doctor.ts";
|
|
20
|
+
import { ageText } from "../../services/doctor-sources.ts";
|
|
21
|
+
import { valuesUsed, type ValueUsed } from "../../services/provenance.ts";
|
|
22
|
+
import { evidenceLine, evidenceOf, runEvidence } from "../../services/run-evidence.ts";
|
|
23
|
+
import { type SyncReport, sourcesStatus, syncSources } from "../../services/source-sync.ts";
|
|
24
|
+
import { type Suggestion, suggestStandIns } from "../../services/standins.ts";
|
|
20
25
|
import type { Synced } from "../../services/agent-links.ts";
|
|
21
26
|
import {
|
|
22
27
|
activate,
|
|
@@ -139,6 +144,46 @@ export interface Effects {
|
|
|
139
144
|
create(name: string, from?: string): Saved;
|
|
140
145
|
remove(name: string): Synced;
|
|
141
146
|
refreshCatalog(): Promise<Refreshed[]>;
|
|
147
|
+
/** spec 1.2 §9 (`r`): syncs the public sources (each at most every 12 hours) */
|
|
148
|
+
syncSources(): Promise<SyncReport>;
|
|
149
|
+
/** spec 1.2 §9: each source's name, age and last error, as the `r` dialog shows them */
|
|
150
|
+
sources(): { source: string; name: string; age: string; error: string | null }[];
|
|
151
|
+
/** spec 1.2 §6.3: the three nearest stand-ins for a rung, for the treat-like picker */
|
|
152
|
+
suggest(c: Catalog, rung: string): Suggestion[];
|
|
153
|
+
/** spec 1.2 §5.3, §8: a rung's values with confidence and source, and catherd's own runs on it */
|
|
154
|
+
rungDetail(c: Catalog, rung: string): { values: ValueUsed[]; evidence: string | null };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Spec 1.2 §9: each source's age and last error, from the sync's state. */
|
|
158
|
+
export function sourceRows(now = Date.now()): ReturnType<Effects["sources"]> {
|
|
159
|
+
return sourcesStatus().sources.map((s) => ({
|
|
160
|
+
source: s.source,
|
|
161
|
+
name: s.name,
|
|
162
|
+
age: s.fetchedAt ? `${ageText(now - Date.parse(s.fetchedAt))} ago` : "never fetched",
|
|
163
|
+
error: s.error,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Spec 1.2 §5.3, §8: what the rung detail shows; an unparseable rung has nothing to show. */
|
|
168
|
+
export function rungDetailOf(c: Catalog, rung: string): ReturnType<Effects["rungDetail"]> {
|
|
169
|
+
try {
|
|
170
|
+
const canonical = rungInfo(c, rung).canonical;
|
|
171
|
+
return {
|
|
172
|
+
values: valuesUsed(c, canonical),
|
|
173
|
+
evidence: evidenceLine(evidenceOf(runEvidence(c), canonical)),
|
|
174
|
+
};
|
|
175
|
+
} catch {
|
|
176
|
+
return { values: [], evidence: null };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Spec 1.2 §6.3: a rung's nearest stand-ins; none for a rung that does not parse. */
|
|
181
|
+
export function suggestFor(c: Catalog, rung: string): Suggestion[] {
|
|
182
|
+
try {
|
|
183
|
+
return suggestStandIns(c, rungInfo(c, rung).canonical);
|
|
184
|
+
} catch {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
142
187
|
}
|
|
143
188
|
|
|
144
189
|
/** The word for `here`: the bound repo's profile, or the global active one. */
|
|
@@ -308,5 +353,9 @@ export function liveEffects(repo: string | null = null): Effects {
|
|
|
308
353
|
create: createProfile,
|
|
309
354
|
remove: deleteProfile,
|
|
310
355
|
refreshCatalog: () => refreshDiscovery(),
|
|
356
|
+
syncSources: () => syncSources(),
|
|
357
|
+
sources: () => sourceRows(),
|
|
358
|
+
suggest: suggestFor,
|
|
359
|
+
rungDetail: rungDetailOf,
|
|
311
360
|
};
|
|
312
361
|
}
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type ProfileDoc,
|
|
7
7
|
resolveProfile,
|
|
8
8
|
} from "../../domain/profile.ts";
|
|
9
|
-
import { validateProfile } from "../../domain/profile-rules.ts";
|
|
9
|
+
import { repairs, validateProfile } from "../../domain/profile-rules.ts";
|
|
10
10
|
import { catalogQuery, loadCatalog } from "../../services/catalog-service.ts";
|
|
11
11
|
import type { RunRecord } from "../../domain/record.ts";
|
|
12
12
|
import type { DoctorReport } from "../../services/doctor.ts";
|
|
@@ -17,8 +17,10 @@ import {
|
|
|
17
17
|
type RoleDetail,
|
|
18
18
|
type RoleRow,
|
|
19
19
|
rowOf,
|
|
20
|
+
rungDetailOf,
|
|
20
21
|
type SessionRow,
|
|
21
22
|
type SessionRun,
|
|
23
|
+
suggestFor,
|
|
22
24
|
} from "./effects.ts";
|
|
23
25
|
import { withStaged } from "./profile-tree.ts";
|
|
24
26
|
|
|
@@ -401,6 +403,14 @@ export interface FixtureOptions {
|
|
|
401
403
|
watchable?: boolean;
|
|
402
404
|
}
|
|
403
405
|
|
|
406
|
+
/** Spec 1.2 §9: the sources as the `r` dialog shows them, one failing. */
|
|
407
|
+
export const FIXTURE_SOURCES: ReturnType<Effects["sources"]> = [
|
|
408
|
+
{ source: "models-dev", name: "models.dev", age: "2 h ago", error: null },
|
|
409
|
+
{ source: "arena", name: "Arena (LMArena)", age: "2 h ago", error: null },
|
|
410
|
+
{ source: "epoch", name: "Epoch AI benchmarks", age: "1 d ago", error: "network error" },
|
|
411
|
+
{ source: "artificial-analysis", name: "Artificial Analysis", age: "never fetched", error: null },
|
|
412
|
+
];
|
|
413
|
+
|
|
404
414
|
export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
405
415
|
writes: string[];
|
|
406
416
|
/** what a change to a watched run file does: every open watch hears of it */
|
|
@@ -425,9 +435,12 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
425
435
|
staged: Record<string, string> = {},
|
|
426
436
|
) => {
|
|
427
437
|
const p = resolveProfile(after, name);
|
|
428
|
-
const
|
|
438
|
+
const c = withStaged(loadCatalog({ timings: false }), staged);
|
|
439
|
+
const v = validateProfile(p, c, BACKENDS);
|
|
440
|
+
// spec 1.2 §6.2, as the ProfileService rules: a save that repairs part of an invalid profile goes through
|
|
441
|
+
const was = docs.has(name) ? validateProfile(resolveProfile(before, name), c, BACKENDS) : null;
|
|
429
442
|
return {
|
|
430
|
-
saved: v
|
|
443
|
+
saved: repairs(was, v),
|
|
431
444
|
...v,
|
|
432
445
|
diff: diffProfiles(resolveProfile(before, name), p),
|
|
433
446
|
linked: [],
|
|
@@ -581,5 +594,20 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
|
|
|
581
594
|
writes.push("refresh");
|
|
582
595
|
return [{ backend: "codex", models: 14, fetchedAt: "2026-09-26T12:00:00.000Z" }];
|
|
583
596
|
},
|
|
597
|
+
async syncSources() {
|
|
598
|
+
writes.push("sync");
|
|
599
|
+
return {
|
|
600
|
+
busy: false,
|
|
601
|
+
sources: [],
|
|
602
|
+
newlyScored: [],
|
|
603
|
+
noLongerNeeded: [],
|
|
604
|
+
failed: [],
|
|
605
|
+
warnings: [],
|
|
606
|
+
unmatched: {},
|
|
607
|
+
};
|
|
608
|
+
},
|
|
609
|
+
sources: () => FIXTURE_SOURCES,
|
|
610
|
+
suggest: suggestFor,
|
|
611
|
+
rungDetail: rungDetailOf,
|
|
584
612
|
};
|
|
585
613
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Suggestion } from "../../services/standins.ts";
|
|
1
2
|
import { type Catalog, rungInfo, scoresOf } from "../../domain/catalog.ts";
|
|
2
3
|
import { parseRung, tryParseRung } from "../../domain/ids.ts";
|
|
3
4
|
import { NOTIFY, type Profile, type ProfilePatch } from "../../domain/profile.ts";
|
|
@@ -113,26 +114,33 @@ export function failoverOptions(
|
|
|
113
114
|
if (!(r.enabled || usable(r.rung)) || r.rung.startsWith("claude:")) continue;
|
|
114
115
|
const q = quotaOf(parseRung(r.rung));
|
|
115
116
|
if (q === quota) continue;
|
|
116
|
-
const
|
|
117
|
+
const note = inferredScores(c, rungInfo(c, r.rung)).note;
|
|
117
118
|
out.push({
|
|
118
119
|
value: r.rung,
|
|
119
120
|
title: shortRung(r.rung),
|
|
120
121
|
group: m.billing,
|
|
121
|
-
detail:
|
|
122
|
+
detail: note ?? "",
|
|
122
123
|
current: r.rung === cur,
|
|
123
124
|
});
|
|
124
125
|
}
|
|
125
126
|
return out;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
/**
|
|
129
|
-
|
|
129
|
+
/**
|
|
130
|
+
* The treat-like picker: every rung with scores of its own, by model; spec 1.2 §6.4: the suggested stand-ins
|
|
131
|
+
* (`suggestions`, nearest first) say their distance and what they would lend.
|
|
132
|
+
*/
|
|
133
|
+
export function treatLikeOptions(c: Catalog, suggestions: Suggestion[] = []): SelectOption[] {
|
|
130
134
|
return Object.keys(c.scores)
|
|
131
135
|
.filter((canonical) => scoresOf(c, canonical)?.via === null)
|
|
132
136
|
.sort()
|
|
133
|
-
.map((canonical) =>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
.map((canonical) => {
|
|
138
|
+
const s = suggestions.find((x) => x.like === canonical);
|
|
139
|
+
return {
|
|
140
|
+
value: canonical,
|
|
141
|
+
title: canonical,
|
|
142
|
+
group: canonical.slice(0, canonical.lastIndexOf("#")),
|
|
143
|
+
...(s ? { detail: `distance ${s.distance.toFixed(2)} · lends ${s.lends.join(", ")}` } : {}),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
138
146
|
}
|
|
@@ -350,8 +350,8 @@ export function buildRows(i: TreeInput): Row[] {
|
|
|
350
350
|
let mark = "";
|
|
351
351
|
if (to) {
|
|
352
352
|
try {
|
|
353
|
-
const
|
|
354
|
-
mark =
|
|
353
|
+
const note = inferredScores(i.catalog, rungInfo(i.catalog, to)).note;
|
|
354
|
+
mark = note ? ` (${note})` : "";
|
|
355
355
|
} catch {
|
|
356
356
|
mark = "";
|
|
357
357
|
}
|
package/src/entry/tui/run.tsx
CHANGED
|
@@ -10,6 +10,16 @@ import { liveEffects } from "./effects.ts";
|
|
|
10
10
|
import { fixtureEffects } from "./fixtures.ts";
|
|
11
11
|
import { createAppKeymap } from "./providers/keymap.tsx";
|
|
12
12
|
import { Providers } from "./providers/root.tsx";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* OpenTUI's SystemClock counts from process start (performance.now), but the pages compare its time with the
|
|
16
|
+
* wall-clock timestamps catherd writes (admittedAt, createdAt, lastActivity), so it must be epoch milliseconds.
|
|
17
|
+
*/
|
|
18
|
+
export class WallClock extends SystemClock {
|
|
19
|
+
override now(): number {
|
|
20
|
+
return Date.now();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
13
23
|
import { initialState, type Tab } from "./state.ts";
|
|
14
24
|
import { detectUi, type ThemeMode } from "./theme.ts";
|
|
15
25
|
import { App } from "./views/app.tsx";
|
|
@@ -72,7 +82,7 @@ export async function openTui(o: TuiOptions): Promise<number> {
|
|
|
72
82
|
keybinds={keybinds}
|
|
73
83
|
keymap={createAppKeymap(renderer)}
|
|
74
84
|
effects={effects}
|
|
75
|
-
clock={new
|
|
85
|
+
clock={new WallClock()}
|
|
76
86
|
initial={initialState(o.tab ?? "status")}
|
|
77
87
|
copy={(text) => renderer.copyToClipboardOSC52(text)}
|
|
78
88
|
onExit={(code, kept) => {
|
package/src/entry/tui/state.ts
CHANGED
|
@@ -54,6 +54,10 @@ export type Purpose =
|
|
|
54
54
|
| { type: "start"; role: Role }
|
|
55
55
|
| { type: "treatLike"; rung: string; role: Role | null }
|
|
56
56
|
| { type: "failover"; rung: string }
|
|
57
|
+
/** spec 1.2 §9: each source's age and last error, after `r` synced them */
|
|
58
|
+
| { type: "sources" }
|
|
59
|
+
/** spec 1.2 §9: a rung's values with confidence and source, and its runs */
|
|
60
|
+
| { type: "rung"; rung: string }
|
|
57
61
|
| { type: "stories" };
|
|
58
62
|
|
|
59
63
|
export interface SelectOption {
|
|
@@ -314,7 +314,15 @@ export function useProfileDialogs(): void {
|
|
|
314
314
|
} catch (e) {
|
|
315
315
|
fail(app, e);
|
|
316
316
|
}
|
|
317
|
-
|
|
317
|
+
// spec 1.2 §6.2: a save that repaired part of an invalid profile says what is still open
|
|
318
|
+
app.toast(
|
|
319
|
+
r.errors.length
|
|
320
|
+
? {
|
|
321
|
+
variant: "warning",
|
|
322
|
+
message: `Saved profile ${p.name}; ${r.errors.length} ${r.errors.length === 1 ? "error" : "errors"} still open`,
|
|
323
|
+
}
|
|
324
|
+
: { variant: "success", message: `Saved profile ${p.name}` },
|
|
325
|
+
);
|
|
318
326
|
sessionsNeeded(app, r.newSessionNeededFor);
|
|
319
327
|
if (value === "activate") activateNow(app, data, p.name, shown);
|
|
320
328
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { type Profile, resolveProfile } from "../../../domain/profile.ts";
|
|
3
|
+
import type { Role } from "../../../domain/roles.ts";
|
|
3
4
|
import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
|
|
4
5
|
import { hereWord } from "../effects.ts";
|
|
5
6
|
import { useApp, useDialogHandler } from "../providers/app.tsx";
|
|
@@ -215,17 +216,7 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
215
216
|
empty: "no scored rung on another quota",
|
|
216
217
|
},
|
|
217
218
|
});
|
|
218
|
-
else if (a.type === "rung" && !a.scored)
|
|
219
|
-
app.dispatch({
|
|
220
|
-
type: "open",
|
|
221
|
-
dialog: {
|
|
222
|
-
kind: "select",
|
|
223
|
-
purpose: { type: "treatLike", rung: a.rung, role: a.role },
|
|
224
|
-
title: `Treat ${a.rung} like…`,
|
|
225
|
-
options: treatLikeOptions(catalog),
|
|
226
|
-
empty: "no scored rung",
|
|
227
|
-
},
|
|
228
|
-
});
|
|
219
|
+
else if (a.type === "rung" && !a.scored) openTreatLike(a.rung, a.role);
|
|
229
220
|
else if (a.type === "number") {
|
|
230
221
|
const v = numberValue(profile, a.path);
|
|
231
222
|
app.dispatch({
|
|
@@ -241,6 +232,49 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
241
232
|
});
|
|
242
233
|
}
|
|
243
234
|
};
|
|
235
|
+
/** spec 1.2 §6.4: the treat-like picker, its three nearest stand-ins first */
|
|
236
|
+
const openTreatLike = (rung: string, role: Role | null) => {
|
|
237
|
+
if (!catalog) return;
|
|
238
|
+
const suggestions = app.effects.suggest(catalog, rung);
|
|
239
|
+
app.dispatch({
|
|
240
|
+
type: "open",
|
|
241
|
+
dialog: {
|
|
242
|
+
kind: "select",
|
|
243
|
+
purpose: { type: "treatLike", rung, role },
|
|
244
|
+
title: `Treat ${rung} like…`,
|
|
245
|
+
options: treatLikeOptions(catalog, suggestions),
|
|
246
|
+
suggested: suggestions.map((s) => s.like),
|
|
247
|
+
empty: "no scored rung",
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
/** spec 1.2 §9: the rung's values with confidence and source, and catherd's runs on it */
|
|
252
|
+
const openDetail = (rung: string) => {
|
|
253
|
+
if (!catalog) return;
|
|
254
|
+
const d = app.effects.rungDetail(catalog, rung);
|
|
255
|
+
app.dispatch({
|
|
256
|
+
type: "open",
|
|
257
|
+
dialog: {
|
|
258
|
+
kind: "select",
|
|
259
|
+
purpose: { type: "rung", rung },
|
|
260
|
+
title: rung,
|
|
261
|
+
options: [
|
|
262
|
+
...d.values.map((v) => ({
|
|
263
|
+
value: v.dim,
|
|
264
|
+
title: `${v.dim} ${v.value}`,
|
|
265
|
+
group: "values",
|
|
266
|
+
detail: `${v.inferred ? `inferred from ${v.from}` : v.confidence} · ${v.source} ${v.benchmark} · ${v.date}`,
|
|
267
|
+
})),
|
|
268
|
+
{
|
|
269
|
+
value: "runs",
|
|
270
|
+
title: d.evidence ?? "no runs yet",
|
|
271
|
+
group: "catherd's runs (never used to route)",
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
empty: "no values",
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
};
|
|
244
278
|
const primary = (r: Row | null) => {
|
|
245
279
|
const p = current();
|
|
246
280
|
if (!r || !p) return;
|
|
@@ -267,10 +301,31 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
267
301
|
"catalog.refresh": () => {
|
|
268
302
|
// after a failed read, r reads it again (and only that)
|
|
269
303
|
if (failure) return failure.retry();
|
|
270
|
-
|
|
271
|
-
|
|
304
|
+
// spec 1.2 §9: r lists every backend's models and syncs the public sources, then shows each source's
|
|
305
|
+
// age and last error
|
|
306
|
+
void Promise.all([app.effects.refreshCatalog(), app.effects.syncSources()]).then(
|
|
307
|
+
([, sync]) => {
|
|
272
308
|
loaded.refresh();
|
|
273
|
-
app.toast({
|
|
309
|
+
app.toast({
|
|
310
|
+
variant: sync.failed.length ? "warning" : "success",
|
|
311
|
+
message: sync.busy
|
|
312
|
+
? "Catalog refreshed; another sync is running"
|
|
313
|
+
: `Catalog refreshed${sync.newlyScored.length ? `; ${sync.newlyScored.length} rungs newly scored` : ""}`,
|
|
314
|
+
});
|
|
315
|
+
app.dispatch({
|
|
316
|
+
type: "open",
|
|
317
|
+
dialog: {
|
|
318
|
+
kind: "select",
|
|
319
|
+
purpose: { type: "sources" },
|
|
320
|
+
title: "Sources",
|
|
321
|
+
options: app.effects.sources().map((s) => ({
|
|
322
|
+
value: s.source,
|
|
323
|
+
title: s.name,
|
|
324
|
+
detail: s.error ? `${s.age} · ${s.error}` : s.age,
|
|
325
|
+
})),
|
|
326
|
+
empty: "no sources",
|
|
327
|
+
},
|
|
328
|
+
});
|
|
274
329
|
},
|
|
275
330
|
(e: unknown) => app.toast(errorToast(e)),
|
|
276
331
|
);
|
|
@@ -279,6 +334,16 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
279
334
|
"edit.redo": () => app.dispatch({ type: "redo" }),
|
|
280
335
|
});
|
|
281
336
|
useCommandLayer("row.profiles", {
|
|
337
|
+
"tree.detail": () => {
|
|
338
|
+
const a = rowNow()?.action;
|
|
339
|
+
if (a?.type === "rung") openDetail(a.rung);
|
|
340
|
+
else app.toast({ variant: "info", message: "Pick a rung (an effort row) to see its values" });
|
|
341
|
+
},
|
|
342
|
+
"tree.treatLike": () => {
|
|
343
|
+
const a = rowNow()?.action;
|
|
344
|
+
if (a?.type === "rung") openTreatLike(a.rung, a.role);
|
|
345
|
+
else app.toast({ variant: "info", message: "Pick a rung (an effort row) to map it" });
|
|
346
|
+
},
|
|
282
347
|
"tree.toggle": () => toggle(rowNow()),
|
|
283
348
|
"tree.open": () => primary(rowNow()),
|
|
284
349
|
"tree.expand": () => {
|
|
@@ -313,6 +378,8 @@ export function ProfilesView(props: { width: number; height: number }) {
|
|
|
313
378
|
: null;
|
|
314
379
|
app.dispatch({ type: "treatLike", rung: p.rung, like: value, ...(tick ? { patch: tick } : {}) });
|
|
315
380
|
});
|
|
381
|
+
useDialogHandler("sources", () => app.dispatch({ type: "close" }));
|
|
382
|
+
useDialogHandler("rung", () => app.dispatch({ type: "close" }));
|
|
316
383
|
useDialogHandler("number", (p, value) => {
|
|
317
384
|
if (p.type !== "number") return;
|
|
318
385
|
const r = parseNumber(p.path, value);
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
patchBetween,
|
|
10
10
|
resolveProfile,
|
|
11
11
|
} from "../../../domain/profile.ts";
|
|
12
|
-
import type
|
|
12
|
+
import { repairs, type Validation } from "../../../domain/profile-rules.ts";
|
|
13
13
|
import type { Effects } from "../effects.ts";
|
|
14
14
|
import { useApp } from "../providers/app.tsx";
|
|
15
15
|
import { useData, useLoad } from "../providers/data.tsx";
|
|
@@ -36,6 +36,8 @@ export interface SavePreview {
|
|
|
36
36
|
changes: Change[];
|
|
37
37
|
treatLikes: [string, string][];
|
|
38
38
|
validation: Validation;
|
|
39
|
+
/** spec 1.2 §6.2: the save leaves errors but removes one of the stored profile's and adds none */
|
|
40
|
+
repair: boolean;
|
|
39
41
|
agentsAdded: string[];
|
|
40
42
|
agentsRemoved: string[];
|
|
41
43
|
}
|
|
@@ -54,10 +56,13 @@ function previewSave(
|
|
|
54
56
|
const { catalog } = fx.catalog(after.billing);
|
|
55
57
|
const a = new Set(fx.agents(before));
|
|
56
58
|
const b = new Set(fx.agents(after));
|
|
59
|
+
const staged = withStaged(catalog, d.treatLikes);
|
|
60
|
+
const validation = fx.validate(after, staged);
|
|
57
61
|
return {
|
|
58
62
|
changes: diffProfiles(before, after),
|
|
59
63
|
treatLikes: Object.entries(d.treatLikes),
|
|
60
|
-
validation
|
|
64
|
+
validation,
|
|
65
|
+
repair: validation.errors.length > 0 && repairs(fx.validate(before, staged), validation),
|
|
61
66
|
agentsAdded: [...b].filter((x) => !a.has(x)),
|
|
62
67
|
agentsRemoved: [...a].filter((x) => !b.has(x)),
|
|
63
68
|
};
|
|
@@ -200,7 +205,8 @@ export function SaveDialog(props: { dialog: Save }) {
|
|
|
200
205
|
setChanged(true);
|
|
201
206
|
return { same: false, now: doc };
|
|
202
207
|
};
|
|
203
|
-
|
|
208
|
+
// spec 1.2 §6.2: a save that repairs part of an invalid profile is offered; any other error leaves Cancel
|
|
209
|
+
const blocked = error !== null || ((preview?.validation.errors.length ?? 0) > 0 && !preview?.repair);
|
|
204
210
|
// spec §9.2's order: Save / Save & make active / Cancel; errors leave only Cancel
|
|
205
211
|
const labels = blocked ? ["Cancel"] : ["Save", "Save & make active", "Cancel"];
|
|
206
212
|
const [focused, setFocusedState] = useState(0);
|
|
@@ -272,7 +278,9 @@ export function SaveDialog(props: { dialog: Save }) {
|
|
|
272
278
|
],
|
|
273
279
|
},
|
|
274
280
|
{
|
|
275
|
-
head:
|
|
281
|
+
head: preview.repair
|
|
282
|
+
? [[], ...text("This save fixes an error and adds none; these stay open:", "warning")]
|
|
283
|
+
: [[]],
|
|
276
284
|
hold: "error",
|
|
277
285
|
more: (n) => `… and ${n} more ${n === 1 ? "error" : "errors"}`,
|
|
278
286
|
items: preview.validation.errors.map((e) =>
|
package/src/infra/env.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
const SECRET_ENV = new Set([
|
|
8
8
|
"TYPESAFE_API_KEY",
|
|
9
|
+
// spec 1.2 §9: the user's Artificial Analysis key reads scores for catherd alone
|
|
10
|
+
"ARTIFICIAL_ANALYSIS_API_KEY",
|
|
9
11
|
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
10
12
|
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
11
13
|
"CLAUDE_CODE_SESSION_ID",
|
package/src/infra/jev-client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Spec §5.5 transport: a thin client (the official SDK is 0.x and Node-targeted) with the SDK's retry
|
|
2
|
-
// semantics (research 2026-09-25-jev.md §4, §5.6).
|
|
2
|
+
// semantics (research 2026-09-25-jev.md §4, §5.6). The public sources of spec 1.2 §3.3 reuse its policy
|
|
3
|
+
// through `retryingFetch`.
|
|
3
4
|
|
|
4
5
|
export const JEV_BASE = "https://api.typesafe.ai/v1";
|
|
5
6
|
|
|
@@ -20,6 +21,19 @@ export type JevResponse =
|
|
|
20
21
|
| { ok: true; body: unknown; requestId: string | null; latencyMs: number; attempts: number }
|
|
21
22
|
| { ok: false; error: string; status: number | null; latencyMs: number; attempts: number };
|
|
22
23
|
|
|
24
|
+
/** What `retryingFetch` got: the body's bytes on a 2xx, else the last error and status. */
|
|
25
|
+
export type FetchOutcome =
|
|
26
|
+
| { ok: true; status: number; headers: Headers; bytes: Uint8Array; latencyMs: number; attempts: number }
|
|
27
|
+
| {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: string;
|
|
30
|
+
status: number | null;
|
|
31
|
+
/** the last answer's headers, when there was an answer */
|
|
32
|
+
headers: Headers | null;
|
|
33
|
+
latencyMs: number;
|
|
34
|
+
attempts: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
23
37
|
const RETRY_AFTER_MAX_MS = 10_000;
|
|
24
38
|
const retryable = (status: number) => status === 408 || status === 429 || status >= 500;
|
|
25
39
|
|
|
@@ -64,25 +78,30 @@ function httpError(status: number, text: string): string {
|
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
/**
|
|
67
|
-
* `
|
|
68
|
-
* 5xx, a network error or a timeout, honouring Retry-After, and never past
|
|
69
|
-
* timer covers the whole exchange, body included, and aborts the request when it fires.
|
|
81
|
+
* `url` with the Jev client's retry policy: `attemptMs` per attempt (10 s), up to `retries` (two) retries on
|
|
82
|
+
* 408, 429, 5xx, a network error or a timeout, honouring Retry-After, and never past `deadlineMs` (25 s). The
|
|
83
|
+
* attempt timer covers the whole exchange, body included, and aborts the request when it fires. `headers`
|
|
84
|
+
* builds each attempt's headers from its number (1 first); `errorOf` names a failed status (`http <status>`).
|
|
85
|
+
* Bun's fetch honours HTTPS_PROXY and HTTP_PROXY.
|
|
70
86
|
*/
|
|
71
|
-
export async function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
o: JevTransport = {},
|
|
77
|
-
): Promise<JevResponse> {
|
|
87
|
+
export async function retryingFetch(
|
|
88
|
+
url: string,
|
|
89
|
+
init: { method: "GET" | "POST"; headers: (attempt: number) => Record<string, string>; body?: string },
|
|
90
|
+
o: JevTransport & { errorOf?: (status: number, text: string) => string } = {},
|
|
91
|
+
): Promise<FetchOutcome> {
|
|
78
92
|
const now = o.now ?? Date.now;
|
|
79
93
|
const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
80
94
|
const random = o.random ?? Math.random;
|
|
81
95
|
const fetchImpl = o.fetchImpl ?? globalThis.fetch;
|
|
96
|
+
const errorOf = o.errorOf ?? ((status: number) => `http ${status}`);
|
|
82
97
|
const start = now();
|
|
83
98
|
const deadline = start + (o.deadlineMs ?? 25_000);
|
|
84
99
|
const retries = o.retries ?? 2;
|
|
85
|
-
let last: { error: string; status: number | null
|
|
100
|
+
let last: { error: string; status: number | null; headers: Headers | null } = {
|
|
101
|
+
error: "network error",
|
|
102
|
+
status: null,
|
|
103
|
+
headers: null,
|
|
104
|
+
};
|
|
86
105
|
let attempt = 0;
|
|
87
106
|
for (;;) {
|
|
88
107
|
attempt++;
|
|
@@ -100,52 +119,37 @@ export async function jevRequest(
|
|
|
100
119
|
);
|
|
101
120
|
});
|
|
102
121
|
const exchange = async () => {
|
|
103
|
-
const res = await fetchImpl(
|
|
104
|
-
method,
|
|
105
|
-
headers:
|
|
106
|
-
|
|
107
|
-
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
108
|
-
...(attempt > 1 ? { "x-typesafe-retry-count": String(attempt - 1) } : {}),
|
|
109
|
-
},
|
|
110
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
122
|
+
const res = await fetchImpl(url, {
|
|
123
|
+
method: init.method,
|
|
124
|
+
headers: init.headers(attempt),
|
|
125
|
+
...(init.body === undefined ? {} : { body: init.body }),
|
|
111
126
|
signal: ctl.signal,
|
|
112
127
|
});
|
|
113
|
-
|
|
114
|
-
return { res, text };
|
|
128
|
+
if (res.ok) return { res, bytes: new Uint8Array(await res.arrayBuffer()), text: "" };
|
|
129
|
+
return { res, bytes: new Uint8Array(), text: await res.text().catch(() => "") };
|
|
115
130
|
};
|
|
116
131
|
let wait: number | null = null;
|
|
117
132
|
try {
|
|
118
133
|
const r = await Promise.race([exchange(), timeout]);
|
|
119
134
|
if (r === "timeout") {
|
|
120
|
-
last = { error: "timeout", status: null };
|
|
135
|
+
last = { error: "timeout", status: null, headers: null };
|
|
121
136
|
} else if (r.res.ok) {
|
|
122
|
-
let parsed: unknown = null;
|
|
123
|
-
try {
|
|
124
|
-
parsed = JSON.parse(r.text);
|
|
125
|
-
} catch {
|
|
126
|
-
return {
|
|
127
|
-
ok: false,
|
|
128
|
-
error: "unexpected response",
|
|
129
|
-
status: r.res.status,
|
|
130
|
-
latencyMs: now() - start,
|
|
131
|
-
attempts: attempt,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
137
|
return {
|
|
135
138
|
ok: true,
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
status: r.res.status,
|
|
140
|
+
headers: r.res.headers,
|
|
141
|
+
bytes: r.bytes,
|
|
138
142
|
latencyMs: now() - start,
|
|
139
143
|
attempts: attempt,
|
|
140
144
|
};
|
|
141
145
|
} else {
|
|
142
|
-
last = { error:
|
|
146
|
+
last = { error: errorOf(r.res.status, r.text), status: r.res.status, headers: r.res.headers };
|
|
143
147
|
if (!retryable(r.res.status))
|
|
144
148
|
return { ok: false, ...last, latencyMs: now() - start, attempts: attempt };
|
|
145
149
|
wait = retryAfterMs(r.res.headers, now());
|
|
146
150
|
}
|
|
147
151
|
} catch {
|
|
148
|
-
last = { error: "network error", status: null };
|
|
152
|
+
last = { error: "network error", status: null, headers: null };
|
|
149
153
|
} finally {
|
|
150
154
|
clearTimeout(timer);
|
|
151
155
|
ctl.abort();
|
|
@@ -153,10 +157,54 @@ export async function jevRequest(
|
|
|
153
157
|
if (attempt > retries) break;
|
|
154
158
|
const pause = wait ?? backoffMs(attempt, random);
|
|
155
159
|
if (now() + pause >= deadline) {
|
|
156
|
-
last = { error: `deadline (${last.error})
|
|
160
|
+
last = { ...last, error: `deadline (${last.error})` };
|
|
157
161
|
break;
|
|
158
162
|
}
|
|
159
163
|
await sleep(pause);
|
|
160
164
|
}
|
|
161
165
|
return { ok: false, ...last, latencyMs: now() - start, attempts: attempt };
|
|
162
166
|
}
|
|
167
|
+
|
|
168
|
+
/** `method path` against the Jev API with a bearer key, under `retryingFetch`'s policy. */
|
|
169
|
+
export async function jevRequest(
|
|
170
|
+
method: "GET" | "POST",
|
|
171
|
+
path: string,
|
|
172
|
+
key: string,
|
|
173
|
+
body: unknown,
|
|
174
|
+
o: JevTransport = {},
|
|
175
|
+
): Promise<JevResponse> {
|
|
176
|
+
const r = await retryingFetch(
|
|
177
|
+
`${JEV_BASE}${path}`,
|
|
178
|
+
{
|
|
179
|
+
method,
|
|
180
|
+
headers: (attempt) => ({
|
|
181
|
+
authorization: `Bearer ${key}`,
|
|
182
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
183
|
+
...(attempt > 1 ? { "x-typesafe-retry-count": String(attempt - 1) } : {}),
|
|
184
|
+
}),
|
|
185
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
186
|
+
},
|
|
187
|
+
{ ...o, errorOf: httpError },
|
|
188
|
+
);
|
|
189
|
+
if (!r.ok)
|
|
190
|
+
return { ok: false, error: r.error, status: r.status, latencyMs: r.latencyMs, attempts: r.attempts };
|
|
191
|
+
let parsed: unknown = null;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(new TextDecoder().decode(r.bytes));
|
|
194
|
+
} catch {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
error: "unexpected response",
|
|
198
|
+
status: r.status,
|
|
199
|
+
latencyMs: r.latencyMs,
|
|
200
|
+
attempts: r.attempts,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
body: parsed,
|
|
206
|
+
requestId: r.headers.get("x-typesafe-request-id"),
|
|
207
|
+
latencyMs: r.latencyMs,
|
|
208
|
+
attempts: r.attempts,
|
|
209
|
+
};
|
|
210
|
+
}
|
package/src/infra/paths.ts
CHANGED
|
@@ -36,3 +36,5 @@ export const runsDir = (toplevel: string): string => join(repoDir(toplevel), "ru
|
|
|
36
36
|
export const logsDir = (): string => join(dataDir(), "logs");
|
|
37
37
|
export const discoveryDir = (): string => join(dataDir(), "discovery");
|
|
38
38
|
export const locksDir = (): string => join(dataDir(), "locks");
|
|
39
|
+
/** Spec 1.2 §3.3: each source's last good answer, the sync state and lock, and what the sync derived. */
|
|
40
|
+
export const sourcesDir = (): string => join(dataDir(), "sources");
|