@mrclrchtr/supi-extras 7.2.1 → 7.3.1
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 -24
- 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 +3 -3
- package/src/index.ts +2 -0
- package/src/session-capabilities-state.ts +60 -0
- package/src/session-capabilities-tools.ts +103 -0
- package/src/session-capabilities-ui-rows.ts +192 -0
- package/src/session-capabilities-ui.ts +356 -0
- package/src/session-capabilities.ts +350 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionCommandContext,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
SessionManager,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { FOOTER_INVALIDATE_EVENT, footerContributions } from "@mrclrchtr/supi-core/footer-registry";
|
|
8
|
+
import {
|
|
9
|
+
clearPendingSessionCapabilityForks,
|
|
10
|
+
createEmptySessionCapabilityState,
|
|
11
|
+
decodeSessionCapabilities,
|
|
12
|
+
findSessionCapabilities,
|
|
13
|
+
getSessionCapabilitySkillProvider,
|
|
14
|
+
readPendingSessionCapabilityFork,
|
|
15
|
+
SESSION_CAPABILITIES_ENTRY,
|
|
16
|
+
type SessionCapabilitySkill,
|
|
17
|
+
type SessionCapabilityState,
|
|
18
|
+
sessionCapabilityState,
|
|
19
|
+
setPendingSessionCapabilityFork,
|
|
20
|
+
} from "@mrclrchtr/supi-core/session";
|
|
21
|
+
import {
|
|
22
|
+
addActiveToolsToBaseline,
|
|
23
|
+
baselineFromState,
|
|
24
|
+
captureToolBaseline,
|
|
25
|
+
initialSessionCapabilityState,
|
|
26
|
+
isExtensionTool,
|
|
27
|
+
type ToolBaseline,
|
|
28
|
+
} from "./session-capabilities-state.ts";
|
|
29
|
+
import {
|
|
30
|
+
applyToolDenylist,
|
|
31
|
+
observeNewTools,
|
|
32
|
+
reconcileToolInventory,
|
|
33
|
+
} from "./session-capabilities-tools.ts";
|
|
34
|
+
import { type CapabilityToolItem, createCapabilitySelector } from "./session-capabilities-ui.ts";
|
|
35
|
+
|
|
36
|
+
const COMMAND_NAME = "supi-capabilities";
|
|
37
|
+
const FOOTER_KEY = "supi-capabilities";
|
|
38
|
+
const FOOTER_ICON = "◈";
|
|
39
|
+
|
|
40
|
+
type SkillProviderContext = ExtensionCommandContext;
|
|
41
|
+
|
|
42
|
+
function currentSessionState(ctx: ExtensionContext): SessionCapabilityState | undefined {
|
|
43
|
+
return sessionCapabilityState.get(ctx.sessionManager.getSessionId());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function saveState(pi: ExtensionAPI, ctx: ExtensionContext, state: SessionCapabilityState): void {
|
|
47
|
+
const normalized = decodeSessionCapabilities(state) ?? createEmptySessionCapabilityState();
|
|
48
|
+
sessionCapabilityState.set(ctx.sessionManager.getSessionId(), normalized);
|
|
49
|
+
pi.appendEntry(SESSION_CAPABILITIES_ENTRY, normalized);
|
|
50
|
+
updateFooter(pi, ctx, normalized);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function updateFooter(
|
|
54
|
+
pi: ExtensionAPI,
|
|
55
|
+
ctx: ExtensionContext,
|
|
56
|
+
state: SessionCapabilityState,
|
|
57
|
+
): void {
|
|
58
|
+
if (ctx.mode !== "tui") return;
|
|
59
|
+
const count =
|
|
60
|
+
state.toolDenylist.length +
|
|
61
|
+
(state.toolEnabledNames?.length ?? 0) +
|
|
62
|
+
state.hiddenSkillNames.length;
|
|
63
|
+
if (count === 0) {
|
|
64
|
+
footerContributions.unregister(FOOTER_KEY);
|
|
65
|
+
ctx.ui.setStatus(FOOTER_KEY, undefined);
|
|
66
|
+
} else {
|
|
67
|
+
const render = (): string => ctx.ui.theme.fg("accent", `${FOOTER_ICON}${count}`);
|
|
68
|
+
footerContributions.register({ key: FOOTER_KEY, placement: "stats-end", render });
|
|
69
|
+
ctx.ui.setStatus(FOOTER_KEY, render());
|
|
70
|
+
}
|
|
71
|
+
pi.events.emit(FOOTER_INVALIDATE_EVENT, {});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readSessionFileState(sessionFile: string | undefined): SessionCapabilityState | undefined {
|
|
75
|
+
if (!sessionFile) return undefined;
|
|
76
|
+
try {
|
|
77
|
+
return findSessionCapabilities(SessionManager.open(sessionFile).getEntries());
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ToolGroupOptions {
|
|
84
|
+
enabled: boolean;
|
|
85
|
+
names: readonly string[];
|
|
86
|
+
pi: ExtensionAPI;
|
|
87
|
+
restorableNames: ReadonlySet<string>;
|
|
88
|
+
state: SessionCapabilityState;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function setToolGroup(options: ToolGroupOptions): void {
|
|
92
|
+
const { pi, state, names, enabled, restorableNames } = options;
|
|
93
|
+
const eligible = new Set(state.eligibleToolNames);
|
|
94
|
+
const initiallyInactive = new Set(state.initiallyInactiveToolNames);
|
|
95
|
+
const denylist = new Set(state.toolDenylist);
|
|
96
|
+
const enabledNames = new Set(state.toolEnabledNames ?? []);
|
|
97
|
+
const active = new Set(pi.getActiveTools());
|
|
98
|
+
const restoreNames: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
if (!eligible.has(name) || initiallyInactive.has(name)) continue;
|
|
102
|
+
if (enabled) {
|
|
103
|
+
denylist.delete(name);
|
|
104
|
+
enabledNames.add(name);
|
|
105
|
+
restoreNames.push(name);
|
|
106
|
+
} else {
|
|
107
|
+
enabledNames.delete(name);
|
|
108
|
+
if (active.has(name)) denylist.add(name);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
state.toolDenylist = Array.from(denylist);
|
|
113
|
+
state.toolEnabledNames = Array.from(enabledNames);
|
|
114
|
+
applyToolDenylist(pi, state, restoreNames, restorableNames);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function restoreAllTools(
|
|
118
|
+
pi: ExtensionAPI,
|
|
119
|
+
state: SessionCapabilityState,
|
|
120
|
+
restorableNames: ReadonlySet<string>,
|
|
121
|
+
): void {
|
|
122
|
+
const restoreNames = [...state.toolDenylist];
|
|
123
|
+
state.toolDenylist = [];
|
|
124
|
+
state.toolEnabledNames = [];
|
|
125
|
+
applyToolDenylist(pi, state, restoreNames, restorableNames);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resetTools(
|
|
129
|
+
pi: ExtensionAPI,
|
|
130
|
+
ctx: ExtensionContext,
|
|
131
|
+
state: SessionCapabilityState,
|
|
132
|
+
restorableNames: ReadonlySet<string>,
|
|
133
|
+
): void {
|
|
134
|
+
restoreAllTools(pi, state, restorableNames);
|
|
135
|
+
saveState(pi, ctx, state);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resetSkills(pi: ExtensionAPI, ctx: ExtensionContext, state: SessionCapabilityState): void {
|
|
139
|
+
state.hiddenSkillNames = [];
|
|
140
|
+
saveState(pi, ctx, state);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resetAll(
|
|
144
|
+
pi: ExtensionAPI,
|
|
145
|
+
ctx: ExtensionContext,
|
|
146
|
+
state: SessionCapabilityState,
|
|
147
|
+
restorableNames: ReadonlySet<string>,
|
|
148
|
+
): void {
|
|
149
|
+
restoreAllTools(pi, state, restorableNames);
|
|
150
|
+
state.hiddenSkillNames = [];
|
|
151
|
+
saveState(pi, ctx, state);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface ToolSelectionOptions {
|
|
155
|
+
ctx: ExtensionContext;
|
|
156
|
+
id: string;
|
|
157
|
+
pi: ExtensionAPI;
|
|
158
|
+
restorableNames: ReadonlySet<string>;
|
|
159
|
+
state: SessionCapabilityState;
|
|
160
|
+
tools: CapabilityToolItem[];
|
|
161
|
+
value: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function updateToolSelection(options: ToolSelectionOptions): void {
|
|
165
|
+
const { pi, ctx, state, tools, id, value, restorableNames } = options;
|
|
166
|
+
const isPackage = id.startsWith("package:");
|
|
167
|
+
const key = id.slice(isPackage ? "package:".length : "tool:".length);
|
|
168
|
+
const names = isPackage
|
|
169
|
+
? tools.filter((tool) => tool.source === key).map((tool) => tool.name)
|
|
170
|
+
: [key];
|
|
171
|
+
setToolGroup({
|
|
172
|
+
pi,
|
|
173
|
+
state,
|
|
174
|
+
names,
|
|
175
|
+
enabled: isPackage ? value === "enable" : value === "Enabled",
|
|
176
|
+
restorableNames,
|
|
177
|
+
});
|
|
178
|
+
saveState(pi, ctx, state);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface SelectorChangeOptions extends ToolSelectionOptions {
|
|
182
|
+
id: string;
|
|
183
|
+
skills: SessionCapabilitySkill[] | undefined;
|
|
184
|
+
value: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function handleSelectorChange(options: SelectorChangeOptions): void {
|
|
188
|
+
const { id, pi, ctx, state, tools, skills, value } = options;
|
|
189
|
+
switch (id) {
|
|
190
|
+
case "disable-all-tools":
|
|
191
|
+
setToolGroup({
|
|
192
|
+
pi,
|
|
193
|
+
state,
|
|
194
|
+
names: tools.map((tool) => tool.name),
|
|
195
|
+
enabled: false,
|
|
196
|
+
restorableNames: options.restorableNames,
|
|
197
|
+
});
|
|
198
|
+
saveState(pi, ctx, state);
|
|
199
|
+
return;
|
|
200
|
+
case "hide-all-skills":
|
|
201
|
+
state.hiddenSkillNames = Array.from(new Set((skills ?? []).map((skill) => skill.name)));
|
|
202
|
+
saveState(pi, ctx, state);
|
|
203
|
+
return;
|
|
204
|
+
case "reset-tools":
|
|
205
|
+
resetTools(pi, ctx, state, options.restorableNames);
|
|
206
|
+
return;
|
|
207
|
+
case "reset-skills":
|
|
208
|
+
resetSkills(pi, ctx, state);
|
|
209
|
+
return;
|
|
210
|
+
case "reset-all":
|
|
211
|
+
resetAll(pi, ctx, state, options.restorableNames);
|
|
212
|
+
return;
|
|
213
|
+
default:
|
|
214
|
+
if (id.startsWith("tool:") || id.startsWith("package:")) {
|
|
215
|
+
updateToolSelection(options);
|
|
216
|
+
} else if (id.startsWith("skill:")) {
|
|
217
|
+
const name = id.slice("skill:".length);
|
|
218
|
+
const hidden = new Set(state.hiddenSkillNames);
|
|
219
|
+
if (value === "Hidden") hidden.add(name);
|
|
220
|
+
else hidden.delete(name);
|
|
221
|
+
state.hiddenSkillNames = Array.from(hidden);
|
|
222
|
+
saveState(pi, ctx, state);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Register session-only controls for extension tools and model-visible skills. */
|
|
228
|
+
export default function sessionCapabilities(pi: ExtensionAPI): void {
|
|
229
|
+
let toolBaseline: ToolBaseline | undefined;
|
|
230
|
+
let knownToolNames = new Set<string>();
|
|
231
|
+
let restorableToolNames = new Set<string>();
|
|
232
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
233
|
+
description: "Choose which extension tools and skills appear in this session",
|
|
234
|
+
handler: async (_args, ctx) => {
|
|
235
|
+
if (ctx.mode !== "tui") {
|
|
236
|
+
ctx.ui.notify("/supi-capabilities requires TUI mode", "warning");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
241
|
+
const state =
|
|
242
|
+
currentSessionState(ctx) ??
|
|
243
|
+
initialSessionCapabilityState(toolBaseline ?? captureToolBaseline(pi));
|
|
244
|
+
observeNewTools(pi, state, knownToolNames, restorableToolNames);
|
|
245
|
+
const toolInventory = reconcileToolInventory(pi, state);
|
|
246
|
+
if (toolInventory.changed) saveState(pi, ctx, state);
|
|
247
|
+
|
|
248
|
+
const provider = getSessionCapabilitySkillProvider<SkillProviderContext>(sessionId);
|
|
249
|
+
let skills: SessionCapabilitySkill[] | undefined;
|
|
250
|
+
if (provider) {
|
|
251
|
+
try {
|
|
252
|
+
skills = await provider.listEligibleSkills(ctx);
|
|
253
|
+
const names = new Set(skills.map((skill) => skill.name));
|
|
254
|
+
const previous = state.hiddenSkillNames;
|
|
255
|
+
state.hiddenSkillNames = previous.filter((name) => names.has(name));
|
|
256
|
+
if (previous.length !== state.hiddenSkillNames.length) saveState(pi, ctx, state);
|
|
257
|
+
} catch {
|
|
258
|
+
ctx.ui.notify("Could not load the skill list", "warning");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await ctx.ui.custom((tui, theme, _keybindings, done) =>
|
|
263
|
+
createCapabilitySelector({
|
|
264
|
+
state,
|
|
265
|
+
tools: toolInventory.tools,
|
|
266
|
+
skills,
|
|
267
|
+
theme,
|
|
268
|
+
tui,
|
|
269
|
+
done,
|
|
270
|
+
onChange: (id, value) =>
|
|
271
|
+
handleSelectorChange({
|
|
272
|
+
pi,
|
|
273
|
+
ctx,
|
|
274
|
+
state,
|
|
275
|
+
tools: toolInventory.tools,
|
|
276
|
+
skills,
|
|
277
|
+
id,
|
|
278
|
+
value,
|
|
279
|
+
restorableNames: restorableToolNames,
|
|
280
|
+
}),
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
pi.on("session_start", (event, ctx) => {
|
|
287
|
+
const startupTools = pi.getAllTools().filter(isExtensionTool);
|
|
288
|
+
const startupActiveNames = new Set(pi.getActiveTools());
|
|
289
|
+
knownToolNames = new Set(startupTools.map((tool) => tool.name));
|
|
290
|
+
restorableToolNames = new Set(
|
|
291
|
+
startupTools.filter((tool) => startupActiveNames.has(tool.name)).map((tool) => tool.name),
|
|
292
|
+
);
|
|
293
|
+
const entries = ctx.sessionManager.getEntries();
|
|
294
|
+
const currentEntryState = findSessionCapabilities(entries);
|
|
295
|
+
let state: SessionCapabilityState | undefined;
|
|
296
|
+
|
|
297
|
+
if (event.reason === "new") {
|
|
298
|
+
state = undefined;
|
|
299
|
+
} else if (event.reason === "fork") {
|
|
300
|
+
const fileState = readSessionFileState(event.previousSessionFile);
|
|
301
|
+
const pendingState = readPendingSessionCapabilityFork(event.previousSessionFile);
|
|
302
|
+
state = fileState ?? pendingState ?? currentEntryState;
|
|
303
|
+
} else {
|
|
304
|
+
state = currentEntryState;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const copiedForkState = event.reason === "fork" && state !== undefined;
|
|
308
|
+
toolBaseline ??=
|
|
309
|
+
event.reason !== "new" && state ? baselineFromState(state) : captureToolBaseline(pi);
|
|
310
|
+
addActiveToolsToBaseline(pi, toolBaseline);
|
|
311
|
+
state ??= initialSessionCapabilityState(toolBaseline);
|
|
312
|
+
const toolStateChanged = reconcileToolInventory(pi, state).changed;
|
|
313
|
+
sessionCapabilityState.set(ctx.sessionManager.getSessionId(), state);
|
|
314
|
+
applyToolDenylist(pi, state, state.toolEnabledNames ?? [], restorableToolNames);
|
|
315
|
+
if (!currentEntryState || copiedForkState || event.reason === "new" || toolStateChanged) {
|
|
316
|
+
pi.appendEntry(SESSION_CAPABILITIES_ENTRY, state);
|
|
317
|
+
}
|
|
318
|
+
updateFooter(pi, ctx, state);
|
|
319
|
+
if (event.reason !== "fork") clearPendingSessionCapabilityForks();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
323
|
+
const state = currentSessionState(ctx);
|
|
324
|
+
if (!state) return;
|
|
325
|
+
observeNewTools(pi, state, knownToolNames, restorableToolNames);
|
|
326
|
+
applyToolDenylist(pi, state, state.toolEnabledNames ?? [], restorableToolNames);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
330
|
+
const state = currentSessionState(ctx);
|
|
331
|
+
if (!state) return;
|
|
332
|
+
observeNewTools(pi, state, knownToolNames, restorableToolNames);
|
|
333
|
+
applyToolDenylist(pi, state, state.toolEnabledNames ?? [], restorableToolNames);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
pi.on("session_before_fork", (_event, ctx) => {
|
|
337
|
+
const state =
|
|
338
|
+
currentSessionState(ctx) ?? findSessionCapabilities(ctx.sessionManager.getEntries());
|
|
339
|
+
if (state) setPendingSessionCapabilityFork(ctx.sessionManager.getSessionFile(), state);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
pi.on("session_shutdown", (event, ctx) => {
|
|
343
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
344
|
+
footerContributions.unregister(FOOTER_KEY);
|
|
345
|
+
if (ctx.mode === "tui") ctx.ui.setStatus(FOOTER_KEY, undefined);
|
|
346
|
+
pi.events.emit(FOOTER_INVALIDATE_EVENT, {});
|
|
347
|
+
sessionCapabilityState.clear(sessionId);
|
|
348
|
+
if (event.reason !== "fork") clearPendingSessionCapabilityForks();
|
|
349
|
+
});
|
|
350
|
+
}
|