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/role-archetypes.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { SuffixLevel } from "./engine.ts";
|
|
19
|
+
import type { FeatureId } from "./features.ts";
|
|
19
20
|
import { DEFAULT_ROLES } from "./settings.ts";
|
|
20
21
|
|
|
21
22
|
export type Archetype = {
|
|
@@ -33,6 +34,14 @@ export type Archetype = {
|
|
|
33
34
|
tools: string[];
|
|
34
35
|
/** Require image input (`filters.image`). */
|
|
35
36
|
image?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Capability flags the archetype proposes for a role fitted to it. **No
|
|
39
|
+
* shipped archetype sets one**: a plugin-created role starts from nothing
|
|
40
|
+
* unless the user asks (`--feature`), so creating an agent never silently
|
|
41
|
+
* changes what the role ranks on. The field exists so a future archetype can
|
|
42
|
+
* propose a preset without a second mechanism.
|
|
43
|
+
*/
|
|
44
|
+
features?: FeatureId[];
|
|
36
45
|
/** Bullets for the generated agent body's `<criteria>` section. */
|
|
37
46
|
criteria: string[];
|
|
38
47
|
};
|
|
@@ -128,8 +137,9 @@ export const ARCHETYPES: readonly Archetype[] = [
|
|
|
128
137
|
id: "design",
|
|
129
138
|
label: "design specialist",
|
|
130
139
|
keywords: ["design", "ui", "ux", "visual", "layout", "style", "css", "frontend", "mockup", "wireframe", "screenshot", "image", "logo", "brand", "accessib", "typograph"],
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
// The shipped `designer` role's own weights: one canonical design weighting.
|
|
141
|
+
weights: DEFAULT_ROLES.designer.weights,
|
|
142
|
+
required: DEFAULT_ROLES.designer.required,
|
|
133
143
|
thinking: "auto",
|
|
134
144
|
tools: ["read", "grep", "glob", "find", "write", "edit"],
|
|
135
145
|
image: true,
|
package/src/role-settings.ts
CHANGED
|
@@ -45,14 +45,36 @@ export function mergeExport(existing: unknown, dirty: Record<string, RoleDef>):
|
|
|
45
45
|
if (Object.keys(nested).length === 0) delete plugin.roles;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// An Export means "make this role's keys match this def", so a key the def
|
|
49
|
+
// no longer carries (a capability flag set back to inherit, a cleared knob,
|
|
50
|
+
// a dropped filter) is removed rather than left stale. Same prefix rule as
|
|
51
|
+
// `mergeRemove` below.
|
|
48
52
|
const prefix = `roles.${name}`;
|
|
53
|
+
for (const key of Object.keys(plugin)) {
|
|
54
|
+
if (key === prefix || key.startsWith(`${prefix}.`)) delete plugin[key];
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
plugin[`${prefix}.description`] = def.description;
|
|
50
58
|
if (def.enabled !== undefined) plugin[`${prefix}.enabled`] = def.enabled;
|
|
51
59
|
if (def.locked !== undefined) plugin[`${prefix}.locked`] = def.locked;
|
|
52
60
|
plugin[`${prefix}.required`] = def.required;
|
|
53
61
|
if (def.thinking !== undefined) plugin[`${prefix}.thinking`] = def.thinking;
|
|
62
|
+
if (def.providerPin !== undefined) plugin[`${prefix}.providerPin`] = def.providerPin;
|
|
63
|
+
if (def.preferOwnProvider !== undefined) plugin[`${prefix}.preferOwnProvider`] = def.preferOwnProvider;
|
|
64
|
+
if (def.cacheHitRate !== undefined) plugin[`${prefix}.cacheHitRate`] = def.cacheHitRate;
|
|
54
65
|
if (def.lambda !== undefined) plugin[`${prefix}.lambda`] = def.lambda;
|
|
55
66
|
if (def.filters?.image !== undefined) plugin[`${prefix}.filters.image`] = def.filters.image;
|
|
67
|
+
if (def.filters?.tools !== undefined) plugin[`${prefix}.filters.tools`] = def.filters.tools;
|
|
68
|
+
if (def.filters?.minContextTokens !== undefined) plugin[`${prefix}.filters.minContextTokens`] = def.filters.minContextTokens;
|
|
69
|
+
if (def.filters?.minOutputTokens !== undefined) plugin[`${prefix}.filters.minOutputTokens`] = def.filters.minOutputTokens;
|
|
70
|
+
if (def.filters?.maxPriceUsdPerM !== undefined) plugin[`${prefix}.filters.maxPriceUsdPerM`] = def.filters.maxPriceUsdPerM;
|
|
71
|
+
// The capability flags are written as flat dotted keys too, so omp's
|
|
72
|
+
// shallow settings merge keeps them and `mergeRemove`'s `roles.<name>.`
|
|
73
|
+
// prefix deletes them with the rest of the role. Both `true` and `false`
|
|
74
|
+
// are written: a per-role `false` is how a role opts out of a global flag.
|
|
75
|
+
if (def.features !== undefined) {
|
|
76
|
+
for (const [id, on] of Object.entries(def.features)) plugin[`${prefix}.features.${id}`] = on;
|
|
77
|
+
}
|
|
56
78
|
for (const [metric, weight] of Object.entries(def.weights)) plugin[`${prefix}.weights.${metric}`] = weight;
|
|
57
79
|
}
|
|
58
80
|
return { lock };
|
package/src/settings.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* overrides from the omp plugin settings map.
|
|
4
4
|
*
|
|
5
5
|
* Source of truth (decision D1): `~/.omp/plugins/omp-plugins.lock.json` ->
|
|
6
|
-
* `settings["omp-llm-role"]`, with `<
|
|
6
|
+
* `settings["omp-llm-role"]`, with `<cwd>/.omp/plugins/omp-plugins.lock.json`
|
|
7
7
|
* merged under it (project wins). Keys are flat and dotted
|
|
8
8
|
* (`roles.slow.weights.code=0.2`, written by `omp plugin config omp-llm-role
|
|
9
9
|
* --set=k=v`) and are nested here; a `config` key holding JSON is accepted as a
|
|
@@ -14,6 +14,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
import { SUFFIX_LEVELS, type RoleDef } from "./engine.ts";
|
|
17
|
+
import { FEATURES, expandFeatures, featureById } from "./features.ts";
|
|
17
18
|
import { isRecord } from "./guards.ts";
|
|
18
19
|
|
|
19
20
|
/** User-level plugin settings lock file (the explorer's read/export target and
|
|
@@ -23,7 +24,7 @@ export const PLUGIN_SETTINGS_PATH = join(homedir(), ".omp", "plugins", "omp-plug
|
|
|
23
24
|
export const DEFAULT_ROLES: Record<string, RoleDef> = {
|
|
24
25
|
default: {
|
|
25
26
|
description: "Main workhorse: strong general coding-agent quality, sane cost",
|
|
26
|
-
weights: { general: 0.
|
|
27
|
+
weights: { general: 0.4926, code: 0.1705, agents: 0.1137, tool_calling: 0.0947, throughput: 0.0285, price: 0.1 },
|
|
27
28
|
required: ["general", "price", "throughput"],
|
|
28
29
|
// `auto`, not bare: the session default is `auto` (config.yml
|
|
29
30
|
// `defaultThinkingLevel`), so a bare selector runs at auto effort while
|
|
@@ -32,13 +33,13 @@ export const DEFAULT_ROLES: Record<string, RoleDef> = {
|
|
|
32
33
|
},
|
|
33
34
|
smol: {
|
|
34
35
|
description: "Fast lightweight model: cheap and quick, still competent",
|
|
35
|
-
weights: { price: 0.
|
|
36
|
+
weights: { price: 0.45, throughput: 0.22, general: 0.1571, code: 0.0943, tool_calling: 0.0786 },
|
|
36
37
|
required: ["general", "price", "throughput"],
|
|
37
38
|
thinking: "off",
|
|
38
39
|
},
|
|
39
40
|
slow: {
|
|
40
41
|
description: "Most capable model for hard problems; cost and speed as tiebreakers",
|
|
41
|
-
weights: { general: 0.
|
|
42
|
+
weights: { general: 0.60, code: 0.18, agents: 0.13, throughput: 0.04, price: 0.05 },
|
|
42
43
|
required: ["general", "price", "throughput"],
|
|
43
44
|
thinking: "high",
|
|
44
45
|
},
|
|
@@ -50,8 +51,19 @@ export const DEFAULT_ROLES: Record<string, RoleDef> = {
|
|
|
50
51
|
filters: { image: true },
|
|
51
52
|
},
|
|
52
53
|
plan: {
|
|
53
|
-
description: "Planning: reasoning
|
|
54
|
-
|
|
54
|
+
description: "Planning: reasoning and long-context coherence, priced so the cheapest capable planner wins",
|
|
55
|
+
// 2026-10-03 (issue #16): `price` raised 0.12 → 0.25 so the cost posture
|
|
56
|
+
// binds on the *reachable* pool — the pool the selector is chosen from.
|
|
57
|
+
// Measured reachable leader-flip w_price 0.2175 (Hy4 preview →
|
|
58
|
+
// DeepSeek-V4.1-Flash) vs full-pool 0.311 (Muse Spark 1.3 → Qwen3.8 Flash),
|
|
59
|
+
// so 0.25 flips the actionable decision while leaving the full-pool #1
|
|
60
|
+
// unchanged. Non-price weights rescaled by (1−0.25)/(1−0.12) = 0.852273:
|
|
61
|
+
// general 0.704 → 0.6, long_context 0.1369 → 0.116676, throughput 0.0391 →
|
|
62
|
+
// 0.033324 (Σ(non-price) = 0.75 = 1 − price). `q` is unchanged by the
|
|
63
|
+
// rescale; only λ moves (0.00682 → 0.016667). The reachable flip lands on a
|
|
64
|
+
// model whose `long_context` is imputed (capability fill 0.195, not
|
|
65
|
+
// measured) — see Scoring rule 6.
|
|
66
|
+
weights: { general: 0.6, long_context: 0.116676, throughput: 0.033324, price: 0.25 },
|
|
55
67
|
required: ["reasoning", "general", "price", "throughput"],
|
|
56
68
|
// `auto`, not `medium`: no reachable planning candidate lists `medium` in its
|
|
57
69
|
// catalog thinking[], so a `medium` pin silently writes bare (the session
|
|
@@ -60,7 +72,7 @@ export const DEFAULT_ROLES: Record<string, RoleDef> = {
|
|
|
60
72
|
},
|
|
61
73
|
commit: {
|
|
62
74
|
description: "Commit messages: cheap and fast with decent general quality",
|
|
63
|
-
weights: { price: 0.
|
|
75
|
+
weights: { price: 0.45, throughput: 0.2285, general: 0.2369, code: 0.0846 },
|
|
64
76
|
required: ["general", "price", "throughput"],
|
|
65
77
|
thinking: "off",
|
|
66
78
|
},
|
|
@@ -90,14 +102,15 @@ export const DEFAULT_ROLES: Record<string, RoleDef> = {
|
|
|
90
102
|
// lottery at 76% coverage); `long_context` is kept (the most independent
|
|
91
103
|
// capability axis, r 0.739/0.767 with reasoning/general) and is now
|
|
92
104
|
// capability-filled at 0.195 like `website` (src/engine.ts CAPABILITY_FILL),
|
|
93
|
-
// so its 44.7% coverage no longer penalizes. `reasoning`↔`general`
|
|
94
|
-
// collinear (r 0.972)
|
|
105
|
+
// so its 44.7% coverage no longer penalizes. `reasoning`↔`general` were
|
|
106
|
+
// collinear (r 0.972) and are now merged into `general` (2026-10-03, issue
|
|
107
|
+
// #13), so the independent axes carry more. `price` is
|
|
95
108
|
// raised to 0.20 (λ 0.0125) because the advisor fires on every primary turn
|
|
96
109
|
// *and* every `task` subagent turn (config.yml task.agentAdvisor.task: on),
|
|
97
110
|
// so spend is ~2× a per-turn count; `throughput` is raised to 0.1049 because
|
|
98
111
|
// `syncBacklog: "1"` lets a slow advisor stall the primary up to 30s.
|
|
99
112
|
// Non-price weights sum to 0.80 = 1 − price, as the (w/qW) blend requires.
|
|
100
|
-
weights: {
|
|
113
|
+
weights: { general: 0.5947, long_context: 0.1004, price: 0.2, throughput: 0.1049 },
|
|
101
114
|
required: ["reasoning", "general", "price", "throughput"],
|
|
102
115
|
// `auto`, matching `plan`: the same model (Hy4 preview) runs both roles, so
|
|
103
116
|
// pricing advisor bare while plan is `auto` understated advisor by 1.857×.
|
|
@@ -115,7 +128,7 @@ export const DEFAULT_ROLES: Record<string, RoleDef> = {
|
|
|
115
128
|
// role is disabled (an unresolved `@x` is a literal pattern, so a bare
|
|
116
129
|
// `@designer` would hard-fail — the chain is what keeps the agent spawnable).
|
|
117
130
|
enabled: false,
|
|
118
|
-
weights: { general: 0.26, code: 0.
|
|
131
|
+
weights: { general: 0.26, code: 0.06, vision: 0.18, throughput: 0.15, price: 0.17, website: 0.18 },
|
|
119
132
|
required: ["general", "price", "throughput"],
|
|
120
133
|
// `auto` for the same reason as `plan`: nothing in the designer pool lists
|
|
121
134
|
// `medium`, and `auto` is a meta level written as-is at the same overhead.
|
|
@@ -135,7 +148,7 @@ export const SHIPPED_AGENTS: string[] = ["designer"];
|
|
|
135
148
|
|
|
136
149
|
|
|
137
150
|
/**
|
|
138
|
-
* Metrics a role weight/required entry may name
|
|
151
|
+
* Metrics a role weight/required entry may name.
|
|
139
152
|
*
|
|
140
153
|
* The six raw llm-stats benchmark pass rates (`gpqa`, `aime`, `swe_bench`,
|
|
141
154
|
* `arc_agi`, `terminal_bench`, `tau_bench`) are weightable too: they are already
|
|
@@ -155,6 +168,10 @@ export const KNOWN_METRICS: Record<string, true> = {
|
|
|
155
168
|
vision: true,
|
|
156
169
|
tool_calling: true,
|
|
157
170
|
long_context: true,
|
|
171
|
+
index_communication: true,
|
|
172
|
+
index_finance: true,
|
|
173
|
+
index_healthcare: true,
|
|
174
|
+
index_legal: true,
|
|
158
175
|
mrcr: true,
|
|
159
176
|
website: true,
|
|
160
177
|
writing: true,
|
|
@@ -164,6 +181,21 @@ export const KNOWN_METRICS: Record<string, true> = {
|
|
|
164
181
|
arc_agi: true,
|
|
165
182
|
terminal_bench: true,
|
|
166
183
|
tau_bench: true,
|
|
184
|
+
simpleqa_score: true,
|
|
185
|
+
hle_score: true,
|
|
186
|
+
mmmu_score: true,
|
|
187
|
+
mmmu_pro_score: true,
|
|
188
|
+
mmmlu_score: true,
|
|
189
|
+
browsecomp_score: true,
|
|
190
|
+
swe_bench_pro_score: true,
|
|
191
|
+
mcp_atlas_score: true,
|
|
192
|
+
apex_agents_score: true,
|
|
193
|
+
osworld_score: true,
|
|
194
|
+
scicode_score: true,
|
|
195
|
+
screenspot_pro_score: true,
|
|
196
|
+
charxiv_r_score: true,
|
|
197
|
+
frontiermath_score: true,
|
|
198
|
+
toolathlon_score: true,
|
|
167
199
|
price: true,
|
|
168
200
|
throughput: true,
|
|
169
201
|
};
|
|
@@ -181,7 +213,7 @@ export function isKnownMetric(name: string): boolean {
|
|
|
181
213
|
}
|
|
182
214
|
|
|
183
215
|
/**
|
|
184
|
-
* Live-session coupling
|
|
216
|
+
* Live-session coupling: after the day-gated
|
|
185
217
|
* session-start write, a freshly started session whose conversation is still
|
|
186
218
|
* empty had its active model resolved from the pre-write config. Reapply the
|
|
187
219
|
* new `default` selector to the session's active model so the user's first
|
|
@@ -192,7 +224,7 @@ export const ACTIVATE_DEFAULT_KEY = "activateDefaultOnEmptySession";
|
|
|
192
224
|
export type ResolvedSettings = {
|
|
193
225
|
switchMargin: number;
|
|
194
226
|
/**
|
|
195
|
-
* Cost-side escape hatch from `switchMargin
|
|
227
|
+
* Cost-side escape hatch from `switchMargin`: the margin is a flat
|
|
196
228
|
* band on `value`, so a role with a loose posture (small λ) can refuse a
|
|
197
229
|
* switch worth up to `switchMargin/λ` $/M. When a challenger sitting inside
|
|
198
230
|
* the margin undercuts the incumbent's effective price by at least this
|
|
@@ -204,6 +236,14 @@ export type ResolvedSettings = {
|
|
|
204
236
|
fallbackChainDepth: number;
|
|
205
237
|
roles: Record<string, RoleDef>;
|
|
206
238
|
activateDefaultOnEmptySession: boolean;
|
|
239
|
+
/** Opt-in reporting of unexpected plugin errors as GitHub issues on the
|
|
240
|
+
* plugin's own tracker: `off` (default) never files, `ask` shows the exact
|
|
241
|
+
* payload and files on confirmation (degrading to a prefilled URL when the
|
|
242
|
+
* session has no UI), `auto` files without asking. */
|
|
243
|
+
errorReporting: "off" | "ask" | "auto";
|
|
244
|
+
/** Global capability flags (`features.<id>`); a role's `features.<id>` overrides
|
|
245
|
+
* the global in either direction. Expanded per role by `resolveSettings`. */
|
|
246
|
+
features: Record<string, boolean>;
|
|
207
247
|
};
|
|
208
248
|
|
|
209
249
|
export const DEFAULT_SETTINGS: ResolvedSettings = {
|
|
@@ -213,6 +253,8 @@ export const DEFAULT_SETTINGS: ResolvedSettings = {
|
|
|
213
253
|
fallbackChainDepth: 2,
|
|
214
254
|
roles: DEFAULT_ROLES,
|
|
215
255
|
activateDefaultOnEmptySession: true,
|
|
256
|
+
errorReporting: "off",
|
|
257
|
+
features: {},
|
|
216
258
|
};
|
|
217
259
|
|
|
218
260
|
/** One row of the plugin's settings schema (omp's `PluginSettingSchema`): the
|
|
@@ -264,7 +306,20 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
|
|
|
264
306
|
description: "Reapply the new default selector to an empty session's active model",
|
|
265
307
|
default: DEFAULT_SETTINGS.activateDefaultOnEmptySession,
|
|
266
308
|
},
|
|
309
|
+
errorReporting: {
|
|
310
|
+
type: "enum",
|
|
311
|
+
description: "File unexpected plugin errors as GitHub issues (off, ask, auto)",
|
|
312
|
+
default: DEFAULT_SETTINGS.errorReporting,
|
|
313
|
+
values: ["off", "ask", "auto"],
|
|
314
|
+
},
|
|
267
315
|
};
|
|
316
|
+
for (const row of FEATURES) {
|
|
317
|
+
schema[`features.${row.id}`] = {
|
|
318
|
+
type: "boolean",
|
|
319
|
+
description: row.description,
|
|
320
|
+
default: false,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
268
323
|
for (const [name, def] of Object.entries(DEFAULT_ROLES)) {
|
|
269
324
|
const p = `roles.${name}`;
|
|
270
325
|
schema[`${p}.enabled`] = {
|
|
@@ -283,6 +338,30 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
|
|
|
283
338
|
default: def.thinking ?? "auto",
|
|
284
339
|
values: Object.keys(SUFFIX_LEVELS),
|
|
285
340
|
};
|
|
341
|
+
schema[`${p}.providerPin`] = {
|
|
342
|
+
type: "string",
|
|
343
|
+
description: `Provider slug the ${name} role's requests are pinned to (OpenRouter @<slug> routing; empty = default routing)`,
|
|
344
|
+
};
|
|
345
|
+
schema[`${p}.cacheHitRate`] = {
|
|
346
|
+
type: "number",
|
|
347
|
+
description: `Assumed cache-hit rate (0–1) for ${name}'s input tokens; blends each route's cache-read price before the 3:1 blend (0 = off)`,
|
|
348
|
+
default: def.cacheHitRate ?? 0,
|
|
349
|
+
min: 0,
|
|
350
|
+
max: 1,
|
|
351
|
+
step: 0.01,
|
|
352
|
+
};
|
|
353
|
+
for (const row of FEATURES) {
|
|
354
|
+
schema[`${p}.features.${row.id}`] = {
|
|
355
|
+
type: "boolean",
|
|
356
|
+
description: `Enable the ${row.label} capability for the ${name} role`,
|
|
357
|
+
default: false,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
schema[`${p}.preferOwnProvider`] = {
|
|
361
|
+
type: "boolean",
|
|
362
|
+
description: `Bind ${name} requests to each model's best provider route (soft preference; never drops a model)`,
|
|
363
|
+
default: false,
|
|
364
|
+
};
|
|
286
365
|
schema[`${p}.required`] = {
|
|
287
366
|
type: "string",
|
|
288
367
|
description: `Comma-separated metrics a model must have to rank for ${name}`,
|
|
@@ -293,6 +372,35 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
|
|
|
293
372
|
description: `Restrict the ${name} pool to image-capable models`,
|
|
294
373
|
default: def.filters?.image ?? false,
|
|
295
374
|
};
|
|
375
|
+
schema[`${p}.filters.tools`] = {
|
|
376
|
+
type: "boolean",
|
|
377
|
+
description: `Restrict the ${name} pool to models with a tool-capable route`,
|
|
378
|
+
default: def.filters?.tools ?? false,
|
|
379
|
+
};
|
|
380
|
+
schema[`${p}.filters.minContextTokens`] = {
|
|
381
|
+
type: "number",
|
|
382
|
+
description: `Drop ${name} routes whose endpoint context is below this (tokens; 0 = off)`,
|
|
383
|
+
default: def.filters?.minContextTokens ?? 0,
|
|
384
|
+
min: 0,
|
|
385
|
+
};
|
|
386
|
+
schema[`${p}.filters.minOutputTokens`] = {
|
|
387
|
+
type: "number",
|
|
388
|
+
description: `Drop ${name} routes whose endpoint output ceiling is below this (tokens; 0 = off)`,
|
|
389
|
+
default: def.filters?.minOutputTokens ?? 0,
|
|
390
|
+
min: 0,
|
|
391
|
+
};
|
|
392
|
+
schema[`${p}.filters.maxPriceUsdPerM`] = {
|
|
393
|
+
type: "number",
|
|
394
|
+
description: `Drop ${name} models whose role-priced blend exceeds this ($/M; 0 = off)`,
|
|
395
|
+
default: def.filters?.maxPriceUsdPerM ?? 0,
|
|
396
|
+
min: 0,
|
|
397
|
+
};
|
|
398
|
+
schema[`${p}.filters.preferredMinThroughput`] = {
|
|
399
|
+
type: "number",
|
|
400
|
+
description: `Soft throughput floor for ${name} routes (tok/s; 0 = off) — a below-floor route sorts last but is never dropped`,
|
|
401
|
+
default: def.filters?.preferredMinThroughput ?? 0,
|
|
402
|
+
min: 0,
|
|
403
|
+
};
|
|
296
404
|
schema[`${p}.lambda`] = {
|
|
297
405
|
type: "number",
|
|
298
406
|
description: `Explicit λ ($ per quality point) override for ${name}`,
|
|
@@ -338,9 +446,12 @@ function setNested(target: Record<string, unknown>, path: string[], value: unkno
|
|
|
338
446
|
else node[leaf] = value;
|
|
339
447
|
}
|
|
340
448
|
|
|
341
|
-
/** Nearest ancestor dir (or cwd itself) containing a `.omp` or `.git` anchor; null when none.
|
|
342
|
-
|
|
343
|
-
|
|
449
|
+
/** Nearest ancestor dir (or `cwd` itself) containing a `.omp` or `.git` anchor; null when none.
|
|
450
|
+
* Exported for the `/project-roles` divergence warning: omp reads `<cwd>/.omp` with no
|
|
451
|
+
* walk-up, so a session launched in a subdirectory has an anchor that differs from the dir
|
|
452
|
+
* omp actually reads. */
|
|
453
|
+
export function findProjectAnchor(cwd: string = process.cwd()): string | null {
|
|
454
|
+
let dir = cwd;
|
|
344
455
|
for (;;) {
|
|
345
456
|
if (existsSync(join(dir, ".omp")) || existsSync(join(dir, ".git"))) return dir;
|
|
346
457
|
const parent = dirname(dir);
|
|
@@ -349,15 +460,26 @@ function findProjectAnchor(): string | null {
|
|
|
349
460
|
}
|
|
350
461
|
}
|
|
351
462
|
|
|
463
|
+
/** The project plugin settings lock file omp reads for `cwd`:
|
|
464
|
+
* `<cwd>/.omp/plugins/omp-plugins.lock.json`. omp resolves the project dir as
|
|
465
|
+
* `<cwd>/.omp` with no walk-up, so the project-scope read must use the same dir
|
|
466
|
+
* (not the walk-up anchor `findProjectAnchor` returns). */
|
|
467
|
+
export function projectLockPath(cwd: string = process.cwd()): string {
|
|
468
|
+
return join(cwd, ".omp", "plugins", "omp-plugins.lock.json");
|
|
469
|
+
}
|
|
470
|
+
|
|
352
471
|
/**
|
|
353
472
|
* Read the raw `settings["omp-llm-role"]` map: user-level lock file, with the
|
|
354
|
-
* project
|
|
473
|
+
* project lock file (`<cwd>/.omp/plugins/omp-plugins.lock.json`, omp's project
|
|
474
|
+
* dir with no walk-up) merged over it. Missing files (or missing settings
|
|
355
475
|
* entries) yield {}; unparseable lock files warn and yield {}.
|
|
356
476
|
*/
|
|
357
|
-
export function readPluginSettingsMap(paths?: { global?: string; project?: string }): Record<string, unknown> {
|
|
477
|
+
export function readPluginSettingsMap(paths?: { global?: string; project?: string | null }): Record<string, unknown> {
|
|
358
478
|
const globalPath = paths?.global ?? PLUGIN_SETTINGS_PATH;
|
|
359
|
-
|
|
360
|
-
|
|
479
|
+
// omp reads the project dir as `<cwd>/.omp` with no walk-up, so the default
|
|
480
|
+
// project source is that dir's lock file (not the walk-up anchor). A missing
|
|
481
|
+
// file yields {} and the global path stands alone.
|
|
482
|
+
const projectPath = paths?.project === undefined ? projectLockPath() : paths?.project;
|
|
361
483
|
|
|
362
484
|
const read = (path: string): Record<string, unknown> => {
|
|
363
485
|
let parsed: unknown;
|
|
@@ -423,7 +545,7 @@ function mergeRawSettings(raw: Record<string, unknown>): { merged: ResolvedSetti
|
|
|
423
545
|
|
|
424
546
|
/**
|
|
425
547
|
* Deep-merge the raw settings map over the shipped defaults and validate the
|
|
426
|
-
* result
|
|
548
|
+
* result. Roles whose resolved `weights` is null are dropped (explicit
|
|
427
549
|
* opt-out). Never mutates the defaults. One error string per violation; an empty
|
|
428
550
|
* error list means the settings are valid.
|
|
429
551
|
*/
|
|
@@ -447,6 +569,22 @@ export function resolveSettings(raw: Record<string, unknown>): { settings: Resol
|
|
|
447
569
|
`${ACTIVATE_DEFAULT_KEY}: must be a boolean, got ${JSON.stringify(merged.activateDefaultOnEmptySession)}`,
|
|
448
570
|
);
|
|
449
571
|
}
|
|
572
|
+
if (merged.errorReporting !== "off" && merged.errorReporting !== "ask" && merged.errorReporting !== "auto") {
|
|
573
|
+
errors.push(`errorReporting: must be one of off, ask, auto, got ${JSON.stringify(merged.errorReporting)}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Global capability flags (`features.<id>`): an unknown id or a non-boolean
|
|
577
|
+
// value aborts the run (a typo must never silently do nothing). A non-record
|
|
578
|
+
// `features` is reset so the per-role expansion below always gets a map.
|
|
579
|
+
if (!isRecord(merged.features)) {
|
|
580
|
+
errors.push(`features: must be an object, got ${JSON.stringify(merged.features)}`);
|
|
581
|
+
merged.features = {};
|
|
582
|
+
} else {
|
|
583
|
+
for (const [id, v] of Object.entries(merged.features)) {
|
|
584
|
+
if (featureById(id) === null) errors.push(`features.${id} is not a known capability`);
|
|
585
|
+
if (typeof v !== "boolean") errors.push(`features.${id}: must be a boolean, got ${JSON.stringify(v)}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
450
588
|
|
|
451
589
|
for (const [name, rdef] of Object.entries(merged.roles)) {
|
|
452
590
|
if (!isRecord(rdef)) {
|
|
@@ -508,8 +646,19 @@ export function resolveSettings(raw: Record<string, unknown>): { settings: Resol
|
|
|
508
646
|
}
|
|
509
647
|
if (rdef.filters !== undefined) {
|
|
510
648
|
if (!isRecord(rdef.filters)) errors.push(`role ${name}: filters must be an object`);
|
|
511
|
-
else
|
|
512
|
-
|
|
649
|
+
else {
|
|
650
|
+
if (rdef.filters.image !== undefined && typeof rdef.filters.image !== "boolean") {
|
|
651
|
+
errors.push(`role ${name}: filters.image must be a boolean`);
|
|
652
|
+
}
|
|
653
|
+
if (rdef.filters.tools !== undefined && typeof rdef.filters.tools !== "boolean") {
|
|
654
|
+
errors.push(`role ${name}: filters.tools must be a boolean`);
|
|
655
|
+
}
|
|
656
|
+
for (const key of ["minContextTokens", "minOutputTokens", "maxPriceUsdPerM", "preferredMinThroughput"] as const) {
|
|
657
|
+
const v = rdef.filters[key];
|
|
658
|
+
if (v !== undefined && (typeof v !== "number" || !Number.isFinite(v) || v < 0)) {
|
|
659
|
+
errors.push(`role ${name}: filters.${key} must be a number ≥ 0`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
513
662
|
}
|
|
514
663
|
}
|
|
515
664
|
if (rdef.thinking !== undefined && (typeof rdef.thinking !== "string" || !(rdef.thinking in SUFFIX_LEVELS))) {
|
|
@@ -522,7 +671,45 @@ export function resolveSettings(raw: Record<string, unknown>): { settings: Resol
|
|
|
522
671
|
delete rdef.lambda;
|
|
523
672
|
}
|
|
524
673
|
}
|
|
674
|
+
if (rdef.cacheHitRate !== undefined) {
|
|
675
|
+
if (typeof rdef.cacheHitRate !== "number" || !Number.isFinite(rdef.cacheHitRate) || rdef.cacheHitRate < 0 || rdef.cacheHitRate > 1) {
|
|
676
|
+
errors.push(`role ${name}: cacheHitRate must be a number in [0, 1]`);
|
|
677
|
+
delete rdef.cacheHitRate;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// providerPin is a provider slug (may be tiered like `deepinfra/fp8`); `@`
|
|
681
|
+
// and `:` are the selector's own delimiters, so they cannot appear in it.
|
|
682
|
+
if (rdef.providerPin !== undefined) {
|
|
683
|
+
if (
|
|
684
|
+
typeof rdef.providerPin !== "string" ||
|
|
685
|
+
rdef.providerPin.length === 0 ||
|
|
686
|
+
rdef.providerPin.includes("@") ||
|
|
687
|
+
rdef.providerPin.includes(":")
|
|
688
|
+
) {
|
|
689
|
+
errors.push(`role ${name}: providerPin must be a non-empty string without "@" or ":"`);
|
|
690
|
+
delete rdef.providerPin;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Per-role capability flags (`roles.<role>.features.<id>`): an unknown id or
|
|
694
|
+
// a non-boolean value aborts the run, naming the role and the key.
|
|
695
|
+
if (rdef.features !== undefined) {
|
|
696
|
+
if (!isRecord(rdef.features)) {
|
|
697
|
+
errors.push(`role ${name}: features must be an object`);
|
|
698
|
+
delete rdef.features;
|
|
699
|
+
} else {
|
|
700
|
+
for (const [id, v] of Object.entries(rdef.features)) {
|
|
701
|
+
if (featureById(id) === null) errors.push(`role ${name}: features.${id} is not a known capability`);
|
|
702
|
+
if (typeof v !== "boolean") errors.push(`role ${name}: features.${id} must be a boolean, got ${JSON.stringify(v)}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (rdef.preferOwnProvider !== undefined && typeof rdef.preferOwnProvider !== "boolean") {
|
|
707
|
+
errors.push(`role ${name}: preferOwnProvider must be a boolean, got ${JSON.stringify(rdef.preferOwnProvider)}`);
|
|
708
|
+
}
|
|
525
709
|
if (rdef.description === undefined) rdef.description = "";
|
|
710
|
+
// The resolved def is the EFFECTIVE def: the authored def with the enabled
|
|
711
|
+
// flags' recommended knobs filled in (explicit keys win; `features` stripped).
|
|
712
|
+
merged.roles[name] = expandFeatures(rdef, merged.features);
|
|
526
713
|
}
|
|
527
714
|
|
|
528
715
|
return { settings: merged, errors };
|
package/src/state.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugin state, history, and the refresh lock — all under the omp agent dir,
|
|
3
|
-
* next to the config.yml they describe
|
|
3
|
+
* next to the config.yml they describe.
|
|
4
4
|
*
|
|
5
5
|
* - `llm-role-state.json` — day gate, managed roles, last selectors, chain keys
|
|
6
6
|
* the plugin owns, and the pre-write `modelRoles` snapshot (manual rollback aid).
|