@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.
@@ -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
+ }
@@ -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
- | { kind: 'selector'; selectors: string[] | null; china: boolean }
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
- return { kind: 'selector', selectors: selectors.length > 0 ? [...new Set(selectors)] : null, china };
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
  }
@@ -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 covers EVERY role known to
9
- * the host (`getKnownRoleIds(settings)`: the 10 built-in roles plus custom
10
- * roles from cycleOrder/modelRoles/modelTags). `buildRoleOverridePlan` maps
11
- * each known role to a tier (default table, overridable via
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). The landing sequence is
15
- * unchanged: clearOverride('modelRoles') → overrideModelRoles(payload) →
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 { OPSX_ROLES } from './unified-config.js';
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 (change: pick-model-batch-all-roles
42
- * D2). Key source is `MODEL_ROLE_IDS`; the `satisfies` clause forces the
43
- * table to cover every built-in role — no hardcoded role list is enumerated
44
- * in code.
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 an OMP model role should follow: explicit
114
- * `model_role_tiers` value (including the `skip` sentinel) → built-in
115
- * default table → `high` fallback for custom roles (warned once per role;
116
- * D7: custom roles are treated as primary roles unless configured).
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 full-role overlay plan (D2/D3/D4/D7): for every known role,
135
- * resolve its tier (or `skip`), apply capability gating, then assign the
136
- * single picked model for the tier. Roles without a pick are recorded with
137
- * a reason and omitted from the payload — after `clearOverride` they fall
138
- * back to the user's config layer (or stay unconfigured).
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 input.knownRoleIds) {
147
- const tier = resolveOmpRoleTier(role, input.ompRoleTiers, warn);
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 OPSX_ROLES) neutral[AGENT_MODEL_KEYS[role]] = '';
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 full-role plan
240
- * payload (roles skipped for config `skip`, capability gating, or no picked
241
- * model are omitted and fall back to the config layer after the clear) →
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(