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/src/agent-file.ts CHANGED
@@ -16,8 +16,8 @@
16
16
  * the effort and prices it; a second pin would silently disagree with it.
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync, statSync, unlinkSync } from "node:fs";
20
- import { dirname, join } from "node:path";
19
+ import { existsSync, statSync, unlinkSync } from "node:fs";
20
+ import { join } from "node:path";
21
21
  import { writeConfigAtomic } from "./config-edit.ts";
22
22
  import { agentDir } from "./state.ts";
23
23
 
@@ -99,9 +99,6 @@ export function writeAgentFile(path: string, text: string, force: boolean): Agen
99
99
  if (existsSync(path) && !force) {
100
100
  return { ok: false, error: `${path} already exists — pass --force to overwrite` };
101
101
  }
102
- // A first agent may be the first thing in the dir (~/.omp/agent/agents is not
103
- // created by omp until something writes there).
104
- mkdirSync(dirname(path), { recursive: true });
105
102
  const mtimeBefore = existsSync(path) ? statSync(path).mtimeMs : 0;
106
103
  const result = writeConfigAtomic(path, text, mtimeBefore);
107
104
  if (result === "conflict") return { ok: false, error: `${path} changed while writing — refusing to overwrite` };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Provenance for the vendored agent-creation architect prompt.
3
+ *
4
+ * `src/prompts/agent-creation-architect.md` is copied verbatim from omp's
5
+ * `/agents` hub prompt. omp's extension loader does not resolve the SDK subpath
6
+ * that prompt lives under, so the plugin ships its own copy; this constant is the
7
+ * omp version it was copied from. Bump it and the prompt's header comment
8
+ * together when re-syncing after an omp upgrade (see
9
+ * `docs/dev/agent-authoring.md`), so a drift is a visible test failure rather
10
+ * than a silent one.
11
+ *
12
+ * Kept in its own module (not `agent-architect.ts`) because that module imports
13
+ * `@oh-my-pi/pi-coding-agent`, which only resolves inside omp — a test cannot
14
+ * import it.
15
+ */
16
+ export const ARCHITECT_PROMPT_VERSION = "18.4.8";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Availability model (SPEC §5): what the user's OpenRouter key can actually run.
2
+ * Availability model: what the user's OpenRouter key can actually run.
3
3
  *
4
4
  * OpenRouter exposes no per-key model allowlist — availability is derived from
5
5
  * key tier + budget (tier gate), then every candidate must resolve to a concrete
@@ -76,7 +76,7 @@ export async function fetchKeyMeta(token: string, fetchImpl: typeof fetch = fetc
76
76
  };
77
77
  }
78
78
 
79
- /** Tier gate (SPEC §5.2): billed budget wins, else free daily quota, else run aborts. */
79
+ /** Tier gate: billed budget wins, else free daily quota, else run aborts. */
80
80
  export function tierGate(meta: KeyMeta): Tier {
81
81
  const billedUsable = !meta.isFreeTier && meta.limitRemaining > 0 && meta.creditsRemaining > 0;
82
82
  if (billedUsable) return "billed";
@@ -88,12 +88,30 @@ export type ProbeVerdict = "ok" | "blocked" | "unknown";
88
88
 
89
89
  const OPENROUTER_COMPLETIONS_URL = "https://openrouter.ai/api/v1/chat/completions";
90
90
 
91
+ /** POST a one-token completion to OpenRouter with the shared request skeleton
92
+ * (Bearer auth, JSON body, 20s timeout). Shared by `probeModel` and
93
+ * `fetchAllowedProviders`. */
94
+ function completionRequest(token: string, body: unknown, fetchImpl: typeof fetch): Promise<Response> {
95
+ return fetchImpl(OPENROUTER_COMPLETIONS_URL, {
96
+ method: "POST",
97
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
98
+ body: JSON.stringify(body),
99
+ signal: AbortSignal.timeout(20_000),
100
+ });
101
+ }
102
+
103
+ /** Extract `error.message` from a parsed OpenRouter error body, or "" when the
104
+ * body does not carry one. */
105
+ function errorMessage(body: unknown): string {
106
+ return isRecord(body) && isRecord(body.error) && typeof body.error.message === "string" ? body.error.message : "";
107
+ }
108
+
91
109
  /** 404 body OpenRouter returns when the account's allowed-providers privacy
92
110
  * whitelist (or a data-policy toggle) leaves a model without a runnable endpoint. */
93
111
  const BLOCKED_RE = /no allowed providers/i;
94
112
 
95
113
  /**
96
- * One-token completion probe (SPEC §5 addendum): the only reliable signal for
114
+ * One-token completion probe: the only reliable signal for
97
115
  * account-level provider restrictions — neither `/api/v1/key` nor the catalog
98
116
  * endpoints expose the allowed-providers whitelist, but a real request's 404
99
117
  * body names it. Only the narrow no-allowed-providers verdict blocks a
@@ -102,22 +120,165 @@ const BLOCKED_RE = /no allowed providers/i;
102
120
  */
103
121
  export async function probeModel(token: string, catalogId: string, fetchImpl: typeof fetch = fetch): Promise<ProbeVerdict> {
104
122
  try {
105
- const res = await fetchImpl(OPENROUTER_COMPLETIONS_URL, {
106
- method: "POST",
107
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
108
- body: JSON.stringify({ model: catalogId, messages: [{ role: "user", content: "ok" }], max_tokens: 1 }),
109
- signal: AbortSignal.timeout(20_000),
110
- });
123
+ const res = await completionRequest(token, { model: catalogId, messages: [{ role: "user", content: "ok" }], max_tokens: 1 }, fetchImpl);
111
124
  if (res.ok) return "ok";
112
125
  if (res.status !== 404) return "unknown";
113
126
  const body: unknown = await res.json().catch(() => null);
114
- const message = isRecord(body) && isRecord(body.error) && typeof body.error.message === "string" ? body.error.message : "";
127
+ const message = errorMessage(body);
115
128
  return BLOCKED_RE.test(message) ? "blocked" : "unknown";
116
129
  } catch {
117
130
  return "unknown";
118
131
  }
119
132
  }
120
133
 
134
+ /** 404 body OpenRouter returns when the account's allowed-providers privacy
135
+ * whitelist leaves the canary provider without a runnable endpoint; the message
136
+ * names the whitelist after "permits only:". */
137
+ const ALLOWED_PROVIDERS_RE = /permits only:\s*([^.]*)\./i;
138
+
139
+ /**
140
+ * Harvest the account's allowed-providers privacy whitelist. The whitelist is
141
+ * invisible to `/api/v1/key` and the catalog endpoints; the only place it
142
+ * appears is the 404 body of a request that fails because of it. A canary
143
+ * completion on a valid model with a nonexistent provider slug (`provider.only`)
144
+ * 404s and names the whitelist. Returns null when the body does not name a
145
+ * whitelist (no whitelist set), the response is not a 404, or the request
146
+ * throws — never throws.
147
+ */
148
+ export async function fetchAllowedProviders(token: string, modelId: string, fetchImpl: typeof fetch = fetch): Promise<Set<string> | null> {
149
+ try {
150
+ const res = await completionRequest(
151
+ token,
152
+ {
153
+ model: modelId,
154
+ messages: [{ role: "user", content: "ok" }],
155
+ max_tokens: 1,
156
+ provider: { only: ["__omp-llm-role-canary__"] },
157
+ },
158
+ fetchImpl,
159
+ );
160
+ if (res.status !== 404) return null;
161
+ const body: unknown = await res.json().catch(() => null);
162
+ const message = errorMessage(body);
163
+ const match = ALLOWED_PROVIDERS_RE.exec(message);
164
+ if (!match) return null;
165
+ const providers = match[1]
166
+ .split(",")
167
+ .map((s) => s.trim())
168
+ .filter((s) => s.length > 0);
169
+ return providers.length > 0 ? new Set(providers) : null;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Whether a route's `providerSlug` is permitted by the account's
177
+ * allowed-providers whitelist. Whitelist entries are bare lab names
178
+ * (`coreweave`) while route slugs are tiered (`coreweave/fp8`), so a match is
179
+ * the entry itself or an `entry/` prefix.
180
+ */
181
+ export function providerAllowed(allowed: ReadonlySet<string>, slug: string): boolean {
182
+ for (const entry of allowed) {
183
+ if (slug === entry || slug.startsWith(`${entry}/`)) return true;
184
+ }
185
+ return false;
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Keyed-catalog availability
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * What the user's OpenRouter key can actually run, derived from the account
194
+ * setting "Filter the model catalog for API keys": with it on, a
195
+ * Bearer-authenticated `GET /api/v1/models` returns only the models the key may
196
+ * use (provider restrictions and guardrails applied), while the unauthenticated
197
+ * request still sees the public catalog. Set membership is the only signal —
198
+ * the response carries no per-model access flag.
199
+ *
200
+ * `allowed`/`blocked` are populated only when `reason === "active"`; every other
201
+ * reason leaves them empty so a consumer that forgets to check `active` degrades
202
+ * to "unknown" rather than over-claiming "usable".
203
+ */
204
+ export type KeyAvailability = {
205
+ /** keyed catalog ⊊ public catalog → the OpenRouter filter is on. */
206
+ active: boolean;
207
+ reason: "active" | "no-filter" | "unavailable";
208
+ /** ranking ids present in the keyed catalog (empty unless `active`). */
209
+ allowed: ReadonlySet<string>;
210
+ /** ranking ids present in the public catalog but not the keyed one (empty unless `active`). */
211
+ blocked: ReadonlySet<string>;
212
+ publicCount: number;
213
+ keyedCount: number;
214
+ /** ISO 8601. */
215
+ fetchedAt: string;
216
+ };
217
+
218
+ const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
219
+
220
+ /** Tolerant `data[].id` reader; null when the payload is not the expected shape. */
221
+ function modelIdsFromPayload(v: unknown): string[] | null {
222
+ if (!isRecord(v) || !Array.isArray(v.data)) return null;
223
+ const ids: string[] = [];
224
+ for (const row of v.data) {
225
+ if (isRecord(row) && typeof row.id === "string") ids.push(row.id);
226
+ }
227
+ return ids;
228
+ }
229
+
230
+ /**
231
+ * Pure derivation of the availability mark from the two catalog id lists. Both
232
+ * are normalized with `rankingIdOf` — the same identity space `resolveVariant`
233
+ * and the engine's `Model.id` use — so `~org/x-latest`, `org/x:free` and
234
+ * `org/x` collapse to one ranking id and aliases never produce a false "blocked".
235
+ *
236
+ * `active` requires a non-empty keyed list that is a **proper subset** of the
237
+ * public one; equality (the setting is off) or a keyed id absent from public is
238
+ * `no-filter`; an empty list is `unavailable`.
239
+ */
240
+ export function computeKeyAvailability(publicIds: string[], keyedIds: string[], fetchedAt = new Date().toISOString()): KeyAvailability {
241
+ const publicSet = new Set(publicIds.map(rankingIdOf));
242
+ const keyedSet = new Set(keyedIds.map(rankingIdOf));
243
+ const base = { publicCount: publicSet.size, keyedCount: keyedSet.size, fetchedAt };
244
+ if (publicSet.size === 0 || keyedSet.size === 0) {
245
+ return { active: false, reason: "unavailable", allowed: new Set(), blocked: new Set(), ...base };
246
+ }
247
+ const subset = keyedSet.size < publicSet.size && [...keyedSet].every((id) => publicSet.has(id));
248
+ if (!subset) return { active: false, reason: "no-filter", allowed: new Set(), blocked: new Set(), ...base };
249
+ return {
250
+ active: true,
251
+ reason: "active",
252
+ allowed: keyedSet,
253
+ blocked: new Set([...publicSet].filter((id) => !keyedSet.has(id))),
254
+ ...base,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Fetch the public and key-authenticated catalogs in parallel and derive the
260
+ * availability mark. **Never throws**: any transport, status, or shape failure
261
+ * yields `reason: "unavailable"` with empty sets, so both callers (the explorer
262
+ * and the updater) keep their current behavior on a transient OpenRouter outage.
263
+ */
264
+ export async function fetchKeyAvailability(token: string, fetchImpl: typeof fetch = fetch): Promise<KeyAvailability> {
265
+ const fetchedAt = new Date().toISOString();
266
+ const unavailable: KeyAvailability = { active: false, reason: "unavailable", allowed: new Set(), blocked: new Set(), publicCount: 0, keyedCount: 0, fetchedAt };
267
+ try {
268
+ const [publicRes, keyedRes] = await Promise.all([
269
+ fetchImpl(OPENROUTER_MODELS_URL),
270
+ fetchImpl(OPENROUTER_MODELS_URL, { headers: { authorization: `Bearer ${token}` } }),
271
+ ]);
272
+ if (!publicRes.ok || !keyedRes.ok) return unavailable;
273
+ const publicIds = modelIdsFromPayload(await publicRes.json());
274
+ const keyedIds = modelIdsFromPayload(await keyedRes.json());
275
+ if (publicIds === null || keyedIds === null) return unavailable;
276
+ return computeKeyAvailability(publicIds, keyedIds, fetchedAt);
277
+ } catch {
278
+ return unavailable;
279
+ }
280
+ }
281
+
121
282
  /** Keep openrouter catalog rows the tier may run: never `:batch`; `:free` only on free tier. */
122
283
  export function filterCatalog(catalog: CatalogEntry[], tier: Tier): CatalogEntry[] {
123
284
  if (tier === "none") return [];
@@ -134,12 +295,27 @@ export function rankingIdOf(catalogId: string): string {
134
295
  return noFree.endsWith("-latest") ? noFree.slice(0, -"-latest".length) : noFree;
135
296
  }
136
297
 
137
- /** Identity of a configured selector for hysteresis/chain keys: drop `:level`, `openrouter/`, then rankingIdOf. */
138
- export function currentRankingId(selector: string): string {
298
+ /** Split a selector's trailing thinking suffix (`provider/model[:level]`):
299
+ * `base` without the suffix, `level` the suffix or null. The single home for
300
+ * the `:level` strip shared by `currentRankingId`, `providerPinOfSelector`, and
301
+ * the extension's selector handling. */
302
+ export function splitThinkingSuffix(selector: string): { base: string; level: string | null } {
139
303
  const colon = selector.lastIndexOf(":");
140
- const base = colon !== -1 && selector.slice(colon + 1) in THINKING_LEVELS ? selector.slice(0, colon) : selector;
304
+ if (colon !== -1 && selector.slice(colon + 1) in THINKING_LEVELS) {
305
+ return { base: selector.slice(0, colon), level: selector.slice(colon + 1) };
306
+ }
307
+ return { base: selector, level: null };
308
+ }
309
+
310
+ /** Identity of a configured selector for hysteresis/chain keys: drop `:level`,
311
+ * `openrouter/`, and a trailing `@<slug>` provider pin, then rankingIdOf. A
312
+ * tiered pin (`@deepinfra/fp8`) is dropped whole — everything from the last `@`. */
313
+ export function currentRankingId(selector: string): string {
314
+ const { base } = splitThinkingSuffix(selector);
141
315
  const unprefixed = base.startsWith("openrouter/") ? base.slice("openrouter/".length) : base;
142
- return rankingIdOf(unprefixed);
316
+ const at = unprefixed.lastIndexOf("@");
317
+ const unpinned = at !== -1 ? unprefixed.slice(0, at) : unprefixed;
318
+ return rankingIdOf(unpinned);
143
319
  }
144
320
 
145
321
  // "Newest dated" = trailing -MMDD or -YYYYMMDD (max numeric wins; ties lexicographic).
@@ -147,7 +323,7 @@ const DATED_RE = /-(\d{4}|\d{8})$/;
147
323
 
148
324
  /**
149
325
  * Map a ranking row id (llm-stats bare id) to a concrete catalog id among
150
- * tier-eligible candidates (SPEC §5.4). Candidates match when their suffix after
326
+ * tier-eligible candidates. Candidates match when their suffix after
151
327
  * the last `/` equals the ranking id, possibly after stripping `:free` and one
152
328
  * `-latest` (org prefixes, including `~`-prefixed, are ignored for matching).
153
329
  * Order: exact id -> newest dated -> bare -> `~org/…-latest` alias (last resort —
@@ -4,12 +4,13 @@
4
4
  * from the fetched content. The extension then dry-runs the declaration (fetch,
5
5
  * parse, join, coverage/leader) and asks the user to confirm before it is saved.
6
6
  *
7
- * This module is extension-only: it imports `@oh-my-pi/pi-coding-agent`, which
8
- * only resolves inside omp. The tests import the pure validation/execution path
9
- * in `src/benchmark-sources.ts` instead.
7
+ * This module is extension-only: it imports `@oh-my-pi/pi-coding-agent`
8
+ * dynamically, inside the function (the SDK only resolves inside omp; a static
9
+ * import failure would be fatal to the whole `extension.ts` load). The tests
10
+ * import the pure validation/execution path in `src/benchmark-sources.ts`
11
+ * instead.
10
12
  */
11
13
 
12
- import { createAgentSession } from "@oh-my-pi/pi-coding-agent";
13
14
  import { readFileSync } from "node:fs";
14
15
  import { fetchText, parseSourceDeclaration, type SourceDeclaration } from "./benchmark-sources.ts";
15
16
  import { extractAssistantText, extractJsonObject } from "./agent-architect.ts";
@@ -47,6 +48,10 @@ export type BenchmarkAuthorOptions = {
47
48
  */
48
49
  export async function authorBenchmarkSource(opts: BenchmarkAuthorOptions): Promise<SourceDeclaration> {
49
50
  const content = (await fetchText(opts.link)).slice(0, MAX_CONTENT);
51
+ // Dynamic, inside the function: `@oh-my-pi/pi-coding-agent` only resolves
52
+ // inside omp, and a static import failure would be fatal to the whole
53
+ // `extension.ts` load (see `agent-architect.ts`).
54
+ const { createAgentSession } = await import("@oh-my-pi/pi-coding-agent");
50
55
  const { session } = await createAgentSession({
51
56
  cwd: opts.cwd,
52
57
  model: opts.model as never,