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
package/web/app.js CHANGED
@@ -12,6 +12,7 @@ const state = {
12
12
  defaults: {},
13
13
  metrics: [],
14
14
  metricMeta: {},
15
+ focusAssessments: {},
15
16
  levels: [],
16
17
  thinkingFactors: {},
17
18
  rows: [],
@@ -20,8 +21,14 @@ const state = {
20
21
  derivedLambda: 0,
21
22
  errors: [],
22
23
  lockPath: "",
24
+ scopes: [],
25
+ activeScope: "",
26
+ features: {},
27
+ featureRegistry: [],
23
28
  filter: "",
24
29
  topn: "25",
30
+ hideBlocked: false,
31
+ availability: null,
25
32
  explain: null,
26
33
  exportMessage: "",
27
34
  };
@@ -109,7 +116,11 @@ const TIPS = {
109
116
  models: "Models in the cached llm-stats leaderboard snapshot.",
110
117
  fetched: "UTC day of the cached snapshot; a cache older than today is refetched on boot.",
111
118
  orJoin: "OpenRouter join: matched = models paired by slug suffix (source of throughput and price); priced = matched models that carry an OpenRouter price.",
112
- lock: "Plugin settings lock file that Export writes to.",
119
+ availability: "Whether your OpenRouter key can run each model, from the keyed catalog. Enable OpenRouter → Settings → \"Filter the model catalog for API keys\" so the keyed catalog is a per-model allowlist; otherwise every mark reads unknown.",
120
+ key: "Key usability: usable = your OpenRouter key can run this model (in the keyed catalog); blocked = the public catalog has it but your key cannot; unknown = the keyed catalog is inactive or the model is in neither catalog.",
121
+ hideBlocked: "Drop rows your OpenRouter key cannot run (key = blocked). Applied after the text filter and the top-n slice.",
122
+ lock: "Plugin settings lock file that Export writes to (the active scope's lock file).",
123
+ scope: "Role-config source: the user-level scope, or a project where /project-roles was used. Switching re-reads that scope's roles and universe from disk and points Export at its lock file. A project whose lock file is gone is unavailable.",
113
124
  refresh: "Refetch llm-stats and OpenRouter data (hits the network).",
114
125
  rank: "Rank among this role's eligible models, by value, descending.",
115
126
  delta: "Rank change against the role's saved (effective) definition: + means this definition ranks the model higher than the plugin does today.",
@@ -132,7 +143,22 @@ const TIPS = {
132
143
  lambda: "λ = price of one quality point, in $/M. Derived as " + LAMBDA_DERIVATION + " unless the role overrides lambda.",
133
144
  sum: "Weights must sum to 1.0 (±0.01) or the plugin rejects the role.",
134
145
  required: "Eligibility gate, not a weight: a model missing this metric is not ranked at all for the role.",
146
+ focus: "Nine-axis audit of each weighted benchmark/percentile metric, over the same pool the updater ranks on. A below-bar axis is warned; unknown means the metric's scores were not loaded (never a silent ok).",
147
+ focusCoverage: "Coverage: models carrying the metric (not the imputed fill) out of the pool, and the share. Below the coverage floor — or a capability-filled metric — is warned.",
148
+ focusDispersion: "Dispersion: IQR/median of the cardinal-normalized covered values. Too little spread means the metric barely separates models.",
149
+ focusComposition: "Composition: pool orgs above the minimum share that carry no covered model. A missing major provider is warned.",
150
+ focusFreshness: "Freshness: months between the newest covered model and the newest pool model. A stale benchmark is warned.",
151
+ focusTrust: "Trust: the self-reported share of the payload's covered entries (the per-entry self_reported flag). A mostly vendor-submitted source is warned; unknown means the payload carried no entry flags or the scores were not loaded.",
152
+ focusModality: "Modality: the catalog row's modality (text/image/audio/video/multimodal) and the payload's multimodal share. Informational only — never a gate; unknown means neither the catalog row nor the payload carried a modality signal.",
153
+ focusMaintenance: "Maintenance: months since the catalog row's updated_at (the dataset's own age), against a 12-month bar, with version_count/star_count as supporting detail. This is the dataset-date axis, distinct from freshness (the model-date axis). An annotation, never a gate — a stale dataset is warned but never drops a candidate; unknown means the catalog row is absent or its updated_at is missing/unparseable.",
154
+ focusProvenance: "Provenance: the benchmark's owner (the catalog row's dataset_org_id, falling back to dataset_slug) and the source's own per-entry org mix (the dominant lab and its share). A cross-check on the pool-derived composition axis: agree/disagree records whether the two concur. An annotation, never a gate — a vendor-populated source is warned but never drops a candidate; unknown means there is neither an owner nor a payload org mix.",
155
+ focusCrossSource: "Cross-source: the zeroeval per-entry price/context compared against the plugin's own price/context for the models carrying the metric (the median relative divergence, within a 0.5 tolerance). The speed figure is informational only (rps vs tok/s). An annotation, never a gate — it never feeds the 1/price² blend; unknown means no model joined or the payload carried none of the four fields.",
135
156
  imageFilter: "Require image input (filters.image) — the gate that shrinks the vision role's eligible set.",
157
+ providerPin: "Hard provider pin: the OpenRouter provider slug this role's requests are routed to (a tiered slug like deepinfra/fp8 is accepted verbatim). Setting it opts the role out of the automatic best-route pin; empty = default OpenRouter routing.",
158
+ features: "Opt-in capability presets (issue #40). Each flag applies the plugin's recommended settings for that capability to this role; an explicit knob value always wins. Export writes the flag, not the expanded values, so a future change to the recommendation keeps reaching the role.",
159
+ featureState: "inherit = use the global flag (features.<id> in the lock file); on/off = a per-role override (roles.<role>.features.<id>) that beats the global in either direction.",
160
+ featureKnob: "An individual knob the capability's recommended bundle fills. Leave it at inherit to take the recommendation; set a value to override it (an explicit value always wins over the flag).",
161
+ featureDerived: "derived = this value comes from the enabled capability's recommended bundle, not from an explicit key in the role. Set the knob to make it authored.",
136
162
  thinking: "Thinking level appended to the role's selector (`:level`) and used to scale the price axis. The factor (3ρ+1+T)/(3ρ+1) applies only to models that will actually run the level (omp catalog `thinking[]` membership; meta levels off/auto need only a non-empty list). Bare = no suffix — the session's defaultThinkingLevel applies and the price is unadjusted.",
137
163
  thinkingBare: "Bare (no suffix). Not restorable once the role's shipped default or the lock file sets a level: the plugin deep-merges roles over DEFAULT_ROLES, so an omitted key keeps the inherited value.",
138
164
  normalize: "Rescale every weight so the sum is 1.0 (the parked ε is rescaled too).",
@@ -211,18 +237,52 @@ function initTips() {
211
237
  // Header + role tabs
212
238
  // ---------------------------------------------------------------------------
213
239
 
240
+ /** Header availability summary. When the keyed catalog is active it reports the
241
+ * allowlist size; otherwise it names the OpenRouter setting that turns it on and
242
+ * every row's mark reads unknown. */
243
+ function availabilitySpan() {
244
+ const a = state.availability;
245
+ if (a && a.active) {
246
+ return el("span", { "data-tip": TIPS.availability, text: "key: " + a.keyedCount + " usable / " + a.blockedCount + " blocked" });
247
+ }
248
+ return el("span", { class: "muted", "data-tip": TIPS.availability, text: "availability unknown — enable OpenRouter → Settings → \"Filter the model catalog for API keys\"" });
249
+ }
250
+
251
+ /** The scope selector: the user-level scope plus every known project. A scope
252
+ * whose lock file is gone is disabled with a reason tooltip. */
253
+ function scopeSelect() {
254
+ const sel = el("select", { id: "scope", "data-tip": TIPS.scope });
255
+ for (const s of state.scopes) {
256
+ sel.append(
257
+ el("option", {
258
+ value: s.id,
259
+ text: s.label,
260
+ disabled: s.present ? null : "",
261
+ title: s.present ? null : "unavailable — lock file missing",
262
+ }),
263
+ );
264
+ }
265
+ sel.value = state.activeScope;
266
+ sel.addEventListener("change", onScopeChange);
267
+ return sel;
268
+ }
269
+
214
270
  function renderMeta(data) {
215
271
  const meta = document.getElementById("meta");
216
272
  meta.textContent = "";
217
273
  meta.append(
218
274
  el("span", { class: "brand", text: "omp-llm-role explorer" }),
219
275
  el("span", { class: "sep", text: "·" }),
276
+ scopeSelect(),
277
+ el("span", { class: "sep", text: "·" }),
220
278
  el("span", { "data-tip": TIPS.models, text: data.modelCount + " models" }),
221
279
  el("span", { class: "sep", text: "·" }),
222
280
  el("span", { "data-tip": TIPS.fetched, text: "fetched " + data.fetchedAt.slice(0, 10) }),
223
281
  el("span", { class: "sep", text: "·" }),
224
282
  el("span", { "data-tip": TIPS.orJoin, text: "OpenRouter " + data.orMatched + " matched / " + data.orPriced + " priced" }),
225
283
  el("span", { class: "sep", text: "·" }),
284
+ availabilitySpan(),
285
+ el("span", { class: "sep", text: "·" }),
226
286
  el("span", { class: "lock", "data-tip": TIPS.lock, text: "lock: " + data.lockPath }),
227
287
  el("button", { id: "refresh", "data-tip": TIPS.refresh, text: "Refresh data", onclick: onRefresh }),
228
288
  );
@@ -337,6 +397,21 @@ async function recompute() {
337
397
  }
338
398
  }
339
399
 
400
+ /** Tooltip text for one key verdict. The server owns the verdict; the client
401
+ * only explains it, so the three states cannot drift from the ranking. */
402
+ function keyTip(key) {
403
+ if (key === "usable") return "usable — your OpenRouter key can run this model (in the keyed catalog)";
404
+ if (key === "blocked") return "key-blocked — the public catalog has this model but your key cannot run it (OpenRouter → Settings → \"Filter the model catalog for API keys\")";
405
+ return "unknown — the keyed catalog is inactive or this model is in neither catalog (OpenRouter → Settings → \"Filter the model catalog for API keys\")";
406
+ }
407
+
408
+ /** Three-state key badge. Renders exactly the verdict the server sent; the
409
+ * client never invents an availability rule. */
410
+ function keyBadge(key) {
411
+ const state_ = key === "usable" || key === "blocked" ? key : "unknown";
412
+ return el("span", { class: "badge " + state_, "data-tip": keyTip(state_), text: state_ });
413
+ }
414
+
340
415
  function renderTable() {
341
416
  const table = document.getElementById("table");
342
417
  table.textContent = "";
@@ -347,6 +422,7 @@ function renderTable() {
347
422
  el("th", { "data-tip": TIPS.frontier, text: "★" }),
348
423
  el("th", { text: "model" }),
349
424
  el("th", { text: "org" }),
425
+ el("th", { "data-tip": TIPS.key, text: "key" }),
350
426
  el("th", { class: "num", "data-tip": TIPS.value, text: "value" }),
351
427
  el("th", { class: "num", "data-tip": TIPS.q, text: "q" }),
352
428
  el("th", { class: "num", "data-tip": TIPS.price, text: "$/M" }),
@@ -359,6 +435,9 @@ function renderTable() {
359
435
  let rows = state.rows;
360
436
  if (filter) rows = rows.filter((r) => (r.name + " " + r.org + " " + r.id).toLowerCase().includes(filter));
361
437
  if (state.topn !== "all") rows = rows.slice(0, Number(state.topn));
438
+ // Order: text filter → top-n slice → hide-blocked drop. The drop runs last so
439
+ // the top-n count is the ranking's own prefix, not a post-filter count.
440
+ if (state.hideBlocked) rows = rows.filter((r) => r.key !== "blocked");
362
441
  for (const r of rows) {
363
442
  tbody.append(
364
443
  el("tr", { class: "row" + (r.id === state.selectedId ? " selected" : ""), onclick: () => selectModel(r.id) }, [
@@ -367,6 +446,7 @@ function renderTable() {
367
446
  el("td", { class: "star", text: r.frontier ? "★" : "" }),
368
447
  el("td", { class: "model", text: r.name }),
369
448
  el("td", { class: "org", text: r.org }),
449
+ el("td", { class: "key" }, keyBadge(r.key)),
370
450
  el("td", { class: "num", text: fmt(r.value, 3) }),
371
451
  el("td", { class: "num", text: fmt(r.q, 3) }),
372
452
  el("td", { class: "num", text: fmt(r.priceEff, 2) }),
@@ -422,6 +502,21 @@ function renderExplain() {
422
502
  }),
423
503
  );
424
504
 
505
+ // Key verdict line: the server's overlay verdict, plus the reason and the
506
+ // OpenRouter setting name when the keyed catalog is not active.
507
+ let keyLine = "key: " + keyTip(ex.key);
508
+ if (ex.keyReason !== "active") keyLine += " (reason: " + ex.keyReason + ")";
509
+ panel.append(el("p", { class: "sub key-line", "data-tip": TIPS.key, text: keyLine }));
510
+
511
+ // The pin basis: a manual pin (from the edited def) overrides the automatic
512
+ // best-route pin the updater would write. Read-only here.
513
+ const manualPin = state.defs[state.role]?.providerPin;
514
+ if (manualPin) {
515
+ panel.append(el("p", { class: "sub pin-line", "data-tip": TIPS.providerPin, text: "pin: " + manualPin + " (manual)" }));
516
+ } else if (ex.autoPin) {
517
+ panel.append(el("p", { class: "sub pin-line", "data-tip": TIPS.providerPin, text: "auto pin: " + ex.autoPin }));
518
+ }
519
+
425
520
  panel.append(el("h3", { "data-tip": TIPS.composition, text: "Value composition" }));
426
521
  const comp = el("table", { class: "comp" });
427
522
  comp.append(
@@ -463,7 +558,9 @@ function renderExplain() {
463
558
  const derived = ex.role.lambda === ex.role.derivedLambda;
464
559
  panel.append(el("p", { class: "cost", "data-tip": TIPS.lambda, text: "λ = " + ex.role.lambda.toFixed(5) + " $/quality-point" + (derived ? " (derived = " + LAMBDA_DERIVATION + ")" : " (override)") }));
465
560
  const thinkNote = ex.role.thinking === undefined ? "bare" : ":" + ex.role.thinking;
466
- panel.append(el("p", { class: "cost", "data-tip": TIPS.costPenalty, text: "eff price $" + fmt(ex.cost.priceEff, 2) + "/M" + (ex.cost.priceEff !== ex.cost.billedPrice ? " (billed $" + fmt(ex.cost.billedPrice, 2) + " × " + thinkNote + ")" : " (" + thinkNote + ")") + " · penalty = λ·price = " + fmt(ex.cost.penalty, 4) + " · value = q − penalty = " + fmt(ex.cost.value, 4) }));
561
+ const cacheNote = ex.role.cacheHitRate > 0 ? " · cache " + ex.role.cacheHitRate : "";
562
+ const ownNote = ex.ownProvider ? " · best route" : "";
563
+ panel.append(el("p", { class: "cost", "data-tip": TIPS.costPenalty, text: "eff price $" + fmt(ex.cost.priceEff, 2) + "/M" + (ex.cost.priceEff !== ex.cost.billedPrice ? " (billed $" + fmt(ex.cost.billedPrice, 2) + " × " + thinkNote + ")" : " (" + thinkNote + ")") + cacheNote + ownNote + " · penalty = λ·price = " + fmt(ex.cost.penalty, 4) + " · value = q − penalty = " + fmt(ex.cost.value, 4) }));
467
564
 
468
565
  panel.append(el("h3", { "data-tip": TIPS.whyNotHigher, text: "Why not higher" }));
469
566
  if (ex.gapAbove === null) {
@@ -531,6 +628,269 @@ function normalizeWeights(def) {
531
628
  for (const key of Object.keys(def.weights)) def.weights[key] = def.weights[key] / sum;
532
629
  }
533
630
 
631
+ /** The role's focus metrics: the weighted benchmark/percentile metrics, the same
632
+ * rule the server's focusMetricsOf applies (shipped roles weight index metrics,
633
+ * so they carry none). */
634
+ function focusMetricsOf(def) {
635
+ return Object.keys(def.weights).filter((metric) => {
636
+ const meta = state.metricMeta[metric];
637
+ return !!meta && (meta.kind === "benchmark" || meta.kind === "percentile");
638
+ });
639
+ }
640
+
641
+ /** One focus-signal cell: the value text plus a status class. `unknown` renders
642
+ * as unknown (muted), never as ok; `below-bar` gets the warning tint and glyph. */
643
+ function focusSignal(text, status) {
644
+ const cls = status === "ok" ? "ok" : status === "below-bar" ? "below-bar" : "unknown";
645
+ return el("td", { class: "sig " + cls, text });
646
+ }
647
+
648
+ /** The nine-axis focus assessment of the role's weighted benchmark/percentile
649
+ * metrics, from the bootstrap payload's focusAssessments[role]. The payload is
650
+ * computed over the resolved definition, so a metric the editor just added reads
651
+ * "not assessed" until it is Exported and the page reloaded. */
652
+ function renderFocus(panel) {
653
+ const def = state.defs[state.role];
654
+ const assessments = state.focusAssessments[state.role] || {};
655
+ const metrics = focusMetricsOf(def);
656
+ panel.append(el("h3", { "data-tip": TIPS.focus, text: "Focus metrics" }));
657
+ if (metrics.length === 0) {
658
+ panel.append(el("p", { class: "muted", text: "No benchmark/percentile metric weighted — the role ranks on the index/price/throughput backbone." }));
659
+ return;
660
+ }
661
+ const table = el("table", { class: "focus" });
662
+ table.append(
663
+ el("thead", {}, el("tr", {}, [
664
+ el("th", { text: "metric" }),
665
+ el("th", { "data-tip": TIPS.focusCoverage, text: "coverage" }),
666
+ el("th", { "data-tip": TIPS.focusDispersion, text: "dispersion" }),
667
+ el("th", { "data-tip": TIPS.focusComposition, text: "composition" }),
668
+ el("th", { "data-tip": TIPS.focusFreshness, text: "freshness" }),
669
+ el("th", { "data-tip": TIPS.focusTrust, text: "trust" }),
670
+ el("th", { "data-tip": TIPS.focusModality, text: "modality" }),
671
+ el("th", { "data-tip": TIPS.focusMaintenance, text: "maintenance" }),
672
+ el("th", { "data-tip": TIPS.focusProvenance, text: "provenance" }),
673
+ el("th", { "data-tip": TIPS.focusCrossSource, text: "cross-source" }),
674
+ ])),
675
+ );
676
+ const body = el("tbody");
677
+ for (const metric of metrics) {
678
+ const a = assessments[metric];
679
+ if (!a) {
680
+ body.append(
681
+ el("tr", {}, [
682
+ el("td", { "data-tip": metricTip(metric), text: metricLabel(metric) }),
683
+ el("td", { class: "sig unknown", colspan: "9", text: "not assessed — Export, then reload to assess the edited definition" }),
684
+ ]),
685
+ );
686
+ continue;
687
+ }
688
+ const c = a.coverage;
689
+ const coverage = c.status === "unknown" ? "unknown" : c.covered + "/" + c.total + " (" + (c.share === null ? "?" : (c.share * 100).toFixed(1) + "%") + ")";
690
+ const dispersion = a.dispersion.status === "unknown" ? "unknown" : a.dispersion.value.toFixed(3);
691
+ const composition = a.composition.status === "unknown" ? "unknown" : a.composition.status === "ok" ? "ok" : "omits " + a.composition.omittedOrgs.join(", ");
692
+ const freshness = a.freshness.status === "unknown" ? "unknown" : a.freshness.monthsBehind.toFixed(1) + "mo";
693
+ const community = a.trust.community ?? false;
694
+ const trust =
695
+ a.trust.status === "unknown"
696
+ ? community ? "community" : "unknown"
697
+ : a.trust.selfReported + "/" + a.trust.covered + " " + a.trust.status + (community ? " community" : "");
698
+ const modality =
699
+ a.modality.status === "unknown"
700
+ ? "unknown"
701
+ : (a.modality.value ?? "?") + (a.modality.multimodalShare === null ? "" : " " + (a.modality.multimodalShare * 100).toFixed(0) + "% multimodal");
702
+ const maintenance = a.maintenance.status === "unknown" ? "unknown" : a.maintenance.monthsOld.toFixed(1) + "mo";
703
+ const provenance =
704
+ a.provenance.status === "unknown"
705
+ ? "unknown"
706
+ : (a.provenance.owner ?? "?") +
707
+ (a.provenance.dominantShare === null ? "" : " " + (a.provenance.dominantShare * 100).toFixed(0) + "% " + (a.provenance.dominantOrg ?? "?")) +
708
+ " " +
709
+ a.provenance.compositionAgreement;
710
+ const crossSource =
711
+ a.crossSource.status === "unknown"
712
+ ? "unknown"
713
+ : a.crossSource.compared +
714
+ " priceΔ" +
715
+ (a.crossSource.priceDivergence === null ? "?" : (a.crossSource.priceDivergence * 100).toFixed(0) + "%") +
716
+ " ctxΔ" +
717
+ (a.crossSource.contextDivergence === null ? "?" : (a.crossSource.contextDivergence * 100).toFixed(0) + "%");
718
+ const statuses = [c.status, a.dispersion.status, a.composition.status, a.freshness.status, a.trust.status, a.modality.status, a.maintenance.status, a.provenance.status, a.crossSource.status];
719
+ body.append(
720
+ el("tr", { class: statuses.includes("below-bar") ? "warned" : "" }, [
721
+ el("td", { "data-tip": metricTip(metric), text: metricLabel(metric) }),
722
+ focusSignal(coverage, c.status),
723
+ focusSignal(dispersion, a.dispersion.status),
724
+ focusSignal(composition, a.composition.status),
725
+ focusSignal(freshness, a.freshness.status),
726
+ focusSignal(trust, a.trust.status),
727
+ focusSignal(modality, a.modality.status),
728
+ focusSignal(maintenance, a.maintenance.status),
729
+ focusSignal(provenance, a.provenance.status),
730
+ focusSignal(crossSource, a.crossSource.status),
731
+ ]),
732
+ );
733
+ }
734
+ table.append(body);
735
+ panel.append(table);
736
+ }
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // Capability flags (issue #40)
740
+ // ---------------------------------------------------------------------------
741
+
742
+ /** The knobs a capability's recommended bundle fills, derived from the registry
743
+ * so the panel cannot drift from `src/features.ts`. */
744
+ function bundleKnobs(recommended) {
745
+ const out = [];
746
+ if (recommended.filters) {
747
+ for (const [key, value] of Object.entries(recommended.filters)) {
748
+ out.push({ key: "filters." + key, kind: typeof value === "boolean" ? "boolean" : "number", value });
749
+ }
750
+ }
751
+ if (recommended.cacheHitRate !== undefined) out.push({ key: "cacheHitRate", kind: "number", value: recommended.cacheHitRate });
752
+ if (recommended.preferOwnProvider !== undefined) out.push({ key: "preferOwnProvider", kind: "boolean", value: recommended.preferOwnProvider });
753
+ return out;
754
+ }
755
+
756
+ /** Render a recommended bundle as `key=value` pairs (mirrors formatBundle). */
757
+ function bundleText(recommended) {
758
+ return bundleKnobs(recommended).map((k) => k.key + "=" + k.value).join(", ");
759
+ }
760
+
761
+ /** Read one knob from the AUTHORED def by its dotted key. */
762
+ function getKnob(def, key) {
763
+ if (key === "cacheHitRate") return def.cacheHitRate;
764
+ if (key === "preferOwnProvider") return def.preferOwnProvider;
765
+ if (key.startsWith("filters.")) return def.filters ? def.filters[key.slice("filters.".length)] : undefined;
766
+ return undefined;
767
+ }
768
+
769
+ /** Write (or clear, with `undefined`) one knob on the AUTHORED def. */
770
+ function setKnob(def, key, value) {
771
+ if (key === "cacheHitRate" || key === "preferOwnProvider") {
772
+ if (value === undefined) delete def[key];
773
+ else def[key] = value;
774
+ return;
775
+ }
776
+ if (key.startsWith("filters.")) {
777
+ const leaf = key.slice("filters.".length);
778
+ if (value === undefined) {
779
+ if (def.filters) {
780
+ delete def.filters[leaf];
781
+ if (Object.keys(def.filters).length === 0) delete def.filters;
782
+ }
783
+ } else {
784
+ def.filters = def.filters || {};
785
+ def.filters[leaf] = value;
786
+ }
787
+ }
788
+ }
789
+
790
+ /** The effective state of one capability for a role: the authored per-role flag
791
+ * when present (it wins either way), else the scope's global flag. */
792
+ function effectiveFlag(def, id) {
793
+ const authored = def.features ? def.features[id] : undefined;
794
+ if (authored !== undefined) return authored;
795
+ return state.features[id] === true;
796
+ }
797
+
798
+ /** The effective value of one knob and whether it is DERIVED (filled by an
799
+ * enabled capability) rather than authored (an explicit key on the def). */
800
+ function effectiveKnob(def, featureId, knob) {
801
+ const authored = getKnob(def, knob.key);
802
+ if (authored !== undefined) return { value: authored, derived: false };
803
+ if (effectiveFlag(def, featureId) && knob.value !== undefined) return { value: knob.value, derived: true };
804
+ return { value: undefined, derived: false };
805
+ }
806
+
807
+ /** One knob field: an editable input (number, or inherit/true/false select) plus
808
+ * a badge marking the value authored or derived from the capability. */
809
+ function knobField(def, featureId, knob) {
810
+ const authored = getKnob(def, knob.key);
811
+ const eff = effectiveKnob(def, featureId, knob);
812
+ const cell = el("span", { class: "knob" + (eff.derived ? " derived" : "") });
813
+ let input;
814
+ if (knob.kind === "boolean") {
815
+ input = el("select", { class: "knob-input", "data-tip": TIPS.featureKnob });
816
+ input.append(el("option", { value: "inherit", text: "inherit" }));
817
+ input.append(el("option", { value: "true", text: "true" }));
818
+ input.append(el("option", { value: "false", text: "false" }));
819
+ input.value = authored === undefined ? "inherit" : String(authored);
820
+ input.addEventListener("change", () => {
821
+ setKnob(def, knob.key, input.value === "inherit" ? undefined : input.value === "true");
822
+ renderEditor();
823
+ renderRoles();
824
+ scheduleRecompute();
825
+ });
826
+ } else {
827
+ input = el("input", { type: "number", class: "knob-input", step: "any", "data-tip": TIPS.featureKnob, placeholder: eff.value === undefined ? "—" : String(eff.value) });
828
+ if (authored !== undefined) input.value = String(authored);
829
+ input.addEventListener("input", () => {
830
+ setKnob(def, knob.key, input.value === "" ? undefined : Number(input.value));
831
+ scheduleRecompute();
832
+ });
833
+ input.addEventListener("change", () => {
834
+ renderEditor();
835
+ renderRoles();
836
+ });
837
+ }
838
+ cell.append(input);
839
+ cell.append(el("span", { class: "knob-name", text: knob.key }));
840
+ const badge = eff.derived ? "derived " + eff.value : authored !== undefined ? "authored" : "—";
841
+ cell.append(el("span", { class: "knob-badge " + (eff.derived ? "derived" : authored !== undefined ? "authored" : "none"), "data-tip": TIPS.featureDerived, text: badge }));
842
+ return cell;
843
+ }
844
+
845
+ /** The per-role Features panel: one tri-state control per capability (inherit /
846
+ * on / off) writing `def.features[id]`, the recommended bundle, and the
847
+ * individual knobs the bundle fills, each marked derived or authored. */
848
+ function renderFeatures(panel) {
849
+ panel.append(el("h3", { "data-tip": TIPS.features, text: "Capabilities" }));
850
+ if (state.featureRegistry.length === 0) {
851
+ panel.append(el("p", { class: "muted", text: "No capability registry in the payload." }));
852
+ return;
853
+ }
854
+ const def = state.defs[state.role];
855
+ const wrap = el("div", { class: "features" });
856
+ for (const feature of state.featureRegistry) {
857
+ const authored = def.features ? def.features[feature.id] : undefined;
858
+ const on = effectiveFlag(def, feature.id);
859
+ const row = el("div", { class: "feature" + (on ? " on" : "") });
860
+ const sel = el("select", { class: "feature-state", "data-tip": TIPS.featureState });
861
+ sel.append(el("option", { value: "inherit", text: "inherit" }));
862
+ sel.append(el("option", { value: "on", text: "on" }));
863
+ sel.append(el("option", { value: "off", text: "off" }));
864
+ sel.value = authored === undefined ? "inherit" : authored ? "on" : "off";
865
+ sel.addEventListener("change", () => {
866
+ def.features = def.features || {};
867
+ if (sel.value === "inherit") {
868
+ delete def.features[feature.id];
869
+ if (Object.keys(def.features).length === 0) delete def.features;
870
+ } else {
871
+ def.features[feature.id] = sel.value === "on";
872
+ }
873
+ renderEditor();
874
+ renderRoles();
875
+ scheduleRecompute();
876
+ });
877
+ const origin = authored === undefined ? (state.features[feature.id] === true ? "global on" : "global off") : "role override";
878
+ row.append(
879
+ el("div", { class: "feature-head" }, [
880
+ sel,
881
+ el("span", { class: "feature-label", "data-tip": feature.description + "\n\n" + feature.buys, text: feature.label }),
882
+ el("span", { class: "feature-origin muted", text: origin }),
883
+ ]),
884
+ );
885
+ row.append(el("div", { class: "feature-bundle muted", text: "recommended: " + bundleText(feature.recommended) }));
886
+ const knobs = el("div", { class: "feature-knobs" });
887
+ for (const knob of bundleKnobs(feature.recommended)) knobs.append(knobField(def, feature.id, knob));
888
+ row.append(knobs);
889
+ wrap.append(row);
890
+ }
891
+ panel.append(wrap);
892
+ }
893
+
534
894
  function renderEditor() {
535
895
  const panel = document.getElementById("editor");
536
896
  panel.textContent = "";
@@ -650,6 +1010,8 @@ function renderEditor() {
650
1010
  }
651
1011
  panel.append(checks);
652
1012
 
1013
+ renderFocus(panel);
1014
+
653
1015
  const imgCb = el("input", { type: "checkbox" });
654
1016
  imgCb.checked = !!(def.filters && def.filters.image);
655
1017
  imgCb.addEventListener("change", () => {
@@ -659,6 +1021,21 @@ function renderEditor() {
659
1021
  });
660
1022
  panel.append(el("label", { class: "check" }, [imgCb, el("span", { "data-tip": TIPS.imageFilter, text: "requires image input (filters.image)" })]));
661
1023
 
1024
+ panel.append(el("h3", { "data-tip": TIPS.providerPin, text: "Provider pin" }));
1025
+ const pin = el("input", { type: "text", class: "desc", "data-tip": TIPS.providerPin, value: def.providerPin || "", placeholder: "provider slug, e.g. deepinfra/fp8" });
1026
+ pin.addEventListener("change", () => {
1027
+ // Empty clears the key (default routing); a tiered slug is kept verbatim.
1028
+ const value = pin.value.trim();
1029
+ if (value === "") delete def.providerPin;
1030
+ else def.providerPin = value;
1031
+ renderEditor();
1032
+ renderRoles();
1033
+ scheduleRecompute();
1034
+ });
1035
+ panel.append(el("div", { class: "row-controls" }, [pin]));
1036
+
1037
+ renderFeatures(panel);
1038
+
662
1039
  panel.append(el("h3", { "data-tip": TIPS.thinking, text: "Thinking level" }));
663
1040
  const bareLocked = state.effective[state.role]?.thinking !== undefined;
664
1041
  const levelSel = el("select", { id: "thinking", "data-tip": TIPS.thinking });
@@ -746,7 +1123,7 @@ async function onExport() {
746
1123
  try {
747
1124
  const res = await api("/api/export", { roles: dirty });
748
1125
  if (res.ok) {
749
- state.exportMessage = "wrote " + res.roles.length + " role(s)" + (res.backupPath ? " — backup: " + res.backupPath : "");
1126
+ state.exportMessage = "wrote " + res.roles.length + " role(s) to " + (res.lockPath || state.lockPath) + (res.backupPath ? " — backup: " + res.backupPath : "");
750
1127
  for (const role of res.roles) state.effective[role] = structuredClone(state.defs[role]);
751
1128
  renderRoles();
752
1129
  renderEditor();
@@ -785,20 +1162,40 @@ function onDownload() {
785
1162
  async function onRefresh() {
786
1163
  if (!confirm("Refetch the leaderboard and OpenRouter data? This hits the network.")) return;
787
1164
  try {
788
- const data = await api("/api/refresh", {});
789
- renderMeta(data);
790
- await recompute();
1165
+ applyBootstrap(await api("/api/refresh", {}));
791
1166
  } catch (err) {
792
1167
  alert("Refresh failed: " + err.message);
793
1168
  }
794
1169
  }
795
1170
 
1171
+ /** Switch the active scope. Unsaved edits are confirmed first (mirroring
1172
+ * Refresh); the returned payload rebuilds every per-scope panel. */
1173
+ async function onScopeChange(event) {
1174
+ const id = event.target.value;
1175
+ if (id === state.activeScope) return;
1176
+ if (Object.keys(dirtyRoles()).length > 0 && !confirm("Discard unsaved role edits and switch scope?")) {
1177
+ event.target.value = state.activeScope;
1178
+ return;
1179
+ }
1180
+ try {
1181
+ applyBootstrap(await api("/api/scope", { scope: id }));
1182
+ } catch (err) {
1183
+ alert("Scope switch failed: " + err.message);
1184
+ event.target.value = state.activeScope;
1185
+ }
1186
+ }
1187
+
796
1188
  // ---------------------------------------------------------------------------
797
1189
  // Boot
798
1190
  // ---------------------------------------------------------------------------
799
1191
 
800
- async function boot() {
801
- const data = await api("/api/bootstrap");
1192
+ /** Rebuild every panel from a bootstrap payload (boot, scope switch, refresh).
1193
+ * The per-scope editing state is rebuilt from the payload, so dirty tracking is
1194
+ * per scope; the previously selected role is kept when the new scope has it. */
1195
+ function applyBootstrap(data) {
1196
+ const previousRole = state.role;
1197
+ state.scopes = data.scopes || [];
1198
+ state.activeScope = data.activeScope;
802
1199
  state.universe = data.universe;
803
1200
  state.effective = {};
804
1201
  for (const [name, entry] of Object.entries(state.universe)) state.effective[name] = entry.def;
@@ -806,12 +1203,34 @@ async function boot() {
806
1203
  state.defaults = data.defaults;
807
1204
  state.metrics = data.metrics;
808
1205
  state.metricMeta = data.metricMeta;
1206
+ state.focusAssessments = data.focusAssessments || {};
809
1207
  state.levels = data.levels;
810
1208
  state.thinkingFactors = data.thinkingFactors;
811
1209
  state.lockPath = data.lockPath;
812
- initTips();
1210
+ state.availability = data.availability;
1211
+ state.features = data.features || {};
1212
+ state.featureRegistry = data.featureRegistry || [];
1213
+ state.rows = [];
1214
+ state.selectedId = null;
1215
+ state.explain = null;
1216
+ state.errors = [];
1217
+ state.exportMessage = "";
813
1218
  renderMeta(data);
814
1219
  renderRoles();
1220
+ renderErrors();
1221
+ renderTable();
1222
+ const role = previousRole && state.defs[previousRole] ? previousRole : Object.keys(state.defs)[0];
1223
+ if (role) selectRole(role);
1224
+ else {
1225
+ state.role = null;
1226
+ renderEditor();
1227
+ renderExplain();
1228
+ }
1229
+ }
1230
+
1231
+ async function boot() {
1232
+ initTips();
1233
+ applyBootstrap(await api("/api/bootstrap"));
815
1234
 
816
1235
  document.getElementById("filter").addEventListener("input", (e) => {
817
1236
  state.filter = e.target.value;
@@ -821,6 +1240,12 @@ async function boot() {
821
1240
  state.topn = e.target.value;
822
1241
  renderTable();
823
1242
  });
1243
+ const hideBlocked = document.getElementById("hide-blocked");
1244
+ hideBlocked.dataset.tip = TIPS.hideBlocked;
1245
+ hideBlocked.addEventListener("change", (e) => {
1246
+ state.hideBlocked = e.target.checked;
1247
+ renderTable();
1248
+ });
824
1249
  const exportBtn = document.getElementById("export");
825
1250
  exportBtn.dataset.tip = TIPS.exportBtn;
826
1251
  exportBtn.addEventListener("click", onExport);
@@ -830,9 +1255,6 @@ async function boot() {
830
1255
  const downloadBtn = document.getElementById("download");
831
1256
  downloadBtn.dataset.tip = TIPS.download;
832
1257
  downloadBtn.addEventListener("click", onDownload);
833
-
834
- const first = Object.keys(state.defs)[0];
835
- if (first) selectRole(first);
836
1258
  }
837
1259
 
838
1260
  boot().catch((err) => {
package/web/index.html CHANGED
@@ -21,6 +21,7 @@
21
21
  <option value="all">all</option>
22
22
  </select>
23
23
  </label>
24
+ <label class="check"><input id="hide-blocked" type="checkbox"> hide key-blocked</label>
24
25
  </div>
25
26
  <div id="errors"></div>
26
27
  <div id="table-wrap"><table id="table"></table></div>