@mrclrchtr/supi-skills 4.10.0 → 6.0.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 CHANGED
@@ -35,6 +35,25 @@ Use `Tab` to switch between project and global scope. Project settings inherit g
35
35
 
36
36
  Skills that an extension adds only at runtime support Enabled and Model invocation disabled. PI does not provide a persistent load setting for these resources. PI exposes only the active source after a name collision, so disabling a static winner can reveal a runtime source after reload. The refreshed row then shows the runtime limitation.
37
37
 
38
+ ## Config shape
39
+
40
+ SuPi stores Model Invocation overrides as per-skill records in the SuPi config:
41
+
42
+ ```json
43
+ {
44
+ "skills": {
45
+ "$schemaVersion": 2,
46
+ "review": {
47
+ "modelInvocation": "disabled"
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ Use `enabled` or `disabled` as the stored value. An absent record or field inherits the source default, global value, or project value. Skill Load remains in PI's native settings. SuPi adds the schema marker so a skill named `modelInvocation` can use an ordinary record.
54
+
55
+ Older versions stored boolean values in `skills.modelInvocation`. SuPi reads that format and migrates valid entries on the next settings write. Invalid values remain preserved, marked as invalid, and produce a warning until they are repaired. A conflicting legacy fallback is kept under `$legacyModelInvocation` until the invalid record is repaired.
56
+
38
57
  ## Input shortcut
39
58
 
40
59
  `$skill-name` expands to `/skill:skill-name`. Skill-only autocomplete is active while the cursor is in a `$...` token.
@@ -28,6 +28,7 @@ pnpm add @mrclrchtr/supi-core
28
28
  - `loadSupiConfig()` — merged config with resolution order `defaults <- global <- project`
29
29
  - `loadSupiConfigForScope()` — load one scope at a time for settings UIs
30
30
  - `writeSupiConfig()` — persist values
31
+ - `replaceSupiConfigSection()` — replace one nested section while preserving other sections
31
32
  - `removeSupiConfigKey()` — remove a key or override
32
33
 
33
34
  Config file locations:
@@ -49,6 +50,7 @@ Config file locations:
49
50
 
50
51
  - context-provider registry for `/supi-context`
51
52
  - debug-event registry and monotonic phase timers for producers that want shared debug capture
53
+ - optional Debug Operation IDs for exact, directly owned public Tool-call correlation; ambient events stay uncorrelated
52
54
  - settings registry used by `/supi-settings`
53
55
 
54
56
  ### Project and session helpers
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "4.10.0",
3
+ "version": "6.0.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,7 +53,7 @@
53
53
  "./api": "./src/api.ts",
54
54
  "./config": "./src/config.ts",
55
55
  "./context": "./src/context.ts",
56
- "./debug": "./src/debug-registry.ts",
56
+ "./debug": "./src/debug.ts",
57
57
  "./evidence-badge": "./src/evidence-badge.ts",
58
58
  "./footer-registry": "./src/footer-registry.ts",
59
59
  "./llm": "./src/llm.ts",
@@ -11,7 +11,7 @@ export * from "./config.ts";
11
11
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
12
12
  export * from "./context.ts";
13
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
14
- export * from "./debug-registry.ts";
14
+ export * from "./debug.ts";
15
15
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
16
  export * from "./evidence-badge.ts";
17
17
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
@@ -148,6 +148,37 @@ export function writeSupiConfig(
148
148
  fs.writeFileSync(configPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8");
149
149
  }
150
150
 
151
+ /**
152
+ * Replace one complete config section while preserving other sections.
153
+ *
154
+ * This is useful for nested settings that must remove stale keys as part of
155
+ * one update. An empty section is removed from the config file.
156
+ */
157
+ export function replaceSupiConfigSection(
158
+ loc: SupiConfigLocation,
159
+ value: Record<string, unknown>,
160
+ options?: SupiConfigOptions,
161
+ ): void {
162
+ const configPath = getSupiConfigPath(loc.scope, loc.cwd, options);
163
+ const existing = readJsonFile(configPath) ?? {};
164
+
165
+ if (Object.keys(value).length > 0) existing[loc.section] = value;
166
+ else delete existing[loc.section];
167
+
168
+ const content = Object.keys(existing).length > 0 ? `${JSON.stringify(existing, null, 2)}\n` : "";
169
+ if (content) {
170
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
171
+ fs.writeFileSync(configPath, content, "utf-8");
172
+ return;
173
+ }
174
+
175
+ try {
176
+ fs.unlinkSync(configPath);
177
+ } catch {
178
+ // File may not exist.
179
+ }
180
+ }
181
+
151
182
  /**
152
183
  * Remove a key from a config section.
153
184
  * Used by `interval default` to remove the project override.
@@ -1,10 +1,12 @@
1
1
  // supi-core config domain — config loading.
2
2
  export type { SupiConfigLocation, SupiConfigOptions } from "./config/config.ts";
3
3
  export {
4
+ getSupiConfigPath,
4
5
  loadSupiConfig,
5
6
  loadSupiConfigForScope,
6
7
  loadSupiConfigSectionForScope,
7
8
  readJsonFile,
8
9
  removeSupiConfigKey,
10
+ replaceSupiConfigSection,
9
11
  writeSupiConfig,
10
12
  } from "./config/config.ts";
@@ -4,9 +4,6 @@
4
4
  // supi-debug extension owns policy/configuration and exposes events through a
5
5
  // command/tool while this module stays dependency-free for producers.
6
6
 
7
- // biome-ignore lint/performance/noReExportAll: preserve the stable debug domain entry point
8
- export * from "./debug-timing.ts";
9
-
10
7
  export type DebugLevel = "debug" | "info" | "warning" | "error";
11
8
  export type DebugAgentAccess = "off" | "sanitized" | "raw";
12
9
  export interface DebugRegistryConfig {
@@ -25,6 +22,8 @@ export const DEBUG_REGISTRY_DEFAULTS: DebugRegistryConfig = {
25
22
  };
26
23
 
27
24
  export interface DebugEventInput {
25
+ /** Opaque identity for events directly owned by one public Tool call. */
26
+ operationId?: string;
28
27
  source: string;
29
28
  level: DebugLevel;
30
29
  category: string;
@@ -42,6 +41,8 @@ export interface DebugEvent extends DebugEventInput {
42
41
  }
43
42
 
44
43
  export interface DebugEventQuery {
44
+ /** Match one exact Debug Operation ID. */
45
+ operationId?: string;
45
46
  source?: string;
46
47
  level?: DebugLevel;
47
48
  category?: string;
@@ -53,6 +54,7 @@ export interface DebugEventQuery {
53
54
  export interface DebugEventView {
54
55
  id: number;
55
56
  timestamp: number;
57
+ operationId?: string;
56
58
  source: string;
57
59
  level: DebugLevel;
58
60
  category: string;
@@ -84,6 +86,7 @@ interface DebugRegistryState {
84
86
  }
85
87
 
86
88
  const REGISTRY_KEY = Symbol.for("@mrclrchtr/supi-core/debug-registry");
89
+ const DEBUG_OPERATION_ID_RE = /^op-[A-Za-z0-9_-]{21}[AQgw]$/;
87
90
  const SECRET_KEY_RE = /(?:token|password|passwd|secret|api[_-]?key|authorization|credential)/i;
88
91
  const ENV_SECRET_RE =
89
92
  /\b([A-Za-z0-9_]*(?:token|password|passwd|secret|api[_-]?key|authorization|credential)[A-Za-z0-9_]*)=(?:'[^']*'|"[^"]*"|\S+)/gi;
@@ -135,11 +138,17 @@ export function isDebugLevel(value: unknown): value is DebugLevel {
135
138
  return value === "debug" || value === "info" || value === "warning" || value === "error";
136
139
  }
137
140
 
138
- /** Match a debug event against the supported source, level, and category filters. */
141
+ /** Return whether a value has the exact 16-byte base64url Debug Operation ID form. */
142
+ export function isDebugOperationId(value: unknown): value is string {
143
+ return typeof value === "string" && DEBUG_OPERATION_ID_RE.test(value);
144
+ }
145
+
146
+ /** Match a debug event against the supported exact filters. */
139
147
  export function matchesDebugEventQuery(
140
- event: Pick<DebugEventView, "source" | "level" | "category">,
141
- query: Pick<DebugEventQuery, "source" | "level" | "category">,
148
+ event: Pick<DebugEventView, "operationId" | "source" | "level" | "category">,
149
+ query: Pick<DebugEventQuery, "operationId" | "source" | "level" | "category">,
142
150
  ): boolean {
151
+ if (query.operationId && event.operationId !== query.operationId) return false;
143
152
  if (query.source && event.source !== query.source) return false;
144
153
  if (query.level && event.level !== query.level) return false;
145
154
  if (query.category && event.category !== query.category) return false;
@@ -197,6 +206,7 @@ function toSanitizedView(event: DebugEvent): DebugEventView {
197
206
  return {
198
207
  id: event.id,
199
208
  timestamp: event.timestamp,
209
+ operationId: event.operationId,
200
210
  source: event.source,
201
211
  level: event.level,
202
212
  category: event.category,
@@ -216,6 +226,9 @@ export function subscribeDebugEvents(listener: DebugEventListener): () => void {
216
226
  /** Record a session-local debug event if debugging is enabled. */
217
227
  export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
218
228
  const state = getState();
229
+ if (input.operationId !== undefined && !isDebugOperationId(input.operationId)) {
230
+ return null;
231
+ }
219
232
  if (!state.config.enabled) {
220
233
  return null;
221
234
  }
@@ -0,0 +1,9 @@
1
+ // Debug domain entry for `@mrclrchtr/supi-core/debug`.
2
+ //
3
+ // Kept separate from debug-registry.ts so debug-timing.ts can import the
4
+ // registry without creating an import cycle through the barrel re-export.
5
+
6
+ // biome-ignore lint/performance/noReExportAll: preserve the stable debug domain entry point
7
+ export * from "./debug-registry.ts";
8
+ // biome-ignore lint/performance/noReExportAll: preserve the stable debug domain entry point
9
+ export * from "./debug-timing.ts";
@@ -11,7 +11,7 @@ export * from "./config.ts";
11
11
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
12
12
  export * from "./context.ts";
13
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
14
- export * from "./debug-registry.ts";
14
+ export * from "./debug.ts";
15
15
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
16
  export * from "./footer-registry.ts";
17
17
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
@@ -41,7 +41,10 @@ export interface SettingsApplyResult {
41
41
  */
42
42
  export interface SettingsModule {
43
43
  id: string;
44
+ /** Human-readable section label shown in the UI. */
44
45
  label: string;
46
+ /** Optional label that groups this module within its section. */
47
+ subsection?: string;
45
48
  read(context: SettingsContext): Promise<SettingsSnapshot>;
46
49
  apply(request: SettingsActionRequest): Promise<SettingsApplyResult>;
47
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-skills",
3
- "version": "4.10.0",
3
+ "version": "6.0.0",
4
4
  "description": "Scoped skill controls and skill input shortcuts for PI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "README.md"
31
31
  ],
32
32
  "dependencies": {
33
- "@mrclrchtr/supi-core": "4.10.0"
33
+ "@mrclrchtr/supi-core": "6.0.0"
34
34
  },
35
35
  "bundledDependencies": [
36
36
  "@mrclrchtr/supi-core"
@@ -0,0 +1,373 @@
1
+ import {
2
+ loadSupiConfigSectionForScope,
3
+ replaceSupiConfigSection,
4
+ } from "@mrclrchtr/supi-core/config";
5
+ import type { SettingsScope, ValueSource } from "@mrclrchtr/supi-core/settings";
6
+
7
+ const CONFIG_SECTION = "skills";
8
+ const MODEL_INVOCATION_KEY = "modelInvocation";
9
+ const SCHEMA_VERSION_KEY = "$schemaVersion";
10
+ const SCHEMA_VERSION = 2;
11
+ const LEGACY_MODEL_INVOCATION_KEY = "$legacyModelInvocation";
12
+ const INVALID_MODEL_INVOCATION_KEY = "$invalidModelInvocation";
13
+
14
+ /** Persisted Model Invocation states for one skill. */
15
+ export type ModelInvocationState = "enabled" | "disabled";
16
+
17
+ type SkillConfigRecord = Record<string, unknown>;
18
+
19
+ interface InvocationOptions {
20
+ name: string;
21
+ scope: SettingsScope;
22
+ cwd: string;
23
+ homeDir?: string;
24
+ }
25
+
26
+ interface ResolveInvocationOptions extends InvocationOptions {
27
+ sourceDefault: boolean;
28
+ projectTrusted: boolean;
29
+ }
30
+
31
+ interface ParsedInvocationConfig {
32
+ section: SkillConfigRecord;
33
+ records: Map<string, SkillConfigRecord>;
34
+ invalidRecords: Map<string, unknown>;
35
+ legacy: Map<string, unknown>;
36
+ legacyMapKeys: Set<string>;
37
+ invalidNames: Set<string>;
38
+ }
39
+
40
+ interface InvocationConfigSet {
41
+ global: ParsedInvocationConfig;
42
+ project?: ParsedInvocationConfig;
43
+ }
44
+
45
+ function isRecord(value: unknown): value is SkillConfigRecord {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+
49
+ function hasOwn(value: object, key: string): boolean {
50
+ return Object.hasOwn(value, key);
51
+ }
52
+
53
+ function setOwn(target: SkillConfigRecord, key: string, value: unknown): void {
54
+ Object.defineProperty(target, key, {
55
+ configurable: true,
56
+ enumerable: true,
57
+ value,
58
+ writable: true,
59
+ });
60
+ }
61
+
62
+ function cloneRecord(value: SkillConfigRecord): SkillConfigRecord {
63
+ return Object.fromEntries(Object.entries(value));
64
+ }
65
+
66
+ function isModelInvocationState(value: unknown): value is ModelInvocationState {
67
+ return value === "enabled" || value === "disabled";
68
+ }
69
+
70
+ function legacyState(value: unknown): ModelInvocationState | undefined {
71
+ if (typeof value !== "boolean") return undefined;
72
+ return value ? "disabled" : "enabled";
73
+ }
74
+
75
+ /** An unversioned `modelInvocation` object is always the old boolean map. */
76
+ function isLegacyMapCandidate(value: unknown): value is SkillConfigRecord {
77
+ return isRecord(value);
78
+ }
79
+
80
+ function addInvalidName(config: ParsedInvocationConfig, name: string): void {
81
+ config.invalidNames.add(name);
82
+ }
83
+
84
+ function parseLegacyMap(
85
+ config: ParsedInvocationConfig,
86
+ key: string,
87
+ value: SkillConfigRecord,
88
+ ): void {
89
+ config.legacyMapKeys.add(key);
90
+ for (const [skillName, skillValue] of Object.entries(value)) {
91
+ config.legacy.set(skillName, skillValue);
92
+ if (legacyState(skillValue) === undefined) addInvalidName(config, skillName);
93
+ }
94
+ }
95
+
96
+ function parseRecord(config: ParsedInvocationConfig, name: string, value: unknown): void {
97
+ if (!isRecord(value)) {
98
+ config.invalidRecords.set(name, value);
99
+ addInvalidName(config, name);
100
+ return;
101
+ }
102
+
103
+ const record = cloneRecord(value);
104
+ config.records.set(name, record);
105
+ const state = record[MODEL_INVOCATION_KEY];
106
+ if (
107
+ hasOwn(record, MODEL_INVOCATION_KEY) &&
108
+ (!isModelInvocationState(state) || record[INVALID_MODEL_INVOCATION_KEY] === true)
109
+ ) {
110
+ addInvalidName(config, name);
111
+ }
112
+ }
113
+
114
+ function parseInvocationConfig(section: Record<string, unknown> | null): ParsedInvocationConfig {
115
+ const parsed: ParsedInvocationConfig = {
116
+ section: section ? cloneRecord(section) : {},
117
+ records: new Map(),
118
+ invalidRecords: new Map(),
119
+ legacy: new Map(),
120
+ legacyMapKeys: new Set(),
121
+ invalidNames: new Set(),
122
+ };
123
+ const versioned = parsed.section[SCHEMA_VERSION_KEY] === SCHEMA_VERSION;
124
+
125
+ for (const [name, value] of Object.entries(parsed.section)) {
126
+ if (name === SCHEMA_VERSION_KEY) continue;
127
+ if (name === LEGACY_MODEL_INVOCATION_KEY) {
128
+ if (isRecord(value)) parseLegacyMap(parsed, name, value);
129
+ else addInvalidName(parsed, name);
130
+ continue;
131
+ }
132
+ if (name === MODEL_INVOCATION_KEY && !versioned && isLegacyMapCandidate(value)) {
133
+ parseLegacyMap(parsed, name, value);
134
+ continue;
135
+ }
136
+ parseRecord(parsed, name, value);
137
+ }
138
+
139
+ return parsed;
140
+ }
141
+
142
+ function readInvocationConfig(
143
+ scope: SettingsScope,
144
+ cwd: string,
145
+ homeDir?: string,
146
+ ): ParsedInvocationConfig {
147
+ return parseInvocationConfig(
148
+ loadSupiConfigSectionForScope(CONFIG_SECTION, cwd, { scope, homeDir }),
149
+ );
150
+ }
151
+
152
+ function readInvocationConfigs(
153
+ cwd: string,
154
+ projectTrusted: boolean,
155
+ homeDir?: string,
156
+ ): InvocationConfigSet {
157
+ return {
158
+ global: readInvocationConfig("global", cwd, homeDir),
159
+ ...(projectTrusted ? { project: readInvocationConfig("project", cwd, homeDir) } : {}),
160
+ };
161
+ }
162
+
163
+ function stateFromConfig(
164
+ config: ParsedInvocationConfig,
165
+ name: string,
166
+ ): ModelInvocationState | undefined {
167
+ const record = config.records.get(name);
168
+ if (
169
+ record &&
170
+ record[INVALID_MODEL_INVOCATION_KEY] !== true &&
171
+ isModelInvocationState(record[MODEL_INVOCATION_KEY])
172
+ ) {
173
+ return record[MODEL_INVOCATION_KEY];
174
+ }
175
+ return legacyState(config.legacy.get(name));
176
+ }
177
+
178
+ interface ResolveFromConfigsOptions {
179
+ name: string;
180
+ sourceDefault: boolean;
181
+ scope: SettingsScope;
182
+ projectTrusted: boolean;
183
+ configs: InvocationConfigSet;
184
+ }
185
+
186
+ function resolveFromConfigs({
187
+ name,
188
+ sourceDefault,
189
+ scope,
190
+ projectTrusted,
191
+ configs,
192
+ }: ResolveFromConfigsOptions): { disabled: boolean; source: ValueSource } {
193
+ if (scope === "project" && projectTrusted && configs.project) {
194
+ const projectState = stateFromConfig(configs.project, name);
195
+ if (projectState) return { disabled: projectState === "disabled", source: "project" };
196
+ }
197
+
198
+ const globalState = stateFromConfig(configs.global, name);
199
+ if (globalState) return { disabled: globalState === "disabled", source: "global" };
200
+
201
+ return { disabled: sourceDefault, source: "default" };
202
+ }
203
+
204
+ /** Resolve a scoped Model Invocation preference without reading untrusted project config. */
205
+ export function resolveInvocation({
206
+ name,
207
+ sourceDefault,
208
+ scope,
209
+ cwd,
210
+ projectTrusted,
211
+ homeDir,
212
+ }: ResolveInvocationOptions): { disabled: boolean; source: ValueSource } {
213
+ return resolveFromConfigs({
214
+ name,
215
+ sourceDefault,
216
+ scope,
217
+ projectTrusted,
218
+ configs: readInvocationConfigs(cwd, projectTrusted && scope === "project", homeDir),
219
+ });
220
+ }
221
+
222
+ function removeOwn(target: SkillConfigRecord, key: string): void {
223
+ if (hasOwn(target, key)) delete target[key];
224
+ }
225
+
226
+ function setInvalidState(record: SkillConfigRecord, value: unknown): void {
227
+ setOwn(record, MODEL_INVOCATION_KEY, value);
228
+ setOwn(record, INVALID_MODEL_INVOCATION_KEY, true);
229
+ }
230
+
231
+ function removeLegacyKeys(section: SkillConfigRecord, keys: ReadonlySet<string>): void {
232
+ for (const key of keys) removeOwn(section, key);
233
+ }
234
+
235
+ interface MigrateLegacyEntryOptions {
236
+ config: ParsedInvocationConfig;
237
+ name: string;
238
+ value: unknown;
239
+ nextSection: SkillConfigRecord;
240
+ remainingLegacy: SkillConfigRecord;
241
+ }
242
+
243
+ function migrateLegacyEntry({
244
+ config,
245
+ name,
246
+ value,
247
+ nextSection,
248
+ remainingLegacy,
249
+ }: MigrateLegacyEntryOptions): void {
250
+ const record = config.records.get(name);
251
+ const oldState = legacyState(value);
252
+ if (config.invalidRecords.has(name)) {
253
+ setOwn(remainingLegacy, name, value);
254
+ return;
255
+ }
256
+
257
+ if (!record) {
258
+ const migrated: SkillConfigRecord = {};
259
+ if (oldState) setOwn(migrated, MODEL_INVOCATION_KEY, oldState);
260
+ else setInvalidState(migrated, value);
261
+ config.records.set(name, migrated);
262
+ setOwn(nextSection, name, migrated);
263
+ return;
264
+ }
265
+
266
+ if (!hasOwn(record, MODEL_INVOCATION_KEY)) {
267
+ if (oldState) setOwn(record, MODEL_INVOCATION_KEY, oldState);
268
+ else setInvalidState(record, value);
269
+ setOwn(nextSection, name, record);
270
+ return;
271
+ }
272
+
273
+ const currentValid =
274
+ record[INVALID_MODEL_INVOCATION_KEY] !== true &&
275
+ isModelInvocationState(record[MODEL_INVOCATION_KEY]);
276
+ if (!currentValid || !oldState) setOwn(remainingLegacy, name, value);
277
+ }
278
+
279
+ function migrateLegacyEntries(
280
+ config: ParsedInvocationConfig,
281
+ nextSection: SkillConfigRecord,
282
+ ): SkillConfigRecord {
283
+ const remainingLegacy: SkillConfigRecord = {};
284
+ removeLegacyKeys(nextSection, config.legacyMapKeys);
285
+ for (const [name, value] of config.legacy) {
286
+ migrateLegacyEntry({ config, name, value, nextSection, remainingLegacy });
287
+ }
288
+ return remainingLegacy;
289
+ }
290
+
291
+ interface ApplyInvocationChangeOptions {
292
+ name: string;
293
+ disabled: boolean | undefined;
294
+ records: Map<string, SkillConfigRecord>;
295
+ invalidRecords: Map<string, unknown>;
296
+ nextSection: SkillConfigRecord;
297
+ remainingLegacy: SkillConfigRecord;
298
+ }
299
+
300
+ function applyInvocationChange({
301
+ name,
302
+ disabled,
303
+ records,
304
+ invalidRecords,
305
+ nextSection,
306
+ remainingLegacy,
307
+ }: ApplyInvocationChangeOptions): void {
308
+ if (disabled === undefined) {
309
+ const record = records.get(name);
310
+ if (record) {
311
+ removeOwn(record, MODEL_INVOCATION_KEY);
312
+ removeOwn(record, INVALID_MODEL_INVOCATION_KEY);
313
+ if (Object.keys(record).length === 0) removeOwn(nextSection, name);
314
+ else setOwn(nextSection, name, record);
315
+ } else if (invalidRecords.has(name)) {
316
+ removeOwn(nextSection, name);
317
+ }
318
+ removeOwn(remainingLegacy, name);
319
+ return;
320
+ }
321
+
322
+ const record = records.get(name) ?? {};
323
+ setOwn(record, MODEL_INVOCATION_KEY, disabled ? "disabled" : "enabled");
324
+ removeOwn(record, INVALID_MODEL_INVOCATION_KEY);
325
+ setOwn(nextSection, name, record);
326
+ removeOwn(remainingLegacy, name);
327
+ }
328
+
329
+ /**
330
+ * Set or remove one scoped Model Invocation preference.
331
+ *
332
+ * Writes use per-skill records. Legacy booleans are converted. Invalid legacy
333
+ * values use a marker so their raw value stays invalid and visible to repair.
334
+ */
335
+ export function persistInvocation({
336
+ name,
337
+ disabled,
338
+ scope,
339
+ cwd,
340
+ homeDir,
341
+ }: InvocationOptions & { disabled: boolean | undefined }): void {
342
+ const config = readInvocationConfig(scope, cwd, homeDir);
343
+ const nextSection = cloneRecord(config.section);
344
+ setOwn(nextSection, SCHEMA_VERSION_KEY, SCHEMA_VERSION);
345
+ const remainingLegacy = migrateLegacyEntries(config, nextSection);
346
+ applyInvocationChange({
347
+ name,
348
+ disabled,
349
+ records: config.records,
350
+ invalidRecords: config.invalidRecords,
351
+ nextSection,
352
+ remainingLegacy,
353
+ });
354
+
355
+ if (Object.keys(remainingLegacy).length > 0) {
356
+ setOwn(nextSection, LEGACY_MODEL_INVOCATION_KEY, remainingLegacy);
357
+ }
358
+ replaceSupiConfigSection({ section: CONFIG_SECTION, scope, cwd }, nextSection, { homeDir });
359
+ }
360
+
361
+ /** Return invalid skill names grouped by config scope for warning displays. */
362
+ export function readInvalidInvocationConfigNames(
363
+ cwd: string,
364
+ projectTrusted: boolean,
365
+ homeDir?: string,
366
+ ): Array<{ scope: SettingsScope; names: string[] }> {
367
+ const configs = readInvocationConfigs(cwd, projectTrusted, homeDir);
368
+ return (Object.entries(configs) as Array<[SettingsScope, ParsedInvocationConfig | undefined]>)
369
+ .filter((entry): entry is [SettingsScope, ParsedInvocationConfig] =>
370
+ Boolean(entry[1] && entry[1].invalidNames.size > 0),
371
+ )
372
+ .map(([scope, config]) => ({ scope, names: [...config.invalidNames] }));
373
+ }
@@ -0,0 +1,40 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getSupiConfigPath } from "@mrclrchtr/supi-core/config";
3
+ import { readInvalidInvocationConfigNames } from "./skill-model-invocation-config.ts";
4
+
5
+ const INVALID_CONFIG_WARNING_KEY = Symbol.for(
6
+ "@mrclrchtr/supi-skills/invalid-model-invocation-config",
7
+ );
8
+
9
+ function warningSessionId(ctx: ExtensionContext): string {
10
+ const sessionManager = ctx.sessionManager as ExtensionContext["sessionManager"] & {
11
+ getSessionId?: () => string;
12
+ };
13
+ return typeof sessionManager.getSessionId === "function"
14
+ ? sessionManager.getSessionId()
15
+ : `cwd:${ctx.cwd}`;
16
+ }
17
+
18
+ /** Show invalid config warnings once for each scope file in one PI session. */
19
+ export function notifyInvocationConfigWarnings(ctx: ExtensionContext, homeDir?: string): void {
20
+ const invalidConfigs = readInvalidInvocationConfigNames(ctx.cwd, ctx.isProjectTrusted(), homeDir);
21
+ const globalRecord = globalThis as Record<symbol, Set<string> | undefined>;
22
+ const warned = globalRecord[INVALID_CONFIG_WARNING_KEY] ?? new Set<string>();
23
+ globalRecord[INVALID_CONFIG_WARNING_KEY] = warned;
24
+ const sessionId = warningSessionId(ctx);
25
+
26
+ for (const { scope, names } of invalidConfigs) {
27
+ const configPath = getSupiConfigPath(scope, ctx.cwd, { homeDir });
28
+ const warningKey = `${sessionId}\0${configPath}`;
29
+ if (warned.has(warningKey)) continue;
30
+ warned.add(warningKey);
31
+
32
+ const skillNames = names.sort((left, right) => left.localeCompare(right)).join(", ");
33
+ const message = `Invalid skills config in ${configPath} for ${skillNames}. Invalid Model Invocation values are ignored.`;
34
+ if (ctx.hasUI !== false) ctx.ui.notify(message, "warning");
35
+ else {
36
+ // biome-ignore lint/suspicious/noConsole: config warnings need a non-UI fallback
37
+ console.warn(`[supi-skills] ${message}`);
38
+ }
39
+ }
40
+ }
@@ -2,100 +2,16 @@ import {
2
2
  type BuildSystemPromptOptions,
3
3
  formatSkillsForPrompt,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import {
6
- loadSupiConfigSectionForScope,
7
- removeSupiConfigKey,
8
- writeSupiConfig,
9
- } from "@mrclrchtr/supi-core/config";
10
- import type { SettingsScope, ValueSource } from "@mrclrchtr/supi-core/settings";
5
+ import { resolveInvocation } from "./skill-model-invocation-config.ts";
11
6
 
12
- const CONFIG_SECTION = "skills";
13
- const MODEL_INVOCATION_KEY = "modelInvocation";
7
+ export type { ModelInvocationState } from "./skill-model-invocation-config.ts";
8
+ export { persistInvocation, resolveInvocation } from "./skill-model-invocation-config.ts";
9
+ export { notifyInvocationConfigWarnings } from "./skill-model-invocation-warnings.ts";
14
10
 
15
11
  export const ENABLED = "Enabled";
16
12
  export const MODEL_DISABLED = "Model invocation disabled";
17
13
  export const DISABLED = "Disabled";
18
14
 
19
- function invocationMap(
20
- scope: SettingsScope,
21
- cwd: string,
22
- homeDir?: string,
23
- ): Record<string, boolean> {
24
- const section = loadSupiConfigSectionForScope(CONFIG_SECTION, cwd, { scope, homeDir });
25
- const value = section?.[MODEL_INVOCATION_KEY];
26
- if (!value || typeof value !== "object" || Array.isArray(value)) return {};
27
- return Object.fromEntries(
28
- Object.entries(value).filter(
29
- (entry): entry is [string, boolean] => typeof entry[1] === "boolean",
30
- ),
31
- );
32
- }
33
-
34
- interface InvocationOptions {
35
- name: string;
36
- scope: SettingsScope;
37
- cwd: string;
38
- homeDir?: string;
39
- }
40
-
41
- interface ResolveInvocationOptions extends InvocationOptions {
42
- sourceDefault: boolean;
43
- projectTrusted: boolean;
44
- }
45
-
46
- /** Resolve a scoped model-invocation preference without reading untrusted project config. */
47
- export function resolveInvocation({
48
- name,
49
- sourceDefault,
50
- scope,
51
- cwd,
52
- projectTrusted,
53
- homeDir,
54
- }: ResolveInvocationOptions): { disabled: boolean; source: ValueSource } {
55
- if (scope === "project" && projectTrusted) {
56
- const project = invocationMap("project", cwd, homeDir);
57
- if (Object.hasOwn(project, name)) {
58
- return { disabled: project[name] ?? sourceDefault, source: "project" };
59
- }
60
- }
61
- const global = invocationMap("global", cwd, homeDir);
62
- if (Object.hasOwn(global, name)) {
63
- return { disabled: global[name] ?? sourceDefault, source: "global" };
64
- }
65
- return { disabled: sourceDefault, source: "default" };
66
- }
67
-
68
- /** Set or remove one scoped model-invocation preference. */
69
- export function persistInvocation({
70
- name,
71
- disabled,
72
- scope,
73
- cwd,
74
- homeDir,
75
- }: InvocationOptions & { disabled: boolean | undefined }): void {
76
- const values = invocationMap(scope, cwd, homeDir);
77
- if (disabled === undefined) delete values[name];
78
- else {
79
- Object.defineProperty(values, name, {
80
- value: disabled,
81
- enumerable: true,
82
- configurable: true,
83
- writable: true,
84
- });
85
- }
86
- if (Object.keys(values).length === 0) {
87
- removeSupiConfigKey({ section: CONFIG_SECTION, scope, cwd }, MODEL_INVOCATION_KEY, {
88
- homeDir,
89
- });
90
- return;
91
- }
92
- writeSupiConfig(
93
- { section: CONFIG_SECTION, scope, cwd },
94
- { [MODEL_INVOCATION_KEY]: values },
95
- { homeDir },
96
- );
97
- }
98
-
99
15
  /** Replace PI's generated skill block with the effective scoped invocation state. */
100
16
  export function applyPromptOverrides({
101
17
  options,
@@ -33,17 +33,16 @@ import {
33
33
  DISABLED,
34
34
  ENABLED,
35
35
  MODEL_DISABLED,
36
+ notifyInvocationConfigWarnings,
36
37
  persistInvocation,
37
38
  resolveInvocation,
38
39
  } from "./skill-model-invocation.ts";
39
40
 
40
41
  const SETTINGS_SECTION_ID = "skills";
41
-
42
42
  interface SkillSettingsOptions {
43
43
  agentDir?: string;
44
44
  homeDir?: string;
45
45
  }
46
-
47
46
  interface SkillSettingsControllerOptions {
48
47
  cwd: string;
49
48
  agentDir: string;
@@ -56,14 +55,12 @@ interface SkillSettingsControllerOptions {
56
55
  function recordResources(record: SkillRecord): ResolvedResource[] {
57
56
  return record.sources.flatMap((source) => (source.resource ? [source.resource] : []));
58
57
  }
59
-
60
58
  function isLoaded(record: SkillRecord): boolean {
61
59
  return (
62
60
  record.activeSkill !== undefined ||
63
61
  record.sources.some((source) => source.runtime || source.resource?.enabled)
64
62
  );
65
63
  }
66
-
67
64
  function canDisable(record: SkillRecord): boolean {
68
65
  return record.sources.length > 0 && record.sources.every((source) => !source.runtime);
69
66
  }
@@ -370,9 +367,10 @@ function createSkillSettingsModule(options: SkillSettingsOptions): SettingsModul
370
367
  return {
371
368
  id: SETTINGS_SECTION_ID,
372
369
  label: "Skills",
373
- read: async (context) => ({
374
- rows: (await getController(context)).read(context.scope, context.ctx),
375
- }),
370
+ read: async (context) => {
371
+ if (context.ctx) notifyInvocationConfigWarnings(context.ctx, options.homeDir);
372
+ return { rows: (await getController(context)).read(context.scope, context.ctx) };
373
+ },
376
374
  apply: async (request) =>
377
375
  (await getController(request)).apply(
378
376
  request.scope,
@@ -388,6 +386,7 @@ export default function skillSettings(pi: ExtensionAPI, options: SkillSettingsOp
388
386
  registerSettings(pi, createSkillSettingsModule(options));
389
387
 
390
388
  pi.on("before_agent_start", (event, ctx) => {
389
+ notifyInvocationConfigWarnings(ctx, options.homeDir);
391
390
  const systemPrompt = applyPromptOverrides({
392
391
  options: event.systemPromptOptions,
393
392
  systemPrompt: event.systemPrompt,