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
@@ -44,6 +44,8 @@ const UA =
44
44
  const WRITING_URL = "https://llm-stats.com/research/best-ai-for-writing/evidence.json";
45
45
  /** The llm-stats backend that serves one benchmark's leaderboard as JSON. */
46
46
  const LLM_STATS_BENCHMARK_URL = "https://api.zeroeval.com/leaderboard/benchmarks";
47
+ /** The catalog cache file under `CACHE_DIR` (shared by the loader and `cachedSourceInfo`). */
48
+ const CATALOG_CACHE_FILE = "benchmark-catalog-fetched-data.json";
47
49
 
48
50
  /** How a source's ids map to the llm-stats bare id. */
49
51
  export type JoinRule = "direct" | "slug-suffix" | "normalized";
@@ -121,6 +123,16 @@ export function catalogMetric(benchmarkId: string): string {
121
123
  return SHIPPED_CATALOG_METRICS[benchmarkId] ?? externalMetricKey("bench", benchmarkId);
122
124
  }
123
125
 
126
+ /** The declaration a catalog benchmark id needs so its metric key resolves on
127
+ * the next update, or `null` when none is needed: a shipped id (its static
128
+ * source already resolves) or a dot-free id the generic metric-key fallback
129
+ * reconstructs. `normalizeMetricKey` folds separators (`.`, case, …), so a
130
+ * dotted id's raw form survives only here — the declaration must carry it. */
131
+ export function catalogBenchmarkDeclaration(benchmarkId: string): SourceDeclaration | null {
132
+ if (SHIPPED_CATALOG_METRICS[benchmarkId] !== undefined) return null;
133
+ return normalizeMetricKey(benchmarkId) !== benchmarkId ? llmStatsBenchmarkDeclaration(benchmarkId) : null;
134
+ }
135
+
124
136
  // ---------------------------------------------------------------------------
125
137
  // Parsers
126
138
  // ---------------------------------------------------------------------------
@@ -164,6 +176,104 @@ export function parseLlmStatsBenchmark(v: unknown): Record<string, number> | nul
164
176
  return out;
165
177
  }
166
178
 
179
+ /**
180
+ * The per-entry summary of an llm-stats benchmark payload, read in one pass:
181
+ * the trust flags (`self_reported`/`verified`) and the `multimodal` flag.
182
+ *
183
+ * `trust` is `null` when no parsed entry carried a boolean `self_reported` — a
184
+ * declared or writing-evidence payload has no entry flags, so the axis degrades
185
+ * to `unknown` rather than a silent `ok`. `verified` is uniformly `false` today
186
+ * (the source never sets it); it is kept for when the source starts to.
187
+ *
188
+ * `modality` is the per-entry `multimodal` summary: how many of the `entries[]`
189
+ * carry a boolean `multimodal` (the share denominator) and how many of those are
190
+ * `true`. It is `null` when no parsed entry carried the flag, so the axis
191
+ * degrades to `unknown` rather than a silent `ok`.
192
+ *
193
+ * `provenance` is the per-entry org distribution: each row's `organization_id`
194
+ * (falling back to `provider_id` when `organization_id` is not a non-empty
195
+ * string) is the lab a model's score comes from. `covered` counts the entries
196
+ * carrying a non-empty org string, `orgs` the distinct org count, and
197
+ * `dominantOrg`/`dominantShare` the most frequent org and its count / `covered`.
198
+ * It is `null` when no entry carried an org, so the axis degrades to `unknown`
199
+ * rather than a silent `ok`.
200
+ *
201
+ * `crossSource` is the per-entry **second source** of the price/throughput/
202
+ * context quantities, keyed by the entry's `model_id`: each row's
203
+ * `input_cost_per_million`/`output_cost_per_million`/`speed_rps`/`context_window`
204
+ * (each a finite number, else `null`). It is `null` when no entry carried any of
205
+ * the four. The focus-metric cross-source axis (scoring.md) compares it against
206
+ * the plugin's own `price`/`throughput`/`context`; it never feeds a weight.
207
+ */
208
+ export type BenchmarkPayloadMeta = {
209
+ trust: { selfReported: number; verified: number; covered: number } | null;
210
+ modality: { multimodal: number; covered: number } | null;
211
+ provenance: { dominantOrg: string | null; dominantShare: number; orgs: number; covered: number } | null;
212
+ crossSource: Record<string, { input: number | null; output: number | null; speed: number | null; context: number | null }> | null;
213
+ };
214
+
215
+ /**
216
+ * Pure: the per-entry summary of a raw llm-stats benchmark payload, or `null`
217
+ * when the payload has no llm-stats entries shape. Junk rows are skipped exactly
218
+ * like `parseLlmStatsBenchmark`; `verified` counts the covered rows (those
219
+ * carrying a boolean `self_reported`), so `verified <= covered` holds. The four
220
+ * summaries have independent denominators (a row may carry one flag and not the
221
+ * others).
222
+ */
223
+ export function parseBenchmarkPayloadMeta(v: unknown): BenchmarkPayloadMeta | null {
224
+ if (!isRecord(v) || !Array.isArray(v.entries)) return null;
225
+ let selfReported = 0;
226
+ let verified = 0;
227
+ let covered = 0;
228
+ let multimodal = 0;
229
+ let modalityCovered = 0;
230
+ const orgCounts = new Map<string, number>();
231
+ let orgCovered = 0;
232
+ const crossSource: Record<string, { input: number | null; output: number | null; speed: number | null; context: number | null }> = {};
233
+ let crossSourceCovered = 0;
234
+ for (const row of v.entries) {
235
+ if (!isRecord(row)) continue;
236
+ if (typeof row.self_reported === "boolean") {
237
+ covered++;
238
+ if (row.self_reported) selfReported++;
239
+ if (row.verified === true) verified++;
240
+ }
241
+ if (typeof row.multimodal === "boolean") {
242
+ modalityCovered++;
243
+ if (row.multimodal) multimodal++;
244
+ }
245
+ const org = typeof row.organization_id === "string" && row.organization_id !== "" ? row.organization_id : typeof row.provider_id === "string" && row.provider_id !== "" ? row.provider_id : null;
246
+ if (org !== null) {
247
+ orgCovered++;
248
+ orgCounts.set(org, (orgCounts.get(org) ?? 0) + 1);
249
+ }
250
+ if (typeof row.model_id === "string") {
251
+ const input = typeof row.input_cost_per_million === "number" && Number.isFinite(row.input_cost_per_million) ? row.input_cost_per_million : null;
252
+ const output = typeof row.output_cost_per_million === "number" && Number.isFinite(row.output_cost_per_million) ? row.output_cost_per_million : null;
253
+ const speed = typeof row.speed_rps === "number" && Number.isFinite(row.speed_rps) ? row.speed_rps : null;
254
+ const context = typeof row.context_window === "number" && Number.isFinite(row.context_window) ? row.context_window : null;
255
+ if (input !== null || output !== null || speed !== null || context !== null) {
256
+ crossSource[row.model_id] = { input, output, speed, context };
257
+ crossSourceCovered++;
258
+ }
259
+ }
260
+ }
261
+ let dominantOrg: string | null = null;
262
+ let dominantCount = 0;
263
+ for (const [org, count] of orgCounts) {
264
+ if (count > dominantCount) {
265
+ dominantOrg = org;
266
+ dominantCount = count;
267
+ }
268
+ }
269
+ return {
270
+ trust: covered === 0 ? null : { selfReported, verified, covered },
271
+ modality: modalityCovered === 0 ? null : { multimodal, covered: modalityCovered },
272
+ provenance: orgCovered === 0 ? null : { dominantOrg, dominantShare: dominantCount / orgCovered, orgs: orgCounts.size, covered: orgCovered },
273
+ crossSource: crossSourceCovered === 0 ? null : crossSource,
274
+ };
275
+ }
276
+
167
277
  /** One row of the llm-stats benchmark catalog (`GET /leaderboard/benchmarks`). */
168
278
  export type BenchmarkCatalogEntry = {
169
279
  id: string;
@@ -171,6 +281,39 @@ export type BenchmarkCatalogEntry = {
171
281
  description: string;
172
282
  categories: string[];
173
283
  modelCount: number;
284
+ /** The catalog row's `is_community` flag (a community-submitted benchmark);
285
+ * `false` when absent or non-boolean. The only catalog-level trust signal. */
286
+ isCommunity: boolean;
287
+ /** The catalog row's `modality` (e.g. `"text"`, `"image"`, `"audio"`,
288
+ * `"video"`, `"multimodal"`); `null` when absent or non-string. Kept as an
289
+ * open string (not a closed enum) so a future value survives. */
290
+ modality: string | null;
291
+ /** The catalog row's `updated_at` (when the dataset was last updated); `null`
292
+ * when absent or non-string. Drives the maintenance axis's dataset age
293
+ * (distinct from the model-date freshness axis). */
294
+ updatedAt: string | null;
295
+ /** The catalog row's `version_count` (how many revisions the dataset has);
296
+ * `null` when absent or non-finite. Supporting detail for the maintenance
297
+ * axis (churn). */
298
+ versionCount: number | null;
299
+ /** The catalog row's `latest_version_row_count` (the current dataset size);
300
+ * `null` when absent or non-finite. Supporting detail for the maintenance
301
+ * axis. */
302
+ latestVersionRowCount: number | null;
303
+ /** The catalog row's `star_count`; `null` when absent or non-finite.
304
+ * Supporting detail for the maintenance axis. */
305
+ starCount: number | null;
306
+ /** The catalog row's `dataset_id` (the benchmark dataset's id); `null` when
307
+ * absent or non-string. Provenance detail (the dataset the benchmark is built
308
+ * on). */
309
+ datasetId: string | null;
310
+ /** The catalog row's `dataset_org_id` (the owning organisation of the
311
+ * benchmark dataset); `null` when absent or non-string. The provenance axis's
312
+ * owner — who publishes the benchmark. */
313
+ datasetOrgId: string | null;
314
+ /** The catalog row's `dataset_slug`; `null` when absent or non-string. The
315
+ * provenance axis's owner fallback when `dataset_org_id` is absent. */
316
+ datasetSlug: string | null;
174
317
  };
175
318
 
176
319
  /** Pure: the catalog payload (a top-level array) -> entries; null when unusable. */
@@ -188,14 +331,54 @@ export function parseBenchmarkCatalog(v: unknown): BenchmarkCatalogEntry[] | nul
188
331
  description: typeof row.description === "string" ? row.description : "",
189
332
  categories: Array.isArray(row.categories) ? row.categories.filter((c): c is string => typeof c === "string") : [],
190
333
  modelCount: typeof row.model_count === "number" && Number.isFinite(row.model_count) ? row.model_count : 0,
334
+ isCommunity: row.is_community === true,
335
+ modality: typeof row.modality === "string" && row.modality !== "" ? row.modality : null,
336
+ updatedAt: typeof row.updated_at === "string" ? row.updated_at : null,
337
+ versionCount: typeof row.version_count === "number" && Number.isFinite(row.version_count) ? row.version_count : null,
338
+ latestVersionRowCount:
339
+ typeof row.latest_version_row_count === "number" && Number.isFinite(row.latest_version_row_count) ? row.latest_version_row_count : null,
340
+ starCount: typeof row.star_count === "number" && Number.isFinite(row.star_count) ? row.star_count : null,
341
+ datasetId: typeof row.dataset_id === "string" && row.dataset_id !== "" ? row.dataset_id : null,
342
+ datasetOrgId: typeof row.dataset_org_id === "string" && row.dataset_org_id !== "" ? row.dataset_org_id : null,
343
+ datasetSlug: typeof row.dataset_slug === "string" && row.dataset_slug !== "" ? row.dataset_slug : null,
191
344
  });
192
345
  }
193
346
  return out;
194
347
  }
195
348
 
196
- /** Pure: run a source's parser over a payload. */
197
- export function parseBenchmarkPayload(source: BenchmarkSource, payload: unknown): Record<string, number> | null {
198
- return source.parse(payload);
349
+ /** The llm-stats per-benchmark endpoint's hard cap on `entries`: it returns at
350
+ * most this many rows regardless of `limit`/`offset`/`page`/`per_page`, so a
351
+ * generic `bench:<id>` metric can never load more and no pagination loop can
352
+ * help. `total_models` still reports the full set, so `loaded < total` marks a
353
+ * capped load. */
354
+ export const BENCHMARK_ENTRY_CAP = 20;
355
+
356
+ /** A parsed benchmark payload: the joined scores plus the loadable entry count
357
+ * and the payload's declared total (`total_models`), so the caller can annotate
358
+ * a capped load. `meta` carries the payload's per-entry trust summary; it is
359
+ * `null` when the payload has no llm-stats entries shape (a declared or
360
+ * writing-evidence payload carries no entry flags). */
361
+ export type BenchmarkPayload = {
362
+ scores: Record<string, number>;
363
+ /** Entries actually read from the payload — the loadable count. */
364
+ loaded: number;
365
+ /** The payload's declared total model count, when it carries one. */
366
+ total: number | null;
367
+ /** The payload's per-entry trust summary; `null` when the payload has no
368
+ * llm-stats entries shape. */
369
+ meta: BenchmarkPayloadMeta | null;
370
+ };
371
+
372
+ /** Pure: run a source's parser over a payload, carrying the loadable count (the
373
+ * post-fetch coverage count) and the payload's declared total so the caller can
374
+ * annotate `loaded < total` — never the catalog's `model_count`. The trust meta
375
+ * is read independently of `source.parse`, so a declared/writing source (whose
376
+ * parser reads no entry flags) still reports `meta: null`. */
377
+ export function parseBenchmarkPayload(source: BenchmarkSource, payload: unknown): BenchmarkPayload | null {
378
+ const scores = source.parse(payload);
379
+ if (scores === null) return null;
380
+ const total = isRecord(payload) && typeof payload.total_models === "number" && Number.isFinite(payload.total_models) ? payload.total_models : null;
381
+ return { scores, loaded: Object.keys(scores).length, total, meta: parseBenchmarkPayloadMeta(payload) };
199
382
  }
200
383
 
201
384
  // ---------------------------------------------------------------------------
@@ -587,10 +770,11 @@ export function applyBenchmarkScores(models: Model[], source: BenchmarkSource, s
587
770
  // Cache chain
588
771
  // ---------------------------------------------------------------------------
589
772
 
590
- type ScoresCacheFile = { fetchedAt: string; source: string; scores: Record<string, number> };
773
+ type ScoresCacheFile = { fetchedAt: string; source: string; scores: Record<string, number>; total?: number | null; meta?: BenchmarkPayloadMeta | null };
591
774
 
592
- /** Daily cache: current when fetchedAt is the current UTC day; requireFresh=false accepts stale. */
593
- function readScoresCache(path: string, requireFresh: boolean): ScoresCacheFile | null {
775
+ /** Daily cache: current when fetchedAt is the current UTC day; requireFresh=false accepts stale.
776
+ * Exported for the shape-guard test (a cache predating the payload-meta fields must be rejected). */
777
+ export function readScoresCache(path: string, requireFresh: boolean): ScoresCacheFile | null {
594
778
  let parsed: ScoresCacheFile;
595
779
  try {
596
780
  parsed = JSON.parse(readFileSync(path, "utf8")) as ScoresCacheFile;
@@ -598,19 +782,45 @@ function readScoresCache(path: string, requireFresh: boolean): ScoresCacheFile |
598
782
  return null;
599
783
  }
600
784
  if (typeof parsed?.scores !== "object" || parsed.scores === null) return null;
785
+ // A cache written before the payload-meta fields (#34/#35/#37/#38) landed lacks
786
+ // the `meta` key; refetch rather than silently reporting `undefined` (the four
787
+ // payload-derived focus axes would go inert). `meta` is legitimately `null` for
788
+ // a declared/writing payload, so the guard keys on the key, not its value.
789
+ if (!("meta" in parsed)) return null;
601
790
  if (requireFresh && parsed.fetchedAt?.slice(0, 10) !== new Date().toISOString().slice(0, 10)) return null;
602
791
  return parsed;
603
792
  }
604
793
 
605
794
  /** Pretty-printed with sorted keys for scannable diffs, mirroring the other caches. */
606
- function writeScoresCache(path: string, fetchedAt: string, source: string, scores: Record<string, number>): void {
607
- const cache: ScoresCacheFile = { fetchedAt, source, scores };
795
+ function writeScoresCache(path: string, fetchedAt: string, source: string, scores: Record<string, number>, total: number | null, meta: BenchmarkPayloadMeta | null): void {
796
+ const cache: ScoresCacheFile = { fetchedAt, source, scores, total, meta };
608
797
  mkdirSync(dirname(path), { recursive: true });
609
798
  writeFileSync(path, JSON.stringify(cache, null, 2));
610
799
  console.error(`wrote ${path}`);
611
800
  }
612
801
 
613
- export type BenchmarkScores = { scores: Record<string, number>; label: string };
802
+ /** Annotate a capped load: the llm-stats endpoint returns at most
803
+ * `BENCHMARK_ENTRY_CAP` entries, so the loadable count (the post-fetch coverage
804
+ * count) is below the payload's `total_models`. */
805
+ function annotateCappedLoad(source: BenchmarkSource, loaded: number, total: number | null): void {
806
+ if (total !== null && loaded < total) {
807
+ console.error(
808
+ `${source.id}: loaded ${loaded}/${total} models — the endpoint caps entries at ${BENCHMARK_ENTRY_CAP}; ${source.metric} covers only the loadable ${loaded}`,
809
+ );
810
+ }
811
+ }
812
+
813
+ export type BenchmarkScores = {
814
+ scores: Record<string, number>;
815
+ label: string;
816
+ /** Entries actually loaded — the post-fetch coverage count, never the catalog count. */
817
+ loaded: number;
818
+ /** The payload's declared total model count, when known. */
819
+ total: number | null;
820
+ /** The payload's per-entry trust summary; `null` when the payload carried no
821
+ * entry flags (a declared/writing source) or the cache predates the field. */
822
+ meta: BenchmarkPayloadMeta | null;
823
+ };
614
824
 
615
825
  /** Fetch a URL as JSON (the cache chain and the authoring dry-run). Throws on a
616
826
  * non-200 or a non-JSON body. */
@@ -646,16 +856,20 @@ export async function loadBenchmarkScores(source: BenchmarkSource, refresh: bool
646
856
  const cached = readScoresCache(path, true);
647
857
  if (cached) {
648
858
  console.error(`using cache ${path} (fetched ${cached.fetchedAt})`);
649
- return { scores: cached.scores, label: source.label };
859
+ const loaded = Object.keys(cached.scores).length;
860
+ const total = cached.total ?? null;
861
+ annotateCappedLoad(source, loaded, total);
862
+ return { scores: cached.scores, label: source.label, loaded, total, meta: cached.meta ?? null };
650
863
  }
651
864
  }
652
865
  if (source.fetch) {
653
866
  try {
654
867
  const payload = await fetchJson(source.fetch.url, source.fetch.method, source.fetch.body);
655
- const scores = parseBenchmarkPayload(source, payload);
656
- if (scores && Object.keys(scores).length > 0) {
657
- writeScoresCache(path, new Date().toISOString(), source.fetch.url, scores);
658
- return { scores, label: source.labelFromPayload?.(payload) ?? source.label };
868
+ const parsed = parseBenchmarkPayload(source, payload);
869
+ if (parsed && parsed.loaded > 0) {
870
+ writeScoresCache(path, new Date().toISOString(), source.fetch.url, parsed.scores, parsed.total, parsed.meta);
871
+ annotateCappedLoad(source, parsed.loaded, parsed.total);
872
+ return { scores: parsed.scores, label: source.labelFromPayload?.(payload) ?? source.label, loaded: parsed.loaded, total: parsed.total, meta: parsed.meta };
659
873
  }
660
874
  console.error(`${source.id}: unexpected payload shape; skipping`);
661
875
  } catch (err) {
@@ -665,15 +879,20 @@ export async function loadBenchmarkScores(source: BenchmarkSource, refresh: bool
665
879
  const stale = readScoresCache(path, false);
666
880
  if (stale) {
667
881
  console.error(`using stale cache ${path} (fetched ${stale.fetchedAt})`);
668
- return { scores: stale.scores, label: source.label };
882
+ const loaded = Object.keys(stale.scores).length;
883
+ const total = stale.total ?? null;
884
+ annotateCappedLoad(source, loaded, total);
885
+ return { scores: stale.scores, label: source.label, loaded, total, meta: stale.meta ?? null };
669
886
  }
670
887
  return null;
671
888
  }
672
889
 
673
890
  type CatalogCacheFile = { fetchedAt: string; source: string; entries: BenchmarkCatalogEntry[] };
674
891
 
675
- /** Daily cache: current when fetchedAt is the current UTC day; requireFresh=false accepts stale. */
676
- function readCatalogCache(path: string, requireFresh: boolean): CatalogCacheFile | null {
892
+ /** Daily cache: current when fetchedAt is the current UTC day; requireFresh=false accepts stale.
893
+ * Exported for the shape-guard test (a cache predating `isCommunity`/`modality`, the
894
+ * maintenance fields or the provenance fields must be rejected). */
895
+ export function readCatalogCache(path: string, requireFresh: boolean): CatalogCacheFile | null {
677
896
  let parsed: CatalogCacheFile;
678
897
  try {
679
898
  parsed = JSON.parse(readFileSync(path, "utf8")) as CatalogCacheFile;
@@ -681,6 +900,27 @@ function readCatalogCache(path: string, requireFresh: boolean): CatalogCacheFile
681
900
  return null;
682
901
  }
683
902
  if (!Array.isArray(parsed?.entries)) return null;
903
+ // A cache written before `isCommunity`/`modality` (#34/#35), the maintenance
904
+ // fields `updatedAt`/`versionCount`/`latestVersionRowCount`/`starCount` (#36)
905
+ // or the provenance fields `datasetId`/`datasetOrgId`/`datasetSlug` (#37)
906
+ // landed lacks the field; refetch rather than silently reporting `undefined`
907
+ // (mirrors `readEndpointsCache`). An empty catalog is still `null` (nothing to
908
+ // sample, nothing to discover).
909
+ const sample = parsed.entries[0];
910
+ if (
911
+ typeof sample !== "object" ||
912
+ sample === null ||
913
+ !("isCommunity" in sample) ||
914
+ !("modality" in sample) ||
915
+ !("updatedAt" in sample) ||
916
+ !("versionCount" in sample) ||
917
+ !("latestVersionRowCount" in sample) ||
918
+ !("starCount" in sample) ||
919
+ !("datasetId" in sample) ||
920
+ !("datasetOrgId" in sample) ||
921
+ !("datasetSlug" in sample)
922
+ )
923
+ return null;
684
924
  if (requireFresh && parsed.fetchedAt?.slice(0, 10) !== new Date().toISOString().slice(0, 10)) return null;
685
925
  return parsed;
686
926
  }
@@ -700,7 +940,7 @@ function writeCatalogCache(path: string, fetchedAt: string, source: string, entr
700
940
  * payload is never cached, so the next run retries.
701
941
  */
702
942
  export async function loadBenchmarkCatalog(refresh: boolean, cacheDir = CACHE_DIR): Promise<BenchmarkCatalogEntry[] | null> {
703
- const path = join(cacheDir, "benchmark-catalog-fetched-data.json");
943
+ const path = join(cacheDir, CATALOG_CACHE_FILE);
704
944
  if (!refresh) {
705
945
  const cached = readCatalogCache(path, true);
706
946
  if (cached) {
@@ -727,6 +967,31 @@ export async function loadBenchmarkCatalog(refresh: boolean, cacheDir = CACHE_DI
727
967
  return null;
728
968
  }
729
969
 
970
+ /** The cached source inputs for one focus metric: the payload's per-entry
971
+ * summary (trust/modality/provenance/cross-source, from the source's scores
972
+ * cache) and the catalog row (from the catalog cache). Either half is `null`
973
+ * when its cache is absent or predates the field. */
974
+ export type FocusSourceInfo = {
975
+ payload?: BenchmarkPayloadMeta | null;
976
+ catalog?: BenchmarkCatalogEntry | null;
977
+ };
978
+
979
+ /**
980
+ * The cached source inputs for a focus metric, with no network: the source's
981
+ * scores cache (if any) for the payload meta, and the catalog cache (if any) for
982
+ * the matching row. The metric is reverse-mapped to a catalog id through
983
+ * `catalogMetric` (a shipped key via `SHIPPED_CATALOG_METRICS`, else the
984
+ * `bench:<local>` form), so a shipped metric and a generic `bench:<id>` both
985
+ * resolve. `null` for either half when the cache is absent or the metric is
986
+ * unknown to the catalog.
987
+ */
988
+ export function cachedSourceInfo(metric: string, declared: readonly SourceDeclaration[] = [], cacheDir = CACHE_DIR): FocusSourceInfo {
989
+ const source = sourceForMetric(metric, declared);
990
+ const payload = source === null ? null : readScoresCache(join(cacheDir, source.cacheFile), false)?.meta ?? null;
991
+ const catalog = readCatalogCache(join(cacheDir, CATALOG_CACHE_FILE), false);
992
+ return { payload, catalog: catalog?.entries.find((entry) => catalogMetric(entry.id) === metric) ?? null };
993
+ }
994
+
730
995
  // ---------------------------------------------------------------------------
731
996
  // Dry run (the authoring step's guardrail)
732
997
  // ---------------------------------------------------------------------------
@@ -20,6 +20,7 @@
20
20
  import { homedir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  import type { RoleDef, SuffixLevel } from "../engine.ts";
23
+ import { FEATURES, featureById, formatFeatures } from "../features.ts";
23
24
  import { writeRoleSettings } from "../role-settings.ts";
24
25
 
25
26
  type CreateRoleArgs = {
@@ -30,6 +31,8 @@ type CreateRoleArgs = {
30
31
  description: string;
31
32
  image: boolean;
32
33
  lambda: number | undefined;
34
+ features: Record<string, boolean> | undefined;
35
+ preferOwnProvider: boolean;
33
36
  lockPath: string;
34
37
  dryRun: boolean;
35
38
  json: boolean;
@@ -45,6 +48,10 @@ const USAGE = [
45
48
  " --description <text> one-line role description",
46
49
  " --image require image input (filters.image)",
47
50
  " --lambda <n> explicit λ override ($ per quality point)",
51
+ " --feature <id,...> capability flag(s) to author on the role (see --list-features);",
52
+ " repeatable, comma-separated, merged",
53
+ " --prefer-own-provider bind the role to each model's best provider route where one exists",
54
+ " --list-features print the capability flags and the settings each applies, then exit",
48
55
  " --lock <path> settings lock file (default ~/.omp/plugins/omp-plugins.lock.json)",
49
56
  " --dry-run validate and print without writing",
50
57
  " --json print the result as JSON",
@@ -84,6 +91,8 @@ function parseArgs(argv: string[]): CreateRoleArgs {
84
91
  description: "",
85
92
  image: false,
86
93
  lambda: undefined,
94
+ features: undefined,
95
+ preferOwnProvider: false,
87
96
  lockPath: join(homedir(), ".omp", "plugins", "omp-plugins.lock.json"),
88
97
  dryRun: false,
89
98
  json: false,
@@ -94,6 +103,10 @@ function parseArgs(argv: string[]): CreateRoleArgs {
94
103
  args.image = true;
95
104
  continue;
96
105
  }
106
+ if (flag === "--prefer-own-provider") {
107
+ args.preferOwnProvider = true;
108
+ continue;
109
+ }
97
110
  if (flag === "--dry-run") {
98
111
  args.dryRun = true;
99
112
  continue;
@@ -102,6 +115,10 @@ function parseArgs(argv: string[]): CreateRoleArgs {
102
115
  args.json = true;
103
116
  continue;
104
117
  }
118
+ if (flag === "--list-features") {
119
+ console.log(formatFeatures());
120
+ process.exit(0);
121
+ }
105
122
  if (flag === "--help" || flag === "-h") {
106
123
  console.log(USAGE);
107
124
  process.exit(0);
@@ -114,7 +131,16 @@ function parseArgs(argv: string[]): CreateRoleArgs {
114
131
  else if (flag === "--thinking") args.thinking = value as SuffixLevel;
115
132
  else if (flag === "--description") args.description = value;
116
133
  else if (flag === "--lambda") args.lambda = Number(value);
117
- else if (flag === "--lock") args.lockPath = value;
134
+ else if (flag === "--feature") {
135
+ const ids = parseList(value);
136
+ if (ids.length === 0) fail(`--feature: no capability id given\n\n${USAGE}`);
137
+ const features = args.features ?? {};
138
+ for (const id of ids) {
139
+ if (featureById(id) === null) fail(`--feature: unknown capability "${id}" — one of ${FEATURES.map((f) => f.id).join(", ")}`);
140
+ features[id] = true;
141
+ }
142
+ args.features = features;
143
+ } else if (flag === "--lock") args.lockPath = value;
118
144
  else fail(`unknown flag "${flag}"\n\n${USAGE}`);
119
145
  }
120
146
  if (!args.name) fail(`--name is required\n\n${USAGE}`);
@@ -131,6 +157,8 @@ function buildRoleDef(args: CreateRoleArgs): RoleDef {
131
157
  if (args.thinking !== undefined) def.thinking = args.thinking;
132
158
  if (args.image) def.filters = { image: true };
133
159
  if (args.lambda !== undefined) def.lambda = args.lambda;
160
+ if (args.features !== undefined) def.features = args.features;
161
+ if (args.preferOwnProvider) def.preferOwnProvider = true;
134
162
  return def;
135
163
  }
136
164
 
@@ -28,8 +28,11 @@
28
28
  import { execFile } from "node:child_process";
29
29
  import { writeFileSync } from "node:fs";
30
30
  import { promisify } from "node:util";
31
+ import { assessFocusMetric, type FocusMetricAssessment } from "../agent-create.ts";
31
32
  import { catalogFromOmpModelsJson, enrichThinkingLevels, resolveVariant, type CatalogEntry } from "../availability.ts";
33
+ import { cachedSourceInfo, loadDeclaredSources } from "../benchmark-sources.ts";
32
34
  import { computeRankings, loadRankData, paretoFrontier, roleLambda, thinkingPriceFactor, type Model, type Ranked, type RoleDef } from "../engine.ts";
35
+ import { focusMetricsOf } from "../explorer/explain.ts";
33
36
  import { DEFAULT_ROLES as ROLES } from "../settings.ts";
34
37
 
35
38
  const execFileP = promisify(execFile);
@@ -65,6 +68,37 @@ function metricAbbr(metric: string): string {
65
68
  return local.slice(0, 10);
66
69
  }
67
70
 
71
+ /** One focus-metric annotation line: the nine signals, same rule as the report. */
72
+ function focusLine(metric: string, a: FocusMetricAssessment): string {
73
+ const c = a.coverage;
74
+ const coverage =
75
+ c.status === "unknown" ? "unknown" : `${c.covered}/${c.total} (${c.share === null ? "?" : `${(c.share * 100).toFixed(1)}%`}) ${c.status}`;
76
+ const dispersion = a.dispersion.status === "unknown" ? "unknown" : `${a.dispersion.value?.toFixed(3)} ${a.dispersion.status}`;
77
+ const composition =
78
+ a.composition.status === "unknown" ? "unknown" : a.composition.status === "ok" ? "ok" : `omits ${a.composition.omittedOrgs.join(", ")}`;
79
+ const freshness = a.freshness.status === "unknown" ? "unknown" : `${a.freshness.monthsBehind?.toFixed(1)}mo ${a.freshness.status}`;
80
+ const trust =
81
+ a.trust.status === "unknown"
82
+ ? a.trust.community
83
+ ? "community"
84
+ : "unknown"
85
+ : `${a.trust.selfReported}/${a.trust.covered} ${a.trust.status}${a.trust.community ? " community" : ""}`;
86
+ const modality =
87
+ a.modality.status === "unknown"
88
+ ? "unknown"
89
+ : `${a.modality.value ?? "?"}${a.modality.multimodalShare === null ? "" : ` ${(a.modality.multimodalShare * 100).toFixed(0)}% multimodal`}`;
90
+ const maintenance = a.maintenance.status === "unknown" ? "unknown" : `${a.maintenance.monthsOld?.toFixed(1)}mo ${a.maintenance.status}`;
91
+ const provenance =
92
+ a.provenance.status === "unknown"
93
+ ? "unknown"
94
+ : `${a.provenance.owner ?? "?"}${a.provenance.dominantShare === null ? "" : ` ${(a.provenance.dominantShare * 100).toFixed(0)}% ${a.provenance.dominantOrg ?? "?"}`} ${a.provenance.compositionAgreement}`;
95
+ const crossSource =
96
+ a.crossSource.status === "unknown"
97
+ ? "unknown"
98
+ : `${a.crossSource.compared} priceΔ${a.crossSource.priceDivergence === null ? "?" : `${(a.crossSource.priceDivergence * 100).toFixed(0)}%`} ctxΔ${a.crossSource.contextDivergence === null ? "?" : `${(a.crossSource.contextDivergence * 100).toFixed(0)}%`} ${a.crossSource.status}`;
99
+ return `focus: ${metric} — coverage ${coverage}; dispersion ${dispersion}; composition ${composition}; freshness ${freshness}; trust ${trust}; modality ${modality}; maintenance ${maintenance}; provenance ${provenance}; cross-source ${crossSource}`;
100
+ }
101
+
68
102
  function formatRankings(
69
103
  rankings: Record<string, Ranked[]>,
70
104
  roles: Record<string, RoleDef>,
@@ -85,7 +119,7 @@ function formatRankings(
85
119
  "(no ranks): llm-stats index_* affine (v+20)/80 (interval scale, observed −16..+60),",
86
120
  "benchmarks chance-anchored pass rates, throughput log-anchored 10..300 tok/s.",
87
121
  "q = Σ weight × metric over the quality metrics (weights renormalized excluding",
88
- "price); value = q − λ·$/M sorts each role. λ = price-weight share ÷ $20, per-role",
122
+ "price); value = q − λ·$/M sorts each role. λ = w/(1−w) ÷ $20 (w = price weight), per-role",
89
123
  "override via plugin settings roles.<role>.lambda. Metric columns show weighted",
90
124
  "contributions and sum to q; — = metric missing (contributes 0).",
91
125
  "Roles with a thinking level rank on the thinking-adjusted price: the billed",
@@ -124,6 +158,7 @@ function formatRankings(
124
158
  );
125
159
  lines.push("");
126
160
 
161
+ const declared = loadDeclaredSources();
127
162
  for (const [role, def] of Object.entries(roles)) {
128
163
  const ranked = rankings[role] ?? [];
129
164
  lines.push(`## @${role} — ${def.description}`);
@@ -189,6 +224,10 @@ function formatRankings(
189
224
  `| ${widths.map((w, c) => (c === 3 || c === 4 ? "-".repeat(w) : `${"-".repeat(w - 1)}:`)).join(" | ")} |`,
190
225
  );
191
226
  for (let ri = 1; ri < padded.length; ri++) lines.push(`| ${padded[ri].join(" | ")} |`);
227
+ // A role's focus metrics (the benchmarks it was ranked on) carry the same
228
+ // nine-axis annotation as the create-agent report. Shipped roles weight index
229
+ // metrics, so this is empty for them.
230
+ for (const metric of focusMetricsOf(def, declared)) lines.push(focusLine(metric, assessFocusMetric(models, metric, declared, cachedSourceInfo(metric, declared))));
192
231
  lines.push("");
193
232
  }
194
233
  return lines.join("\n");