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