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.
- package/README.md +202 -12
- package/agents/designer.md +4 -3
- package/docs/dev/README.md +44 -7
- package/docs/dev/agent-authoring.md +59 -1
- package/docs/dev/architecture.md +106 -28
- package/docs/dev/ci.md +163 -0
- package/docs/dev/current-state.md +364 -24
- package/docs/dev/data-sources.md +231 -38
- package/docs/dev/explorer.md +175 -46
- package/docs/dev/quirks.md +77 -4
- package/docs/dev/releasing.md +130 -17
- package/docs/dev/scoring.md +851 -27
- package/docs/dev/testing.md +253 -0
- package/docs/dev/writes-and-state.md +232 -28
- package/docs/llm-role-rankings.md +149 -148
- package/package.json +814 -80
- package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
- package/src/agent-architect.ts +148 -19
- package/src/agent-create.ts +800 -40
- package/src/agent-file.ts +2 -5
- package/src/architect-provenance.ts +16 -0
- package/src/availability.ts +191 -15
- package/src/benchmark-author.ts +9 -4
- package/src/benchmark-sources.ts +283 -18
- package/src/cli/create-role.ts +29 -1
- package/src/cli/llm-role-rank.ts +40 -1
- package/src/config-edit.ts +207 -80
- package/src/engine.ts +519 -75
- package/src/error-report.ts +562 -0
- package/src/explorer/boot.ts +36 -24
- package/src/explorer/explain.ts +128 -13
- package/src/explorer/scopes.ts +110 -0
- package/src/explorer/server.ts +126 -33
- package/src/extension.ts +464 -48
- package/src/features.ts +105 -0
- package/src/project-registry.ts +65 -0
- package/src/project-setup.ts +382 -0
- package/src/prompts/agent-creation-architect.md +2 -0
- package/src/prompts/project-profile-architect.md +33 -0
- package/src/prompts/project-profile-user.md +8 -0
- package/src/provider-display.ts +67 -0
- package/src/role-archetypes.ts +12 -2
- package/src/role-settings.ts +22 -0
- package/src/settings.ts +211 -24
- package/src/state.ts +1 -1
- package/src/updater.ts +234 -37
- package/web/app.js +434 -12
- package/web/index.html +1 -0
- package/web/style.css +44 -1
- package/docs/dev/spec.md +0 -633
package/src/extension.ts
CHANGED
|
@@ -10,20 +10,26 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
13
|
-
import { execFile } from "node:child_process";
|
|
14
|
-
import { readFileSync } from "node:fs";
|
|
13
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
14
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
15
16
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import { createAgent, CREATE_AGENT_USAGE, discoverBenchmarks, formatArchetypes, formatBenchmarks, formatCreateAgentReport, parseCreateAgentInput, tokenizeArgs, type
|
|
17
|
+
import { assessFocusMetric, countMetricCoverage, createAgent, CREATE_AGENT_USAGE, differentiationWarning, discoverBenchmarks, formatArchetypes, formatBenchmarks, formatCreateAgentReport, parseCreateAgentInput, resolveRole, tokenizeArgs, type DiscoveryOutcome, type FocusAssessor, type FocusMetricAssessment } from "./agent-create.ts";
|
|
17
18
|
import { formatRemoveAgentReport, parseRemoveAgentArgs, removeAgent, REMOVE_AGENT_USAGE } from "./agent-remove.ts";
|
|
18
|
-
import { generateAgentSpec } from "./agent-architect.ts";
|
|
19
|
+
import { generateAgentSpec, generateProjectProfile } from "./agent-architect.ts";
|
|
19
20
|
import { authorBenchmarkSource } from "./benchmark-author.ts";
|
|
20
|
-
import { declarationToSource, declaredSourceForLink, dryRunDeclaration, fetchJson, loadBenchmarkCatalog, loadBenchmarkScores, loadDeclaredSources, resolveBenchmarkSource, saveDeclaredSource, validateDeclaration, type BenchmarkCatalogEntry, type BenchmarkSource, type SourceDeclaration } from "./benchmark-sources.ts";
|
|
21
|
+
import { applyBenchmarkScores, BENCHMARK_ENTRY_CAP, cachedSourceInfo, declarationToSource, declaredSourceForLink, dryRunDeclaration, fetchJson, loadBenchmarkCatalog, loadBenchmarkScores, loadDeclaredSources, resolveBenchmarkSource, saveDeclaredSource, sourceForMetric, validateDeclaration, type BenchmarkCatalogEntry, type BenchmarkPayloadMeta, type BenchmarkSource, type SourceDeclaration } from "./benchmark-sources.ts";
|
|
21
22
|
import { judgeBenchmarkRelevance } from "./benchmark-discovery.ts";
|
|
22
|
-
import {
|
|
23
|
-
import { loadRankData } from "./engine.ts";
|
|
23
|
+
import { catalogFromOmpModelsJson, fetchKeyAvailability, splitThinkingSuffix, type KeyAvailability } from "./availability.ts";
|
|
24
|
+
import { loadRankData, rankRole, type Model } from "./engine.ts";
|
|
25
|
+
import { defaultErrorReportDeps, reportUnexpectedError, type ErrorReportInput } from "./error-report.ts";
|
|
26
|
+
import { formatFeatures } from "./features.ts";
|
|
24
27
|
import { startExplorer, type ExplorerHandle } from "./explorer/boot.ts";
|
|
28
|
+
import { resolveScopes, unionRoles } from "./explorer/scopes.ts";
|
|
25
29
|
import { isRecord } from "./guards.ts";
|
|
26
|
-
import {
|
|
30
|
+
import { applyProviderDisplay, providerPinOf, providerPinOfSelector } from "./provider-display.ts";
|
|
31
|
+
import { applyProfileOverrides, formatProjectRolesReport, parseProjectRolesArgs, PROJECT_ROLES_USAGE, setupProject, type ProjectProfile } from "./project-setup.ts";
|
|
32
|
+
import { DEFAULT_ROLES, findProjectAnchor, KNOWN_METRICS, PLUGIN_SETTINGS_PATH, projectLockPath, readPluginSettingsMap, resolveSettings } from "./settings.ts";
|
|
27
33
|
import { runUpdater, type Deps } from "./updater.ts";
|
|
28
34
|
|
|
29
35
|
/** SPA directory shipped beside this extension (repo `web/`). */
|
|
@@ -66,17 +72,41 @@ function hasEmptyConversation(ctx: ExtContext): boolean {
|
|
|
66
72
|
return true;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
75
|
+
/**
|
|
76
|
+
* True when `selector` names the live session model: the selector's base
|
|
77
|
+
* (thinking suffix stripped) resolves to a model with the same `provider` and
|
|
78
|
+
* `id` as `ctx.models.current()`. Any thinking level or provider pin is
|
|
79
|
+
* ignored. Used both to detect a session booted on the old default role and to
|
|
80
|
+
* decide whether a fallback event's selector is authoritative for the session
|
|
81
|
+
* (advisor fallbacks emit the same events with the advisor's selector).
|
|
82
|
+
*/
|
|
83
|
+
function liveModelIsSelector(ctx: ExtContext, selector: string): boolean {
|
|
71
84
|
const current = ctx.models?.current();
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
const { base } = splitThinkingSuffix(selector);
|
|
86
|
+
const target = ctx.models?.resolve(base);
|
|
87
|
+
if (current === undefined || current === null || target === undefined || target === null) return false;
|
|
88
|
+
if (typeof current !== "object" || typeof target !== "object") return false;
|
|
89
|
+
if (!("provider" in current) || !("id" in current) || !("provider" in target) || !("id" in target)) return false;
|
|
90
|
+
return current.provider === target.provider && current.id === target.id;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Apply the provider display suffix to the live session model (spec #77).
|
|
95
|
+
* `selector` is the event's authoritative selector string when the event
|
|
96
|
+
* carries one; the live model's routing block is the fallback. The event's
|
|
97
|
+
* selector is trusted only when it names the live session model — the fallback
|
|
98
|
+
* events are also emitted for advisor fallbacks, where only the advisor's model
|
|
99
|
+
* changes and the session model is untouched. Contained: a throw must not tear
|
|
100
|
+
* down the session or escape into omp's dispatch.
|
|
101
|
+
*/
|
|
102
|
+
function applyProviderDisplayFor(ctx: ExtContext, selector?: string): void {
|
|
103
|
+
try {
|
|
104
|
+
const live = ctx.models?.current();
|
|
105
|
+
const eventPin = typeof selector === "string" && liveModelIsSelector(ctx, selector) ? providerPinOfSelector(selector) : null;
|
|
106
|
+
applyProviderDisplay(live, eventPin ?? providerPinOf(live));
|
|
107
|
+
} catch {
|
|
108
|
+
// display-only; never let a failure escape into omp's dispatch
|
|
109
|
+
}
|
|
80
110
|
}
|
|
81
111
|
|
|
82
112
|
/**
|
|
@@ -92,10 +122,8 @@ function makeApplySessionModel(pi: ExtensionAPI, ctx: ExtContext, notify: (lines
|
|
|
92
122
|
if (ctx.agent.kind !== "main") return;
|
|
93
123
|
if (!hasEmptyConversation(ctx)) return;
|
|
94
124
|
if (previous === null) return; // first run ever wrote a fresh default; the session predates no old role
|
|
95
|
-
if (!
|
|
96
|
-
const
|
|
97
|
-
const level = colon !== -1 && selector.slice(colon + 1) in THINKING_LEVELS ? selector.slice(colon + 1) : null;
|
|
98
|
-
const base = level === null ? selector : selector.slice(0, selector.length - level.length - 1);
|
|
125
|
+
if (!liveModelIsSelector(ctx, previous)) return;
|
|
126
|
+
const { base, level } = splitThinkingSuffix(selector);
|
|
99
127
|
const model = ctx.models?.resolve(base);
|
|
100
128
|
if (model === undefined || model === null) {
|
|
101
129
|
notify([`omp-llm-role: session model not updated — "${base}" matches no available model`]);
|
|
@@ -160,12 +188,59 @@ function parseExplorerArgs(args: string): { port: number | undefined; open: bool
|
|
|
160
188
|
return { port: valid, open };
|
|
161
189
|
}
|
|
162
190
|
|
|
163
|
-
/** Mirror
|
|
164
|
-
function
|
|
165
|
-
ctx.ui.notify(line,
|
|
191
|
+
/** Mirror a line to the UI, and to stderr when the mode has no UI. */
|
|
192
|
+
function notifyLine(ctx: ExtContext, line: string, level: "info" | "warning"): void {
|
|
193
|
+
ctx.ui.notify(line, level);
|
|
166
194
|
if (!ctx.hasUI) console.error(line);
|
|
167
195
|
}
|
|
168
196
|
|
|
197
|
+
/** The explorer/authoring line sink: the shared sink at `info`. */
|
|
198
|
+
function notifyLines(ctx: ExtContext, line: string): void {
|
|
199
|
+
notifyLine(ctx, line, "info");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The `ask` posture's yes/no prompt, built from `ctx.ui.select?` — present only
|
|
203
|
+
* in a UI session, so a headless `ask` degrades to notify-plus-URL. */
|
|
204
|
+
function reportConfirm(ctx: ExtContext): ((title: string, payload: string) => Promise<boolean>) | undefined {
|
|
205
|
+
if (!ctx.hasUI || !ctx.ui.select) return undefined;
|
|
206
|
+
return async (title, payload) => {
|
|
207
|
+
const answer = await ctx.ui.select?.(`${title}\n\n${payload}`, [
|
|
208
|
+
{ label: "File", description: "create the GitHub issue with the text above" },
|
|
209
|
+
{ label: "Cancel" },
|
|
210
|
+
]);
|
|
211
|
+
return answer === "File";
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The reporting module's input for one failure: the shared line sink and the
|
|
216
|
+
* `ask` prompt, both bound to this context. */
|
|
217
|
+
function reportInput(ctx: ExtContext, label: string, error: unknown): ErrorReportInput {
|
|
218
|
+
return {
|
|
219
|
+
label,
|
|
220
|
+
error,
|
|
221
|
+
hasUI: ctx.hasUI,
|
|
222
|
+
notify: (line, level) => notifyLine(ctx, line, level),
|
|
223
|
+
confirm: reportConfirm(ctx),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Route an unexpected command failure through the reporting module. The
|
|
228
|
+
* one-line summary is unchanged from the pre-reporting behaviour; the module
|
|
229
|
+
* adds its own lines (filed/commented/offered) when reporting is on. */
|
|
230
|
+
function reportCommandError(ctx: ExtContext, label: string, err: unknown, prefix = "llm-role"): void {
|
|
231
|
+
ctx.ui.notify(`${prefix}: ${err instanceof Error ? err.message : err}`, "warning");
|
|
232
|
+
void reportUnexpectedError(reportInput(ctx, label, err), defaultErrorReportDeps());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Report an updater defect (the abort line is already notified by runUpdater).
|
|
236
|
+
* The defect is passed as a string, not wrapped in an `Error`: the module then
|
|
237
|
+
* derives the failure site from the message, so each distinct abort gets its
|
|
238
|
+
* own fingerprint instead of collapsing onto this construction site. */
|
|
239
|
+
function reportDefect(ctx: ExtContext, label: string, defect: string | undefined): void {
|
|
240
|
+
if (defect === undefined) return;
|
|
241
|
+
void reportUnexpectedError(reportInput(ctx, label, defect), defaultErrorReportDeps());
|
|
242
|
+
}
|
|
243
|
+
|
|
169
244
|
/** External metrics already weighted by a resolved role, for `--list-benchmarks`. */
|
|
170
245
|
function externalMetricsInUse(): string[] {
|
|
171
246
|
const { settings } = resolveSettings(readPluginSettingsMap());
|
|
@@ -252,11 +327,23 @@ async function resolveBenchmarkLinks(ctx: ExtContext, links: readonly string[],
|
|
|
252
327
|
source = await authorLink(ctx, link, yes);
|
|
253
328
|
if (source === null) return null;
|
|
254
329
|
}
|
|
330
|
+
// A generic llm-stats benchmark's raw id lives only in its declaration (the
|
|
331
|
+
// metric key folds `.`→`_`), so persist it before the update run — otherwise
|
|
332
|
+
// the updater's metric-key fallback fetches the wrong, 404-ing URL.
|
|
333
|
+
const declaration = source.declaration;
|
|
334
|
+
if (declaration !== undefined && source.labelFromPayload !== undefined && !declared.some((d) => d.metric === declaration.metric)) {
|
|
335
|
+
const saved = saveDeclaredSource(declaration);
|
|
336
|
+
if (saved.ok) declared.push(declaration);
|
|
337
|
+
else notifyLines(ctx, `create-agent: could not persist ${source.metric} source: ${saved.error}`);
|
|
338
|
+
}
|
|
255
339
|
const loaded = await loadBenchmarkScores(source, false);
|
|
256
340
|
if (loaded === null) {
|
|
257
341
|
notifyLines(ctx, `create-agent: ${source.metric} (${source.label}) — no data fetched; the role will rank on its other weights`);
|
|
258
342
|
} else {
|
|
259
|
-
notifyLines(
|
|
343
|
+
notifyLines(
|
|
344
|
+
ctx,
|
|
345
|
+
`create-agent: ${source.metric} (${loaded.label}) — ${loaded.loaded} rows${loaded.total !== null && loaded.loaded < loaded.total ? ` of ${loaded.total} (endpoint caps entries at ${BENCHMARK_ENTRY_CAP})` : ""}`,
|
|
346
|
+
);
|
|
260
347
|
}
|
|
261
348
|
metrics.push(source.metric);
|
|
262
349
|
labels.push(loaded?.label ?? source.label);
|
|
@@ -267,9 +354,10 @@ async function resolveBenchmarkLinks(ctx: ExtContext, links: readonly string[],
|
|
|
267
354
|
/**
|
|
268
355
|
* Discover the catalog benchmarks relevant to `purpose` (non-fatal): a catalog
|
|
269
356
|
* fetch or judge failure just skips discovery, so the command still creates the
|
|
270
|
-
* role.
|
|
357
|
+
* role. `fieldSize` is the ranking universe's model count (null when unknown),
|
|
358
|
+
* for the coverage gate. Returns null when discovery could not run.
|
|
271
359
|
*/
|
|
272
|
-
async function discoverForPurpose(ctx: ExtContext, purpose: string, exclude: readonly string[]): Promise<
|
|
360
|
+
async function discoverForPurpose(ctx: ExtContext, purpose: string, exclude: readonly string[], fieldSize: number | null, assess: FocusAssessor): Promise<DiscoveryOutcome | null> {
|
|
273
361
|
let catalog: BenchmarkCatalogEntry[] | null;
|
|
274
362
|
try {
|
|
275
363
|
catalog = await loadBenchmarkCatalog(false);
|
|
@@ -282,13 +370,57 @@ async function discoverForPurpose(ctx: ExtContext, purpose: string, exclude: rea
|
|
|
282
370
|
return null;
|
|
283
371
|
}
|
|
284
372
|
try {
|
|
285
|
-
return await discoverBenchmarks(purpose, catalog, (p, candidates) => judgeBenchmarkRelevance(p, candidates, ctx.cwd), exclude);
|
|
373
|
+
return await discoverBenchmarks(purpose, catalog, (p, candidates) => judgeBenchmarkRelevance(p, candidates, ctx.cwd), exclude, fieldSize, assess);
|
|
286
374
|
} catch (err) {
|
|
287
375
|
notifyLines(ctx, `create-agent: benchmark discovery failed (${err instanceof Error ? err.message : err}) — skipping`);
|
|
288
376
|
return null;
|
|
289
377
|
}
|
|
290
378
|
}
|
|
291
379
|
|
|
380
|
+
/** Bounded repository artifacts for the `/project-roles` profile architect:
|
|
381
|
+
* README, package.json, AGENTS.md, a docs listing, the recent git log and a
|
|
382
|
+
* shallow file tree. Every read is best-effort — a missing file or a non-git cwd
|
|
383
|
+
* just contributes nothing. */
|
|
384
|
+
function gatherProjectArtifacts(cwd: string): string {
|
|
385
|
+
const parts: string[] = [];
|
|
386
|
+
const readBounded = (rel: string, cap = 4000): string | null => {
|
|
387
|
+
try {
|
|
388
|
+
return readFileSync(join(cwd, rel), "utf8").slice(0, cap);
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
for (const rel of ["README.md", "package.json", "AGENTS.md", "pyproject.toml", "Cargo.toml", "go.mod"]) {
|
|
394
|
+
const text = readBounded(rel);
|
|
395
|
+
if (text !== null) parts.push(`### ${rel}\n${text}`);
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const docs = readdirSync(join(cwd, "docs"), { recursive: true })
|
|
399
|
+
.map(String)
|
|
400
|
+
.filter((p) => p.endsWith(".md"))
|
|
401
|
+
.slice(0, 40);
|
|
402
|
+
if (docs.length > 0) parts.push(`### docs/\n${docs.join("\n")}`);
|
|
403
|
+
} catch {
|
|
404
|
+
// no docs dir
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const log = execFileSync("git", ["log", "--oneline", "-20"], { cwd, encoding: "utf8", timeout: 5000 });
|
|
408
|
+
parts.push(`### git log\n${log}`);
|
|
409
|
+
} catch {
|
|
410
|
+
// not a git repo
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const entries = readdirSync(cwd, { withFileTypes: true })
|
|
414
|
+
.filter((entry) => !entry.name.startsWith(".") && entry.name !== "node_modules")
|
|
415
|
+
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
|
|
416
|
+
.slice(0, 60);
|
|
417
|
+
parts.push(`### files\n${entries.join("\n")}`);
|
|
418
|
+
} catch {
|
|
419
|
+
// unreadable cwd
|
|
420
|
+
}
|
|
421
|
+
return parts.join("\n\n");
|
|
422
|
+
}
|
|
423
|
+
|
|
292
424
|
export default function (pi: ExtensionAPI) {
|
|
293
425
|
/** Loopback explorer owned by this session binding (null until launched). */
|
|
294
426
|
let explorer: ExplorerHandle | null = null;
|
|
@@ -302,19 +434,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
302
434
|
// mid-loop, no write). runUpdater catches its own abort paths; a throw
|
|
303
435
|
// here is contained by handler dispatch (extension error channel).
|
|
304
436
|
try {
|
|
305
|
-
await runUpdater("session-start", extDeps(pi, ctx));
|
|
437
|
+
const runResult = await runUpdater("session-start", extDeps(pi, ctx));
|
|
438
|
+
reportDefect(ctx, "session-start", runResult.defect);
|
|
306
439
|
} catch (err) {
|
|
307
|
-
ctx
|
|
440
|
+
reportCommandError(ctx, "session-start", err);
|
|
308
441
|
}
|
|
309
442
|
});
|
|
310
443
|
|
|
444
|
+
// Provider display (spec #77): keep the live model's name suffixed with its
|
|
445
|
+
// provider pin. Never pi.setModel — that would wipe omp's in-flight fallback
|
|
446
|
+
// state and clobber the thinking level; mutate the live model's name only.
|
|
447
|
+
// Registered after the updater so the session-start pin reflects the model
|
|
448
|
+
// the run just selected.
|
|
449
|
+
pi.on("session_start", async (_event, ctx: ExtContext) => {
|
|
450
|
+
applyProviderDisplayFor(ctx);
|
|
451
|
+
});
|
|
452
|
+
pi.on("retry_fallback_applied", async (event, ctx: ExtContext) => {
|
|
453
|
+
applyProviderDisplayFor(ctx, event.to);
|
|
454
|
+
});
|
|
455
|
+
pi.on("retry_fallback_succeeded", async (event, ctx: ExtContext) => {
|
|
456
|
+
applyProviderDisplayFor(ctx, event.model);
|
|
457
|
+
});
|
|
458
|
+
// Self-healing hook: omp restores the primary after a fallback cooldown with
|
|
459
|
+
// no session event, so re-derive the pin from the live model before each turn.
|
|
460
|
+
pi.on("turn_start", async (_event, ctx: ExtContext) => {
|
|
461
|
+
applyProviderDisplayFor(ctx);
|
|
462
|
+
});
|
|
463
|
+
|
|
311
464
|
pi.registerCommand("refresh-roles", {
|
|
312
465
|
description: "Refresh model roles from today's llm-stats/OpenRouter rankings",
|
|
313
466
|
handler: async (_args, ctx: ExtContext) => {
|
|
314
467
|
try {
|
|
315
|
-
await runUpdater("manual", extDeps(pi, ctx), { force: true });
|
|
468
|
+
const runResult = await runUpdater("manual", extDeps(pi, ctx), { force: true });
|
|
469
|
+
reportDefect(ctx, "refresh-roles", runResult.defect);
|
|
316
470
|
} catch (err) {
|
|
317
|
-
ctx
|
|
471
|
+
reportCommandError(ctx, "refresh-roles", err);
|
|
318
472
|
}
|
|
319
473
|
},
|
|
320
474
|
});
|
|
@@ -333,13 +487,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
333
487
|
return;
|
|
334
488
|
}
|
|
335
489
|
notifyLines(ctx, "llm-role explorer: loading today's rankings…");
|
|
336
|
-
|
|
490
|
+
// The explorer ranks the resolved roles of every known scope, so its
|
|
491
|
+
// dataset must carry every metric any of them weights — including
|
|
492
|
+
// generic llm-stats benchmarks (`bench:<id>`) that only a project role
|
|
493
|
+
// pulls in. `loadRankData({})` loads declared sources only, so a role
|
|
494
|
+
// weighting an undeclared benchmark would rank on a dataset missing that
|
|
495
|
+
// metric and the explorer would disagree with the updater. Pass the
|
|
496
|
+
// union of every scope's resolved roles plus the shipped keys, so the
|
|
497
|
+
// dataset stays a superset of any def the UI can rank (the UI can weight
|
|
498
|
+
// `website`/`writing` even when no role does) and a scope switch is free.
|
|
499
|
+
const scopeInputs = { userLockPath: PLUGIN_SETTINGS_PATH, cwd: ctx.cwd };
|
|
500
|
+
const explorerRoles = () => unionRoles(resolveScopes(scopeInputs), PLUGIN_SETTINGS_PATH);
|
|
501
|
+
const rank = await loadRankData({ roles: explorerRoles(), extraMetrics: Object.keys(KNOWN_METRICS) });
|
|
337
502
|
const catalog = await extDeps(pi, ctx).getCatalog();
|
|
503
|
+
// Best-effort key-availability overlay: a missing key or a failed fetch
|
|
504
|
+
// degrades to "unavailable" and never blocks Explorer boot.
|
|
505
|
+
const loadAvailability = async (): Promise<KeyAvailability> => {
|
|
506
|
+
const token = await ctx.modelRegistry.getApiKeyForProvider("openrouter").catch(() => undefined);
|
|
507
|
+
return token
|
|
508
|
+
? fetchKeyAvailability(token)
|
|
509
|
+
: { active: false, reason: "unavailable", allowed: new Set(), blocked: new Set(), publicCount: 0, keyedCount: 0, fetchedAt: new Date().toISOString() };
|
|
510
|
+
};
|
|
338
511
|
explorer = await startExplorer({
|
|
339
512
|
webDir: WEB_DIR,
|
|
513
|
+
cwd: ctx.cwd,
|
|
340
514
|
rank,
|
|
341
515
|
catalog,
|
|
342
|
-
|
|
516
|
+
availability: await loadAvailability(),
|
|
517
|
+
reloadAvailability: loadAvailability,
|
|
518
|
+
reload: (refresh) => loadRankData({ refresh, roles: explorerRoles(), extraMetrics: Object.keys(KNOWN_METRICS) }),
|
|
343
519
|
port,
|
|
344
520
|
open,
|
|
345
521
|
unref: true,
|
|
@@ -347,7 +523,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
347
523
|
});
|
|
348
524
|
notifyLines(ctx, `llm-role explorer: ${explorer.url}`);
|
|
349
525
|
} catch (err) {
|
|
350
|
-
ctx
|
|
526
|
+
reportCommandError(ctx, "explore-roles", err, "llm-role explorer");
|
|
351
527
|
}
|
|
352
528
|
},
|
|
353
529
|
});
|
|
@@ -376,6 +552,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
376
552
|
notifyLines(ctx, formatBenchmarks(undefined, externalMetricsInUse()));
|
|
377
553
|
return;
|
|
378
554
|
}
|
|
555
|
+
if (parsed.listFeatures) {
|
|
556
|
+
notifyLines(ctx, formatFeatures());
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
379
559
|
if (parsed.bodyFile !== undefined) {
|
|
380
560
|
try {
|
|
381
561
|
parsed.request.body = readFileSync(parsed.bodyFile, "utf8");
|
|
@@ -397,15 +577,64 @@ export default function (pi: ExtensionAPI) {
|
|
|
397
577
|
parsed.request.benchmarkLabels = resolved.labels;
|
|
398
578
|
}
|
|
399
579
|
|
|
580
|
+
// 1a. Load today's ranking universe once: the coverage denominator, the
|
|
581
|
+
// per-metric coverage of named benchmarks, and the differentiation
|
|
582
|
+
// comparison all need it. Cached (the updater re-reads the same daily
|
|
583
|
+
// cache), and non-fatal — an unknown field size skips the share rule.
|
|
584
|
+
let rankModels: Model[] | null = null;
|
|
585
|
+
try {
|
|
586
|
+
rankModels = (await loadRankData({})).models;
|
|
587
|
+
} catch (err) {
|
|
588
|
+
notifyLines(ctx, `create-agent: ranking universe unavailable (${err instanceof Error ? err.message : err}) — coverage share checks skipped`);
|
|
589
|
+
}
|
|
590
|
+
const fieldSize = rankModels === null ? null : rankModels.length;
|
|
591
|
+
|
|
592
|
+
// 1a-bis. The focus-metric gate must not run on the score-less create-agent
|
|
593
|
+
// pool: it carries no `bench:<id>` scores, so a gate over it would see
|
|
594
|
+
// nothing for exactly the benchmarks it targets. Probe-fetch each
|
|
595
|
+
// candidate's source, join it onto a copy of the pool, and assess the
|
|
596
|
+
// joined pool. A metric whose scores were not loaded reports every new
|
|
597
|
+
// axis `unknown` (never a silent `ok`).
|
|
598
|
+
const declared = loadDeclaredSources();
|
|
599
|
+
const focusPool: Model[] = rankModels === null ? [] : rankModels.map((m) => ({ ...m, metrics: { ...m.metrics } }));
|
|
600
|
+
const assess: FocusAssessor = async (metric) => {
|
|
601
|
+
const source = sourceForMetric(metric, declared);
|
|
602
|
+
let payload: BenchmarkPayloadMeta | null = null;
|
|
603
|
+
if (source !== null) {
|
|
604
|
+
const loaded = await loadBenchmarkScores(source, false);
|
|
605
|
+
if (loaded !== null) {
|
|
606
|
+
applyBenchmarkScores(focusPool, source, loaded.scores);
|
|
607
|
+
payload = loaded.meta;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return assessFocusMetric(focusPool, metric, declared, { payload, catalog: cachedSourceInfo(metric, declared).catalog });
|
|
611
|
+
};
|
|
612
|
+
|
|
400
613
|
// 1b. Discover the catalog benchmarks relevant to the purpose (unless
|
|
401
614
|
// --no-discover or an explicit --benchmarks list was given). Non-fatal:
|
|
402
|
-
// a catalog or judge failure just skips discovery.
|
|
615
|
+
// a catalog or judge failure just skips discovery. A candidate whose
|
|
616
|
+
// coverage is too low to rank on is dropped, not folded in.
|
|
617
|
+
const discoveredCovered = new Map<string, number>();
|
|
403
618
|
if (!parsed.noDiscover && !parsed.explicitBenchmarks && parsed.request.purpose.trim() !== "") {
|
|
404
|
-
const
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
619
|
+
const outcome = await discoverForPurpose(ctx, parsed.request.purpose, parsed.request.extraBenchmarks ?? [], fieldSize, assess);
|
|
620
|
+
if (outcome !== null) {
|
|
621
|
+
if (outcome.dropped.length > 0) {
|
|
622
|
+
notifyLines(ctx, `create-agent: dropped benchmarks: ${outcome.dropped.map((d) => `${d.label} (${d.metric}) — ${d.reason}`).join(", ")}`);
|
|
623
|
+
}
|
|
624
|
+
if (outcome.discovered.length > 0) {
|
|
625
|
+
parsed.request.extraBenchmarks = [...(parsed.request.extraBenchmarks ?? []), ...outcome.discovered.map((d) => d.metric)];
|
|
626
|
+
parsed.request.benchmarkLabels = [...(parsed.request.benchmarkLabels ?? []), ...outcome.discovered.map((d) => d.label)];
|
|
627
|
+
for (const d of outcome.discovered) discoveredCovered.set(d.metric, d.covered);
|
|
628
|
+
// A dotted id's raw form cannot be reconstructed from its metric key,
|
|
629
|
+
// so persist its declaration now — otherwise the updater fetches the
|
|
630
|
+
// normalized (404-ing) URL and the metric is dead weight.
|
|
631
|
+
for (const d of outcome.discovered) {
|
|
632
|
+
if (d.declaration === undefined) continue;
|
|
633
|
+
const saved = saveDeclaredSource(d.declaration);
|
|
634
|
+
if (!saved.ok) notifyLines(ctx, `create-agent: could not persist ${d.metric} source: ${saved.error}`);
|
|
635
|
+
}
|
|
636
|
+
notifyLines(ctx, `create-agent: discovered benchmarks: ${outcome.discovered.map((d) => `${d.label} (${d.metric})`).join(", ")}`);
|
|
637
|
+
}
|
|
409
638
|
}
|
|
410
639
|
}
|
|
411
640
|
|
|
@@ -441,7 +670,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
441
670
|
// user can see what is already in use and avoid duplicates. `--benchmarks`
|
|
442
671
|
// covers headless runs, where there is no prompt.
|
|
443
672
|
if (ctx.hasUI && ctx.ui.input && parsed.request.extraBenchmarks === undefined) {
|
|
444
|
-
|
|
673
|
+
// Preview the request's resolved weights so the list marks what the role
|
|
674
|
+
// already weights (the "already in this role's weights" marker).
|
|
675
|
+
const preview = resolveRole(parsed.request);
|
|
676
|
+
notifyLines(ctx, formatBenchmarks("errors" in preview ? undefined : preview.def.weights, externalMetricsInUse()));
|
|
445
677
|
const answer = await ctx.ui.input(
|
|
446
678
|
"Additional benchmarks",
|
|
447
679
|
"comma-separated metric names to add to the weights, or empty",
|
|
@@ -454,18 +686,201 @@ export default function (pi: ExtensionAPI) {
|
|
|
454
686
|
}
|
|
455
687
|
}
|
|
456
688
|
|
|
689
|
+
// 3b. Assess the focus metrics: a discovered metric's coverage is the
|
|
690
|
+
// catalog's model count; a named metric's is the loaded models that
|
|
691
|
+
// carry it. The nine-axis assessment is computed over the joined pool
|
|
692
|
+
// (the same object the gate and the report consume).
|
|
693
|
+
const covered: Record<string, number> = {};
|
|
694
|
+
const assessments: Record<string, FocusMetricAssessment> = {};
|
|
695
|
+
for (const metric of parsed.request.extraBenchmarks ?? []) {
|
|
696
|
+
assessments[metric] = await assess(metric);
|
|
697
|
+
covered[metric] = discoveredCovered.get(metric) ?? countMetricCoverage(focusPool, metric, declared);
|
|
698
|
+
}
|
|
699
|
+
parsed.request.coverage = { total: fieldSize, covered, declared, assessments };
|
|
700
|
+
|
|
457
701
|
// 4. Write the role + the agent file, then wire `modelRoles.<name>` in-process.
|
|
458
702
|
const result = createAgent(parsed.request);
|
|
459
703
|
if (!result.ok) {
|
|
460
704
|
notifyLines(ctx, `create-agent: ${result.errors.join("\n")}`);
|
|
461
705
|
return;
|
|
462
706
|
}
|
|
463
|
-
|
|
707
|
+
const defaultDef = resolveSettings(readPluginSettingsMap({ global: parsed.request.lockPath, project: null })).settings.roles.default;
|
|
708
|
+
const differentiation = rankModels !== null && defaultDef !== undefined ? differentiationWarning(result.def, defaultDef, rankModels) : null;
|
|
709
|
+
if (parsed.json) notifyLines(ctx, JSON.stringify({ ...result, differentiation }, null, 2));
|
|
710
|
+
else notifyLines(ctx, formatCreateAgentReport(result, "ranking the new role…", { differentiation }));
|
|
464
711
|
if (result.dryRun) return;
|
|
465
712
|
try {
|
|
466
|
-
await runUpdater("manual", extDeps(pi, ctx), { force: true });
|
|
713
|
+
const runResult = await runUpdater("manual", extDeps(pi, ctx), { force: true });
|
|
714
|
+
reportDefect(ctx, "create-agent", runResult.defect);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
reportCommandError(ctx, "create-agent", err);
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// Discover the project's usecase from its own artifacts and apply a
|
|
722
|
+
// project-scoped role set: the project plugin settings, the new roles' agents,
|
|
723
|
+
// and the project's modelRoles in <cwd>/.omp/config.yml. The project-aware
|
|
724
|
+
// updater (run in-process at the end) keeps the global config untouched.
|
|
725
|
+
pi.registerCommand("project-roles", {
|
|
726
|
+
description: "Discover the project's usecase and apply a project-scoped role set",
|
|
727
|
+
handler: async (args, ctx: ExtContext) => {
|
|
728
|
+
const parsed = parseProjectRolesArgs(typeof args === "string" ? args : "");
|
|
729
|
+
if (!parsed.ok) {
|
|
730
|
+
notifyLines(ctx, parsed.error);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (parsed.help) {
|
|
734
|
+
notifyLines(ctx, PROJECT_ROLES_USAGE);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const cwd = ctx.cwd;
|
|
739
|
+
const projectDir = join(cwd, ".omp");
|
|
740
|
+
const lockPath = projectLockPath(cwd);
|
|
741
|
+
const configPath = join(projectDir, "config.yml");
|
|
742
|
+
|
|
743
|
+
// omp reads <cwd>/.omp with no walk-up; the plugin's walk-up anchor may
|
|
744
|
+
// differ (a session launched in a subdirectory). The written config is what
|
|
745
|
+
// omp reads, but the anchor would read/write a different .omp — warn.
|
|
746
|
+
const warnings: string[] = [];
|
|
747
|
+
const anchor = findProjectAnchor(cwd);
|
|
748
|
+
if (anchor !== null && anchor !== cwd) {
|
|
749
|
+
warnings.push(
|
|
750
|
+
`the session cwd (${cwd}) is not the project anchor (${anchor}); the config is written to ${projectDir}, which is what omp reads — the plugin's walk-up anchor would read a different .omp`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// 1. Gather the repo artifacts and run the profile architect.
|
|
755
|
+
notifyLines(ctx, "project-roles: reading the project…");
|
|
756
|
+
let profile: ProjectProfile;
|
|
757
|
+
try {
|
|
758
|
+
profile = await generateProjectProfile({
|
|
759
|
+
artifacts: gatherProjectArtifacts(cwd),
|
|
760
|
+
cwd,
|
|
761
|
+
model: ctx.models?.current(),
|
|
762
|
+
modelRegistry: ctx.modelRegistry,
|
|
763
|
+
purpose: parsed.purpose,
|
|
764
|
+
});
|
|
765
|
+
} catch (err) {
|
|
766
|
+
notifyLines(ctx, `project-roles: profile discovery failed: ${err instanceof Error ? err.message : err}`);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
profile = applyProfileOverrides(profile, { purpose: parsed.purpose, roles: parsed.roles });
|
|
770
|
+
|
|
771
|
+
// 2. Load the ranking universe once: the coverage denominator, the focus
|
|
772
|
+
// assessment pool and the top picks all need it. Non-fatal.
|
|
773
|
+
let rankModels: Model[] | null = null;
|
|
774
|
+
try {
|
|
775
|
+
rankModels = (await loadRankData({})).models;
|
|
776
|
+
} catch (err) {
|
|
777
|
+
notifyLines(ctx, `project-roles: ranking universe unavailable (${err instanceof Error ? err.message : err}) — coverage checks skipped`);
|
|
778
|
+
}
|
|
779
|
+
const fieldSize = rankModels === null ? null : rankModels.length;
|
|
780
|
+
const declared = loadDeclaredSources();
|
|
781
|
+
const focusPool: Model[] = rankModels === null ? [] : rankModels.map((m) => ({ ...m, metrics: { ...m.metrics } }));
|
|
782
|
+
const assess: FocusAssessor = async (metric) => {
|
|
783
|
+
const source = sourceForMetric(metric, declared);
|
|
784
|
+
let payload: BenchmarkPayloadMeta | null = null;
|
|
785
|
+
if (source !== null) {
|
|
786
|
+
const loaded = await loadBenchmarkScores(source, false);
|
|
787
|
+
if (loaded !== null) {
|
|
788
|
+
applyBenchmarkScores(focusPool, source, loaded.scores);
|
|
789
|
+
payload = loaded.meta;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return assessFocusMetric(focusPool, metric, declared, { payload, catalog: cachedSourceInfo(metric, declared).catalog });
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// 3. Per new role: discover benchmarks (judge-backed) and author the agent.
|
|
796
|
+
const covered: Record<string, number> = {};
|
|
797
|
+
const assessments: Record<string, FocusMetricAssessment> = {};
|
|
798
|
+
for (const role of profile.roles) {
|
|
799
|
+
if (role.keep === false) continue;
|
|
800
|
+
if (role.name.trim() in DEFAULT_ROLES) continue; // shipped role — no new agent
|
|
801
|
+
if (role.benchmarks === undefined || role.benchmarks.length === 0) {
|
|
802
|
+
const outcome = await discoverForPurpose(ctx, role.purpose, [], fieldSize, assess);
|
|
803
|
+
if (outcome !== null) {
|
|
804
|
+
if (outcome.dropped.length > 0) {
|
|
805
|
+
notifyLines(ctx, `project-roles: dropped benchmarks (coverage): ${outcome.dropped.map((d) => `${d.label} (${d.metric}) — ${d.reason}`).join(", ")}`);
|
|
806
|
+
}
|
|
807
|
+
role.benchmarks = outcome.discovered.map((d) => d.metric);
|
|
808
|
+
for (const d of outcome.discovered) {
|
|
809
|
+
if (d.declaration === undefined) continue;
|
|
810
|
+
const saved = saveDeclaredSource(d.declaration);
|
|
811
|
+
if (!saved.ok) notifyLines(ctx, `project-roles: could not persist ${d.metric} source: ${saved.error}`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
for (const metric of role.benchmarks ?? []) {
|
|
816
|
+
assessments[metric] = await assess(metric);
|
|
817
|
+
covered[metric] = rankModels === null ? 0 : countMetricCoverage(focusPool, metric, declared);
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
role.spec = await generateAgentSpec({
|
|
821
|
+
description: role.purpose,
|
|
822
|
+
cwd,
|
|
823
|
+
model: ctx.models?.current(),
|
|
824
|
+
modelRegistry: ctx.modelRegistry,
|
|
825
|
+
benchmarks: role.benchmarks,
|
|
826
|
+
});
|
|
827
|
+
} catch (err) {
|
|
828
|
+
notifyLines(ctx, `project-roles: architect failed for "${role.name}": ${err instanceof Error ? err.message : err}`);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// 4. Plan first (a dry run of the same all-or-nothing core), print it, then
|
|
834
|
+
// apply. `--dry-run` stops after the plan; without `--force` an existing
|
|
835
|
+
// project role config is refused by the core.
|
|
836
|
+
const planOpts = {
|
|
837
|
+
projectDir,
|
|
838
|
+
lockPath,
|
|
839
|
+
configPath,
|
|
840
|
+
force: parsed.force,
|
|
841
|
+
yes: parsed.yes,
|
|
842
|
+
features: parsed.features,
|
|
843
|
+
coverage: { total: fieldSize, covered, declared, assessments },
|
|
844
|
+
};
|
|
845
|
+
const plan = setupProject(profile, { ...planOpts, dryRun: true });
|
|
846
|
+
if (!plan.ok) {
|
|
847
|
+
notifyLines(ctx, `project-roles: ${plan.errors.join("\n")}`);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const topPicks: Record<string, string> = {};
|
|
851
|
+
if (rankModels !== null) {
|
|
852
|
+
for (const name of plan.added) {
|
|
853
|
+
const ranked = rankRole(plan.roles[name], rankModels);
|
|
854
|
+
if (ranked.length > 0) topPicks[name] = ranked[0].model.id;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (parsed.json) notifyLines(ctx, JSON.stringify({ profile, ...plan, warnings }, null, 2));
|
|
858
|
+
else notifyLines(ctx, formatProjectRolesReport(profile, plan, { topPicks, warnings }));
|
|
859
|
+
if (parsed.dryRun) return;
|
|
860
|
+
|
|
861
|
+
if (!parsed.yes && ctx.hasUI && ctx.ui.select) {
|
|
862
|
+
const answer = await ctx.ui.select("Apply this project role set?", [
|
|
863
|
+
{ label: "Apply", description: "write the project settings, agents and modelRoles" },
|
|
864
|
+
{ label: "Cancel" },
|
|
865
|
+
]);
|
|
866
|
+
if (answer !== "Apply") {
|
|
867
|
+
notifyLines(ctx, "project-roles: cancelled");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const result = setupProject(profile, { ...planOpts, dryRun: false });
|
|
873
|
+
if (!result.ok) {
|
|
874
|
+
notifyLines(ctx, `project-roles: ${result.errors.join("\n")}`);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// 5. Run the updater in-process so the project's modelRoles land.
|
|
879
|
+
try {
|
|
880
|
+
const runResult = await runUpdater("manual", extDeps(pi, ctx), { force: true });
|
|
881
|
+
reportDefect(ctx, "project-roles", runResult.defect);
|
|
467
882
|
} catch (err) {
|
|
468
|
-
ctx
|
|
883
|
+
reportCommandError(ctx, "project-roles", err);
|
|
469
884
|
}
|
|
470
885
|
},
|
|
471
886
|
});
|
|
@@ -503,9 +918,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
503
918
|
notifyLines(ctx, formatRemoveAgentReport(result, "cleaning up config.yml…"));
|
|
504
919
|
if (result.dryRun) return;
|
|
505
920
|
try {
|
|
506
|
-
await runUpdater("manual", extDeps(pi, ctx), { force: true });
|
|
921
|
+
const runResult = await runUpdater("manual", extDeps(pi, ctx), { force: true });
|
|
922
|
+
reportDefect(ctx, "remove-agent", runResult.defect);
|
|
507
923
|
} catch (err) {
|
|
508
|
-
ctx
|
|
924
|
+
reportCommandError(ctx, "remove-agent", err);
|
|
509
925
|
}
|
|
510
926
|
},
|
|
511
927
|
});
|