@mrclrchtr/supi-skills 7.2.0 → 7.3.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.
@@ -1,398 +1,91 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
2
  import {
2
- DefaultPackageManager,
3
- type ExtensionAPI,
4
- type ExtensionContext,
5
- getAgentDir,
6
- type ResolvedResource,
7
- SettingsManager,
8
- } from "@earendil-works/pi-coding-agent";
9
- import {
10
- registerSettings,
11
- type ScopedFieldValue,
12
- type SettingsAction,
13
- type SettingsApplyResult,
14
- type SettingsContext,
15
- type SettingsModule,
16
- type SettingsScope,
17
- type ValueSource,
18
- } from "@mrclrchtr/supi-core/settings";
19
- import {
20
- buildSkillCatalog,
21
- mergeRuntimeSkills,
22
- type SkillCatalog,
23
- type SkillRecord,
24
- skillSourceIdentity,
25
- } from "./skill-catalog.ts";
26
- import {
27
- hasExactSkillLoadOverride,
28
- type SkillLoadOverride,
29
- updateSkillLoadOverrides,
30
- } from "./skill-load-settings.ts";
31
- import {
32
- applyPromptOverrides,
33
- DISABLED,
34
- ENABLED,
35
- MODEL_DISABLED,
36
- notifyInvocationConfigWarnings,
37
- persistInvocation,
38
- resolveInvocation,
39
- } from "./skill-model-invocation.ts";
3
+ registerSessionCapabilitySkillProvider,
4
+ sessionCapabilityState,
5
+ } from "@mrclrchtr/supi-core/session";
6
+ import { registerSettings, type SettingsModule } from "@mrclrchtr/supi-core/settings";
7
+ import { applyPromptOverrides, notifyInvocationConfigWarnings } from "./skill-model-invocation.ts";
8
+ import { SkillSettingsController, type SkillSettingsOptions } from "./skill-settings-controller.ts";
40
9
 
41
10
  const SETTINGS_SECTION_ID = "skills";
42
- interface SkillSettingsOptions {
43
- agentDir?: string;
44
- homeDir?: string;
45
- }
46
- interface SkillSettingsControllerOptions {
47
- cwd: string;
48
- agentDir: string;
49
- homeDir?: string;
50
- projectTrusted: boolean;
51
- settingsManager: SettingsManager;
52
- globalSettingsManager: SettingsManager;
53
- }
54
-
55
- function recordResources(record: SkillRecord): ResolvedResource[] {
56
- return record.sources.flatMap((source) => (source.resource ? [source.resource] : []));
57
- }
58
- function isLoaded(record: SkillRecord): boolean {
59
- return (
60
- record.activeSkill !== undefined ||
61
- record.sources.some((source) => source.runtime || source.resource?.enabled)
62
- );
63
- }
64
- function canDisable(record: SkillRecord): boolean {
65
- return record.sources.length > 0 && record.sources.every((source) => !source.runtime);
66
- }
67
-
68
- function sourceDefault(record: SkillRecord): boolean {
69
- const winner =
70
- record.activeSkill ??
71
- record.sources.find((source) => source.runtime || source.resource?.enabled)?.skill ??
72
- record.sources[0]?.skill;
73
- return winner?.disableModelInvocation ?? false;
74
- }
75
-
76
- function moreSpecificSource(left: ValueSource, right: ValueSource): ValueSource {
77
- const rank: Record<ValueSource, number> = { default: 0, global: 1, project: 2 };
78
- return rank[left] >= rank[right] ? left : right;
79
- }
80
-
81
- function displayValue(value: string, source: ValueSource): string {
82
- return `${value} (${source})`;
83
- }
11
+ type SkillControllerGetter = (
12
+ cwd: string,
13
+ projectTrusted: boolean,
14
+ ) => Promise<SkillSettingsController>;
84
15
 
85
- function validateAction(record: SkillRecord, action: SettingsAction): void {
86
- if (action.kind !== "set") return;
87
- if (![ENABLED, MODEL_DISABLED, DISABLED].includes(action.value)) {
88
- throw new Error(`Invalid skill state: "${action.value}"`);
89
- }
90
- if (action.value === DISABLED && !canDisable(record)) {
91
- throw new Error(`Full disable is unavailable for skill "${record.name}"`);
92
- }
93
- }
94
-
95
- function loadOverride(
96
- record: SkillRecord,
97
- action: SettingsAction,
98
- hasExactOverride: boolean,
99
- ): SkillLoadOverride | undefined {
100
- if (action.kind === "unset") return hasExactOverride ? "inherit" : undefined;
101
- if (action.value === DISABLED) return "unload";
102
- return isLoaded(record) ? undefined : "load";
103
- }
104
-
105
- class SkillSettingsController {
106
- private readonly cwd: string;
107
- private readonly agentDir: string;
108
- private readonly homeDir: string | undefined;
109
- private readonly projectTrusted: boolean;
110
- private readonly settingsManager: SettingsManager;
111
- private readonly globalSettingsManager: SettingsManager;
112
- private readonly pendingDisabled = new Map<string, Set<string>>();
113
- private globalCatalog: SkillCatalog = new Map();
114
- private projectCatalog: SkillCatalog = new Map();
115
-
116
- private constructor(options: SkillSettingsControllerOptions) {
117
- this.cwd = options.cwd;
118
- this.agentDir = options.agentDir;
119
- this.homeDir = options.homeDir;
120
- this.projectTrusted = options.projectTrusted;
121
- this.settingsManager = options.settingsManager;
122
- this.globalSettingsManager = options.globalSettingsManager;
123
- }
124
-
125
- static async create(
126
- cwd: string,
127
- projectTrusted: boolean,
128
- options: SkillSettingsOptions,
129
- ): Promise<SkillSettingsController> {
130
- const agentDir = options.agentDir ?? getAgentDir();
131
- const controller = new SkillSettingsController({
132
- cwd,
133
- agentDir,
134
- homeDir: options.homeDir,
135
- projectTrusted,
136
- settingsManager: SettingsManager.create(cwd, agentDir, { projectTrusted }),
137
- globalSettingsManager: SettingsManager.create(cwd, agentDir, { projectTrusted: false }),
138
- });
139
- await controller.refreshCatalogs();
140
- return controller;
141
- }
142
-
143
- private async refreshCatalogs(): Promise<void> {
144
- await Promise.all([this.settingsManager.reload(), this.globalSettingsManager.reload()]);
145
- const [globalPaths, projectPaths] = await Promise.all([
146
- new DefaultPackageManager({
147
- cwd: this.cwd,
148
- agentDir: this.agentDir,
149
- settingsManager: this.globalSettingsManager,
150
- }).resolve(async () => "skip"),
151
- new DefaultPackageManager({
152
- cwd: this.cwd,
153
- agentDir: this.agentDir,
154
- settingsManager: this.settingsManager,
155
- }).resolve(async () => "skip"),
156
- ]);
157
- this.globalCatalog = buildSkillCatalog(globalPaths.skills, this.cwd, this.agentDir);
158
- this.projectCatalog = buildSkillCatalog(projectPaths.skills, this.cwd, this.agentDir);
159
- }
160
-
161
- private records(scope: SettingsScope, ctx?: ExtensionContext): SkillCatalog {
162
- return mergeRuntimeSkills(
163
- scope === "project" ? this.projectCatalog : this.globalCatalog,
164
- ctx,
165
- scope,
166
- this.pendingDisabled,
167
- );
168
- }
169
-
170
- /**
171
- * Prefer original global provenance when a project support path resolves the
172
- * same file as a project resource. This lets Unset remove both the exact
173
- * override and the generated support path after a catalog refresh.
174
- */
175
- private actionResources(record: SkillRecord, scope: SettingsScope): ResolvedResource[] {
176
- const globalResources =
177
- scope === "project" ? recordResources(this.globalCatalog.get(record.name) ?? record) : [];
178
- const unique = new Map<string, ResolvedResource>();
179
- for (const resource of [...globalResources, ...recordResources(record)]) {
180
- const key = `${resource.path}\0${resource.metadata.origin}\0${resource.metadata.source}`;
181
- if (!unique.has(key)) unique.set(key, resource);
182
- }
183
- return Array.from(unique.values());
184
- }
185
-
186
- /**
187
- * Attribute the effective load value to the narrowest scoped override.
188
- * Project resources can be support-path surrogates, so attribution compares
189
- * their effective load state with the original global catalog.
190
- */
191
- private loadSource(record: SkillRecord, scope: SettingsScope): ValueSource {
192
- const resources = recordResources(record);
193
- const exact = hasExactSkillLoadOverride({
194
- settingsManager: this.settingsManager,
195
- resources,
196
- scope,
197
- cwd: this.cwd,
198
- agentDir: this.agentDir,
199
- });
200
- if (exact) return scope;
201
- if (scope === "global") {
202
- return resources.some((resource) => !resource.enabled) ? "global" : "default";
203
- }
204
- if (resources.some((resource) => resource.metadata.scope === "project" && !resource.enabled)) {
205
- return "project";
206
- }
207
- const globalRecord = this.globalCatalog.get(record.name);
208
- if (!globalRecord) return "default";
209
- const globalResources = recordResources(globalRecord);
210
- const projectLoaded = resources.some((resource) => resource.enabled);
211
- const globalLoaded = globalResources.some((resource) => resource.enabled);
212
- if (projectLoaded !== globalLoaded) return "project";
213
- return this.loadSource(globalRecord, "global");
214
- }
215
-
216
- private rowSource(
217
- record: SkillRecord,
218
- scope: SettingsScope,
219
- invocationSource: ValueSource,
220
- ): ValueSource {
221
- return moreSpecificSource(this.loadSource(record, scope), invocationSource);
222
- }
223
-
224
- private row(record: SkillRecord, scope: SettingsScope): ScopedFieldValue {
225
- const invocation = resolveInvocation({
226
- name: record.name,
227
- sourceDefault: sourceDefault(record),
228
- scope,
229
- cwd: this.cwd,
230
- projectTrusted: this.projectTrusted,
231
- homeDir: this.homeDir,
232
- });
233
- const value = !isLoaded(record) ? DISABLED : invocation.disabled ? MODEL_DISABLED : ENABLED;
234
- const source = this.rowSource(record, scope, invocation.source);
235
- const values = canDisable(record)
236
- ? [ENABLED, MODEL_DISABLED, DISABLED]
237
- : [ENABLED, MODEL_DISABLED];
238
- const sourceCount = record.sources.length;
239
- const limitation = canDisable(record)
240
- ? ""
241
- : " Full disable is unavailable because PI does not expose a load setting for one or more sources.";
242
- return {
243
- field: {
244
- kind: "enum",
245
- key: record.name,
246
- label: record.name,
247
- values,
248
- description: `${record.description}${sourceCount > 1 ? ` ${sourceCount} sources.` : ""}${limitation}`,
249
- },
250
- displayValue: displayValue(value, source),
251
- editValue: value,
252
- source,
253
- ...(scope === "project" && source === "project"
254
- ? {
255
- inheritanceSource:
256
- this.globalRowSource(record.name) === "global" ? "global" : "default",
257
- }
258
- : {}),
259
- };
260
- }
261
-
262
- private globalRowSource(name: string): ValueSource {
263
- const record = this.globalCatalog.get(name);
264
- if (!record) return "default";
265
- const invocation = resolveInvocation({
266
- name,
267
- sourceDefault: sourceDefault(record),
268
- scope: "global",
269
- cwd: this.cwd,
270
- projectTrusted: false,
271
- homeDir: this.homeDir,
272
- });
273
- return this.rowSource(record, "global", invocation.source);
274
- }
275
-
276
- read(scope: SettingsScope, ctx?: ExtensionContext): ScopedFieldValue[] {
277
- return Array.from(this.records(scope, ctx).values())
278
- .sort((left, right) => left.name.localeCompare(right.name))
279
- .map((record) => this.row(record, scope));
280
- }
281
-
282
- private updatePendingDisabled(
283
- fieldKey: string,
284
- nextLoadOverride: SkillLoadOverride,
285
- activeSkillIdentity: string | undefined,
286
- ): void {
287
- if (nextLoadOverride !== "unload") {
288
- this.pendingDisabled.delete(fieldKey);
289
- return;
290
- }
291
- if (activeSkillIdentity) {
292
- this.pendingDisabled.set(fieldKey, new Set([activeSkillIdentity]));
293
- }
294
- }
295
-
296
- async apply(
297
- scope: SettingsScope,
298
- fieldKey: string,
299
- action: SettingsAction,
300
- ctx?: ExtensionContext,
301
- ): Promise<SettingsApplyResult> {
302
- if (scope === "project" && !this.projectTrusted) {
303
- throw new Error("Project is not trusted; refusing to write project skill settings");
304
- }
305
- const record = this.records(scope, ctx).get(fieldKey);
306
- if (!record) return {};
307
- validateAction(record, action);
308
- const activeSkillIdentity = record.activeSkill
309
- ? skillSourceIdentity(record.activeSkill)
310
- : undefined;
311
-
312
- if (action.kind !== "set" || action.value !== DISABLED) {
313
- persistInvocation({
314
- name: fieldKey,
315
- disabled: action.kind === "set" ? action.value === MODEL_DISABLED : undefined,
316
- scope,
317
- cwd: this.cwd,
318
- homeDir: this.homeDir,
319
- });
320
- }
321
-
322
- const resources = this.actionResources(record, scope);
323
- const hasLoadOverride = hasExactSkillLoadOverride({
324
- settingsManager: this.settingsManager,
325
- resources,
326
- scope,
327
- cwd: this.cwd,
328
- agentDir: this.agentDir,
329
- });
330
- const nextLoadOverride = loadOverride(record, action, hasLoadOverride);
331
- if (nextLoadOverride && resources.length > 0) {
332
- updateSkillLoadOverrides({
333
- settingsManager: this.settingsManager,
334
- resources,
335
- scope,
336
- state: nextLoadOverride,
337
- cwd: this.cwd,
338
- agentDir: this.agentDir,
339
- });
340
- await this.settingsManager.flush();
341
- const error = this.settingsManager.drainErrors()[0];
342
- await this.refreshCatalogs();
343
- if (error) throw error.error;
344
- this.updatePendingDisabled(fieldKey, nextLoadOverride, activeSkillIdentity);
345
- return {
346
- notice: { message: "Reload required for skill load changes", level: "info" },
347
- };
348
- }
349
- return {};
350
- }
351
- }
352
-
353
- function createSkillSettingsModule(options: SkillSettingsOptions): SettingsModule {
16
+ function createSkillControllerGetter(options: SkillSettingsOptions): SkillControllerGetter {
354
17
  let controllerKey: string | undefined;
355
18
  let controllerPromise: Promise<SkillSettingsController> | undefined;
356
-
357
- const getController = (context: SettingsContext): Promise<SkillSettingsController> => {
358
- const projectTrusted = context.ctx?.isProjectTrusted() ?? false;
359
- const key = `${context.cwd}\0${projectTrusted}`;
19
+ return (cwd, projectTrusted) => {
20
+ const key = `${cwd}\0${projectTrusted}`;
360
21
  if (!controllerPromise || controllerKey !== key) {
361
22
  controllerKey = key;
362
- controllerPromise = SkillSettingsController.create(context.cwd, projectTrusted, options);
23
+ controllerPromise = SkillSettingsController.create(cwd, projectTrusted, options);
363
24
  }
364
25
  return controllerPromise;
365
26
  };
27
+ }
366
28
 
29
+ function createSkillSettingsModule(
30
+ options: SkillSettingsOptions,
31
+ getController: SkillControllerGetter,
32
+ ): SettingsModule {
367
33
  return {
368
34
  id: SETTINGS_SECTION_ID,
369
35
  label: "Skills",
370
36
  read: async (context) => {
371
37
  if (context.ctx) notifyInvocationConfigWarnings(context.ctx, options.homeDir);
372
- return { rows: (await getController(context)).read(context.scope, context.ctx) };
38
+ const projectTrusted = context.ctx?.isProjectTrusted() ?? false;
39
+ return {
40
+ rows: (await getController(context.cwd, projectTrusted)).read(context.scope, context.ctx),
41
+ };
373
42
  },
374
- apply: async (request) =>
375
- (await getController(request)).apply(
43
+ apply: async (request) => {
44
+ const projectTrusted = request.ctx?.isProjectTrusted() ?? false;
45
+ return (await getController(request.cwd, projectTrusted)).apply(
376
46
  request.scope,
377
47
  request.fieldKey,
378
48
  request.action,
379
49
  request.ctx,
380
- ),
50
+ );
51
+ },
381
52
  };
382
53
  }
383
54
 
384
55
  /** Register scoped skill availability settings and prompt overrides. */
385
56
  export default function skillSettings(pi: ExtensionAPI, options: SkillSettingsOptions = {}): void {
386
- registerSettings(pi, createSkillSettingsModule(options));
57
+ const getController = createSkillControllerGetter(options);
58
+ const providerDisposers = new Map<string, () => void>();
59
+ registerSettings(pi, createSkillSettingsModule(options, getController));
60
+
61
+ pi.on("session_start", (_event, ctx) => {
62
+ const sessionId = ctx.sessionManager.getSessionId();
63
+ providerDisposers.get(sessionId)?.();
64
+ const dispose = registerSessionCapabilitySkillProvider<ExtensionCommandContext>(sessionId, {
65
+ listEligibleSkills: async (commandCtx) => {
66
+ const controller = await getController(commandCtx.cwd, commandCtx.isProjectTrusted());
67
+ return controller.eligibleModelSkills(commandCtx);
68
+ },
69
+ });
70
+ providerDisposers.set(sessionId, dispose);
71
+ });
72
+
73
+ pi.on("session_shutdown", (_event, ctx) => {
74
+ const sessionId = ctx.sessionManager.getSessionId();
75
+ providerDisposers.get(sessionId)?.();
76
+ providerDisposers.delete(sessionId);
77
+ });
387
78
 
388
79
  pi.on("before_agent_start", (event, ctx) => {
389
80
  notifyInvocationConfigWarnings(ctx, options.homeDir);
81
+ const state = sessionCapabilityState.get(ctx.sessionManager.getSessionId());
390
82
  const systemPrompt = applyPromptOverrides({
391
83
  options: event.systemPromptOptions,
392
84
  systemPrompt: event.systemPrompt,
393
85
  cwd: ctx.cwd,
394
86
  projectTrusted: ctx.isProjectTrusted(),
395
87
  homeDir: options.homeDir,
88
+ hiddenSkillNames: new Set(state?.hiddenSkillNames ?? []),
396
89
  });
397
90
  return systemPrompt === undefined ? undefined : { systemPrompt };
398
91
  });