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/docs/dev/spec.md DELETED
@@ -1,633 +0,0 @@
1
- # SPEC — omp-llm-role: daily model-role updater plugin
2
-
3
- Status: spec (implementation not started). Every decision below was confirmed with the
4
- product owner in a three-round interview (2026-09-21). Ground-truth facts were verified
5
- live against OpenRouter's API and the local omp install the same day.
6
-
7
- ---
8
-
9
- ## 1. Goal
10
-
11
- A omp plugin in this repo that keeps `~/.omp/agent/config.yml` → `modelRoles` on today's
12
- best models: it ranks models per role (existing scoring), keeps only models the user's
13
- OpenRouter key can actually run, and surgically rewrites the role selectors — with
14
- thinking suffixes, fallback chains, and a session-start daily trigger.
15
-
16
- Existing `src/cli/llm-role-rank.ts` stays the scoring engine; the plugin is the actuator on top.
17
-
18
- ## 2. Verified ground truth (2026-09-21)
19
-
20
- These facts constrain the design; they were probed live, not assumed.
21
-
22
- | Fact | Evidence |
23
- |---|---|
24
- | OpenRouter has **no per-key model allowlist** | `GET /api/v1/key` response fields: `label, limit, limit_reset, limit_remaining, usage, byok_usage, is_free_tier, expires_at, allowed_data_regions, free_model_daily_requests{used,limit,remaining}, rate_limit` — no model/provider restriction field |
25
- | Key tier + budget is the availability signal | `is_free_tier: false`, `limit: 10` (daily), `limit_remaining`, `/api/v1/credits` → `{total_credits, total_usage}` |
26
- | `omp token openrouter` prints omp's resolved OpenRouter key | verified, non-empty `sk-or-…` |
27
- | omp catalog exposes concrete ids incl. `~org/…-latest` aliases and `:batch`/`:free` variants | `omp models find deepseek-v4-flash`: `~deepseek/deepseek-v4-flash-latest`, `deepseek/deepseek-v4-flash`, `deepseek/deepseek-v4-flash-0731`, `…:batch`, `…:free` |
28
- | `omp models ls --json` is the selector universe | rows: `{provider, id, selector, name, contextWindow, maxTokens, reasoning, thinking[], input[], cost{}}` |
29
- | `modelRoles` values are `provider/modelId[:thinkingLevel]` | `omp://models.md`; levels `off|minimal|low|medium|high|xhigh|max|auto` |
30
- | `retry.fallbackChains` keys are selectors **without** thinking suffix | user's config.yml: role values carry `:off/:high/:max`, chain keys don't (values may — `omp://settings.md`: "selectors accept an optional thinking suffix") |
31
- | omp plugins load via package manifest; `omp plugin link <dir>` for dev | `omp://plugin-manager-installer-plumbing.md`; settings map in `omp-plugins.lock.json`, `omp plugin config set <pkg> <key> <value>` (values stored as strings) |
32
- | Extensions run in-process under Bun; `ctx.modelRegistry`, `ctx.ui.notify`, `registerCommand` available; `ctx.setTimeout` for contained background work | `omp://extensions.md` |
33
- | config.yml hot-reloads (task/eval preflight re-reads settings); omp writes it under `config.yml.lock` | `omp://config-usage.md`, `omp://task-agent-discovery.md` |
34
-
35
- **Interpretation note.** The owner's phrase *"fetch the available providers for that key"*
36
- maps to reality as: OpenRouter exposes no provider list per key; availability is derived
37
- from key tier + budget (§5). This interpretation was presented and accepted in round 1.
38
-
39
- ## 3. Decision record
40
-
41
- | # | Question | Decision |
42
- |---|---|---|
43
- | 1 | Form factor | **omp plugin** living in this repo, installed via `omp plugin link` (dev) / install (later) |
44
- | 2 | Role coverage | **Config-driven weights.** Shipped defaults cover the nine official roles; `designer` ships as an opt-in role (`enabled: false`, §6.2). The settings schema is flexible enough to define full weight sets for custom agent roles |
45
- | 3 | Key-scoped availability | **Tier gate + catalog check** (§5) |
46
- | 4 | Config write | **Surgical in-place edit** of `~/.omp/agent/config.yml` (only managed lines change; atomic tmp+rename) |
47
- | 5 | Thinking suffixes | **Per-role `thinking` field** on the role def, shipped as defaults, overridable in settings |
48
- | 6 | Trigger | **session_start, UTC-day gated** (first omp session of the day refreshes; later sessions no-op) + manual `/refresh-roles` |
49
- | 7 | Switch policy | **Hysteresis**: switch only if current model ineligible or new best beats current score by `switchMargin` (default `0.02`; `0` = always take today's best). The margin is a flat band on `value`, so it can veto up to `switchMargin / λ` $/M of savings; a challenger inside the band that undercuts the incumbent's effective price by `priceSwitchFraction` (default `0.5`) is adopted anyway (2026-09-30, `switched-cost`) |
50
- | 8 | Settings home | **omp plugin settings** (`omp-plugins.lock.json` → `settings["omp-llm-role"]`); plugin deep-merges flat dotted keys and nested objects itself. `omp plugin config set` stores values as strings, so typed role defs are written by `src/cli/create-role.ts` / `/create-agent` / the explorer's Export (all validate through `resolveSettings`); `/remove-agent` deletes them through the same module |
51
- | 9 | Variant pick | **Exact dated slug**: resolution order exact id → newest dated → bare → `-latest` alias (last resort); never `:batch`; `:free` only on free-tier keys |
52
- | 10 | Deliverable | **This spec**; implementation in a later session on owner go |
53
- | 11 | `retry.fallbackChains` | **Auto-populate** #2/#3 per managed role; prune stale keys the plugin wrote |
54
- | 12 | Provider scope | **`openrouter/*` selectors only** (ranking price/throughput is OpenRouter-derived; the key is an OpenRouter key) |
55
- | 13 | Naming | Plugin `omp-llm-role`, slash commands `/refresh-roles` (role refresh) + `/explore-roles` (in-session ranking explorer) + `/create-agent` (agent + role + wiring in one command) + `/remove-agent` (delete an agent and its role) |
56
- | 14 | Provider routing | **Price-based load balancing model**: price and throughput are the 1/price²-weighted means over the stable standard-tier billed routes (OpenRouter's default routing), per-provider data from the model pages; single find route only as fallback |
57
-
58
- ## 4. Architecture
59
-
60
- ```
61
- ~/Documents/omp-llm-role/
62
- package.json # name: omp-llm-role, omp.extensions: ["./src/extension.ts"]
63
- src/
64
- engine.ts # fetch/caches, cardinal transforms, value scoring;
65
- # computeRankings(models, rolesConfig)
66
- settings.ts # defaults + dotted-key deep-merge of plugin settings map
67
- availability.ts # key fetch, tier gate, catalog check, variant resolution
68
- config-edit.ts # surgical YAML edit for modelRoles + retry.fallbackChains
69
- state.ts # state + history files
70
- updater.ts # orchestration: run(trigger, deps) → decisions
71
- extension.ts # Bun/omp entry: session_start day gate + /refresh-roles + /explore-roles + /create-agent + /remove-agent
72
- role-settings.ts # the one validated role write path (validate → merge → backup → atomic write for creation **and removal**)
73
- role-archetypes.ts # purpose -> weight archetype table (10 sets) + keyword fitting
74
- agent-file.ts # agent .md rendering/placement (frontmatter; body from omp's architect)
75
- agent-architect.ts # omp's agent-creation architect, run in-process (extension-only)
76
- agent-create.ts # /create-agent core: purpose -> archetype -> validated role -> agent .md (+ shared arg parser/report)
77
- agent-remove.ts # /remove-agent core: deletes agent .md and role definition
78
- explorer/boot.ts # shared explorer launcher (in-process server: bind, port fallback, close)
79
- explorer/server.ts # explorer HTTP surface (static SPA + JSON API)
80
- explorer/explain.ts # pure explanation layer (rank rows, decomposition, targets)
81
- cli/llm-role-rank.ts # report CLI (per-role tables + suggested modelRoles)
82
- cli/create-role.ts # `node src/cli/create-role.ts --name <role> --weights m=w,...`
83
- docs/llm-role-rankings.md # generated report (value-ranking format)
84
- cache/*.json # daily UTC caches (gitignored)
85
- ```
86
- ```
87
-
88
- Runtimes: extension runs **inside omp (Bun)**; CLI entry runs under **Node ≥ 23.6 (type
89
- stripping)** — this machine has Node 26, no bun. Code must be dual-runtime safe: only
90
- `node:` builtins + global `fetch`; relative imports with explicit `.ts` extensions.
91
-
92
- ### 4.1 Refactor contract (`src/cli/llm-role-rank.ts` → `engine.ts`)
93
-
94
- - `loadRankData(deps?)` — fetch/cache chain per source (fresh cache → live fetch →
95
- stale cache → none), returns models + match counts. Since 2026-09-30 the
96
- OpenRouter step also fetches the model pages (~150/day, own cache) and blends
97
- price/throughput per decision #14; the find payload alone is the fallback.
98
- `opts.roles` names the roles the caller will rank: a role-exclusive source is
99
- fetched only when some role weights its metric — Design Arena (`website`) and the
100
- writing leaderboard (`writing`); skipped, the OpenRouter mirror alone populates
101
- `designElo` and `metrics.writing` is never set. Undefined = fetch all, which is
102
- what the explorer passes so a user-created role always gets real values for the
103
- metrics it weights.
104
- - `computeRankings(models, roles)` — cardinal fixed-anchor transforms (index_* affine
105
- `(v+20)/80`, benchmarks chance-anchored, throughput log-anchored, `website`/`writing`
106
- identity over an already-0-1 percentile/score) + quality composite
107
- `q` + value `q − λ·$/M` + eligibility (`required` non-null, billed price). `roles`
108
- comes from resolved settings (§7), not the hardcoded `ROLES`.
109
- - `src/cli/llm-role-rank.ts` keeps its CLI, flags, report format, and suggested-YAML output; its
110
- suggested `modelRoles` block switches from `PROVIDER_BY_ORG` first-party guesses to the
111
- same catalog-resolved `openrouter/*` selectors the plugin emits (`PROVIDER_BY_ORG`
112
- retires from that path).
113
- - `src/benchmark-sources.ts` is the benchmark-source registry: the single source of
114
- truth for which sources exist, what metric each feeds, and how to fetch/parse/join
115
- each. `BENCHMARK_SOURCES` documents the shipped static sources (the llm-stats index
116
- leaderboard, the writing evidence export, Design Arena, the six raw llm-stats
117
- benchmarks); `resolveBenchmarkSource(link)` maps a link to a source (a static
118
- pattern, then the generic llm-stats benchmark for `llm-stats.com/benchmarks/<id>` or
119
- a bare id); `sourceForMetric(metric)` maps a metric back; `loadBenchmarkScores` is
120
- the shared cache chain and `applyBenchmarkScores` the shared join. The writing
121
- branch moves behind the registry entry (its cache, gating and non-fatal failure
122
- preserved). A new source is one registry entry (or one declaration), not a new
123
- branch in the engine.
124
- - **External metrics** are namespaced `<namespace>:<local>` and MUST be dot-free: the
125
- flat dotted settings path splits on `.` (`setNested(patchObj, key.split("."), value)`),
126
- so a dotted key mis-nests on read-back. `isKnownMetric` (`src/settings.ts`) is the
127
- pure name check (`name in KNOWN_METRICS || /^[a-z0-9_-]+:[a-z0-9_-]+$/`); the registry
128
- is the engine-side resolver, and `settings.ts` never imports it (no cycle). The
129
- generic llm-stats benchmark's metric is `bench:<normalized-id>` while the source
130
- retains the raw id for the fetch URL, so the source is persisted as a declaration.
131
- - **Declarative sources are data, not code**: a user-level `benchmark-sources.json`
132
- under the agent dir declares `{ id, label, metric, urlPatterns, fetch, payloadPath,
133
- idField, scoreField, scoreMax, join, fill }`; the plugin executes it
134
- deterministically (fetch, walk, read, normalize `score / scoreMax`, join, apply).
135
- A declaration whose metric is not `<namespace>:<local>` is rejected. Adding a
136
- provider never executes untrusted code.
137
- - **`/create-agent` resolves benchmark links**: `extractBenchmarkLinks` pulls URLs from
138
- the free text; the extension resolves each (a saved declaration, then the registry,
139
- then the authoring step), warms the cache, reports coverage/leader, and folds the
140
- resolved metric names into `extraBenchmarks`. An unknown link goes through the
141
- authoring step (`src/benchmark-author.ts`: fetch the link, run an in-process
142
- architect, validate, dry-run the coverage/leader, confirm via `ctx.ui.select` or
143
- `--yes`, save the declaration). A link that cannot be resolved or authored fails
144
- with a clear message — never a silent fallback to generic weights.
145
- - **`/create-agent` discovers benchmarks**: when a purpose is given and neither
146
- `--no-discover` nor an explicit `--benchmarks` list is set, the command fetches
147
- the llm-stats benchmark catalog (`GET /leaderboard/benchmarks`, daily-cached),
148
- filters it by coverage (`modelCount >= 3`), ranks the survivors by IDF-weighted
149
- lexical overlap with the purpose (a category that names the purpose's skill is
150
- the strongest signal), caps the list at 40, and asks omp's configured `judge`
151
- role (one `noul` question per candidate, batched into a single judgment) which
152
- are direct measures. A selected benchmark resolves to its shipped metric when
153
- one exists (`writingbench` → `writing`), else `bench:<id>`, and is folded in as
154
- a focus metric. Discovery is non-fatal: a catalog or judge failure skips it and
155
- the role still ranks on its other weights.
156
- - **The focus-share fit** (`applyFocusBenchmarks`) replaces the mean-weight fold: a
157
- named benchmark takes `clamp(specialistShare, 0.25, 0.40)` of the non-price budget
158
- (split across the named set), the archetype's remaining non-price weights are
159
- rescaled to fill `1 − w_price − focusShare`, and both invariants hold exactly.
160
- - Verification after any engine change (existing convention): `node src/cli/llm-role-rank.ts --top 5`,
161
- check stderr `openrouter: matched N/<pool> models (throughput), M priced` plus the
162
- `openrouter endpoints: K/L model pages` line, and per-role eligible counts.
163
-
164
- ### 4.2 Dependency injection
165
-
166
- Core modules take injected adapters so both entries share one code path:
167
-
168
- ```ts
169
- type Deps = {
170
- getToken(): Promise<string>; // extension: ModelRegistry.getApiKey("openrouter")
171
- // CLI: `omp token openrouter` (honors OMP_PROFILE)
172
- getCatalog(): Promise<CatalogEntry[]>;// extension: ctx.modelRegistry.getAvailable()
173
- // CLI: `omp models ls --json`
174
- notify(lines: string[]): void; // extension: ctx.ui.notify; CLI: stdout
175
- nowUtcDay(): string;
176
- };
177
- ```
178
-
179
- ## 5. Availability model (tier gate + catalog check)
180
-
181
- ### 5.1 Key metadata
182
-
183
- Bearer-authenticated GETs with the omp-resolved key:
184
-
185
- - `GET https://openrouter.ai/api/v1/key` → `is_free_tier`, `limit_remaining`, `limit`
186
- - `GET https://openrouter.ai/api/v1/credits` → `total_credits`, `total_usage`
187
-
188
- ### 5.2 Gate
189
-
190
- ```
191
- billedUsable = !is_free_tier && limit_remaining > 0 && (total_credits - total_usage) > 0
192
- freeUsable = free_model_daily_requests.remaining > 0 (from key response)
193
-
194
- tierFilter(model):
195
- billedUsable → billed variants only (exclude `:free`, `:batch`)
196
- else freeUsable → `:free` variants only (exclude `:batch`)
197
- else → no candidates (run aborts, §9)
198
- ```
199
-
200
- ### 5.3 Catalog check
201
-
202
- Every candidate must resolve to a concrete entry in the **authenticated** omp catalog
203
- (`provider === "openrouter"`), i.e. a selector omp can actually dispatch. Catalog entries
204
- also supply `thinking[]` (for suffix clamping sanity) and `input[]` (role filters, §6.3).
205
-
206
- ### 5.4 Variant resolution (per ranked model → selector)
207
-
208
- Matching ranking row (llm-stats bare id) → openrouter-provider catalog ids: a catalog id
209
- matches when its suffix after the last `/` equals the ranking id, or equals it after
210
- stripping a trailing `-latest` (org prefix — including `~`-prefixed — is ignored for
211
- matching). Among matched candidates, emit in this order:
212
-
213
- 1. **exact id match** (bare or dated, whatever equals the ranking id)
214
- 2. **newest dated id** (max trailing `-MMDD`/date suffix)
215
- 3. **bare id**
216
- 4. `~org/…-latest` alias (last resort — a valid selector beats no update)
217
-
218
- Never emit `:batch`. `:free` only when the free-tier branch of §5.2 is active. Ties break
219
- lexicographically. The emitted selector is always `openrouter/<catalogId>` (decision #12).
220
-
221
- ### 5.5 Provider-allowlist probe (added 2026-09-23)
222
-
223
- The account-level **allowed-providers privacy whitelist** (openrouter.ai/settings/privacy)
224
- is enforced at request time and invisible to every catalog surface: `/api/v1/key` has no
225
- provider field (§2 facts), and the key-authenticated `GET /api/v1/models/{id}/endpoints`
226
- still returns 200 with the full serving-provider list for blocked models. The only
227
- reliable signal is a real request: a one-token `POST /api/v1/chat/completions` whose 404
228
- body reads "No allowed providers are available for the selected model …". Models are
229
- runnable iff at least one serving endpoint's provider is whitelisted — the org prefix is
230
- **not** a valid filter (aggregator-org models such as `deepseek/*`, `z-ai/*`,
231
- `inclusionai/*` run via whitelisted third-party endpoints while `openai/*` and
232
- `anthropic/*` fail on first-party-only routing).
233
-
234
- Per role, candidates are verified with `probeModel` (`availability.ts`) in a bounded
235
- walk: the current selector's candidate first (hysteresis must see it), then rank order.
236
- It stops only when `1 + fallbackChainDepth` clean candidates exist AND — when the current
237
- candidate is clean — `fallbackChainDepth` clean candidates lie beyond the current rank,
238
- so every chain entry written to the config is probe-verified and a kept role's chain is
239
- filled to full depth. Budget-capped at 12 probes per role (a short chain is the graceful
240
- degradation); verdicts are cached per run (roles share candidates). Only the
241
- narrow no-allowed-providers 404 disqualifies; every other failure (5xx, timeout, unknown
242
- model) counts as usable and stays in omp's runtime-fallback domain. Blocked candidates
243
- are excluded from selection and from fallback chains, are recorded on the decision
244
- (`blocked[]`) and in history, and a role whose probed candidates are all blocked is left
245
- untouched with a notify note.
246
-
247
- ## 6. Selection pipeline
248
-
249
- Per run, per role in the **resolved role set** (shipped defaults ∪ user-defined roles
250
- that declare weights):
251
-
252
- 1. **Rank** — `computeRankings` with that role's weights/required (cardinal value
253
- math: `value = q − λ·priceEff`; λ from the price-weight share ÷ $20 or
254
- `roles.<r>.lambda`. `priceEff` = billed blend × the role's thinking factor
255
- `(3ρ+1+T)/(3ρ+1)`, gated per model on what it will actually run: the omp
256
- catalog's per-model `thinking[]` wins when available (a model whose level
257
- list excludes the role's level is priced bare — omp clamps unsupported
258
- levels, and the written selector stays bare in that case), meta levels
259
- (`off`, `auto`) need only a non-empty list, and without the catalog the OR
260
- `supports_reasoning` flag gates; bare/off roles are unadjusted).
261
- 2. **Filter** — tier gate (§5.2) → catalog resolution (§5.4); unresolvable models drop.
262
- 3. **Choose** with hysteresis (decision #7):
263
-
264
- ```
265
- best = candidates[0] // highest value
266
- current = today's config.yml value for role // suffix stripped for identity
267
- if no current entry → adopt best
268
- else if current fails tier or catalog gate
269
- (incl. not in today's ranked pool) → adopt best
270
- else if best.value - current.value >= switchMargin → adopt best
271
- else if best.value >= current.value - switchMargin
272
- && priceSwitchFraction > 0
273
- && best.priceEff <= current.priceEff * (1 - priceSwitchFraction)
274
- → adopt best (cost override, reason `switched-cost`)
275
- else → keep current (chain still refreshed, §6.4)
276
- ```
277
-
278
- 4. **Suffix** — append `roles[role].thinking` (§7) only when the chosen model's
279
- catalog `thinking[]` includes the level (meta levels `off`, `auto` need only
280
- a non-empty list) — omp clamps unsupported levels, so an unsupported pin
281
- would run at a different effort than the role intends; the same gate is
282
- applied per target when building fallback-chain values (§6.4).
283
- 5. **Diff** — a role with unchanged final selector produces no write and no notify line.
284
-
285
- Roles in `config.yml` that have **no** weights in resolved settings are never touched
286
- (e.g. custom agent roles until the owner adds weights for them).
287
-
288
- ### 6.1 Determinism
289
-
290
- Same inputs → same output. Ranking ties break by: score desc → blended $/M asc → id asc.
291
-
292
- ### 6.2 Shipped default role set
293
-
294
- Exactly the current `ROLES` from `src/cli/llm-role-rank.ts` (weights/required verbatim, see
295
- `docs/llm-role-rankings.md` legend for metric meanings): `default, smol, slow, vision, plan,
296
- commit, tiny, task, advisor, designer`. No custom roles ship beyond these (decision #2).
297
-
298
- `designer` is the only non-built-in role and ships `enabled: false`: `resolveSettings`
299
- drops it from the resolved set (like `weights: null`), so a stock run ranks the nine
300
- built-in roles and never fetches Design Arena. `roles.designer.enabled=true` opts in.
301
- The shipped `agents/designer.md` is discovered from the plugin's extension root
302
- regardless, but the plugin keeps it in `task.disabledAgents` until
303
- `roles.designer.enabled=true` (below), so it is off the roster by default. Its
304
- `model: "@designer, @default"` chain only matters if a user re-enables the agent by
305
- hand: the unresolved first entry is skipped and the child runs on `@default` (a bare
306
- `@designer` would hard-fail — an unresolved `@x` is a literal pattern, not a
307
- parent-model fallback). The CLI report documents all shipped roles via `--all`.
308
-
309
- The shipped agent is **opt-in too**: omp has no per-agent frontmatter gate
310
- (`parseAgentFields` has no `enabled`; `discoverAgents` scans `<ext>/agents/*.md`
311
- unconditionally), so the plugin manages the core `task.disabledAgents` key in
312
- `config.yml` — the same key the `/agents` hub writes. The updater derives the pin
313
- from each agent's `model:` chain (`src/agent-pins.ts`: `discoverAgentPins` scans the
314
- shipped `agents/`, `~/.omp/agent/agents/` and `<project>/.omp/agents/` dirs, project >
315
- user > plugin) and keeps an agent in `task.disabledAgents` exactly while the role it
316
- pins is known to the plugin but disabled; an agent that pins no role, or a
317
- role the plugin does not know, is left alone. So `designer` is off the roster and
318
- refuses spawns until `roles.designer.enabled=true`, and the same holds for any agent
319
- whose `model:` frontmatter pins a disabled role. The patch is surgical (other entries
320
- and their order preserved, self-checked) and the sync is not day-gated — enabling the
321
- role takes effect on the next session.
322
-
323
- **The `/remove-agent` command deletes the agent `.md` file and its role definition, refusing shipped default roles. The plugin now tracks `managedDisabledAgents` to clean up stale `task.disabledAgents` entries when an agent is removed.**
324
-
325
- ### 6.3 Role filters (schema capability)
326
-
327
- Role definitions may declare `filters: { image: true }` (require image input) and
328
- `filters: { maxPriceUsdPerM, minContextTokens }`. Shipped defaults: `vision` and `designer` set
329
- `image: true` (matching the existing multimodal eligibility filter). Filters apply
330
- before ranking eligibility so percentile norms stay on the unfiltered pool (existing
331
- behavior — vision already filters this way).
332
-
333
- ### 6.4 Fallback chains (decision #11)
334
-
335
- After decisions, for **every managed role** (switched, adopted, or kept):
336
-
337
- - key = chosen selector **without thinking suffix** (a chain key matches the active
338
- model id, never a level)
339
- - value = the next `fallbackChainDepth` (default 2) tier-eligible candidates after the
340
- chosen one, **preferring candidates priced at or below the chosen model** (by
341
- `priceEff`); when fewer than `fallbackChainDepth` such candidates exist, the remainder
342
- is filled with the next-best by value. Deduped, each carrying the role's thinking
343
- suffix when the role has one and that entry's own catalog row advertises thinking
344
- support (§6 step 4 rule, applied per target) — so a fallback runs at the role's effort
345
- instead of the session `defaultThinkingLevel`. **Exception:** a key claimed by more than one managed role (two
346
- roles chosen onto the same model) gets level-free values — one model-scoped chain
347
- cannot serve two different role levels, so no role's suffix is imposed on another.
348
-
349
- Maintenance rules:
350
-
351
- - Write only roles the plugin manages.
352
- - Replace-in-place when a chain key already exists in the file (YAML duplicate keys are
353
- forbidden); never duplicate.
354
- - Prune keys that (a) the plugin wrote on a previous run (tracked in state, §8) and
355
- (b) are no longer referenced by any managed role's selector. Unwritten, unreferenced
356
- keys (e.g. the owner's hand-maintained `openrouter/~deepseek/deepseek-v4-flash-latest`
357
- entry) are left alone unless the plugin needs to write that exact key (then replace).
358
- - Delete the `modelRoles.<role>` line for a role the plugin managed on a previous run
359
- (state `managedRoles`) but no longer does — disabled via `enabled: false` /
360
- `weights: null`, or removed from settings. A stale pin would otherwise keep routing
361
- `@<role>` (the shipped `designer` agent falls through to its `@default` chain entry
362
- only when the key is gone).
363
-
364
- ## 7. Plugin settings schema
365
-
366
- Stored in the omp plugin settings map (`omp-plugins.lock.json` → `settings["omp-llm-role"]`).
367
- The plugin receives the raw map and **deep-merges flat dotted keys and nested objects
368
- itself** (`roles.slow.weights.code=0.2` and `roles: { slow: {...} }` both nest, in either
369
- order); a single `config` key holding JSON is accepted as a power-user escape hatch for
370
- whole-object overrides. `omp plugin config set <pkg> <key> <value>` stores every value as
371
- a **string**, which the validator rejects for numbers/arrays/booleans, so typed role defs
372
- are written by `src/cli/create-role.ts`, `/create-agent` or the explorer's Export (all
373
- go through `src/role-settings.ts`: validate → merge → backup → atomic write). Role
374
- removal goes through the same module (`removeRoleSettings`: delete the flat dotted keys
375
- and any nested entry, backup + atomic write).
376
-
377
- ```jsonc
378
- // logical shape (defaults shown for knobs; role weights default to §6.2)
379
- {
380
- "switchMargin": 0.02, // hysteresis margin on the 0–1 score; 0 = always switch
381
- "priceSwitchFraction": 0.5, // cost override: inside the margin, a challenger this
382
- // much cheaper (0.5 = half the $/M) is adopted anyway;
383
- // 0 disables it
384
- "writeFallbackChains": true,
385
- "fallbackChainDepth": 2,
386
- "roles": {
387
- "slow": {
388
- "description": "…",
389
- // Opt-in gate (default true). `false` drops the role from the resolved
390
- // set, like `weights: null`; the shipped `designer` default is false.
391
- "enabled": true,
392
- "weights": { "general": 0.26, "reasoning": 0.26, "code": 0.18, "agents": 0.13,
393
- "math": 0.08, "throughput": 0.04, "price": 0.05 },
394
- "required": ["general", "price", "throughput"],
395
- "thinking": "high",
396
- // Per-role thinking level (decision #5; moved out of the former `suffixes`
397
- // map). The shipped VALUES are hand-authored (design session, never
398
- // derived from the ranking): smol off, slow high, vision auto, plan auto,
399
- // commit off, designer auto, default/task/advisor auto, tiny off — no
400
- // role is bare (absent = no suffix). The 2026-09-30 value review lowered
401
- // slow max → high and
402
- // plan/designer high → auto: `medium` is not in any reachable model's
403
- // catalog thinking[], so a `medium` pin writes bare at a bare price while
404
- // the session default (`auto`) bills ~1.86×, and the level is a pure cost
405
- // multiplier — the ranking never rewards it. The FIELD is ranking-active:
406
- // it scales the price axis
407
- // (§6 step 1) for models whose catalog thinking[] includes the level, so
408
- // editing a role's level can change its ranking and picks.
409
- "filters": { "image": false }
410
- }
411
- // add "my-custom-role": { weights: {...}, required: [...], filters: {...} }
412
- // → that config.yml role gets updated too (decision #2)
413
- }
414
- }
415
- ```
416
-
417
- Validation (fail the run, notify, no write): weights > 0, each role's weights sum to
418
- 1.0 ± 0.01, `required` entries ∈ {general, reasoning, math, code, agents, search,
419
- vision, tool_calling, long_context, mrcr, website, writing, gpqa, aime, swe_bench,
420
- arc_agi, terminal_bench, tau_bench, price, throughput} (the eligibility gate,
421
- independent of weights), weightable metric names ∈ the same set, `roles.<role>.thinking` ∈
422
- {off, minimal, low, medium, high, xhigh, max, auto}, `switchMargin` and
423
- `priceSwitchFraction` ∈ [0, 1], `roles.<role>.enabled` a boolean. A role entry with
424
- `weights: null` explicitly opts that role out; `enabled: false` drops a shipped role
425
- from the resolved set (the `designer` default) and keeps any agent whose `model:` chain
426
- pins it in `task.disabledAgents` (§6.2). Legacy `suffixes.*` keys are rejected with a migration hint
427
- (moved into `roles.<role>.thinking`).
428
-
429
- Six metrics are the raw llm-stats benchmark pass rates — `gpqa` (chance-anchored at
430
- 0.25), `aime`, `swe_bench`, `arc_agi`, `terminal_bench`, `tau_bench` (raw 0-1). They are
431
- weightable (so `/create-agent`'s "another benchmark" step can name one) but sparse
432
- (gpqa 62.5%, aime 30.5%, swe_bench 29.0%, arc_agi/terminal_bench/tau_bench ~5-6% of the
433
- field), so they are differentiators, never `required` gates.
434
-
435
- Three metrics are **capability-filled**: `website`, `long_context` and `writing`. A
436
- model missing one is scored at `CAPABILITY_FILL[metric]` instead of 0 — absence is not
437
- a coverage penalty — and none of the three may be a `required` gate.
438
-
439
- `website` is the first of them: Design Arena's `models-website` Elo as a percentile
440
- within the design-covered population, computed once per run in
441
- `applyDesignPercentiles` (role-independent), so it is never null for a model with a
442
- general index — the newest frontier models carry no Design Arena data. Shipped weight:
443
- `designer` 0.18 only (with `code` trimmed to 0.10 against their r = 0.876 collinearity).
444
-
445
- `writing` is the WritingBench score (0–1, identity transform) from the writing
446
- leaderboard's canonical export (`/research/best-ai-for-writing/evidence.json`), joined
447
- by the bare llm-stats id (15/400 covered, all Qwen), computed once per run in
448
- `applyWritingScores` (role-independent) and only when some ranked role weights it.
449
-
450
- `long_context` is never written into `metrics`: `rankRole` applies its fill at rank
451
- time, and `explainModel` mirrors that branch (same untransformed fill, no
452
- `cardinalMetric` pass) so the explorer's contributions sum exactly to `q`.
453
-
454
- `CAPABILITY_FILL` (0.195) is a **stated assumption, not a per-metric calibration**: it
455
- is the percentile implied by the *capability* cohort's uncovered-mean general index
456
- (29.8 vs covered 38.2). The `writing` cohort is stronger, not weaker — 19.8 covered vs
457
- 22.2 uncovered, i.e. ≈0.513 on the same construction — so the shared constant
458
- understates an unmeasured model's writing. It stays conservative because 15
459
- self-reported, unverified rows cannot calibrate a per-metric fill, and a value derived
460
- per metric would need its own justification for each.
461
-
462
- ## 8. State, history, and the write
463
-
464
- Files (all under `~/.omp/agent/`, next to the config they describe):
465
-
466
- - `llm-role-state.json` — `{ lastRunDay, managedRoles, role→lastSelector,
467
- pluginWrittenChainKeys[], managedDisabledAgents[], previousModelRoles }`.
468
- `previousModelRoles` is the full snapshot of the last `modelRoles` block before the
469
- plugin changed it (manual rollback aid; no rollback command in scope).
470
- `managedDisabledAgents` is the set of agent names the plugin added to
471
- `task.disabledAgents`; a later run removes a name whose agent file is gone (e.g.
472
- `/remove-agent`) or whose role is no longer disabled.
473
- - `llm-role-history.jsonl` — append-only per run: `{ts, trigger, keyMeta{isFreeTier,
474
- limitRemaining, creditsRemaining}, decisions[{role, from, to, reason: adopted|switched|
475
- switched-cost|kept-margin|kept-eligible|no-current, scores}]}`.
476
-
477
- ### 8.1 Surgical edit algorithm (decision #4)
478
-
479
- 1. Acquire advisory lock `~/.omp/agent/.llm-role-refresh.lock` (serializes concurrent
480
- session starts).
481
- 2. Read `~/.omp/agent/config.yml`; parse with the line-oriented reader (no runtime YAML
482
- dependency — required for marketplace installs); validate structure (block-style
483
- top level, no inline/indented/duplicate blocks).
484
- 3. Build the patched document as **text**, line-oriented: rewrite only value tokens of
485
- managed roles inside the top-level `modelRoles:` block (upsert missing roles at the
486
- block end, two-space indent; delete the line for a role that left the managed set,
487
- §6.4), the managed keys inside `retry.fallbackChains:`, and the plugin-managed names
488
- in `task.disabledAgents` (§6.2: add/remove the name of an agent whose pinned role is
489
- disabled, other entries and their order preserved). All other bytes identical — comments, blank lines, unknown
490
- keys untouched.
491
- 4. Values always emitted YAML-quoted (`"openrouter/z-ai/glm-5.3-flash:high"`).
492
- 5. mtime re-check before write; if config.yml changed underneath, re-read + recompute
493
- (max 3 attempts). Atomic: temp file + `rename`.
494
- 6. Read the patched text back through the same line-oriented reader and assert it equals
495
- the intended state; on any mismatch abort without writing. Never corrupt.
496
-
497
- ## 9. Error handling
498
-
499
- | Failure | Behavior |
500
- |---|---|
501
- | Ranking data: fetch fails, no fresh cache | Existing chain: fresh cache → live fetch → stale cache → **abort, notify** (no write) |
502
- | Key fetch (`omp token`/registry) or `/api/v1/key`,`/api/v1/credits` fails | **Abort, notify** — without tier info no selector is trustworthy |
503
- | Catalog fetch fails | Abort, notify |
504
- | Settings validation fails (§7) | Abort, notify the offending role/key |
505
- | Both tier branches false (no budget, no free quota) | Abort, notify |
506
- | Write conflict after 3 retries | Abort, notify; state file untouched |
507
- | Zero decisions changed | No role write; the non-day-gated `task.disabledAgents` sync (§6.2) can still rewrite `config.yml` (mtime moves) with no role change, and that path appends no history row. A full run that changes nothing still appends a history row |
508
-
509
- Session-start runs are **awaited** before the first prompt is dispatched (a deferred
510
- timer would be cleared when a short-lived session exits before it fires); the day gate
511
- (`state.lastRunDay !== today(UTC)`) makes the ranking run at most once/day; a concurrent
512
- second starter loses the lock and finds the day already stamped → no-op. The
513
- pin-derived `task.disabledAgents` sync (§6.2) is **not** day-gated: it runs on every
514
- session start so enabling/disabling a role takes effect on the next session.
515
-
516
- ## 10. Plugin surfaces
517
-
518
- - **Manifest**: `package.json` → `"omp": { "extensions": ["./src/extension.ts"] }`;
519
- dev install `omp plugin link ~/Documents/omp-llm-role` (repo stays the source of truth).
520
- - **session_start**: day-gated refresh (§9), notify only on switches/errors.
521
- - **`/refresh-roles`**: synchronous forced run in-session; notification summarizes every
522
- decision (kept lines included when verbose).
523
- - **`/explore-roles [--port N] [--no-open]`**: boots the interactive ranking explorer
524
- **in-process** (catalog from the live model registry), notifies the URL and opens the
525
- browser. Roles come from the user-level lock file (`project: null`); a busy port falls
526
- back to an OS-assigned one; a repeat invocation re-notifies the running URL; the handle
527
- is closed on `session_shutdown`.
528
- - **`/create-agent <request> [flags]`** (free text) or
529
- **`/create-agent --name <n> --purpose "<text>" [flags]`**: creates the agent
530
- **and** its role in one command. The free-text form takes the text up to the
531
- first `--flag` as the purpose, folds any benchmark it names into the weights
532
- (`extractBenchmarks`), records any benchmark link it points at
533
- (`extractBenchmarkLinks`), and uses the architect's `identifier` as the name;
534
- trailing flags still apply. Each link is resolved (a saved declaration, then
535
- the registry, then the authoring step) and its metric folded into the weights
536
- as a decisive focus share; an unknown link is authored into a source
537
- declaration after a dry-run coverage/leader report and a confirmation
538
- (`--yes` covers headless runs). Unless `--no-discover` or an explicit
539
- `--benchmarks` list is given, it also discovers the catalog benchmarks relevant
540
- to the purpose (coverage filter → IDF lexical rank → the configured `judge`
541
- role) and folds them in. Runs **omp's agent-creation architect**
542
- in-process (`src/agent-architect.ts`: the `/agents` hub's prompt shipped verbatim
543
- in `src/prompts/`, run through `createAgentSession` with no tools) to author the
544
- routing rule and the body, then adds the `model: "@<n>, @default"` and `tools:`
545
- frontmatter omp's own writer omits. Fits the weights to the purpose from
546
- `src/role-archetypes.ts` (10 sets; `--archetype` forces one, `--weights`
547
- overrides), asks the user for extra benchmarks to fold in (listing every
548
- weightable metric with `--list-benchmarks`; `--benchmarks m,...` covers headless
549
- runs), writes the validated role, authors `~/.omp/agent/agents/<n>.md`
550
- (`--scope project` → `<anchor>/.omp/agents/`), then runs the updater in-process so
551
- `modelRoles.<n>` lands in `config.yml`. All-or-nothing: an existing agent file
552
- without `--force`, a name outside `[A-Za-z0-9_-]+`, a reserved name, or a
553
- weight set violating Σ = 1 / Σ(non-price) = 1 − w_price aborts **before** either
554
- write. `--body-file` replaces the architect body. `--list-archetypes` prints the
555
- archetype table.
556
- - **`/remove-agent --name <n> [--scope user|project] [--lock PATH] [--yes] [--dry-run]`**:
557
- the inverse of `/create-agent`. Deletes the role's lock-file keys
558
- (`removeRoleSettings`: backup + atomic write) and the agent `.md` from the user
559
- and/or project scope, then runs the updater in-process so `modelRoles.<n>` and the
560
- plugin-managed `task.disabledAgents` entry are dropped. Refuses a shipped default
561
- role (`DEFAULT_ROLES`), a reserved name, and an invalid name; errors when there is
562
- nothing to remove. Asks for confirmation in-session unless `--yes` (skipped when
563
- there is no UI).
564
- - **Role authoring**: `node src/cli/create-role.ts --name <role> --weights m=w,...` writes a
565
- validated role def into the settings lock file (backup + atomic write); the shipped
566
- skill `omp-llm-role-create-agent` drives agent authoring + role creation + verification
567
- by hand (for bodies that need real authoring rather than the archetype scaffold).
568
-
569
- ## 11. Verification plan (implementation gate)
570
-
571
- 1. **Refactor sanity** — `node src/cli/llm-role-rank.ts --top 5`: stderr match/eligible counts
572
- identical to pre-refactor run; report diff empty.
573
- 2. **Tier gate fixtures** — unit: paid+credit → billed-only; free-tier key → `:free`-only;
574
- zero budget both branches → abort.
575
- 3. **Variant resolution fixtures** — exact/dated/bare/alias ordering; `:batch` never
576
- emitted; `:free` only on free branch.
577
- 4. **Surgical edit fixtures** — commented config, unknown keys, missing roles upsert,
578
- chain-key replace-in-place (no duplicate YAML keys), unchanged file → zero-byte diff
579
- and no mtime change.
580
- 5. **Hysteresis fixtures** — no-current adopt; ineligible current switch; margin-below
581
- keep; margin-above switch; `switchMargin: 0` always takes best; cost override —
582
- challenger inside the margin at ≥ `priceSwitchFraction` cheaper switches
583
- (`switched-cost`), `priceSwitchFraction: 0` keeps the margin in charge, and an
584
- inside-margin challenger short of the fraction stays kept.
585
- 6. **Chain pruning fixtures** — plugin-written stale keys removed; owner-written keys
586
- preserved.
587
- 7. **Live E2E** — `omp plugin link .` → new omp session → observe day-gated run;
588
- `/refresh-roles` → verify config.yml diff + notification; second same-day session →
589
- no-op; a forced `/refresh-roles` matches in-session decisions;
590
- `/explore-roles --no-open` → `curl` the notified port for `/api/bootstrap`, a repeat
591
- invocation keeps the same port, and the port is released when the session ends.
592
- 8. **Agent creation** — `create-agent` fixtures: a purpose fits the expected archetype;
593
- a `--weights` set violating Σ(non-price) = 1 − w_price is refused; an existing agent
594
- file without `--force` is refused **and writes no role**; `--dry-run` writes neither
595
- file; an architect `spec` replaces the description/body; `applyFocusBenchmarks` gives
596
- a named benchmark a decisive share, is a no-op for the archetype's own specialist set,
597
- and keeps both invariants. Live: `/create-agent` in a session (RPC mode dispatches
598
- slash commands) → the architect authors the body, the updater line
599
- `@<n>: (unset) -> <selector>` + `modelRoles.<n>` land in `config.yml`, then a headless
600
- spawn whose record reads `{"agent":"<n>","agentSource":"user","modelRole":"<n>"}` with
601
- `resolvedModel` equal to the role's selector.
602
- 9. **Benchmark sources** — `benchmark-sources` fixtures: `resolveBenchmarkSource` maps an
603
- llm-stats benchmark page, a bare benchmark id, the writing leaderboard and a Design
604
- Arena link, and returns `null` for an unknown host; `normalizeMetricKey` is dot-free;
605
- `parseBenchmarkPayload` reads the llm-stats `entries[]` and writing evidence shapes and
606
- rejects junk; `applyBenchmarkScores` fills uncovered models; `loadBenchmarkScores`
607
- follows fresh → live → stale with a temp cache dir and an injected fetch; a declaration
608
- executes (payload path, id/score fields, `scoreMax`, join) and a metric colliding with a
609
- shipped key is rejected; `dryRunDeclaration` reports coverage/leader and rejects a
610
- zero-join; `extractBenchmarkLinks` pulls URLs from prose. Live: a role weighting
611
- `bench:<id>` fetches the source and ranks on it; the explorer's `/api/bootstrap` lists
612
- the external metric with its derived metadata.
613
- 10. **Agent removal** — `remove-agent` fixtures: the role's lock-file keys and the agent
614
- file are deleted (backup written); a shipped default role, a reserved name and an
615
- invalid name are refused; nothing-to-remove errors; `--dry-run` writes nothing; the
616
- updater drops `modelRoles.<n>` and the plugin-managed `task.disabledAgents` entry
617
- (state `managedDisabledAgents`). Live: `/remove-agent --name <n>` in a session
618
- deletes both artifacts and the updater pass removes the config entries.
619
-
620
- ## 12. Out of scope
621
-
622
- First-party provider selectors (decision #12), non-OpenRouter scoring sources, a
623
- `/rollback` command (state snapshot only), marketplace publishing (link/install is the
624
- path), and auto-tuning `switchMargin`. Weight editing is the explorer's Export,
625
- `src/cli/create-role.ts` or `/create-agent` (all validate through `resolveSettings`);
626
- role/agent removal is `/remove-agent` (through `removeRoleSettings`).
627
-
628
- Benchmark sources: a generic scraper that auto-detects a payload with no user review
629
- (the declarative spec + authoring step is the mechanism), a UI for browsing/editing
630
- sources (the source file is edited by hand or by the authoring step; the explorer's
631
- role editor gains the external metrics already in use), changing the cardinal
632
- transform classes (external metrics are normalized to 0–1 at parse time), and
633
- re-ranking or re-fetching beyond the existing daily UTC cache chain.