@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 +19 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +2 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +2 -2
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +19 -6
- package/node_modules/@mrclrchtr/supi-core/src/debug.ts +9 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +3 -0
- package/package.json +2 -2
- package/src/skill-model-invocation-config.ts +373 -0
- package/src/skill-model-invocation-warnings.ts +40 -0
- package/src/skill-model-invocation.ts +4 -88
- package/src/skill-settings.ts +6 -7
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": "
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
13
|
-
|
|
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,
|
package/src/skill-settings.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|