@genee/omp-opsx-addon 0.8.0 → 0.10.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 +440 -71
- package/index.ts +1106 -235
- package/lib/agent-defs.ts +80 -89
- package/lib/complexity-router.ts +336 -0
- package/lib/concurrency-control.ts +1246 -0
- package/lib/concurrency-multiproc.worker.ts +376 -0
- package/lib/concurrency-state.ts +397 -0
- package/lib/concurrency-tuner.ts +773 -0
- package/lib/concurrency-writer.ts +894 -0
- package/lib/continuity-guard.ts +117 -0
- package/lib/family-filter.ts +115 -2
- package/lib/model-roles.ts +43 -41
- package/lib/model-selector.ts +300 -36
- package/lib/model-speed.ts +189 -0
- package/lib/pick-model-render.ts +414 -0
- package/lib/pipe-core.ts +303 -93
- package/lib/pipe-push.ts +212 -26
- package/lib/provider-variants.ts +154 -0
- package/lib/reachability-refresh.ts +10 -2
- package/lib/selection-filters.ts +42 -1
- package/lib/stall-detector.ts +185 -0
- package/lib/system-prompt.ts +28 -11
- package/lib/tiers-data.ts +47 -5
- package/lib/tiers-updater.ts +51 -8
- package/lib/unified-config.ts +735 -68
- package/lib/usage-render.ts +57 -4
- package/lib/usage-widget.ts +18 -4
- package/package.json +1 -1
- package/skills/opsx-orchestration-protocol/SKILL.md +87 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Continuity guard (programme smart-model-selection W4 / change:
|
|
3
|
+
* difficulty-routed-selection) — same-session model stickiness at automatic
|
|
4
|
+
* recompute points (Higress 思路: conservative reuse is the DEFAULT state,
|
|
5
|
+
* not a timer state machine — no TTL, no counters).
|
|
6
|
+
*
|
|
7
|
+
* Rule (design D5): at an auto recompute, a write-set role's incumbent pick
|
|
8
|
+
* is PINNED whenever it remains viable (its selector still appears in the
|
|
9
|
+
* fresh candidate pool — not exhausted / unreachable / excluded /
|
|
10
|
+
* selector-filtered out). The pin outranks every tie-break: a faster or
|
|
11
|
+
* cheaper candidate cannot displace the incumbent on its own. A switch is
|
|
12
|
+
* justified only when
|
|
13
|
+
* (a) the new band strictly upgrades the incumbent's band AND the
|
|
14
|
+
* classification confidence ≥ `continuity_guard.weight`,
|
|
15
|
+
* (b) stall escalation is in effect (a system-initiated escalation is a
|
|
16
|
+
* legitimate switch reason), or
|
|
17
|
+
* (c) the incumbent left the pool (availability outranks stickiness).
|
|
18
|
+
*
|
|
19
|
+
* An unknown incumbent band (routing enabled mid-session, prior result
|
|
20
|
+
* carries no band record) compares as the neutral `standard`: complex /
|
|
21
|
+
* reasoning may still switch at sufficient confidence, `simple` never
|
|
22
|
+
* triggers an upgrade switch.
|
|
23
|
+
*
|
|
24
|
+
* Explicit user commands (/pick-model refresh / reset / selector / choices,
|
|
25
|
+
* role-scope) BYPASS the guard — explicit instruction > guard — and do not
|
|
26
|
+
* classify. The guard is a post-selection adjustment: it never inserts keys
|
|
27
|
+
* into the frozen sort chain and never filters the candidate pool.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { bandRank, type TaskBand } from './complexity-router.js';
|
|
31
|
+
import type { SelectorResult } from './model-selector.js';
|
|
32
|
+
|
|
33
|
+
/** `continuity_guard` config (design D7; owned here, SpeedAwareConfig 先例). */
|
|
34
|
+
export interface ContinuityGuardConfig {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
/** Minimum confidence for an upgrade-driven switch; (0,1], default 0.6. */
|
|
37
|
+
weight: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Default switch-confidence weight (0.6: higher = stickier). */
|
|
41
|
+
export const DEFAULT_WEIGHT = 0.6;
|
|
42
|
+
|
|
43
|
+
/** Guard outcome: keep the fresh picks (`pass`) or reuse the incumbent (`pin`). */
|
|
44
|
+
export type GuardDecision = 'pass' | 'pin';
|
|
45
|
+
|
|
46
|
+
export interface GuardInput {
|
|
47
|
+
/** Master switch (off → always pass; the caller normally gates earlier). */
|
|
48
|
+
enabled: boolean;
|
|
49
|
+
/** Minimum classification confidence for an upgrade switch, (0,1]. */
|
|
50
|
+
weight: number;
|
|
51
|
+
/** Explicit command path — bypasses the guard entirely. */
|
|
52
|
+
explicit: boolean;
|
|
53
|
+
/** Prior pick for the role; null = no incumbent (trivial pass). */
|
|
54
|
+
incumbent: SelectorResult | null;
|
|
55
|
+
/** Incumbent's selector still present in the fresh candidate pool. */
|
|
56
|
+
incumbentViable: boolean;
|
|
57
|
+
/** Newly classified band. */
|
|
58
|
+
newBand: TaskBand;
|
|
59
|
+
/** Band recorded on the incumbent; null = unknown → neutral standard. */
|
|
60
|
+
incumbentBand: TaskBand | null;
|
|
61
|
+
/** Classification confidence of the new prompt. */
|
|
62
|
+
confidence: number;
|
|
63
|
+
/** Stall escalation in effect this recompute. */
|
|
64
|
+
stallEscalated: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface GuardOutcome {
|
|
68
|
+
decision: GuardDecision;
|
|
69
|
+
/** The incumbent result to carry forward (decision === 'pin' only). */
|
|
70
|
+
pinned?: SelectorResult;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decide pin vs pass for one write-set role (design D5). Pure function —
|
|
75
|
+
* the assembly layer owns viability (pool membership) and application.
|
|
76
|
+
*/
|
|
77
|
+
export function decideGuard(input: GuardInput): GuardOutcome {
|
|
78
|
+
if (!input.enabled || input.explicit) return { decision: 'pass' };
|
|
79
|
+
if (!input.incumbent || !input.incumbentViable) return { decision: 'pass' };
|
|
80
|
+
if (input.stallEscalated) return { decision: 'pass' };
|
|
81
|
+
// Unknown incumbent band compares as the neutral standard (design D5):
|
|
82
|
+
// complex/reasoning can still switch at sufficient confidence, simple
|
|
83
|
+
// never outranks standard.
|
|
84
|
+
const priorBand = input.incumbentBand ?? 'standard';
|
|
85
|
+
const upgraded = bandRank(input.newBand) > bandRank(priorBand);
|
|
86
|
+
if (upgraded && input.confidence >= input.weight) return { decision: 'pass' };
|
|
87
|
+
return { decision: 'pin', pinned: input.incumbent };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Carry the incumbent forward with a `guard=pin` annotation (design D9):
|
|
92
|
+
* shallow-copies the prior result, appends `, band=<band>` when the prior
|
|
93
|
+
* reason carries no band record yet (routing enabled mid-session), then
|
|
94
|
+
* ` guard=pin` to pickedReason and to the decision log's picked line. The
|
|
95
|
+
* input result is never mutated; re-pinning an already-pinned result is
|
|
96
|
+
* idempotent (no duplicate segments).
|
|
97
|
+
*/
|
|
98
|
+
export function pinPriorPick(prev: SelectorResult, band: TaskBand): SelectorResult {
|
|
99
|
+
const baseReason = prev.pickedReason ?? `${prev.role}: pinned by continuity guard`;
|
|
100
|
+
// A pinned result may return as the NEXT recompute's incumbent (the guard
|
|
101
|
+
// reads the cached selection) — pinning must stay idempotent.
|
|
102
|
+
let reason = baseReason;
|
|
103
|
+
if (!reason.includes('band=')) reason = `${reason}, band=${band}`;
|
|
104
|
+
if (!reason.includes('guard=pin')) reason = `${reason} guard=pin`;
|
|
105
|
+
|
|
106
|
+
const segments = prev.decision.split(' | ');
|
|
107
|
+
const pickedIndex = segments.findIndex((s) => s.startsWith('picked='));
|
|
108
|
+
if (pickedIndex >= 0) {
|
|
109
|
+
if (!segments[pickedIndex].includes('guard=pin')) {
|
|
110
|
+
segments[pickedIndex] = `${segments[pickedIndex]} guard=pin`;
|
|
111
|
+
}
|
|
112
|
+
} else if (!prev.decision.includes('guard=pin')) {
|
|
113
|
+
segments.push('guard=pin');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ...prev, pickedReason: reason, decision: segments.join(' | ') };
|
|
117
|
+
}
|
package/lib/family-filter.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* semantics regardless of selector shape.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { MODEL_ROLE_IDS } from '@oh-my-pi/pi-coding-agent/config/model-roles';
|
|
12
|
+
import { ROLE_TO_OMP_ALIAS } from './model-roles.js';
|
|
13
|
+
|
|
11
14
|
/** Lowercase and split on any run of non-alphanumeric characters into tokens. */
|
|
12
15
|
export function normalizeTokens(s: string): string[] {
|
|
13
16
|
return s
|
|
@@ -116,6 +119,50 @@ export function listProviders(models: readonly { provider: string; id: string }[
|
|
|
116
119
|
return counts;
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
/** Opsx agent-name aliases accepted by the `[roleList:]` prefix → canonical model role. */
|
|
123
|
+
export const AGENT_ROLE_SCOPE_ALIASES: Record<string, string> = {
|
|
124
|
+
...ROLE_TO_OMP_ALIAS,
|
|
125
|
+
'code-reviewer': 'default',
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Usage-error raised by role-scope resolution; the parser converts it to an error result. */
|
|
129
|
+
export class RoleScopeUsageError extends Error {}
|
|
130
|
+
|
|
131
|
+
/** Full legal roleList vocabulary, inlined into usage errors (built-ins + agent aliases + `all`). */
|
|
132
|
+
export function roleScopeVocabularyText(): string {
|
|
133
|
+
const aliases = Object.keys(AGENT_ROLE_SCOPE_ALIASES);
|
|
134
|
+
return `合法 role 词表:内置 model role ${MODEL_ROLE_IDS.join(' / ')};agent 别名 ${aliases.join(' / ')};all(全部 role,等价无前缀)`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve comma-separated roleList entries into canonical model role ids
|
|
139
|
+
* (change: pick-model-role-scope). Matching is case-insensitive; agent
|
|
140
|
+
* aliases (`coder` / `reviewer` / `code-reviewer` / `planner` /
|
|
141
|
+
* `proposal-reviewer`) map to their canonical modelRole id; `all` collapses
|
|
142
|
+
* the whole scope to null (= global constraint, same as no prefix). The
|
|
143
|
+
* result is deduplicated in first-occurrence (input) order so the choices
|
|
144
|
+
* `scope=` segment is deterministic. Unknown entries throw `RoleScopeUsageError`
|
|
145
|
+
* with the full vocabulary inlined.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveRoleScopeEntries(entries: readonly string[]): string[] | null {
|
|
148
|
+
const resolved: string[] = [];
|
|
149
|
+
for (const raw of entries) {
|
|
150
|
+
const entry = raw.trim().toLowerCase();
|
|
151
|
+
if (entry.length === 0) continue;
|
|
152
|
+
if (entry === 'all') return null;
|
|
153
|
+
const canonical = (MODEL_ROLE_IDS as readonly string[]).includes(entry)
|
|
154
|
+
? entry
|
|
155
|
+
: Object.hasOwn(AGENT_ROLE_SCOPE_ALIASES, entry)
|
|
156
|
+
? AGENT_ROLE_SCOPE_ALIASES[entry]
|
|
157
|
+
: undefined;
|
|
158
|
+
if (!canonical) {
|
|
159
|
+
throw new RoleScopeUsageError(`未知 role 定向条目 "${entry}"。${roleScopeVocabularyText()}`);
|
|
160
|
+
}
|
|
161
|
+
if (!resolved.includes(canonical)) resolved.push(canonical);
|
|
162
|
+
}
|
|
163
|
+
return resolved;
|
|
164
|
+
}
|
|
165
|
+
|
|
119
166
|
const RESERVED_SUBCOMMANDS: Record<string, AutoModelParseResult> = {
|
|
120
167
|
choices: { kind: 'choices' },
|
|
121
168
|
u: { kind: 'update' },
|
|
@@ -130,7 +177,13 @@ export type AutoModelParseResult =
|
|
|
130
177
|
| { kind: 'update' }
|
|
131
178
|
| { kind: 'refresh' }
|
|
132
179
|
| { kind: 'reset' }
|
|
133
|
-
| {
|
|
180
|
+
| {
|
|
181
|
+
kind: 'selector';
|
|
182
|
+
selectors: string[] | null;
|
|
183
|
+
china: boolean;
|
|
184
|
+
/** Canonical modelRole ids of the `[roleList:]` prefix; absent/null = global constraint. */
|
|
185
|
+
roleScope?: string[] | null;
|
|
186
|
+
}
|
|
134
187
|
| { kind: 'error'; message: string };
|
|
135
188
|
|
|
136
189
|
/**
|
|
@@ -143,6 +196,13 @@ export type AutoModelParseResult =
|
|
|
143
196
|
* - Selectors are only lowercased (the `*`/`/` structure is preserved, no token
|
|
144
197
|
* splitting) and split on whitespace — a selector containing `/` is kept whole.
|
|
145
198
|
* - Any unknown `-`/`--` flag is a usage error.
|
|
199
|
+
* - `[roleList:]<selector>` prefix (change: pick-model-role-scope): recognized on
|
|
200
|
+
* the first selector token only; the colon is a RESERVED character — a colon
|
|
201
|
+
* token that does not establish a roleList intent (single word not in the
|
|
202
|
+
* vocabulary, e.g. `zhipu:glm`, starred or not, anywhere in the command) is a
|
|
203
|
+
* usage error with the full vocabulary inlined, never a selector (the bare-word
|
|
204
|
+
* matcher splits on non-alphanumerics, so a colon token falling through would
|
|
205
|
+
* silently OR-apply as a global constraint).
|
|
146
206
|
*/
|
|
147
207
|
export function parseAutoModelArgs(args: string): AutoModelParseResult {
|
|
148
208
|
const tokens = args
|
|
@@ -158,6 +218,10 @@ export function parseAutoModelArgs(args: string): AutoModelParseResult {
|
|
|
158
218
|
|
|
159
219
|
const selectors: string[] = [];
|
|
160
220
|
let china = false;
|
|
221
|
+
// undefined = no `[roleList:]` prefix seen; otherwise the resolved scope
|
|
222
|
+
// (null when the prefix was `all`).
|
|
223
|
+
let roleScope: string[] | null | undefined;
|
|
224
|
+
let seenSelectorToken = false;
|
|
161
225
|
for (const tok of tokens) {
|
|
162
226
|
if (tok === '--china') {
|
|
163
227
|
china = true;
|
|
@@ -165,10 +229,59 @@ export function parseAutoModelArgs(args: string): AutoModelParseResult {
|
|
|
165
229
|
return { kind: 'error', message: `未知参数: ${tok}` };
|
|
166
230
|
} else if (Object.hasOwn(RESERVED_SUBCOMMANDS, tok)) {
|
|
167
231
|
return { kind: 'error', message: `保留字 "${tok}" 不能与 selector/flag 混用` };
|
|
232
|
+
} else if (tok.includes(':')) {
|
|
233
|
+
if (seenSelectorToken || roleScope !== undefined) {
|
|
234
|
+
return {
|
|
235
|
+
kind: 'error',
|
|
236
|
+
message: `"${tok}" 含冒号,但 role 定向前缀仅识别于第一个 selector token。${roleScopeVocabularyText()}`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const colon = tok.indexOf(':');
|
|
240
|
+
const entries = tok
|
|
241
|
+
.slice(0, colon)
|
|
242
|
+
.split(',')
|
|
243
|
+
.map((e) => e.trim())
|
|
244
|
+
.filter((e) => e.length > 0);
|
|
245
|
+
// roleList intent ⟺ multiple comma entries, or a single entry in the
|
|
246
|
+
// vocabulary (built-in role, agent alias, or `all`).
|
|
247
|
+
const intent =
|
|
248
|
+
entries.length > 1 ||
|
|
249
|
+
(entries.length === 1 &&
|
|
250
|
+
(entries[0] === 'all' ||
|
|
251
|
+
(MODEL_ROLE_IDS as readonly string[]).includes(entries[0]) ||
|
|
252
|
+
Object.hasOwn(AGENT_ROLE_SCOPE_ALIASES, entries[0])));
|
|
253
|
+
if (!intent) {
|
|
254
|
+
return {
|
|
255
|
+
kind: 'error',
|
|
256
|
+
message: `"${tok}" 含冒号,但前缀不是 role 定向(冒号为保留字符,不能用于 selector)。${roleScopeVocabularyText()}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
roleScope = resolveRoleScopeEntries(entries);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
if (e instanceof RoleScopeUsageError) return { kind: 'error', message: e.message };
|
|
263
|
+
throw e;
|
|
264
|
+
}
|
|
265
|
+
const rest = tok.slice(colon + 1);
|
|
266
|
+
if (rest.length > 0) selectors.push(rest);
|
|
267
|
+
seenSelectorToken = true;
|
|
168
268
|
} else {
|
|
169
269
|
selectors.push(tok);
|
|
270
|
+
seenSelectorToken = true;
|
|
170
271
|
}
|
|
171
272
|
}
|
|
172
273
|
|
|
173
|
-
|
|
274
|
+
if (selectors.length === 0) {
|
|
275
|
+
if (roleScope !== undefined) {
|
|
276
|
+
return { kind: 'error', message: `role 定向前缀后缺少 selector。${roleScopeVocabularyText()}` };
|
|
277
|
+
}
|
|
278
|
+
return { kind: 'selector', selectors: null, china };
|
|
279
|
+
}
|
|
280
|
+
const result: AutoModelParseResult = {
|
|
281
|
+
kind: 'selector',
|
|
282
|
+
selectors: [...new Set(selectors)],
|
|
283
|
+
china,
|
|
284
|
+
};
|
|
285
|
+
if (roleScope) result.roleScope = roleScope;
|
|
286
|
+
return result;
|
|
174
287
|
}
|
package/lib/model-roles.ts
CHANGED
|
@@ -5,21 +5,26 @@
|
|
|
5
5
|
* via session-scoped `settings.overrideModelRoles` instead of per-agent
|
|
6
6
|
* `task.agentModelOverrides` writes.
|
|
7
7
|
*
|
|
8
|
-
* change: pick-model-batch-all-roles — the overlay
|
|
9
|
-
* the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* change: pick-model-ux (supersedes pick-model-batch-all-roles) — the overlay
|
|
9
|
+
* writes ONLY the minimal write set {smol, default, slow, vision}: smol ← mid
|
|
10
|
+
* pick (coder), default ← high pick (reviewer), slow ← the sole supplemental
|
|
11
|
+
* (top) pick, vision ← capability-gated pick. `buildRoleOverridePlan` maps
|
|
12
|
+
* each write-set role to a tier (default table, overridable via
|
|
12
13
|
* `model_role_tiers`), feeds it the single picked model for that tier, and
|
|
13
14
|
* records skip reasons (`skip` config sentinel, capability gate such as
|
|
14
|
-
* vision/image, or no picked model for the tier).
|
|
15
|
-
*
|
|
15
|
+
* vision/image, or no picked model for the tier). Roles outside the write set
|
|
16
|
+
* resolve through OMP native chains: designer via default inheritance,
|
|
17
|
+
* commit/tiny via enumeration fallbacks terminating at smol, task via
|
|
18
|
+
* primary-session inheritance, plan as a graceful no-op, advisor via the
|
|
19
|
+
* static slow chain, custom roles via the config layer. The landing sequence
|
|
20
|
+
* is unchanged: clearOverride('modelRoles') → overrideModelRoles(payload) →
|
|
16
21
|
* empty-string neutralization of persisted per-agent pins.
|
|
17
22
|
*/
|
|
18
23
|
import { type ModelRole } from '@oh-my-pi/pi-coding-agent/config/model-roles';
|
|
19
24
|
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
20
25
|
import type { SelectionContext } from '../index.js';
|
|
21
26
|
import type { TierName } from './model-tiers.js';
|
|
22
|
-
import {
|
|
27
|
+
import { OPSX_AGENTS } from './unified-config.js';
|
|
23
28
|
|
|
24
29
|
/** Per-agent names written into `task.agentModelOverrides` neutralization. */
|
|
25
30
|
const AGENT_MODEL_KEYS: Record<string, string> = {
|
|
@@ -38,10 +43,10 @@ export const ROLE_TO_OMP_ALIAS: Record<string, string> = {
|
|
|
38
43
|
};
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
|
-
* Built-in OMP model role → default tier
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
46
|
+
* Built-in OMP model role → default tier. The `satisfies` clause forces the
|
|
47
|
+
* table to cover every built-in role. Entries outside the write set stay
|
|
48
|
+
* relevant as the tier-key source for role-scope slot resolution
|
|
49
|
+
* (`scopedTierSlots`), which may point any scope role at its slot.
|
|
45
50
|
*/
|
|
46
51
|
export const OMP_ROLE_TO_TIER = {
|
|
47
52
|
tiny: 'tiny',
|
|
@@ -69,6 +74,14 @@ export const ROLE_CAPABILITY: Record<string, (model: Model<Api>) => boolean> = {
|
|
|
69
74
|
vision: (model) => Array.isArray(model.input) && model.input.includes('image'),
|
|
70
75
|
};
|
|
71
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Minimal write set (change: pick-model-ux design D5): the ONLY roles the
|
|
79
|
+
* plugin writes into `overrideModelRoles`. Every other role resolves through
|
|
80
|
+
* OMP native chains that terminate at these writes; `model_role_tiers`
|
|
81
|
+
* entries for other roles are inert for the write plan.
|
|
82
|
+
*/
|
|
83
|
+
export const OMP_WRITE_SET_ROLES = ['smol', 'default', 'slow', 'vision'] as const;
|
|
84
|
+
|
|
72
85
|
/** Why a known role was left out of the overlay payload. */
|
|
73
86
|
export type RoleSkipReason = 'skip' | 'capability' | 'no-pick';
|
|
74
87
|
|
|
@@ -88,8 +101,6 @@ export interface RoleOverridePlan {
|
|
|
88
101
|
|
|
89
102
|
/** Inputs for `buildRoleOverridePlan`. */
|
|
90
103
|
export interface BuildRoleOverridePlanInput {
|
|
91
|
-
/** All roles the host knows (built-in + custom), from `getKnownRoleIds`. */
|
|
92
|
-
knownRoleIds: readonly string[];
|
|
93
104
|
/** Parsed `model_role_tiers` config (role → tier override or 'skip'). */
|
|
94
105
|
ompRoleTiers: Record<string, TierName | 'skip'>;
|
|
95
106
|
/**
|
|
@@ -106,45 +117,36 @@ export interface BuildRoleOverridePlanInput {
|
|
|
106
117
|
capabilityPicks?: Record<string, string | undefined>;
|
|
107
118
|
}
|
|
108
119
|
|
|
109
|
-
/** Roles whose fallback tier was already warned about (one warning per role). */
|
|
110
|
-
const warnedCustomRoles = new Set<string>();
|
|
111
|
-
|
|
112
120
|
/**
|
|
113
|
-
* Resolve the tier
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
121
|
+
* Resolve the tier a role's slot follows: explicit `model_role_tiers` value
|
|
122
|
+
* (including the `skip` sentinel) → built-in default table → `high`
|
|
123
|
+
* fallback. The fallback is only reachable for non-table scope roles in
|
|
124
|
+
* `scopedTierSlots` (role-scope slot resolution); the write plan iterates
|
|
125
|
+
* `OMP_WRITE_SET_ROLES`, all of which are table keys.
|
|
117
126
|
*/
|
|
118
127
|
export function resolveOmpRoleTier(
|
|
119
128
|
role: string,
|
|
120
129
|
ompRoleTiers: Record<string, TierName | 'skip'>,
|
|
121
|
-
warn: (msg: string) => void = () => {},
|
|
122
130
|
): TierName | 'skip' {
|
|
123
131
|
const configured = ompRoleTiers[role];
|
|
124
132
|
if (configured !== undefined) return configured;
|
|
125
133
|
if (role in OMP_ROLE_TO_TIER) return OMP_ROLE_TO_TIER[role as ModelRole];
|
|
126
|
-
if (!warnedCustomRoles.has(role)) {
|
|
127
|
-
warnedCustomRoles.add(role);
|
|
128
|
-
warn(`[omp-opsx-addon] custom model role "${role}" has no tier mapping; using high (set model_role_tiers.${role} to tune or 'skip' to exclude)`);
|
|
129
|
-
}
|
|
130
134
|
return 'high';
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
/**
|
|
134
|
-
* Build the
|
|
135
|
-
* resolve its tier (or `skip`), apply capability
|
|
136
|
-
* single picked model for the tier. Roles without a
|
|
137
|
-
* a reason and omitted from the payload — after
|
|
138
|
-
* back to the user's config layer (or stay
|
|
138
|
+
* Build the minimal write-set overlay plan (design D5): for each role in
|
|
139
|
+
* `OMP_WRITE_SET_ROLES`, resolve its tier (or `skip`), apply capability
|
|
140
|
+
* gating, then assign the single picked model for the tier. Roles without a
|
|
141
|
+
* pick are recorded with a reason and omitted from the payload — after
|
|
142
|
+
* `clearOverride` they fall back to the user's config layer (or stay
|
|
143
|
+
* unconfigured). Roles outside the write set are never iterated.
|
|
139
144
|
*/
|
|
140
|
-
export function buildRoleOverridePlan(
|
|
141
|
-
input: BuildRoleOverridePlanInput,
|
|
142
|
-
warn: (msg: string) => void = () => {},
|
|
143
|
-
): RoleOverridePlan {
|
|
145
|
+
export function buildRoleOverridePlan(input: BuildRoleOverridePlanInput): RoleOverridePlan {
|
|
144
146
|
const overrides: Record<string, string> = {};
|
|
145
147
|
const skipped: SkippedRole[] = [];
|
|
146
|
-
for (const role of
|
|
147
|
-
const tier = resolveOmpRoleTier(role, input.ompRoleTiers
|
|
148
|
+
for (const role of OMP_WRITE_SET_ROLES) {
|
|
149
|
+
const tier = resolveOmpRoleTier(role, input.ompRoleTiers);
|
|
148
150
|
if (tier === 'skip') {
|
|
149
151
|
skipped.push({ role, reason: 'skip' });
|
|
150
152
|
continue;
|
|
@@ -220,7 +222,7 @@ export function dispatchModelHint(role: string, selection: SelectionContext): st
|
|
|
220
222
|
*/
|
|
221
223
|
export function agentOverrideNeutralization(): Record<string, string> {
|
|
222
224
|
const neutral: Record<string, string> = {};
|
|
223
|
-
for (const role of
|
|
225
|
+
for (const role of OPSX_AGENTS) neutral[AGENT_MODEL_KEYS[role]] = '';
|
|
224
226
|
return neutral;
|
|
225
227
|
}
|
|
226
228
|
|
|
@@ -236,10 +238,10 @@ export interface RoleSettings {
|
|
|
236
238
|
*
|
|
237
239
|
* Sequence (design D1/D5/D7): clear the session modelRoles overlay first
|
|
238
240
|
* (overrideModelRoles only adds and skips falsy values, so stale roles from
|
|
239
|
-
* a previous constraint would otherwise linger) → write the
|
|
240
|
-
* payload (roles skipped for config `skip`, capability gating, or no
|
|
241
|
-
* model are omitted and fall back to the config layer after the
|
|
242
|
-
* neutralize persisted per-agent pins with empty strings. Never writes
|
|
241
|
+
* a previous constraint would otherwise linger) → write the minimal write-set
|
|
242
|
+
* plan payload (roles skipped for config `skip`, capability gating, or no
|
|
243
|
+
* picked model are omitted and fall back to the config layer after the
|
|
244
|
+
* clear) → neutralize persisted per-agent pins with empty strings. Never writes
|
|
243
245
|
* disk; the runtime overlay is released with the session.
|
|
244
246
|
*/
|
|
245
247
|
export function applyRoleModelOverrides(
|