@mrclrchtr/supi-skills 7.2.1 → 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 +22 -15
- package/src/skill-settings-controller.ts +365 -0
- package/src/skill-settings.ts +52 -359
package/README.md
CHANGED
|
@@ -56,6 +56,12 @@ Use `enabled` or `disabled` as the stored value. An absent record or field inher
|
|
|
56
56
|
|
|
57
57
|
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.
|
|
58
58
|
|
|
59
|
+
## Session visibility
|
|
60
|
+
|
|
61
|
+
Install `@mrclrchtr/supi-extras` to use `/supi-capabilities`. Its Skills tab lists skills that PI has loaded and that persistent settings allow in the model catalog. It does not list unloaded skills or skills that persistent settings block.
|
|
62
|
+
|
|
63
|
+
A session visibility override hides a skill from the model prompt for the current session. It does not change PI settings or SuPi skill settings. The user can still run `/skill:name` or use `$name`. Resume restores the override. A fork copies it. Use **Reset Skills** to show all eligible skills again.
|
|
64
|
+
|
|
59
65
|
## Input shortcut
|
|
60
66
|
|
|
61
67
|
`$skill-name` expands to `/skill:skill-name`. Skill-only autocomplete is active while the cursor is in a `$...` token.
|
|
@@ -23,6 +23,7 @@ pnpm add @mrclrchtr/supi-core
|
|
|
23
23
|
- `@mrclrchtr/supi-core/api` — reusable helpers for other packages and extensions
|
|
24
24
|
- `@mrclrchtr/supi-core/llm` — PI-owned direct model requests and JSON helpers
|
|
25
25
|
- `@mrclrchtr/supi-core/report` — shared text/report rendering helpers for TUI and plain-text summaries
|
|
26
|
+
- `@mrclrchtr/supi-core/tui` — shared terminal UI helpers for list titles, rows, menus, and descriptions
|
|
26
27
|
|
|
27
28
|
## What you get from the API
|
|
28
29
|
|
|
@@ -70,6 +71,14 @@ Both completion helpers require a stable `affinityScope`. PI owns auth and endpo
|
|
|
70
71
|
- active-branch session helper: `getActiveBranchEntries()`
|
|
71
72
|
- terminal helpers such as `formatTitle()`, `signalWaiting()`, and `signalDone()`
|
|
72
73
|
|
|
74
|
+
### TUI helpers
|
|
75
|
+
|
|
76
|
+
- `formatTuiTitleLine()` — use the shared title style in terminal lists
|
|
77
|
+
- `renderSelectableRow()` — render a selected row with an aligned value
|
|
78
|
+
- `renderDescriptionViewport()` — show a fixed-height description preview
|
|
79
|
+
- `renderTuiHintLine()` — render short key hints in one line
|
|
80
|
+
- `createSelectListMenu()` — build an action menu with shared key hints
|
|
81
|
+
|
|
73
82
|
### Report helpers
|
|
74
83
|
|
|
75
84
|
- `clampReportWidth()` — enforce a minimum readable report width
|
|
@@ -125,3 +134,4 @@ export default function myExtension(pi: ExtensionAPI) {
|
|
|
125
134
|
- `src/debug-timing.ts` — monotonic total and phase timers for Debug Event Producers
|
|
126
135
|
- `src/settings/` — settings registry, schema, scope resolution, and persistence
|
|
127
136
|
- `src/report.ts` — shared text/report rendering helpers
|
|
137
|
+
- `src/tui.ts` — shared terminal UI list and action-menu helpers
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-core",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.0",
|
|
4
4
|
"description": "Shared settings, configuration, reporting, and session infrastructure",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"./settings": "./src/settings.ts",
|
|
68
68
|
"./spinner-frames": "./src/spinner-frames.ts",
|
|
69
69
|
"./status-spinner": "./src/status-spinner.ts",
|
|
70
|
-
"./terminal": "./src/terminal.ts"
|
|
70
|
+
"./terminal": "./src/terminal.ts",
|
|
71
|
+
"./tui": "./src/tui.ts"
|
|
71
72
|
}
|
|
72
73
|
}
|
|
@@ -38,3 +38,5 @@ export * from "./spinner-frames.ts";
|
|
|
38
38
|
export * from "./status-spinner.ts";
|
|
39
39
|
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
40
40
|
export * from "./terminal.ts";
|
|
41
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
42
|
+
export * from "./tui.ts";
|
|
@@ -34,3 +34,5 @@ export * from "./settings.ts";
|
|
|
34
34
|
export * from "./status-spinner.ts";
|
|
35
35
|
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
36
36
|
export * from "./terminal.ts";
|
|
37
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
38
|
+
export * from "./tui.ts";
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** Session-only tool and skill visibility state. */
|
|
2
|
+
export interface SessionCapabilityState {
|
|
3
|
+
version: 1;
|
|
4
|
+
eligibleToolNames: string[];
|
|
5
|
+
initiallyInactiveToolNames: string[];
|
|
6
|
+
toolDenylist: string[];
|
|
7
|
+
/** Tools that the user explicitly enabled for this session. */
|
|
8
|
+
toolEnabledNames?: string[];
|
|
9
|
+
hiddenSkillNames: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A model-visible skill that a provider can offer to the selector. */
|
|
13
|
+
export interface SessionCapabilitySkill {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Skill inventory provider registered by the skills extension. */
|
|
19
|
+
export interface SessionCapabilitySkillProvider<TContext = unknown> {
|
|
20
|
+
listEligibleSkills(
|
|
21
|
+
context: TContext,
|
|
22
|
+
): Promise<SessionCapabilitySkill[]> | SessionCapabilitySkill[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Custom session-entry type used for hidden capability snapshots. */
|
|
26
|
+
export const SESSION_CAPABILITIES_ENTRY = "supi-capabilities";
|
|
27
|
+
|
|
28
|
+
const REGISTRY_PREFIX = "@mrclrchtr/supi-core/session-capabilities/";
|
|
29
|
+
const STATE_KEY = Symbol.for(`${REGISTRY_PREFIX}state`);
|
|
30
|
+
const PROVIDER_KEY = Symbol.for(`${REGISTRY_PREFIX}skill-providers`);
|
|
31
|
+
const FORK_KEY = Symbol.for(`${REGISTRY_PREFIX}pending-forks`);
|
|
32
|
+
|
|
33
|
+
type GlobalRegistry = typeof globalThis & Record<symbol, unknown>;
|
|
34
|
+
|
|
35
|
+
type SkillProviderMap = Map<string, SessionCapabilitySkillProvider<unknown>>;
|
|
36
|
+
type ForkStateMap = Map<string, SessionCapabilityState>;
|
|
37
|
+
|
|
38
|
+
function getStateMap(): Map<string, SessionCapabilityState> {
|
|
39
|
+
const target = globalThis as GlobalRegistry;
|
|
40
|
+
let map = target[STATE_KEY] as Map<string, SessionCapabilityState> | undefined;
|
|
41
|
+
if (!map) {
|
|
42
|
+
map = new Map();
|
|
43
|
+
target[STATE_KEY] = map;
|
|
44
|
+
}
|
|
45
|
+
return map;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getProviderMap(): SkillProviderMap {
|
|
49
|
+
const target = globalThis as GlobalRegistry;
|
|
50
|
+
let map = target[PROVIDER_KEY] as SkillProviderMap | undefined;
|
|
51
|
+
if (!map) {
|
|
52
|
+
map = new Map();
|
|
53
|
+
target[PROVIDER_KEY] = map;
|
|
54
|
+
}
|
|
55
|
+
return map;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getForkMap(): ForkStateMap {
|
|
59
|
+
const target = globalThis as GlobalRegistry;
|
|
60
|
+
let map = target[FORK_KEY] as ForkStateMap | undefined;
|
|
61
|
+
if (!map) {
|
|
62
|
+
map = new Map();
|
|
63
|
+
target[FORK_KEY] = map;
|
|
64
|
+
}
|
|
65
|
+
return map;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stringList(value: unknown): string[] {
|
|
69
|
+
if (!Array.isArray(value)) return [];
|
|
70
|
+
return Array.from(new Set(value.filter((item): item is string => typeof item === "string")));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Create an empty state snapshot. */
|
|
74
|
+
export function createEmptySessionCapabilityState(): SessionCapabilityState {
|
|
75
|
+
return {
|
|
76
|
+
version: 1,
|
|
77
|
+
eligibleToolNames: [],
|
|
78
|
+
initiallyInactiveToolNames: [],
|
|
79
|
+
toolDenylist: [],
|
|
80
|
+
toolEnabledNames: [],
|
|
81
|
+
hiddenSkillNames: [],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Validate and copy one stored snapshot. */
|
|
86
|
+
export function decodeSessionCapabilities(value: unknown): SessionCapabilityState | undefined {
|
|
87
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
88
|
+
const record = value as Record<string, unknown>;
|
|
89
|
+
if (record.version !== 1) return undefined;
|
|
90
|
+
|
|
91
|
+
const initiallyInactiveToolNames = stringList(record.initiallyInactiveToolNames);
|
|
92
|
+
const initiallyInactive = new Set(initiallyInactiveToolNames);
|
|
93
|
+
const eligibleToolNames = stringList(record.eligibleToolNames).filter(
|
|
94
|
+
(name) => !initiallyInactive.has(name),
|
|
95
|
+
);
|
|
96
|
+
const eligible = new Set(eligibleToolNames);
|
|
97
|
+
const toolDenylist = stringList(record.toolDenylist).filter((name) => eligible.has(name));
|
|
98
|
+
const denied = new Set(toolDenylist);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
version: 1,
|
|
102
|
+
eligibleToolNames,
|
|
103
|
+
initiallyInactiveToolNames,
|
|
104
|
+
toolDenylist,
|
|
105
|
+
toolEnabledNames: stringList(record.toolEnabledNames).filter(
|
|
106
|
+
(name) => eligible.has(name) && !denied.has(name),
|
|
107
|
+
),
|
|
108
|
+
hiddenSkillNames: stringList(record.hiddenSkillNames),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Return a safe copy of the latest valid snapshot in all session entries. */
|
|
113
|
+
export function findSessionCapabilities(
|
|
114
|
+
entries: readonly unknown[],
|
|
115
|
+
): SessionCapabilityState | undefined {
|
|
116
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
117
|
+
const entry = entries[index];
|
|
118
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
119
|
+
const record = entry as Record<string, unknown>;
|
|
120
|
+
if (record.type !== "custom" || record.customType !== SESSION_CAPABILITIES_ENTRY) continue;
|
|
121
|
+
const state = decodeSessionCapabilities(record.data);
|
|
122
|
+
if (state) return state;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Shared session state. Session IDs keep sessions in the same cwd separate. */
|
|
128
|
+
export const sessionCapabilityState = {
|
|
129
|
+
get(sessionId: string): SessionCapabilityState | undefined {
|
|
130
|
+
const state = getStateMap().get(sessionId);
|
|
131
|
+
return state ? decodeSessionCapabilities(state) : undefined;
|
|
132
|
+
},
|
|
133
|
+
set(sessionId: string, state: SessionCapabilityState): void {
|
|
134
|
+
const decoded = decodeSessionCapabilities(state);
|
|
135
|
+
if (decoded) getStateMap().set(sessionId, decoded);
|
|
136
|
+
},
|
|
137
|
+
clear(sessionId: string): void {
|
|
138
|
+
getStateMap().delete(sessionId);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Register the current session's skill inventory provider. */
|
|
143
|
+
export function registerSessionCapabilitySkillProvider<TContext>(
|
|
144
|
+
sessionId: string,
|
|
145
|
+
provider: SessionCapabilitySkillProvider<TContext>,
|
|
146
|
+
): () => void {
|
|
147
|
+
const providers = getProviderMap();
|
|
148
|
+
providers.set(sessionId, provider as SessionCapabilitySkillProvider<unknown>);
|
|
149
|
+
return () => {
|
|
150
|
+
if (providers.get(sessionId) === provider) providers.delete(sessionId);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Get the current session's skill inventory provider, if one is loaded. */
|
|
155
|
+
export function getSessionCapabilitySkillProvider<TContext = unknown>(
|
|
156
|
+
sessionId: string,
|
|
157
|
+
): SessionCapabilitySkillProvider<TContext> | undefined {
|
|
158
|
+
return getProviderMap().get(sessionId) as SessionCapabilitySkillProvider<TContext> | undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function forkKey(sourceSessionFile: string | undefined): string {
|
|
162
|
+
return sourceSessionFile ?? "<memory-session>";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Hold a state copy until PI starts the forked session. */
|
|
166
|
+
export function setPendingSessionCapabilityFork(
|
|
167
|
+
sourceSessionFile: string | undefined,
|
|
168
|
+
state: SessionCapabilityState,
|
|
169
|
+
): void {
|
|
170
|
+
const decoded = decodeSessionCapabilities(state);
|
|
171
|
+
if (decoded) getForkMap().set(forkKey(sourceSessionFile), decoded);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Take the state copy for one fork. The copy is used once. */
|
|
175
|
+
export function readPendingSessionCapabilityFork(
|
|
176
|
+
sourceSessionFile: string | undefined,
|
|
177
|
+
): SessionCapabilityState | undefined {
|
|
178
|
+
const forks = getForkMap();
|
|
179
|
+
const key = forkKey(sourceSessionFile);
|
|
180
|
+
const state = forks.get(key);
|
|
181
|
+
forks.delete(key);
|
|
182
|
+
return state ? decodeSessionCapabilities(state) : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Clear pending fork copies after a cancelled or unrelated transition. */
|
|
186
|
+
export function clearPendingSessionCapabilityForks(): void {
|
|
187
|
+
getForkMap().clear();
|
|
188
|
+
}
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
// supi-core session domain — session utilities and registries.
|
|
2
2
|
|
|
3
3
|
export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
|
|
4
|
+
export type {
|
|
5
|
+
SessionCapabilitySkill,
|
|
6
|
+
SessionCapabilitySkillProvider,
|
|
7
|
+
SessionCapabilityState,
|
|
8
|
+
} from "./session-capabilities.ts";
|
|
9
|
+
export {
|
|
10
|
+
clearPendingSessionCapabilityForks,
|
|
11
|
+
createEmptySessionCapabilityState,
|
|
12
|
+
decodeSessionCapabilities,
|
|
13
|
+
findSessionCapabilities,
|
|
14
|
+
getSessionCapabilitySkillProvider,
|
|
15
|
+
readPendingSessionCapabilityFork,
|
|
16
|
+
registerSessionCapabilitySkillProvider,
|
|
17
|
+
SESSION_CAPABILITIES_ENTRY,
|
|
18
|
+
sessionCapabilityState,
|
|
19
|
+
setPendingSessionCapabilityFork,
|
|
20
|
+
} from "./session-capabilities.ts";
|
|
4
21
|
export type { SessionNameTrackerHost } from "./session-utils.ts";
|
|
5
22
|
export {
|
|
6
23
|
createSessionNameTracker,
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type SelectItem,
|
|
4
|
+
SelectList,
|
|
5
|
+
type TuiMouseEvent,
|
|
6
|
+
type TuiMouseEventResult,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
12
|
+
/** The theme methods used by the shared TUI list renderers. */
|
|
13
|
+
export type TuiTheme = Pick<Theme, "fg" | "bold">;
|
|
14
|
+
|
|
15
|
+
/** Settings for one selectable row in a themed list. */
|
|
16
|
+
export interface SelectableRowOptions {
|
|
17
|
+
/** The row label. */
|
|
18
|
+
label: string;
|
|
19
|
+
/** The value shown beside the label. */
|
|
20
|
+
value: string;
|
|
21
|
+
/** The row indent in terminal columns. */
|
|
22
|
+
indent: number;
|
|
23
|
+
/** The largest indent used by the list. */
|
|
24
|
+
maxIndent: number;
|
|
25
|
+
/** The label column width. */
|
|
26
|
+
maxLabelWidth: number;
|
|
27
|
+
/** The row width in terminal columns. */
|
|
28
|
+
width: number;
|
|
29
|
+
/** True when this row is selected. */
|
|
30
|
+
selected: boolean;
|
|
31
|
+
/** Theme used for row colors. */
|
|
32
|
+
theme: TuiTheme;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Settings for a shared action menu. */
|
|
36
|
+
export interface SelectListMenuOptions {
|
|
37
|
+
/** The title shown above the choices. */
|
|
38
|
+
title: string;
|
|
39
|
+
/** The choices shown in the menu. */
|
|
40
|
+
items: SelectItem[];
|
|
41
|
+
/** Theme used for menu colors. */
|
|
42
|
+
theme: TuiTheme;
|
|
43
|
+
/** Called when the user selects a choice. */
|
|
44
|
+
onSelect: (item: SelectItem) => void;
|
|
45
|
+
/** Called when the user closes the menu without a choice. */
|
|
46
|
+
onCancel: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Component methods for a shared action menu. */
|
|
50
|
+
export interface SelectListMenuComponent {
|
|
51
|
+
render(width: number): string[];
|
|
52
|
+
invalidate(): void;
|
|
53
|
+
handleInput(data: string): void;
|
|
54
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build a title line with optional right-side tabs in the shared TUI style. */
|
|
58
|
+
export function formatTuiTitleLine(title: string, theme: TuiTheme, trailing = ""): string {
|
|
59
|
+
const titleText = theme.fg("accent", theme.bold(title));
|
|
60
|
+
return ` ${titleText}${trailing ? ` ${trailing}` : ""}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Render one row with a selected marker and an aligned value column. */
|
|
64
|
+
export function renderSelectableRow(options: SelectableRowOptions): string {
|
|
65
|
+
const indent = Math.max(0, options.indent);
|
|
66
|
+
const maxIndent = Math.max(indent, options.maxIndent);
|
|
67
|
+
const maxLabelWidth = Math.max(0, options.maxLabelWidth);
|
|
68
|
+
const width = Math.max(0, options.width);
|
|
69
|
+
const label = truncateToWidth(options.label, maxLabelWidth, "");
|
|
70
|
+
const labelWidth = visibleWidth(label);
|
|
71
|
+
const prefix = options.selected
|
|
72
|
+
? `${" ".repeat(Math.max(0, indent - 2))}${options.theme.fg("accent", "→ ")}`
|
|
73
|
+
: " ".repeat(indent);
|
|
74
|
+
const labelPadding = " ".repeat(maxIndent - indent + maxLabelWidth - labelWidth);
|
|
75
|
+
const valueWidth = Math.max(0, width - maxIndent - maxLabelWidth - 4);
|
|
76
|
+
const value = truncateToWidth(options.value, valueWidth, "");
|
|
77
|
+
const labelText = options.theme.fg(options.selected ? "accent" : "text", label + labelPadding);
|
|
78
|
+
const valueText = options.theme.fg(options.selected ? "accent" : "muted", value);
|
|
79
|
+
|
|
80
|
+
return truncateToWidth(`${prefix}${labelText} ${valueText}`, width);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Render a fixed-height, four-line description preview for a selected row. */
|
|
84
|
+
export function renderDescriptionViewport(
|
|
85
|
+
description: string | undefined,
|
|
86
|
+
width: number,
|
|
87
|
+
): string[] {
|
|
88
|
+
const height = 4;
|
|
89
|
+
const indent = " ";
|
|
90
|
+
const contentWidth = Math.max(1, width - 4);
|
|
91
|
+
const wrapped = description ? wrapTextWithAnsi(description, contentWidth) : [];
|
|
92
|
+
const visible = wrapped.slice(0, height);
|
|
93
|
+
|
|
94
|
+
if (wrapped.length > height) {
|
|
95
|
+
const lastIndex = height - 1;
|
|
96
|
+
visible[lastIndex] = truncateToWidth(`${visible[lastIndex] ?? ""}…`, contentWidth, "…");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
while (visible.length < height) visible.push("");
|
|
100
|
+
return visible.map((line) => (line ? `${indent}${line}` : ""));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Render a dimmed hint line with the shared separator and truncation rules. */
|
|
104
|
+
export function renderTuiHintLine(hints: string[], theme: TuiTheme, width: number): string {
|
|
105
|
+
return truncateToWidth(theme.fg("dim", hints.join(" · ")), width);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build the shared title, choices, and key hints for an action menu. */
|
|
109
|
+
export function createSelectListMenu(options: SelectListMenuOptions): SelectListMenuComponent {
|
|
110
|
+
const { items, onSelect, onCancel, theme, title } = options;
|
|
111
|
+
const selectList = new SelectList(items, Math.min(items.length + 2, 15), {
|
|
112
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
113
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
114
|
+
description: (text) => theme.fg("muted", text),
|
|
115
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
116
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
117
|
+
});
|
|
118
|
+
selectList.onSelect = onSelect;
|
|
119
|
+
selectList.onCancel = onCancel;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
render(width) {
|
|
123
|
+
return [
|
|
124
|
+
truncateToWidth(theme.fg("accent", ` ${title}`), width),
|
|
125
|
+
...selectList.render(width),
|
|
126
|
+
"",
|
|
127
|
+
renderTuiHintLine([" ↑↓ navigate", "Enter select", "Esc cancel"], theme, width),
|
|
128
|
+
];
|
|
129
|
+
},
|
|
130
|
+
invalidate() {
|
|
131
|
+
selectList.invalidate();
|
|
132
|
+
},
|
|
133
|
+
handleInput(data) {
|
|
134
|
+
selectList.handleInput(data);
|
|
135
|
+
},
|
|
136
|
+
handleMouse(event) {
|
|
137
|
+
if (event.y < 1) return undefined;
|
|
138
|
+
return selectList.handleMouse({ ...event, y: event.y - 1 });
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-skills",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.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": "7.
|
|
33
|
+
"@mrclrchtr/supi-core": "7.3.0"
|
|
34
34
|
},
|
|
35
35
|
"bundledDependencies": [
|
|
36
36
|
"@mrclrchtr/supi-core"
|
|
@@ -38,12 +38,14 @@ export function applyPromptOverrides({
|
|
|
38
38
|
cwd,
|
|
39
39
|
projectTrusted,
|
|
40
40
|
homeDir,
|
|
41
|
+
hiddenSkillNames = new Set(),
|
|
41
42
|
}: {
|
|
42
43
|
options: BuildSystemPromptOptions;
|
|
43
44
|
systemPrompt: string;
|
|
44
45
|
cwd: string;
|
|
45
46
|
projectTrusted: boolean;
|
|
46
47
|
homeDir?: string;
|
|
48
|
+
hiddenSkillNames?: ReadonlySet<string>;
|
|
47
49
|
}): string | undefined {
|
|
48
50
|
const selectedTools = options.selectedTools;
|
|
49
51
|
const skillFileReadTool =
|
|
@@ -52,17 +54,20 @@ export function applyPromptOverrides({
|
|
|
52
54
|
: (["read", "bash"] as const).find((tool) => selectedTools.includes(tool));
|
|
53
55
|
if (!skillFileReadTool) return undefined;
|
|
54
56
|
const skills = options.skills ?? [];
|
|
55
|
-
const effective = skills
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
const effective = skills
|
|
58
|
+
.filter((skill) => !hiddenSkillNames.has(skill.name))
|
|
59
|
+
.map((skill) => ({
|
|
60
|
+
...skill,
|
|
61
|
+
disableModelInvocation: resolveInvocation({
|
|
62
|
+
name: skill.name,
|
|
63
|
+
sourceDefault: skill.disableModelInvocation,
|
|
64
|
+
scope: "project",
|
|
65
|
+
cwd,
|
|
66
|
+
projectTrusted,
|
|
67
|
+
homeDir,
|
|
68
|
+
}).disabled,
|
|
69
|
+
}));
|
|
70
|
+
const effectiveByName = new Map(effective.map((skill) => [skill.name, skill]));
|
|
66
71
|
const original = formatSkillsForPrompt(skills, skillFileReadTool);
|
|
67
72
|
const replacement = formatSkillsForPrompt(effective, skillFileReadTool);
|
|
68
73
|
if (original === replacement) return undefined;
|
|
@@ -97,10 +102,12 @@ export function applyPromptOverrides({
|
|
|
97
102
|
data: {
|
|
98
103
|
skillCount: skills.length,
|
|
99
104
|
changedSkills: skills
|
|
100
|
-
.filter(
|
|
101
|
-
(skill
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
.filter((skill) => {
|
|
106
|
+
const activeSkill = effectiveByName.get(skill.name);
|
|
107
|
+
return (
|
|
108
|
+
!activeSkill || skill.disableModelInvocation !== activeSkill.disableModelInvocation
|
|
109
|
+
);
|
|
110
|
+
})
|
|
104
111
|
.map((skill) => skill.name),
|
|
105
112
|
forceSystemPrompt: true,
|
|
106
113
|
hasSkillsSection: systemPrompt.includes("<skills>"),
|
|
@@ -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
|
+
}
|
package/src/skill-settings.ts
CHANGED
|
@@ -1,398 +1,91 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|