omp-llm-role 1.0.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.
Files changed (50) hide show
  1. package/README.md +202 -12
  2. package/agents/designer.md +4 -3
  3. package/docs/dev/README.md +44 -7
  4. package/docs/dev/agent-authoring.md +59 -1
  5. package/docs/dev/architecture.md +106 -28
  6. package/docs/dev/ci.md +163 -0
  7. package/docs/dev/current-state.md +364 -24
  8. package/docs/dev/data-sources.md +231 -38
  9. package/docs/dev/explorer.md +175 -46
  10. package/docs/dev/quirks.md +77 -4
  11. package/docs/dev/releasing.md +130 -17
  12. package/docs/dev/scoring.md +851 -27
  13. package/docs/dev/testing.md +253 -0
  14. package/docs/dev/writes-and-state.md +232 -28
  15. package/docs/llm-role-rankings.md +149 -148
  16. package/package.json +814 -80
  17. package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
  18. package/src/agent-architect.ts +148 -19
  19. package/src/agent-create.ts +800 -40
  20. package/src/agent-file.ts +2 -5
  21. package/src/architect-provenance.ts +16 -0
  22. package/src/availability.ts +191 -15
  23. package/src/benchmark-author.ts +9 -4
  24. package/src/benchmark-sources.ts +283 -18
  25. package/src/cli/create-role.ts +29 -1
  26. package/src/cli/llm-role-rank.ts +40 -1
  27. package/src/config-edit.ts +207 -80
  28. package/src/engine.ts +519 -75
  29. package/src/error-report.ts +562 -0
  30. package/src/explorer/boot.ts +36 -24
  31. package/src/explorer/explain.ts +128 -13
  32. package/src/explorer/scopes.ts +110 -0
  33. package/src/explorer/server.ts +126 -33
  34. package/src/extension.ts +464 -48
  35. package/src/features.ts +105 -0
  36. package/src/project-registry.ts +65 -0
  37. package/src/project-setup.ts +382 -0
  38. package/src/prompts/agent-creation-architect.md +2 -0
  39. package/src/prompts/project-profile-architect.md +33 -0
  40. package/src/prompts/project-profile-user.md +8 -0
  41. package/src/provider-display.ts +67 -0
  42. package/src/role-archetypes.ts +12 -2
  43. package/src/role-settings.ts +22 -0
  44. package/src/settings.ts +211 -24
  45. package/src/state.ts +1 -1
  46. package/src/updater.ts +234 -37
  47. package/web/app.js +434 -12
  48. package/web/index.html +1 -0
  49. package/web/style.css +44 -1
  50. package/docs/dev/spec.md +0 -633
@@ -6,10 +6,16 @@
6
6
  * No I/O: the server (src/explorer/server.ts) owns the HTTP surface and the
7
7
  * export write; this module only computes. All ranking math is delegated to
8
8
  * src/engine.ts so the numbers on screen are exactly the plugin's numbers.
9
+ *
10
+ * The keyed-catalog availability annotation (`key` on rank rows and the eligible
11
+ * explanation) is a pure overlay computed from an injected `KeyAvailability`
12
+ * value — no I/O here; the caller owns the fetch.
9
13
  */
10
14
 
11
- import { sourceForMetric, type SourceDeclaration } from "../benchmark-sources.ts";
12
- import { CAPABILITY_FILL, cardinalMetric, paretoFrontier, rankRole, roleLambda, type Model, type Ranked, type RoleDef, type SuffixLevel } from "../engine.ts";
15
+ import { assessFocusMetric, type FocusMetricAssessment } from "../agent-create.ts";
16
+ import { type KeyAvailability } from "../availability.ts";
17
+ import { cachedSourceInfo, sourceForMetric, type SourceDeclaration } from "../benchmark-sources.ts";
18
+ import { autoPinApplies, BENCHMARK_CHANCE, CAPABILITY_FILL, cardinalMetric, endpointFilteredModel, paretoFrontier, pinnedRoute, rankRole, roleLambda, roleMetricValue, roleRoute, routeEffectivePrice, thinkingAdjustedPrice, type Model, type Ranked, type RoleDef, type SuffixLevel } from "../engine.ts";
13
19
  import { KNOWN_METRICS } from "../settings.ts";
14
20
 
15
21
  // ---------------------------------------------------------------------------
@@ -39,6 +45,10 @@ export const METRIC_META: Record<string, MetricMeta> = {
39
45
  vision: { label: "Vision index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
40
46
  tool_calling: { label: "Tool-calling index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
41
47
  long_context: { label: "Long-context index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
48
+ index_communication: { label: "Communication index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
49
+ index_finance: { label: "Finance index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
50
+ index_healthcare: { label: "Healthcare index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
51
+ index_legal: { label: "Legal index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
42
52
  mrcr: { label: "MRCR v2", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
43
53
  gpqa: { label: "GPQA", kind: "benchmark", unit: "pass rate", formula: "(v−0.25)/0.75", anchors: "4-way multiple choice, chance 0.25" },
44
54
  aime: { label: "AIME 2025", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
@@ -46,6 +56,21 @@ export const METRIC_META: Record<string, MetricMeta> = {
46
56
  arc_agi: { label: "ARC-AGI v2", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
47
57
  terminal_bench: { label: "Terminal-Bench", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
48
58
  tau_bench: { label: "τ-bench (retail)", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
59
+ simpleqa_score: { label: "SimpleQA", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
60
+ hle_score: { label: "Humanity's Last Exam", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
61
+ mmmu_score: { label: "MMMU", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
62
+ mmmu_pro_score: { label: "MMMU-Pro", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
63
+ mmmlu_score: { label: "MMMLU", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
64
+ browsecomp_score: { label: "BrowseComp", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
65
+ swe_bench_pro_score: { label: "SWE-bench Pro", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
66
+ mcp_atlas_score: { label: "MCP Atlas", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
67
+ apex_agents_score: { label: "APEX Agents", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
68
+ osworld_score: { label: "OSWorld", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
69
+ scicode_score: { label: "SciCode", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
70
+ screenspot_pro_score: { label: "ScreenSpot-Pro", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
71
+ charxiv_r_score: { label: "CharXiv-R", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
72
+ frontiermath_score: { label: "FrontierMath", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
73
+ toolathlon_score: { label: "Toolathlon", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
49
74
  website: { label: "Design Arena (website)", kind: "percentile", unit: "percentile", formula: "identity (already 0-1)", anchors: "percentile within the design-covered field; models without Design Arena data get the capability fill 0.195" },
50
75
  writing: { label: "WritingBench", kind: "percentile", unit: "score 0-1", formula: "identity (already 0-1)", anchors: "WritingBench normalized score (writing leaderboard); models outside the ranking get the capability fill 0.195" },
51
76
  price: { label: "Price", kind: "price", unit: "$/M", formula: "billed blend 3:1 in:out, ×(3ρ+1+T)/(3ρ+1) at the role's thinking level", anchors: "OpenRouter standard route" },
@@ -79,6 +104,28 @@ export function metricMetaFor(metrics: readonly string[], declared: readonly Sou
79
104
  return out;
80
105
  }
81
106
 
107
+ /** A role's focus metrics: the weighted benchmarks (kind `benchmark`/`percentile`),
108
+ * i.e. the metrics a role was ranked on beyond the index/price/throughput
109
+ * backbone. Shipped roles weight index metrics, so they carry none. */
110
+ export function focusMetricsOf(def: RoleDef, declared: readonly SourceDeclaration[] = []): string[] {
111
+ return Object.keys(def.weights).filter((metric) => {
112
+ const kind = metricMeta(metric, declared).kind;
113
+ return kind === "benchmark" || kind === "percentile";
114
+ });
115
+ }
116
+
117
+ /** Nine-axis assessment of each of a role's focus metrics, over the loaded pool
118
+ * (the same dataset the updater ranks on), with the cached source inputs. */
119
+ export function focusAssessments(
120
+ models: readonly Model[],
121
+ def: RoleDef,
122
+ declared: readonly SourceDeclaration[] = [],
123
+ ): Record<string, FocusMetricAssessment> {
124
+ const out: Record<string, FocusMetricAssessment> = {};
125
+ for (const metric of focusMetricsOf(def, declared)) out[metric] = assessFocusMetric(models, metric, declared, cachedSourceInfo(metric, declared));
126
+ return out;
127
+ }
128
+
82
129
  // ---------------------------------------------------------------------------
83
130
  // Rank rows (table data with baseline deltas)
84
131
  // ---------------------------------------------------------------------------
@@ -99,6 +146,11 @@ export type RankRow = {
99
146
  parts: Record<string, number>;
100
147
  /** weighted non-price metrics whose raw value is null (they contribute 0) */
101
148
  missing: string[];
149
+ /** keyed-catalog availability overlay: whether the user's OpenRouter key can
150
+ * run this model. Orthogonal to `missing` (weighted metrics with no value) and
151
+ * to the eligibility gates `rankRole` applies; `"unknown"` when the keyed
152
+ * catalog is inactive or the model is in neither catalog. */
153
+ key: "usable" | "blocked" | "unknown";
102
154
  frontier: boolean;
103
155
  rank: number;
104
156
  baselineRank: number | null;
@@ -106,9 +158,22 @@ export type RankRow = {
106
158
  delta: number | null;
107
159
  };
108
160
 
161
+ /** The availability overlay verdict for one model id: `"usable"` when the keyed
162
+ * catalog allows it, `"blocked"` when the public catalog has it but the keyed
163
+ * one does not, `"unknown"` when the keyed catalog is inactive or the model is
164
+ * in neither catalog. Pure; no I/O. */
165
+ function keyVerdict(availability: KeyAvailability | undefined, id: string): "usable" | "blocked" | "unknown" {
166
+ if (availability?.active !== true) return "unknown";
167
+ if (availability.allowed.has(id)) return "usable";
168
+ if (availability.blocked.has(id)) return "blocked";
169
+ return "unknown";
170
+ }
171
+
109
172
  /** Rank `models` under `def`, annotating each row with its rank delta against
110
- * `baseline` (the effective role's ranking, i.e. what the plugin does today). */
111
- export function rankRows(def: RoleDef, models: Model[], baseline: Ranked[]): RankRow[] {
173
+ * `baseline` (the effective role's ranking, i.e. what the plugin does today) and
174
+ * its keyed-catalog availability overlay. Ranking order, deltas, and every other
175
+ * field are unaffected by `availability`. */
176
+ export function rankRows(def: RoleDef, models: Model[], baseline: Ranked[], availability?: KeyAvailability): RankRow[] {
112
177
  const ranked = rankRole(def, models);
113
178
  const frontier = paretoFrontier(ranked);
114
179
  const baselineRank = new Map<string, number>();
@@ -123,13 +188,14 @@ export function rankRows(def: RoleDef, models: Model[], baseline: Ranked[]): Ran
123
188
  org: r.model.org,
124
189
  price: r.model.price,
125
190
  priceEff: r.priceEff,
126
- throughput: r.model.throughput,
191
+ throughput: roleMetricValue(r.model, "throughput", roleRoute(r.model, def)),
127
192
  context: r.model.context,
128
193
  multimodal: r.model.multimodal,
129
194
  value: r.value,
130
195
  q: r.q,
131
196
  parts: r.parts,
132
197
  missing: weightedNonPrice.filter((k) => r.model.metrics[k] == null),
198
+ key: keyVerdict(availability, r.model.id),
133
199
  frontier: frontier.has(r.model.id),
134
200
  rank,
135
201
  baselineRank: b,
@@ -151,6 +217,8 @@ export function inverseCardinal(metric: string, t: number): number | null {
151
217
  if (t < 0 || t > 1) return null; // forward clamps to [0,1]; outside is unreachable
152
218
  return 10 * 30 ** t;
153
219
  }
220
+ const chance = BENCHMARK_CHANCE[metric];
221
+ if (chance !== undefined) return t * (1 - chance) + chance;
154
222
  return t; // benchmark (mrcr), percentile (website) and price: identity
155
223
  }
156
224
 
@@ -186,12 +254,25 @@ export type Explanation =
186
254
  | {
187
255
  eligible: true;
188
256
  model: { id: string; name: string; org: string };
257
+ /** keyed-catalog availability overlay (see `RankRow.key`) */
258
+ key: "usable" | "blocked" | "unknown";
259
+ /** why the overlay reads as it does: the injected availability reason, or
260
+ * `"unavailable"` when no availability value was supplied */
261
+ keyReason: KeyAvailability["reason"];
189
262
  rank: number;
190
263
  total: number;
191
264
  value: number;
192
265
  q: number;
193
266
  priceEff: number;
194
- role: { name: string; lambda: number; derivedLambda: number; wPrice: number; qW: number; thinking: SuffixLevel | undefined };
267
+ /** true when the model was priced on the soft best route (the
268
+ * `preferOwnProvider` preference, no hard pin) rather than the 1/price²
269
+ * blend — the panel shows WHY the price is what it is. */
270
+ ownProvider: boolean;
271
+ /** the automatic pin: the model's best route's provider slug when the role
272
+ * prefers a bound route and carries no manual pin, else null. Read-only in
273
+ * the UI; a manual `providerPin` overrides it. */
274
+ autoPin: string | null;
275
+ role: { name: string; lambda: number; derivedLambda: number; wPrice: number; qW: number; thinking: SuffixLevel | undefined; cacheHitRate: number };
195
276
  contributions: Contribution[];
196
277
  cost: { priceEff: number; billedPrice: number; lambda: number; penalty: number; q: number; value: number };
197
278
  gapAbove: number | null;
@@ -202,16 +283,46 @@ export type Explanation =
202
283
  };
203
284
 
204
285
  /** Full decomposition of one model's rank for a role, or the eligibility gates it
205
- * failed (same gates `rankRole` applies, listed in the same order). */
206
- export function explainModel(def: RoleDef, models: Model[], modelId: string, roleName = ""): Explanation {
286
+ * failed (same gates `rankRole` applies, listed in the same order). The eligible
287
+ * branch also carries the keyed-catalog availability overlay (`key`/`keyReason`). */
288
+ export function explainModel(def: RoleDef, models: Model[], modelId: string, roleName = "", availability?: KeyAvailability): Explanation {
207
289
  const model = models.find((m) => m.id === modelId);
208
290
  if (!model) return { eligible: false, reasons: ["model not found in today's dataset"] };
209
291
 
210
292
  const reasons: string[] = [];
211
- const missingRequired = def.required.filter((k) => model.metrics[k] == null);
293
+ // A pinned role prices and gates on the model's matching route (mirrors rankRole).
294
+ const pinRoute = pinnedRoute(model, def.providerPin);
295
+ // A role with endpoint filters is priced on the blend over the routes that survive
296
+ // them, with its cache-hit rate blended into each route's billed price: `em` is the
297
+ // model with that effective price/throughput, or null when no route survives.
298
+ const cacheHitRate = def.cacheHitRate ?? 0;
299
+ const em = endpointFilteredModel(model, def.filters, cacheHitRate);
300
+ const eff = em ?? model;
301
+ // Soft best-route preference (mirrors rankRole): with no hard pin, a role that
302
+ // prefers a bound route is priced on the model's best route by the role's own
303
+ // value, else on the blend. Never a gate — a model with no candidate route keeps
304
+ // the blend.
305
+ const route = roleRoute(model, def);
306
+ // The model is priced on the soft best route (not a hard pin, not the blend).
307
+ const ownProvider = pinRoute === null && route !== null;
308
+ // The automatic pin the updater would write: the model's best route's slug,
309
+ // only when the role prefers a bound route and carries no manual pin. Reuses the
310
+ // already-computed `route` (the same best route) instead of recomputing it.
311
+ const autoPin = autoPinApplies(def) ? route?.providerSlug ?? null : null;
312
+ const missingRequired = def.required.filter((k) => roleMetricValue(eff, k, pinRoute, cacheHitRate) == null);
212
313
  if (missingRequired.length > 0) reasons.push(`missing required metric(s): ${missingRequired.join(", ")}`);
213
314
  if (def.filters?.image && !model.multimodal) reasons.push("role requires image input; model is text-only");
214
- if (model.price == null) reasons.push("no billed OpenRouter route");
315
+ if (def.providerPin !== undefined && pinRoute === null) reasons.push("no route matches the role's provider pin");
316
+ if (em === null) reasons.push("no standard-tier route satisfies the role's endpoint filters");
317
+ if (eff.price == null) reasons.push("no billed OpenRouter route");
318
+ // maxPriceUsdPerM caps the model's own price basis, never the soft best-route
319
+ // basis (mirrors rankRole): with no hard pin the gate uses the blend price, so
320
+ // the soft basis cannot change eligibility; a hard pin gates its route price.
321
+ const cap = def.filters?.maxPriceUsdPerM ?? 0;
322
+ if (cap > 0) {
323
+ const capBase = pinRoute !== null ? routeEffectivePrice(pinRoute, cacheHitRate) : eff.price;
324
+ if (capBase != null && thinkingAdjustedPrice(eff, def, capBase) > cap) reasons.push(`price exceeds the role's maxPriceUsdPerM cap (${cap})`);
325
+ }
215
326
  if (reasons.length > 0) return { eligible: false, reasons };
216
327
 
217
328
  const ranked = rankRole(def, models);
@@ -228,7 +339,7 @@ export function explainModel(def: RoleDef, models: Model[], modelId: string, rol
228
339
  const contributions: Contribution[] = [];
229
340
  for (const [metric, w] of Object.entries(def.weights)) {
230
341
  if (metric === "price") continue; // cost enters as the λ·$ penalty, not the blend
231
- const stored = model.metrics[metric] ?? null;
342
+ const stored = roleMetricValue(eff, metric, route, cacheHitRate) ?? null;
232
343
  // Mirror rankRole: a sparse capability metric with no data scores at
233
344
  // CAPABILITY_FILL *as a cardinal value* (no transform), not 0 — otherwise
234
345
  // the parts would not sum to q.
@@ -301,14 +412,18 @@ export function explainModel(def: RoleDef, models: Model[], modelId: string, rol
301
412
  return {
302
413
  eligible: true,
303
414
  model: { id: model.id, name: model.name, org: model.org },
415
+ key: keyVerdict(availability, model.id),
416
+ keyReason: availability?.reason ?? "unavailable",
304
417
  rank,
305
418
  total,
306
419
  value: self.value,
307
420
  q: self.q,
308
421
  priceEff: self.priceEff,
309
- role: { name: roleName, lambda, derivedLambda, wPrice, qW, thinking: def.thinking },
422
+ ownProvider,
423
+ autoPin,
424
+ role: { name: roleName, lambda, derivedLambda, wPrice, qW, thinking: def.thinking, cacheHitRate },
310
425
  contributions,
311
- cost: { priceEff: self.priceEff, billedPrice: model.price, lambda, penalty: lambda * self.priceEff, q: self.q, value: self.value },
426
+ cost: { priceEff: self.priceEff, billedPrice: route !== null ? routeEffectivePrice(route, cacheHitRate) ?? eff.price : eff.price, lambda, penalty: lambda * self.priceEff, q: self.q, value: self.value },
312
427
  gapAbove,
313
428
  gapToTop,
314
429
  above,
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Explorer scopes: a scope is a role-config source — the user-level lock file,
3
+ * or one project where `/project-roles` was used. Resolution lives here (not in
4
+ * the HTTP layer) so the boot path and the dataset union cannot drift, and the
5
+ * per-scope read mirrors the updater's exactly (project lock merged over the
6
+ * user-level lock), so the explorer and the plugin agree.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import type { RoleDef } from "../engine.ts";
11
+ import { readProjectRegistry } from "../project-registry.ts";
12
+ import { PLUGIN_SETTINGS_PATH, projectLockPath, readPluginSettingsMap, resolveSettings, roleUniverse, type UniverseEntry } from "../settings.ts";
13
+
14
+ export type ScopeKind = "user" | "project";
15
+
16
+ export type Scope = {
17
+ /** Stable across requests: `user`, or `project:<root>`. */
18
+ id: string;
19
+ /** Human label: `user-level`, or the project root path. */
20
+ label: string;
21
+ kind: ScopeKind;
22
+ /** The lock file this scope reads and Export writes. */
23
+ lockPath: string;
24
+ /** Whether the lock file exists (the user scope is always available). */
25
+ present: boolean;
26
+ /** Project root (project scopes only). */
27
+ root?: string;
28
+ };
29
+
30
+ export type ScopeInputs = {
31
+ /** The user-level lock file (the user scope's read/export target). */
32
+ userLockPath?: string;
33
+ /** Session cwd; its project joins the list when it has a project role config. */
34
+ cwd: string;
35
+ /** Agent dir holding the project registry (default: `agentDir()`). */
36
+ registryDir?: string;
37
+ };
38
+
39
+ function projectScope(root: string): Scope {
40
+ const lockPath = projectLockPath(root);
41
+ return { id: `project:${root}`, label: root, kind: "project", lockPath, present: existsSync(lockPath), root };
42
+ }
43
+
44
+ /**
45
+ * Every known scope: the user-level scope, the registry's projects (most
46
+ * recently used first), and the session's project when it has a project role
47
+ * config — included immediately, before the registry has recorded it.
48
+ *
49
+ * The session-project test is the updater's own `resolveProjectDir` test (a
50
+ * project lock with a non-empty `omp-llm-role` settings entry), so the
51
+ * explorer's "session project" and the updater's project mode agree.
52
+ */
53
+ export function resolveScopes(inputs: ScopeInputs): Scope[] {
54
+ const userLockPath = inputs.userLockPath ?? PLUGIN_SETTINGS_PATH;
55
+ const byId = new Map<string, Scope>();
56
+ // The user-level scope is always available: Export creates the lock file.
57
+ byId.set("user", { id: "user", label: "user-level", kind: "user", lockPath: userLockPath, present: true });
58
+ const entries = [...readProjectRegistry(inputs.registryDir)].sort((a, b) => (a.lastUsed < b.lastUsed ? 1 : a.lastUsed > b.lastUsed ? -1 : 0));
59
+ for (const entry of entries) {
60
+ const scope = projectScope(entry.root);
61
+ if (!byId.has(scope.id)) byId.set(scope.id, scope);
62
+ }
63
+ if (Object.keys(readPluginSettingsMap({ global: projectLockPath(inputs.cwd), project: null })).length > 0) {
64
+ const scope = projectScope(inputs.cwd);
65
+ if (!byId.has(scope.id)) byId.set(scope.id, scope);
66
+ }
67
+ return [...byId.values()];
68
+ }
69
+
70
+ /** The default active scope: the session's project when present, else user-level. */
71
+ export function defaultScopeId(scopes: Scope[], cwd: string): string {
72
+ const project = scopes.find((s) => s.kind === "project" && s.root === cwd && s.present);
73
+ if (project) return project.id;
74
+ return scopes.find((s) => s.kind === "user")?.id ?? scopes[0]?.id ?? "user";
75
+ }
76
+
77
+ /** The scope a request should use: the requested id when known and present,
78
+ * else the default. */
79
+ export function resolveScope(scopes: Scope[], requestedId: string | null, cwd: string): Scope {
80
+ const found = requestedId === null ? undefined : scopes.find((s) => s.id === requestedId && s.present);
81
+ if (found) return found;
82
+ const fallback = defaultScopeId(scopes, cwd);
83
+ return scopes.find((s) => s.id === fallback) ?? scopes[0];
84
+ }
85
+
86
+ /** Read one scope's roles and universe. A project scope merges the project lock
87
+ * over the user-level lock — the exact read the updater performs.
88
+ *
89
+ * `roles` is the EFFECTIVE def (the authored def with the enabled capability
90
+ * flags' recommended knobs filled in); `universe[role].def` is the AUTHORED def
91
+ * (flags + explicit keys only) — the def the explorer edits and exports.
92
+ * `features` is the scope's GLOBAL capability flags (`features.<id>`), which the
93
+ * explorer's rank/explain handlers expand a posted def with so the preview
94
+ * matches the resolved baseline. */
95
+ export function readScopeRoles(scope: Scope, userLockPath: string): { roles: Record<string, RoleDef>; universe: Record<string, UniverseEntry>; features: Record<string, boolean>; errors: string[] } {
96
+ const raw = scope.kind === "user"
97
+ ? readPluginSettingsMap({ global: scope.lockPath, project: null })
98
+ : readPluginSettingsMap({ global: userLockPath, project: scope.lockPath });
99
+ const { settings, errors } = resolveSettings(raw);
100
+ return { roles: settings.roles, universe: roleUniverse(raw, settings.roles), features: settings.features, errors };
101
+ }
102
+
103
+ /** The union of every known scope's resolved roles — the roles map the ranking
104
+ * loader must receive so a metric only one scope weights (e.g. a project-only
105
+ * declared `bench:<id>`) is fetched, keeping a scope switch free. */
106
+ export function unionRoles(scopes: Scope[], userLockPath: string): Record<string, RoleDef> {
107
+ const out: Record<string, RoleDef> = {};
108
+ for (const scope of scopes) Object.assign(out, readScopeRoles(scope, userLockPath).roles);
109
+ return out;
110
+ }
@@ -4,10 +4,17 @@
4
4
  *
5
5
  * GET / -> web/index.html
6
6
  * GET /app.js /style.css -> static assets (traversal-guarded)
7
- * GET /api/bootstrap -> roles, defaults, metric universe, dataset counts
8
- * POST /api/rank -> { role, def } -> rows with baseline deltas
7
+ * GET /api/bootstrap -> the scope list + the active scope's roles,
8
+ * defaults, metric universe, dataset counts, the
9
+ * key-availability summary, the scope's global
10
+ * capability flags, and the feature registry
11
+ * POST /api/scope -> { scope } -> switch the active scope, return the
12
+ * new bootstrap payload
13
+ * POST /api/rank -> { role, def } -> rows with baseline deltas and a
14
+ * per-row `key` badge (usable/blocked/unknown)
9
15
  * POST /api/explain -> { role, def, modelId } -> full decomposition
10
- * POST /api/export -> { roles } -> validated, backed-up lock-file write
16
+ * POST /api/export -> { roles } -> validated, backed-up write of the
17
+ * active scope's lock file
11
18
  * POST /api/refresh -> refetch the dataset, return the bootstrap payload
12
19
  *
13
20
  * Bound to loopback by the caller; no auth. Every handler is
@@ -17,32 +24,50 @@
17
24
  import { readFileSync } from "node:fs";
18
25
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
19
26
  import { extname, resolve, sep } from "node:path";
27
+ import type { KeyAvailability } from "../availability.ts";
20
28
  import { loadDeclaredSources } from "../benchmark-sources.ts";
21
- import { SUFFIX_LEVELS, rankRole, roleLambda, thinkingPriceFactor, type RankData, type RoleDef, type SuffixLevel } from "../engine.ts";
29
+ import { pinnedRoute, SUFFIX_LEVELS, rankRole, roleLambda, thinkingPriceFactor, type RankData, type RoleDef, type SuffixLevel } from "../engine.ts";
30
+ import { expandFeatures, FEATURES } from "../features.ts";
22
31
  import { isRecord } from "../guards.ts";
23
32
  import { validateRole, writeRoleSettings } from "../role-settings.ts";
24
33
  import { KNOWN_METRICS, type UniverseEntry } from "../settings.ts";
25
- import { explainModel, metricMetaFor, rankRows, weightableMetrics } from "./explain.ts";
34
+ import { explainModel, focusAssessments, metricMetaFor, rankRows, weightableMetrics } from "./explain.ts";
35
+ import type { Scope } from "./scopes.ts";
26
36
 
27
37
  export type ExplorerOpts = {
28
38
  webDir: string;
29
- lockPath: string;
39
+ /** All known scopes, re-resolved per call so a project registered by another
40
+ * session appears without a restart. */
41
+ listScopes(): Scope[];
42
+ /** The scope a request should use: the requested id when known and present,
43
+ * else the default (session project when present, else user-level). */
44
+ resolveScope(requestedId: string | null): Scope;
30
45
  /**
31
- * Fresh state per call: `rank` (swapped by `refresh`) plus `roles`/`universe`
32
- * re-read from the lock file, so a page reload after Export reflects the write
33
- * instead of a boot-time snapshot.
46
+ * Fresh state for one scope: `rank` (swapped by `refresh`) plus
47
+ * `roles`/`universe` re-read from that scope's lock file, so a page reload
48
+ * after Export reflects the write instead of a boot-time snapshot.
49
+ * `availability` is the key-usable/key-blocked overlay derived from the
50
+ * OpenRouter keyed catalog (re-derived on refresh).
34
51
  */
35
- getState(): {
52
+ getState(scope: Scope): {
36
53
  rank: RankData;
37
54
  roles: Record<string, RoleDef>;
38
55
  /** Every known role (disabled included) with kind/enabled/locked + effective def. */
39
56
  universe: Record<string, UniverseEntry>;
40
57
  defaults: Record<string, RoleDef>;
58
+ availability: KeyAvailability;
59
+ /** The scope's GLOBAL capability flags (`features.<id>`); a posted def is
60
+ * expanded with these before ranking so the preview matches the resolved
61
+ * baseline. Defaults to `{}` when a host omits it. */
62
+ features: Record<string, boolean>;
41
63
  };
42
64
  /** Re-runs loadRankData({ refresh: true }) and swaps the snapshot. */
43
65
  refresh(): Promise<void>;
44
66
  };
45
67
 
68
+ /** Per-server mutable state: the active scope id (null = the default scope). */
69
+ type ExplorerSession = { activeScopeId: string | null };
70
+
46
71
  const MIME: Record<string, string> = {
47
72
  ".html": "text/html; charset=utf-8",
48
73
  ".js": "text/javascript; charset=utf-8",
@@ -124,8 +149,16 @@ function thinkingFactors(): Record<string, number> {
124
149
  return out;
125
150
  }
126
151
 
127
- function bootstrapPayload(opts: ExplorerOpts): object {
128
- const state = opts.getState();
152
+ /** The scope a request should use, persisting the fallback so a scope that
153
+ * became unavailable does not keep re-resolving to the default. */
154
+ function activeScope(opts: ExplorerOpts, session: ExplorerSession): Scope {
155
+ const scope = opts.resolveScope(session.activeScopeId);
156
+ session.activeScopeId = scope.id;
157
+ return scope;
158
+ }
159
+
160
+ function bootstrapPayload(opts: ExplorerOpts, scope: Scope): object {
161
+ const state = opts.getState(scope);
129
162
  // The metric universe the UI may weight: the shipped keys plus any external
130
163
  // metric a resolved role weights, with a derived metadata entry for each.
131
164
  const external = new Set<string>();
@@ -133,64 +166,119 @@ function bootstrapPayload(opts: ExplorerOpts): object {
133
166
  for (const metric of Object.keys(def.weights)) if (!(metric in KNOWN_METRICS)) external.add(metric);
134
167
  }
135
168
  const metrics = weightableMetrics([...external]);
169
+ const declared = loadDeclaredSources();
170
+ // Five-axis assessment of each role's focus metrics, over the same resolved
171
+ // dataset the updater ranks on (so the explorer and the updater agree).
172
+ const focus = Object.fromEntries(
173
+ Object.entries(state.roles).map(([role, def]) => [role, focusAssessments(state.rank.models, def, declared)]),
174
+ );
136
175
  return {
137
176
  roles: state.roles,
138
177
  defaults: state.defaults,
139
178
  universe: state.universe,
179
+ // The scope's global capability flags + the registry, so the SPA renders the
180
+ // Features panel and the recommended bundles without duplicating them.
181
+ features: state.features ?? {},
182
+ featureRegistry: FEATURES.map((f) => ({ id: f.id, label: f.label, description: f.description, buys: f.buys, recommended: f.recommended })),
140
183
  metrics,
141
- metricMeta: metricMetaFor(metrics, loadDeclaredSources()),
184
+ metricMeta: metricMetaFor(metrics, declared),
185
+ focusAssessments: focus,
142
186
  levels: Object.keys(SUFFIX_LEVELS),
143
187
  thinkingFactors: thinkingFactors(),
144
188
  fetchedAt: state.rank.fetchedAt,
145
189
  modelCount: state.rank.models.length,
146
190
  orMatched: state.rank.orMatched,
147
191
  orPriced: state.rank.orPriced,
148
- lockPath: opts.lockPath,
192
+ availability: {
193
+ active: state.availability.active,
194
+ reason: state.availability.reason,
195
+ publicCount: state.availability.publicCount,
196
+ keyedCount: state.availability.keyedCount,
197
+ blockedCount: state.availability.blocked.size,
198
+ fetchedAt: state.availability.fetchedAt,
199
+ },
200
+ lockPath: scope.lockPath,
201
+ scopes: opts.listScopes().map((s) => ({ id: s.id, label: s.label, kind: s.kind, present: s.present })),
202
+ activeScope: scope.id,
149
203
  };
150
204
  }
151
205
 
152
- async function handleRank(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts): Promise<void> {
206
+ async function handleScope(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts, session: ExplorerSession): Promise<void> {
207
+ const body = await readJson(req);
208
+ if (!isRecord(body) || typeof body.scope !== "string") throw new HttpError(400, "expected { scope: string }");
209
+ const scope = opts.listScopes().find((s) => s.id === body.scope);
210
+ if (scope === undefined) throw new HttpError(404, `unknown scope: ${body.scope}`);
211
+ if (!scope.present) throw new HttpError(409, `scope unavailable: ${body.scope}`);
212
+ session.activeScopeId = scope.id;
213
+ sendJson(res, 200, bootstrapPayload(opts, scope));
214
+ }
215
+
216
+ async function handleRank(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts, session: ExplorerSession): Promise<void> {
153
217
  const body = await readJson(req);
154
218
  if (!isRecord(body) || typeof body.role !== "string" || !isRecord(body.def)) {
155
219
  throw new HttpError(400, "expected { role: string, def: object }");
156
220
  }
157
221
  const role = body.role;
158
- const def = body.def as unknown as RoleDef;
159
- const state = opts.getState();
222
+ const authored = body.def as unknown as RoleDef;
223
+ const state = opts.getState(activeScope(opts, session));
224
+ // The posted def is AUTHORED (flags + explicit keys); expand it with the
225
+ // scope's global flags so the preview matches the resolved baseline
226
+ // (`state.roles[role]`) — otherwise every row's Δ would be a lie.
227
+ const def = expandFeatures(authored, state.features ?? {});
160
228
  // Enabled roles baseline against their resolved def; a disabled or lock-file-only
161
229
  // role against its effective def (shipped defaults merged with its overrides) so
162
230
  // selecting it still shows deltas, not an empty baseline.
163
231
  const baselineDef = state.roles[role] ?? state.universe[role]?.def ?? state.defaults[role];
164
232
  const baseline = baselineDef ? rankRole(baselineDef, state.rank.models) : [];
165
- const rows = rankRows(def, state.rank.models, baseline);
233
+ const rows = rankRows(def, state.rank.models, baseline, state.availability);
166
234
  sendJson(res, 200, {
167
235
  eligible: rows.length,
168
236
  lambda: roleLambda(def),
169
237
  derivedLambda: roleLambda({ ...def, lambda: undefined }),
170
238
  rows,
171
- errors: validateRole(role, def),
239
+ // Validate the AUTHORED def: an unknown feature id must surface here, and
240
+ // `expandFeatures` strips `features` (validating the expanded def would hide it).
241
+ errors: validateRole(role, authored),
172
242
  });
173
243
  }
174
244
 
175
- async function handleExplain(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts): Promise<void> {
245
+ async function handleExplain(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts, session: ExplorerSession): Promise<void> {
176
246
  const body = await readJson(req);
177
247
  if (!isRecord(body) || typeof body.role !== "string" || !isRecord(body.def) || typeof body.modelId !== "string") {
178
248
  throw new HttpError(400, "expected { role: string, def: object, modelId: string }");
179
249
  }
180
- const def = body.def as unknown as RoleDef;
181
- const explanation = explainModel(def, opts.getState().rank.models, body.modelId, body.role);
182
- sendJson(res, 200, { ...explanation, errors: validateRole(body.role, def) });
250
+ const authored = body.def as unknown as RoleDef;
251
+ const state = opts.getState(activeScope(opts, session));
252
+ // Same expansion as handleRank: the posted def is authored, the baseline is
253
+ // resolved, so the preview must apply the scope's global flags first.
254
+ const def = expandFeatures(authored, state.features ?? {});
255
+ const explanation = explainModel(def, state.rank.models, body.modelId, body.role, state.availability);
256
+ sendJson(res, 200, { ...explanation, errors: validateRole(body.role, authored) });
183
257
  }
184
258
 
185
- async function handleExport(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts): Promise<void> {
259
+ async function handleExport(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts, session: ExplorerSession): Promise<void> {
186
260
  const body = await readJson(req);
187
261
  if (!isRecord(body) || !isRecord(body.roles)) throw new HttpError(400, "expected { roles: object }");
188
- const result = writeRoleSettings(opts.lockPath, body.roles as unknown as Record<string, RoleDef>);
262
+ const scope = activeScope(opts, session);
263
+ const dirty = body.roles as unknown as Record<string, RoleDef>;
264
+ // A manual provider pin must name a route the loaded dataset actually has, or
265
+ // the exported role would be unroutable. Validate before any write; the
266
+ // plugin's own validator (empty/@/:) still runs, so both error sets surface.
267
+ const state = opts.getState(scope);
268
+ const errors: string[] = [];
269
+ for (const [name, def] of Object.entries(dirty)) {
270
+ errors.push(...validateRole(name, def));
271
+ if (def.providerPin === undefined) continue;
272
+ const known = state.rank.models.some((m) => pinnedRoute(m, def.providerPin) !== null);
273
+ if (!known) errors.push(`role "${name}": provider pin "${def.providerPin}" matches no route in the loaded dataset`);
274
+ }
275
+ if (errors.length > 0) return sendJson(res, 200, { ok: false, errors });
276
+ const result = writeRoleSettings(scope.lockPath, dirty);
189
277
  if (!result.ok) return sendJson(res, 200, { ok: false, errors: result.errors });
190
- sendJson(res, 200, { ok: true, backupPath: result.backupPath, roles: result.roles });
278
+ sendJson(res, 200, { ok: true, backupPath: result.backupPath, roles: result.roles, lockPath: scope.lockPath });
191
279
  }
192
280
 
193
- async function handle(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts): Promise<void> {
281
+ async function handle(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts, session: ExplorerSession): Promise<void> {
194
282
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
195
283
  const path = url.pathname;
196
284
  const method = req.method ?? "GET";
@@ -198,24 +286,28 @@ async function handle(req: IncomingMessage, res: ServerResponse, opts: ExplorerO
198
286
  if (path.startsWith("/api/")) {
199
287
  if (path === "/api/bootstrap") {
200
288
  if (method !== "GET") return sendText(res, 405, "method not allowed");
201
- return sendJson(res, 200, bootstrapPayload(opts));
289
+ return sendJson(res, 200, bootstrapPayload(opts, activeScope(opts, session)));
290
+ }
291
+ if (path === "/api/scope") {
292
+ if (method !== "POST") return sendText(res, 405, "method not allowed");
293
+ return handleScope(req, res, opts, session);
202
294
  }
203
295
  if (path === "/api/rank") {
204
296
  if (method !== "POST") return sendText(res, 405, "method not allowed");
205
- return handleRank(req, res, opts);
297
+ return handleRank(req, res, opts, session);
206
298
  }
207
299
  if (path === "/api/explain") {
208
300
  if (method !== "POST") return sendText(res, 405, "method not allowed");
209
- return handleExplain(req, res, opts);
301
+ return handleExplain(req, res, opts, session);
210
302
  }
211
303
  if (path === "/api/export") {
212
304
  if (method !== "POST") return sendText(res, 405, "method not allowed");
213
- return handleExport(req, res, opts);
305
+ return handleExport(req, res, opts, session);
214
306
  }
215
307
  if (path === "/api/refresh") {
216
308
  if (method !== "POST") return sendText(res, 405, "method not allowed");
217
309
  await opts.refresh();
218
- return sendJson(res, 200, bootstrapPayload(opts));
310
+ return sendJson(res, 200, bootstrapPayload(opts, activeScope(opts, session)));
219
311
  }
220
312
  return sendText(res, 404, "not found");
221
313
  }
@@ -225,8 +317,9 @@ async function handle(req: IncomingMessage, res: ServerResponse, opts: ExplorerO
225
317
  }
226
318
 
227
319
  export function createExplorerServer(opts: ExplorerOpts): Server {
320
+ const session: ExplorerSession = { activeScopeId: null };
228
321
  return createServer((req, res) => {
229
- handle(req, res, opts).catch((err: unknown) => {
322
+ handle(req, res, opts, session).catch((err: unknown) => {
230
323
  if (res.headersSent) {
231
324
  res.end();
232
325
  return;