@genee/omp-opsx-addon 0.3.0 → 0.5.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/lib/agent-defs.ts CHANGED
@@ -5,7 +5,7 @@ const MARKER = '<!-- @genee/omp-opsx-addon -->';
5
5
  const CODE_REVIEWER_MD = (model?: string) => `---
6
6
  name: code-reviewer
7
7
  description: OpenSpec 代码审查 agent,审查代码实现并执行全局验证
8
- tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
8
+ tools: read, bash, glob, grep${model ? `\nmodel: "${model}"` : ''}
9
9
  ---
10
10
 
11
11
  你是一位资深代码审查员。你根据 OpenSpec 提案和设计文档审查代码实现,并在审阅的同时执行最后一次全局验证。
@@ -71,7 +71,7 @@ ${MARKER}
71
71
  const PROPOSAL_REVIEWER_MD = (model?: string) => `---
72
72
  name: proposal-reviewer
73
73
  description: OpenSpec 提案审查 agent,审查提案质量
74
- tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
74
+ tools: read, bash, glob, grep${model ? `\nmodel: "${model}"` : ''}
75
75
  ---
76
76
 
77
77
  你是一位资深需求审查员。你审查 OpenSpec 提案的完整性和质量。
@@ -123,7 +123,7 @@ ${MARKER}
123
123
  const CODER_MD = (model?: string) => `---
124
124
  name: coder
125
125
  description: OpenSpec 变更实现 agent,同步 tasks.md 进度
126
- tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: ${model}` : ''}
126
+ tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: "${model}"` : ''}
127
127
  skill: openspec-apply-change
128
128
  ---
129
129
 
@@ -184,7 +184,7 @@ ${MARKER}
184
184
  const PLANNER_MD = (model?: string) => `---
185
185
  name: planner
186
186
  description: OpenSpec 提案规划 agent
187
- tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: ${model}` : ''}\nskill: openspec-propose
187
+ tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: "${model}"` : ''}\nskill: openspec-propose
188
188
  ---
189
189
 
190
190
  你是一个 OpenSpec 提案规划 agent。负责创建和更新 OpenSpec 变更提案文档。
@@ -117,7 +117,7 @@ export function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string
117
117
  | { override(path: string, value: unknown): void; getAgentDir?: () => string }
118
118
  | undefined;
119
119
  if (!settings) {
120
- console.warn('[omp-opsx-addon] pi.pi.settings unavailable; edit variant pin skipped');
120
+ pi.logger?.warn('[omp-opsx-addon] pi.pi.settings unavailable; edit variant pin skipped');
121
121
  return;
122
122
  }
123
123
 
@@ -139,18 +139,18 @@ export function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string
139
139
 
140
140
  const configPath = resolveGlobalConfigPath(agentDir);
141
141
  if (!configPath) {
142
- console.warn(
142
+ pi.logger?.warn(
143
143
  `[omp-opsx-addon] edit variant pin failed: no global config.yml found under ${agentDir}`,
144
144
  );
145
145
  return;
146
146
  }
147
147
  const outcome = persistEditVariantPin(configPath, pin);
148
148
  if (outcome === 'written') {
149
- console.warn(
149
+ pi.logger?.warn(
150
150
  `[omp-opsx-addon] edit variant pin persisted to ${configPath} (effective from the next session)`,
151
151
  );
152
152
  }
153
153
  } catch (err) {
154
- console.warn('[omp-opsx-addon] edit variant pin failed:', err);
154
+ pi.logger?.warn('[omp-opsx-addon] edit variant pin failed', { err });
155
155
  }
156
156
  }
package/lib/logger.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 插件日志通道(模块级注入)。
3
+ *
4
+ * 主进程日志不再走 console.*(会在 omp TUI 里打爆界面),改走宿主
5
+ * pi.logger(滚动文件日志)。lib 模块拿不到 pi 对象,故提供模块级 holder:
6
+ * 入口 setOpsxLogger(pi.logger) 注入,lib 侧 getLogger() 读取。
7
+ * 未注入(测试场景直接调用叶子函数)时默认 console,输出与迁移前一致。
8
+ */
9
+
10
+ /** 窄化日志接口:宿主 pi.logger 与 console 的结构交集,仅本插件使用的四级方法。 */
11
+ export interface OpsxLogger {
12
+ error(message: string, context?: Record<string, unknown>): void;
13
+ warn(message: string, context?: Record<string, unknown>): void;
14
+ info(message: string, context?: Record<string, unknown>): void;
15
+ debug(message: string, context?: Record<string, unknown>): void;
16
+ }
17
+
18
+ /** 模块级当前 logger,默认 console(未注入/测试场景保持旧行为)。 */
19
+ let current: OpsxLogger = console;
20
+
21
+ /** 插件入口注入宿主 pi.logger(进程级单例,多实例重复注入幂等)。
22
+ * 空值注入忽略:保持 getLogger() 恒可用(console 兜底)——测试 mock 的
23
+ * pi 对象可能不含 logger,此时视同未注入,输出与迁移前一致。 */
24
+ export function setOpsxLogger(logger: OpsxLogger): void {
25
+ if (logger) {
26
+ current = logger;
27
+ }
28
+ }
29
+
30
+ /** 读取当前 logger。 */
31
+ export function getLogger(): OpsxLogger {
32
+ return current;
33
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * pick-model → OMP modelRoles application.
3
+ *
4
+ * change: pick-model-role-targeting — selector results are applied by TIER
5
+ * via session-scoped `settings.overrideModelRoles` instead of per-agent
6
+ * `task.agentModelOverrides` writes.
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
12
+ * `model_role_tiers`), feeds it the single picked model for that tier, and
13
+ * 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) →
16
+ * empty-string neutralization of persisted per-agent pins.
17
+ */
18
+ import { type ModelRole } from '@oh-my-pi/pi-coding-agent/config/model-roles';
19
+ import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
20
+ import type { SelectionContext } from '../index.js';
21
+ import type { TierName } from './model-tiers.js';
22
+ import { OPSX_ROLES } from './unified-config.js';
23
+
24
+ /** Per-agent names written into `task.agentModelOverrides` neutralization. */
25
+ const AGENT_MODEL_KEYS: Record<string, string> = {
26
+ coder: 'coder',
27
+ reviewer: 'code-reviewer',
28
+ planner: 'planner',
29
+ 'proposal-reviewer': 'proposal-reviewer',
30
+ };
31
+
32
+ /** Opsx role → OMP tier role alias (without `@`). */
33
+ export const ROLE_TO_OMP_ALIAS: Record<string, string> = {
34
+ coder: 'smol',
35
+ reviewer: 'default',
36
+ planner: 'default',
37
+ 'proposal-reviewer': 'default',
38
+ };
39
+
40
+ /**
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.
45
+ */
46
+ export const OMP_ROLE_TO_TIER = {
47
+ tiny: 'tiny',
48
+ smol: 'mid',
49
+ task: 'mid',
50
+ commit: 'low',
51
+ default: 'high',
52
+ designer: 'high',
53
+ vision: 'high',
54
+ slow: 'top',
55
+ plan: 'top',
56
+ advisor: 'top',
57
+ } satisfies Record<ModelRole, TierName>;
58
+
59
+ /**
60
+ * Capability requirements per role (D3). A role only receives a model that
61
+ * passes its predicate; when no candidate qualifies the role is skipped
62
+ * (reason `capability`) rather than written an incapable model. The table
63
+ * is extensible; vision is the only gated role today.
64
+ *
65
+ * `Model.input` is a required `("text"|"image")[]` in the catalog; a
66
+ * non-array value is treated conservatively as text-only.
67
+ */
68
+ export const ROLE_CAPABILITY: Record<string, (model: Model<Api>) => boolean> = {
69
+ vision: (model) => Array.isArray(model.input) && model.input.includes('image'),
70
+ };
71
+
72
+ /** Why a known role was left out of the overlay payload. */
73
+ export type RoleSkipReason = 'skip' | 'capability' | 'no-pick';
74
+
75
+ /** A role omitted from the overlay payload, with the reason. */
76
+ export interface SkippedRole {
77
+ role: string;
78
+ reason: RoleSkipReason;
79
+ }
80
+
81
+ /** The full-role overlay plan consumed by `applyRoleModelOverrides`. */
82
+ export interface RoleOverridePlan {
83
+ /** role → 'provider/id' written to `overrideModelRoles`. */
84
+ overrides: Record<string, string>;
85
+ /** Roles not written (config skip / capability gate / no picked model). */
86
+ skipped: SkippedRole[];
87
+ }
88
+
89
+ /** Inputs for `buildRoleOverridePlan`. */
90
+ export interface BuildRoleOverridePlanInput {
91
+ /** All roles the host knows (built-in + custom), from `getKnownRoleIds`. */
92
+ knownRoleIds: readonly string[];
93
+ /** Parsed `model_role_tiers` config (role → tier override or 'skip'). */
94
+ ompRoleTiers: Record<string, TierName | 'skip'>;
95
+ /**
96
+ * Tier → picked `provider/id` for that tier. mid/high reuse the opsx
97
+ * selector picks (coder/reviewer); tiny/low/top are supplemental picks
98
+ * over the same candidate set. `undefined` means the tier has no pick
99
+ * (every role mapped to it is skipped with reason `no-pick`).
100
+ */
101
+ tierPicks: Partial<Record<TierName, string | undefined>>;
102
+ /**
103
+ * Capability-gated picks keyed by role (e.g. vision picked from
104
+ * image-capable candidates only). `undefined` means no qualifying model.
105
+ */
106
+ capabilityPicks?: Record<string, string | undefined>;
107
+ }
108
+
109
+ /** Roles whose fallback tier was already warned about (one warning per role). */
110
+ const warnedCustomRoles = new Set<string>();
111
+
112
+ /**
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).
117
+ */
118
+ export function resolveOmpRoleTier(
119
+ role: string,
120
+ ompRoleTiers: Record<string, TierName | 'skip'>,
121
+ warn: (msg: string) => void = () => {},
122
+ ): TierName | 'skip' {
123
+ const configured = ompRoleTiers[role];
124
+ if (configured !== undefined) return configured;
125
+ 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
+ return 'high';
131
+ }
132
+
133
+ /**
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).
139
+ */
140
+ export function buildRoleOverridePlan(
141
+ input: BuildRoleOverridePlanInput,
142
+ warn: (msg: string) => void = () => {},
143
+ ): RoleOverridePlan {
144
+ const overrides: Record<string, string> = {};
145
+ const skipped: SkippedRole[] = [];
146
+ for (const role of input.knownRoleIds) {
147
+ const tier = resolveOmpRoleTier(role, input.ompRoleTiers, warn);
148
+ if (tier === 'skip') {
149
+ skipped.push({ role, reason: 'skip' });
150
+ continue;
151
+ }
152
+ if (role in ROLE_CAPABILITY) {
153
+ // Capability-gated roles (vision) use their own candidate set;
154
+ // a missing pick means no qualifying model OR no pick at all —
155
+ // both surface as `capability` (D3: never write an incapable model).
156
+ const picked = input.capabilityPicks?.[role];
157
+ if (!picked) {
158
+ skipped.push({ role, reason: 'capability' });
159
+ continue;
160
+ }
161
+ overrides[role] = picked;
162
+ continue;
163
+ }
164
+ const picked = input.tierPicks[tier];
165
+ if (!picked) {
166
+ skipped.push({ role, reason: 'no-pick' });
167
+ continue;
168
+ }
169
+ overrides[role] = picked;
170
+ }
171
+ return { overrides, skipped };
172
+ }
173
+
174
+ /**
175
+ * Model string for the agent frontmatter `model:` field.
176
+ *
177
+ * - auto role → the tier role alias (`@smol` for coder, `@default` for the
178
+ * high-tier roles) so resolution binds to the tier at the frontmatter layer;
179
+ * - omp-alias → `@<alias>` (e.g. `@slow`);
180
+ * - pinned → the concrete `provider/model` (or bare model id);
181
+ * - no value → undefined (template omits the `model:` line).
182
+ */
183
+ export function frontmatterModel(role: string, value: { kind: string } & Record<string, unknown>): string | undefined {
184
+ if (value.kind === 'auto') return `@${ROLE_TO_OMP_ALIAS[role] ?? 'default'}`;
185
+ if (value.kind === 'omp-alias' && typeof value.alias === 'string') return `@${value.alias}`;
186
+ if (value.kind === 'provider-model' && typeof value.provider === 'string' && typeof value.modelId === 'string') {
187
+ return `${value.provider}/${value.modelId}`;
188
+ }
189
+ if (value.kind === 'model' && typeof value.modelId === 'string') return value.modelId;
190
+ return undefined;
191
+ }
192
+
193
+ /**
194
+ * Explicit model string for the dispatch `task(..., model:"...")` call, or
195
+ * undefined when resolution must flow through the tier role.
196
+ *
197
+ * auto roles never pass a model — requestModel is the top-priority layer and
198
+ * a concrete model would shadow the frontmatter `@alias`; omp-alias roles
199
+ * (e.g. @slow, roleModels value without `/`) likewise resolve through
200
+ * frontmatter. Only a concrete provider/model pin stays explicit.
201
+ */
202
+ export function dispatchModelHint(role: string, selection: SelectionContext): string | undefined {
203
+ if (selection.roleKind?.[role as keyof SelectionContext['roleKind']] === 'auto') return undefined;
204
+ const picked = selection.selectorResults[role as keyof SelectionContext['selectorResults']]?.picked;
205
+ if (picked) return `${picked.provider}/${picked.id}`;
206
+ const hint = selection.roleModels[role as keyof SelectionContext['roleModels']];
207
+ return hint && hint.includes('/') ? hint : undefined;
208
+ }
209
+
210
+ /**
211
+ * Runtime neutralization for persisted per-agent pins.
212
+ *
213
+ * Empty STRING values (not 'off'/'false'): the OMP model-resolution chain
214
+ * expands `''` to zero patterns (Rdt@12103716 returns on empty trim;
215
+ * VT@12104171 flattens to []), so the settingsOverride layer (LNs@12104228)
216
+ * falls through to the frontmatter `@alias` layer; while cross-layer merge
217
+ * (#X/#Se@11863299 — scalar leaf replace, only `undefined` skipped) lets the
218
+ * runtime `''` shadow the global config pin. 'off'/'false' are NOT empty:
219
+ * they come back as literal patterns and short-circuit the frontmatter layer.
220
+ */
221
+ export function agentOverrideNeutralization(): Record<string, string> {
222
+ const neutral: Record<string, string> = {};
223
+ for (const role of OPSX_ROLES) neutral[AGENT_MODEL_KEYS[role]] = '';
224
+ return neutral;
225
+ }
226
+
227
+ /** Minimal Settings surface this module needs (host-process `pi.pi.settings`). */
228
+ export interface RoleSettings {
229
+ clearOverride(key: string): void;
230
+ overrideModelRoles(roles: Record<string, string>): void;
231
+ override(key: string, value: unknown): void;
232
+ }
233
+
234
+ /**
235
+ * Apply the selector result to OMP model roles (session-scoped).
236
+ *
237
+ * Sequence (design D1/D5/D7): clear the session modelRoles overlay first
238
+ * (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
243
+ * disk; the runtime overlay is released with the session.
244
+ */
245
+ export function applyRoleModelOverrides(
246
+ settings: RoleSettings | null | undefined,
247
+ sel: SelectionContext,
248
+ warn: (msg: string) => void = () => {},
249
+ ): void {
250
+ if (!settings) {
251
+ warn('[omp-opsx-addon] pi.pi.settings unavailable; model role overrides skipped');
252
+ return;
253
+ }
254
+ settings.clearOverride('modelRoles');
255
+ settings.overrideModelRoles(sel.roleOverrides);
256
+ settings.override('task.agentModelOverrides', agentOverrideNeutralization());
257
+ const applied = Object.keys(sel.roleOverrides);
258
+ const skippedText = sel.skippedRoles.length > 0
259
+ ? `; skipped: ${sel.skippedRoles.map((s) => `${s.role}(${s.reason})`).join(', ')}`
260
+ : '';
261
+ const skipWarns = sel.skippedRoles.filter((s) => s.reason !== 'skip');
262
+ warn(`[omp-opsx-addon] modelRoles applied (${applied.length} roles: ${applied.join(', ') || 'none'})${skippedText}; per-agent pins neutralized`);
263
+ for (const s of skipWarns) {
264
+ if (s.reason === 'capability') {
265
+ warn(`[omp-opsx-addon] model role "${s.role}": no capable model in selection; skipped (role keeps configured/default value; change selector/allowlist or set model_role_tiers.${s.role}: skip)`);
266
+ } else {
267
+ warn(`[omp-opsx-addon] model role "${s.role}": no model picked for its tier; skipped (role keeps configured/default value)`);
268
+ }
269
+ }
270
+ }