pi-skill-router 0.2.1 → 0.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 +16 -5
- package/index.ts +27 -4
- package/package.json +6 -4
- package/src/menu.ts +28 -14
- package/src/paths.ts +21 -1
- package/src/skills.ts +360 -0
- package/src/sync.ts +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-skill-router
|
|
2
2
|
|
|
3
|
-
`pi-skill-router` routes to **externally hosted skill collections**. It clones skill repositories into a user-level managed directory, wraps the skills you select as hidden leaves behind a visible gateway, exposes them to Pi, and
|
|
3
|
+
`pi-skill-router` routes to **externally hosted skill collections**. It clones skill repositories into a user-level managed directory, wraps the skills you select as hidden leaves behind a visible gateway, exposes them to Pi, and registers every routed skill as a codemode tool the model searches and calls.
|
|
4
4
|
|
|
5
5
|
**Package version:** see [`package.json`](package.json).
|
|
6
6
|
|
|
@@ -30,7 +30,10 @@ pi install npm:pi-skill-router
|
|
|
30
30
|
1. **Add**: `/skill-router` → *Add collection* → enter `owner/repo[@ref]`, a GitHub URL, or a local path. The repo is shallow-cloned into `cache/`, scanned for `SKILL.md` files, and you choose which skills to route (or all). No prefix is required.
|
|
31
31
|
2. **Describe and materialize**: after selecting skills, the active AI model synthesizes the selected skills' descriptions into a whole-collection capability summary and the router saves it directly, without manual editing or confirmation. If no authenticated model is available or generation fails or returns invalid output, Add collection reports an installation error and leaves the collection uninstalled; it never substitutes a skill-name-based summary. The summary becomes the gateway's frontmatter description. Selected skills are saved into `exposed/collections/<collection>/leaves/<name>/`, while the single visible gateway is created in `exposed/collections/<collection>/gateway/SKILL.md`. Keeping the gateway and leaves in one atomic collection tree prevents partial materialization. Its body indexes the selected workflows with descriptions and exact relative leaf paths. Sub-skills remain clean without synthetic name prefixes.
|
|
32
32
|
3. **Expose**: on session start and `/reload`, the extension answers Pi's `resources_discover` event with only the gateway directory, so only `/skill:<gateway>` appears in Pi's slash-command menu. Sub-skills do not clutter `/`.
|
|
33
|
-
4. **
|
|
33
|
+
4. **Search and call**: each routed skill is registered as one codemode tool, named `skill_router__<collection>__<skill>` with every character outside `[A-Za-z0-9_]` replaced by `_`, as MCP tool names are. All of a collection's skill tools share one namespace named `skill_router__<collection>`, aliased to the collection id. The model searches codemode's tools and calls the matched one, which returns that skill's instructions together with the directory its references and scripts resolve against. The collection's capability summary is the namespace description and the gateway body is its `instructions`, so one lookup answers what a collection contains. Codemode is inactive by default and the router never activates it.
|
|
34
|
+
5. **Fall back without codemode**: while codemode is not active — or while an allowlist has removed the skill tools, or the registered `codemode` declares that it reaches no tool — `before_agent_start` appends a focused suggestion naming the exact sub-skill `SKILL.md` path for the agent to read instead. Your message is never rewritten. The gateway is the sole slash command; sub-skills are never slash commands.
|
|
35
|
+
|
|
36
|
+
To run the tools channel without an MCP server, declare codemode in your settings with `"defaultTools": ["+codemode"]`. Passing `--tools` names an allowlist that also removes extension tools: `--tools 'read,codemode'` hides every skill tool and leaves only the fallback, while `--tools 'read,codemode,skill_router__*'` keeps them callable from a script. An allowlist match cannot declare a `codemode`-exposure tool, so a direct call needs `tool_search` to load it first. The router decides per skill, so an allowlist that kept one skill's tool and removed another still falls back for the removed one.
|
|
34
37
|
|
|
35
38
|
## Managing Collections
|
|
36
39
|
|
|
@@ -38,7 +41,7 @@ Registry changes wait asynchronously for competing operations, for up to 10 seco
|
|
|
38
41
|
|
|
39
42
|
Everything runs through the `/skill-router` menu:
|
|
40
43
|
|
|
41
|
-
- **Add collection** — clone a repo, pick skills, install.
|
|
44
|
+
- **Add collection** — clone a repo, pick skills, install. Every menu change — add, update, routed skills, capability summary, remove, enable/disable — refreshes the skill tools in place, so the tools channel follows the registry in the same session and a removed or disabled collection has its tools withdrawn. `/reload` is needed only to refresh the gateway command and the prompt's skill list. A collection installed by another process, or a hand-edited registry, stays on the routing suggestion until then.
|
|
42
45
|
- **Update collection** — pull upstream and re-materialize the preserved selection. New upstream skills are reported but stay unrouted until you explicitly select them.
|
|
43
46
|
- **Change routed skills** — add currently available upstream skills to an installed collection's explicit selection.
|
|
44
47
|
- **Edit capability summary** — revise the gateway description for an installed collection, then re-materialize its gateway.
|
|
@@ -52,9 +55,17 @@ Routing terms are derived from skill names and stored in `~/.pi/agent/skill-rout
|
|
|
52
55
|
|
|
53
56
|
- Gateway names are unique across collections; duplicate upstream skill names inside one collection fail the install instead of colliding.
|
|
54
57
|
- Materialization is atomic (temporary directory + rename); a failed install leaves no partial exposed directory.
|
|
55
|
-
- Invalid registry entries fail closed: they are ignored for
|
|
56
|
-
- Only skills you selected are ever exposed.
|
|
58
|
+
- Invalid registry entries fail closed: they are ignored for exposure, routing, and tool registration.
|
|
59
|
+
- Only skills you selected are ever exposed, and a disabled collection registers no tools.
|
|
60
|
+
- A skill tool re-resolves its collection on every call and reads only a regular file inside `exposed/collections`. A removed collection, a tampered leaf, or a symlinked instruction file is refused instead of served.
|
|
61
|
+
- A call returns at most 20000 characters of instructions, followed by a note naming the file that holds the rest.
|
|
62
|
+
- A registered skill tool can be filtered out of the session entirely by a `--tools` or `--exclude-tools` allowlist. The router detects that for the matched skill, and the routing suggestion takes over for it.
|
|
63
|
+
- Removing or disabling a collection while the session is live withdraws its skill tools: Pi cannot unregister a tool, so they are re-registered hidden and stay unreachable.
|
|
57
64
|
|
|
58
65
|
## Security
|
|
59
66
|
|
|
60
67
|
Skills are instructions the model follows and may bundle scripts. Review a repository's skills before adding it — installing a collection means trusting its content at user level across all projects.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
package/index.ts
CHANGED
|
@@ -1,29 +1,52 @@
|
|
|
1
1
|
import type { BeforeAgentStartEvent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { routerRoot } from "./src/paths";
|
|
2
|
+
import { routerRoot, skillToolName } from "./src/paths";
|
|
3
3
|
import { loadCollections } from "./src/registry";
|
|
4
4
|
import { isExplicitSkillInvocation, routePrompt, routingGuidance } from "./src/router";
|
|
5
|
+
import { codemodeReachesSkillTool, refreshSkillTools } from "./src/skills";
|
|
5
6
|
import { exposedSkillPaths } from "./src/sync";
|
|
6
7
|
import { showSkillRouterMenu } from "./src/menu";
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Two channels reach a leaf skill.
|
|
11
|
+
*
|
|
12
|
+
* The tools channel registers one codemode tool per routed leaf skill, so the
|
|
13
|
+
* model searches codemode's tools and calls the matched one. The fallback
|
|
14
|
+
* channel keeps the routing suggestion whenever that skill's own tool cannot be
|
|
15
|
+
* called: a session that does not declare codemode, an allowlist that removed
|
|
16
|
+
* the tool, or a collection added after this extension loaded. The gateway stays
|
|
17
|
+
* exposed either way, as the human `/skill:<gateway>` entry point.
|
|
18
|
+
*/
|
|
8
19
|
export default function skillRouter(pi: ExtensionAPI): void {
|
|
20
|
+
refreshSkillTools(pi, routerRoot());
|
|
21
|
+
|
|
9
22
|
pi.on("resources_discover", () => ({
|
|
10
23
|
skillPaths: exposedSkillPaths(routerRoot()),
|
|
11
24
|
}));
|
|
12
25
|
|
|
13
26
|
pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
|
|
14
|
-
|
|
27
|
+
const options = event.systemPromptOptions;
|
|
28
|
+
if (!("sections" in options) || !options.sections || typeof options.sections !== "object" || Array.isArray(options.sections)) {
|
|
29
|
+
throw new Error("This extension requires Pi structured prompt sections.");
|
|
30
|
+
}
|
|
31
|
+
const sections = options.sections;
|
|
32
|
+
Reflect.deleteProperty(sections, "skill_router");
|
|
33
|
+
if (!("prompt" in event) || typeof event.prompt !== "string" || isExplicitSkillInvocation(event.prompt)) return;
|
|
15
34
|
|
|
16
35
|
const skills = event.systemPromptOptions.skills ?? [];
|
|
17
36
|
const match = routePrompt(event.prompt, loadCollections(routerRoot()), new Map(skills.map((skill) => [skill.name, skill])));
|
|
18
37
|
if (!match) return;
|
|
38
|
+
// The tools channel wins only for this skill's own tool. A surviving sibling
|
|
39
|
+
// tool, or a collection that registered nothing before the next reload, must
|
|
40
|
+
// not suppress the suggestion that is the only working path to this skill.
|
|
41
|
+
if (codemodeReachesSkillTool(pi, skillToolName(match.collectionId, match.skillName))) return;
|
|
19
42
|
|
|
20
|
-
|
|
43
|
+
Object.assign(sections, { skill_router: routingGuidance(match) });
|
|
21
44
|
});
|
|
22
45
|
|
|
23
46
|
pi.registerCommand("skill-router", {
|
|
24
47
|
description: "Manage externally hosted skill collections (add, update, remove, toggle, list)",
|
|
25
48
|
handler: async (_args, ctx) => {
|
|
26
|
-
await showSkillRouterMenu(ctx);
|
|
49
|
+
await showSkillRouterMenu(ctx, { refreshTools: () => refreshSkillTools(pi, routerRoot()) });
|
|
27
50
|
},
|
|
28
51
|
});
|
|
29
52
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-skill-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Route to externally hosted skill collections: clone GitHub skill repos, wrap them as hidden leaves behind visible gateways, and get focused suggestions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
],
|
|
11
11
|
"author": "Frad LEE <fradser@gmail.com>",
|
|
12
12
|
"license": "MIT",
|
|
13
|
+
"type": "module",
|
|
13
14
|
"repository": {
|
|
14
15
|
"type": "git",
|
|
15
16
|
"url": "https://github.com/FradSer/pi-packages.git"
|
|
@@ -26,7 +27,8 @@
|
|
|
26
27
|
"peerDependencies": {
|
|
27
28
|
"@earendil-works/pi-ai": "*",
|
|
28
29
|
"@earendil-works/pi-coding-agent": "*",
|
|
29
|
-
"@earendil-works/pi-tui": "*"
|
|
30
|
+
"@earendil-works/pi-tui": "*",
|
|
31
|
+
"typebox": "*"
|
|
30
32
|
},
|
|
31
33
|
"pi": {
|
|
32
34
|
"extensions": [
|
|
@@ -34,6 +36,6 @@
|
|
|
34
36
|
]
|
|
35
37
|
},
|
|
36
38
|
"dependencies": {
|
|
37
|
-
"@fradser/pi-kit": "0.
|
|
39
|
+
"@fradser/pi-kit": "0.6.0"
|
|
38
40
|
}
|
|
39
|
-
}
|
|
41
|
+
}
|
package/src/menu.ts
CHANGED
|
@@ -19,7 +19,15 @@ import {
|
|
|
19
19
|
type UpstreamSkill,
|
|
20
20
|
} from "./sync";
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/** Collection changes apply to the tools channel at once; the gateway command and
|
|
23
|
+
* the prompt's skill list come from Pi's resource discovery, which only runs on
|
|
24
|
+
* startup and reload. */
|
|
25
|
+
const RELOAD_HINT = "Skill tools updated; run /reload (or restart pi) to refresh the gateway command.";
|
|
26
|
+
|
|
27
|
+
/** What the menu needs from the extension to keep the tools channel current. */
|
|
28
|
+
export interface SkillRouterHooks {
|
|
29
|
+
refreshTools: () => void;
|
|
30
|
+
}
|
|
23
31
|
|
|
24
32
|
function reportOperationError(ctx: ExtensionCommandContext, label: string, error: unknown): void {
|
|
25
33
|
if (error instanceof Error && error.name === "AbortError") {
|
|
@@ -114,7 +122,7 @@ async function pickSkills(ctx: ExtensionCommandContext, skills: UpstreamSkill[])
|
|
|
114
122
|
return selected.length > 0 ? selected : undefined;
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
async function addFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
125
|
+
async function addFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
|
|
118
126
|
const root = routerRoot();
|
|
119
127
|
const repo = await ctx.ui.input("Add collection", "owner/repo[@ref], GitHub URL, or local path");
|
|
120
128
|
if (!repo) return;
|
|
@@ -166,6 +174,7 @@ async function addFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
166
174
|
`Installing ${repo}...`,
|
|
167
175
|
(signal) => addCollection(root, { repo, id, description, skills: selection, summaries }, signal),
|
|
168
176
|
);
|
|
177
|
+
hooks.refreshTools();
|
|
169
178
|
notifyPi(ctx.ui, `Installed "${result.id}" with ${result.skills.length} skills. ${RELOAD_HINT}`, "info");
|
|
170
179
|
} catch (error) {
|
|
171
180
|
reportOperationError(ctx, "Failed to install collection", error);
|
|
@@ -186,7 +195,7 @@ async function pickCollection(ctx: ExtensionCommandContext, action: string) {
|
|
|
186
195
|
return collections.find((collection) => choice.startsWith(`${collection.id} (`));
|
|
187
196
|
}
|
|
188
197
|
|
|
189
|
-
async function selectionFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
198
|
+
async function selectionFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
|
|
190
199
|
const collection = await pickCollection(ctx, "Change routed skills");
|
|
191
200
|
if (!collection) return;
|
|
192
201
|
try {
|
|
@@ -209,13 +218,14 @@ async function selectionFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
209
218
|
if (selected.size === collection.routes.length) return;
|
|
210
219
|
const result = await runWithLoading(ctx, `Changing ${collection.id} skills...`,
|
|
211
220
|
(signal) => updateCollectionSelection(routerRoot(), collection.id, [...selected], signal));
|
|
221
|
+
hooks.refreshTools();
|
|
212
222
|
notifyPi(ctx.ui, `Updated "${result.id}" with ${result.selected.length} routed skills. ${RELOAD_HINT}`, "info");
|
|
213
223
|
} catch (error) {
|
|
214
224
|
reportOperationError(ctx, "Selection update failed", error);
|
|
215
225
|
}
|
|
216
226
|
}
|
|
217
227
|
|
|
218
|
-
async function descriptionFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
228
|
+
async function descriptionFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
|
|
219
229
|
const collection = await pickCollection(ctx, "Edit capability summary");
|
|
220
230
|
if (!collection) return;
|
|
221
231
|
const description = await ctx.ui.input("Collection capability summary", collection.description);
|
|
@@ -223,13 +233,14 @@ async function descriptionFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
223
233
|
try {
|
|
224
234
|
await runWithLoading(ctx, `Updating ${collection.id} summary...`,
|
|
225
235
|
(signal) => updateCollectionDescription(routerRoot(), collection.id, description, signal));
|
|
236
|
+
hooks.refreshTools();
|
|
226
237
|
notifyPi(ctx.ui, `Updated "${collection.id}" capability summary. ${RELOAD_HINT}`, "info");
|
|
227
238
|
} catch (error) {
|
|
228
239
|
reportOperationError(ctx, "Capability summary update failed", error);
|
|
229
240
|
}
|
|
230
241
|
}
|
|
231
242
|
|
|
232
|
-
async function updateFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
243
|
+
async function updateFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
|
|
233
244
|
const collection = await pickCollection(ctx, "Update collection");
|
|
234
245
|
if (!collection) return;
|
|
235
246
|
try {
|
|
@@ -238,6 +249,7 @@ async function updateFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
238
249
|
`Updating ${collection.id}...`,
|
|
239
250
|
(signal) => updateCollection(routerRoot(), collection.id, signal),
|
|
240
251
|
);
|
|
252
|
+
hooks.refreshTools();
|
|
241
253
|
const notes = [`Updated "${result.id}": ${result.kept.length} skills re-materialized.`];
|
|
242
254
|
if (result.dropped.length > 0) notes.push(`Removed upstream: ${result.dropped.join(", ")}.`);
|
|
243
255
|
if (result.newUpstream.length > 0) notes.push(`New upstream skills not routed: ${result.newUpstream.join(", ")}.`);
|
|
@@ -247,7 +259,7 @@ async function updateFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
247
259
|
}
|
|
248
260
|
}
|
|
249
261
|
|
|
250
|
-
async function removeFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
262
|
+
async function removeFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
|
|
251
263
|
const collection = await pickCollection(ctx, "Remove collection");
|
|
252
264
|
if (!collection) return;
|
|
253
265
|
const confirmed = await ctx.ui.confirm("Remove collection", `Remove "${collection.id}" and its exposed skills? The cached clone is kept.`);
|
|
@@ -255,18 +267,20 @@ async function removeFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
255
267
|
try {
|
|
256
268
|
await runWithLoading(ctx, `Removing ${collection.id}...`,
|
|
257
269
|
(signal) => removeCollection(routerRoot(), collection.id, signal));
|
|
270
|
+
hooks.refreshTools();
|
|
258
271
|
notifyPi(ctx.ui, `Removed "${collection.id}". ${RELOAD_HINT}`, "info");
|
|
259
272
|
} catch (error) {
|
|
260
273
|
reportOperationError(ctx, "Remove failed", error);
|
|
261
274
|
}
|
|
262
275
|
}
|
|
263
276
|
|
|
264
|
-
async function toggleFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
277
|
+
async function toggleFlow(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
|
|
265
278
|
const collection = await pickCollection(ctx, "Enable/disable collection");
|
|
266
279
|
if (!collection) return;
|
|
267
280
|
try {
|
|
268
281
|
const updated = await runWithLoading(ctx, `Updating ${collection.id}...`,
|
|
269
282
|
(signal) => setCollectionEnabled(routerRoot(), collection.id, !collection.enabled, signal));
|
|
283
|
+
hooks.refreshTools();
|
|
270
284
|
notifyPi(ctx.ui, `"${updated.id}" is now ${updated.enabled ? "enabled" : "disabled"}. ${RELOAD_HINT}`, "info");
|
|
271
285
|
} catch (error) {
|
|
272
286
|
reportOperationError(ctx, "Enable/disable failed", error);
|
|
@@ -288,7 +302,7 @@ async function listFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
288
302
|
);
|
|
289
303
|
}
|
|
290
304
|
|
|
291
|
-
export async function showSkillRouterMenu(ctx: ExtensionCommandContext): Promise<void> {
|
|
305
|
+
export async function showSkillRouterMenu(ctx: ExtensionCommandContext, hooks: SkillRouterHooks): Promise<void> {
|
|
292
306
|
for (;;) {
|
|
293
307
|
const action = await ctx.ui.select("Skill Router", [
|
|
294
308
|
"Add collection",
|
|
@@ -300,12 +314,12 @@ export async function showSkillRouterMenu(ctx: ExtensionCommandContext): Promise
|
|
|
300
314
|
"List collections",
|
|
301
315
|
]);
|
|
302
316
|
if (action === undefined) return;
|
|
303
|
-
if (action === "Add collection") await addFlow(ctx);
|
|
304
|
-
else if (action === "Update collection") await updateFlow(ctx);
|
|
305
|
-
else if (action === "Change routed skills") await selectionFlow(ctx);
|
|
306
|
-
else if (action === "Edit capability summary") await descriptionFlow(ctx);
|
|
307
|
-
else if (action === "Remove collection") await removeFlow(ctx);
|
|
308
|
-
else if (action === "Enable/disable collection") await toggleFlow(ctx);
|
|
317
|
+
if (action === "Add collection") await addFlow(ctx, hooks);
|
|
318
|
+
else if (action === "Update collection") await updateFlow(ctx, hooks);
|
|
319
|
+
else if (action === "Change routed skills") await selectionFlow(ctx, hooks);
|
|
320
|
+
else if (action === "Edit capability summary") await descriptionFlow(ctx, hooks);
|
|
321
|
+
else if (action === "Remove collection") await removeFlow(ctx, hooks);
|
|
322
|
+
else if (action === "Enable/disable collection") await toggleFlow(ctx, hooks);
|
|
309
323
|
else await listFlow(ctx);
|
|
310
324
|
}
|
|
311
325
|
}
|
package/src/paths.ts
CHANGED
|
@@ -17,8 +17,12 @@ export function cacheDir(root: string, cacheKey: string): string {
|
|
|
17
17
|
return join(root, "cache", cacheKey);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function exposedCollectionsDir(root: string): string {
|
|
21
|
+
return join(root, "exposed", "collections");
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
export function exposedDir(root: string, collectionId: string): string {
|
|
21
|
-
return join(root,
|
|
25
|
+
return join(exposedCollectionsDir(root), collectionId);
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export function legacyExposedDir(root: string, collectionId: string): string {
|
|
@@ -40,3 +44,19 @@ export function leafSkillDir(root: string, collectionId: string, skillName: stri
|
|
|
40
44
|
export function leafSkillFile(root: string, collectionId: string, skillName: string): string {
|
|
41
45
|
return join(leafSkillDir(root, collectionId, skillName), "SKILL.md");
|
|
42
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Every character a codemode script identifier cannot carry becomes `_`, as it
|
|
49
|
+
* does for MCP tool names, so the registered name and the script identifier agree. */
|
|
50
|
+
export function codemodeIdentifier(value: string): string {
|
|
51
|
+
return value.replace(/[^A-Za-z0-9_]/g, "_");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The search namespace of one collection: the router prefix and the collection. */
|
|
55
|
+
export function collectionNamespaceName(collectionId: string): string {
|
|
56
|
+
return `skill_router__${codemodeIdentifier(collectionId)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The tool name standing for one leaf skill: the namespace and the skill. */
|
|
60
|
+
export function skillToolName(collectionId: string, skillName: string): string {
|
|
61
|
+
return `${collectionNamespaceName(collectionId)}__${codemodeIdentifier(skillName)}`;
|
|
62
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tools channel: one codemode-callable tool per routed leaf skill.
|
|
3
|
+
*
|
|
4
|
+
* A leaf skill is prose, so the tool exists to be found and called: its name and
|
|
5
|
+
* description are the skill's searchable identity, its namespace is the
|
|
6
|
+
* collection, and its call returns the wrapped instructions with the directory
|
|
7
|
+
* that resolves the skill's references and scripts.
|
|
8
|
+
*
|
|
9
|
+
* Registration reads the registry once, at extension load. A call re-resolves the
|
|
10
|
+
* collection, so a tool whose collection was removed fails instead of serving a
|
|
11
|
+
* path captured at registration. Nothing here activates codemode: a session that
|
|
12
|
+
* does not declare it keeps the routing-suggestion fallback.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, type Stats } from "node:fs";
|
|
16
|
+
import { join, sep } from "node:path";
|
|
17
|
+
import { Type, type Static } from "typebox";
|
|
18
|
+
import { defineTool, keyHint, type AgentToolResult, type ExtensionAPI, type ToolNamespace } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
20
|
+
import { bindLifecycleRenderers, emptyToolCall } from "@fradser/pi-kit";
|
|
21
|
+
import { cacheDir, collectionNamespaceName, exposedCollectionsDir, gatewayDir, leafSkillDir, leafSkillFile, skillToolName } from "./paths";
|
|
22
|
+
import { loadCollections, type RegistryCollection, type RegistryRoute } from "./registry";
|
|
23
|
+
import { scanSkills } from "./sync";
|
|
24
|
+
|
|
25
|
+
/** Instructions one call returns. A longer file is cut at this bound, and the
|
|
26
|
+
* returned body names the file that keeps all of it. */
|
|
27
|
+
const MAX_SKILL_BODY_CHARS = 20000;
|
|
28
|
+
|
|
29
|
+
const TOOL_TAG = "skill-router";
|
|
30
|
+
const CODEMODE_TOOL_NAME = "codemode";
|
|
31
|
+
/** `O_NOFOLLOW` is absent on platforms that cannot express it, where it contributes nothing. */
|
|
32
|
+
const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
33
|
+
|
|
34
|
+
const SKILL_TOOL_PARAMS = Type.Object({}, { additionalProperties: false });
|
|
35
|
+
|
|
36
|
+
const SKILL_TOOL_OUTPUT = Type.Object({
|
|
37
|
+
ok: Type.Boolean(),
|
|
38
|
+
collection: Type.String(),
|
|
39
|
+
skill: Type.String(),
|
|
40
|
+
tool: Type.String(),
|
|
41
|
+
directory: Type.Optional(Type.String()),
|
|
42
|
+
skillFile: Type.Optional(Type.String()),
|
|
43
|
+
body: Type.Optional(Type.String()),
|
|
44
|
+
truncated: Type.Optional(Type.Boolean()),
|
|
45
|
+
error: Type.Optional(Type.String()),
|
|
46
|
+
}, { additionalProperties: false });
|
|
47
|
+
|
|
48
|
+
type SkillToolOutput = Static<typeof SKILL_TOOL_OUTPUT>;
|
|
49
|
+
|
|
50
|
+
/** What the transcript row reads back from a completed call. */
|
|
51
|
+
interface SkillToolDetails {
|
|
52
|
+
subject?: string;
|
|
53
|
+
summary?: string;
|
|
54
|
+
label?: string;
|
|
55
|
+
details?: readonly string[];
|
|
56
|
+
tone?: "success" | "warning" | "error";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type SkillToolResult = AgentToolResult<SkillToolDetails> & { structuredContent: SkillToolOutput };
|
|
60
|
+
|
|
61
|
+
/** A script that searches: `reachesTools` must answer it for every tool a script
|
|
62
|
+
* can call, so what it names is what the session can actually reach. */
|
|
63
|
+
const DISCOVERY_SCRIPT = { code: "await searchTools('')" };
|
|
64
|
+
|
|
65
|
+
/** Whether one skill's tool is reachable from a codemode script: codemode is
|
|
66
|
+
* active, answers a discovery script by naming that exact tool, and the tool
|
|
67
|
+
* survived the session's tool allowlist. A session allowlist (`--tools`,
|
|
68
|
+
* `--exclude-tools`) filters an extension tool out of the registry entirely, so
|
|
69
|
+
* codemode can be active while every call fails. The check is per skill, not per
|
|
70
|
+
* collection: a surviving sibling tool must not stand in for the one the prompt
|
|
71
|
+
* matched, and a collection added after load has no tool at all until a refresh.
|
|
72
|
+
*
|
|
73
|
+
* A `codemode` that declares `reachesTools` but names nothing keeps the fallback:
|
|
74
|
+
* Pi's contract lets it answer per script, and the suggestion is then the only
|
|
75
|
+
* working path. */
|
|
76
|
+
export function codemodeReachesSkillTool(
|
|
77
|
+
pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">,
|
|
78
|
+
toolName: string,
|
|
79
|
+
): boolean {
|
|
80
|
+
if (!pi.getActiveTools().includes(CODEMODE_TOOL_NAME)) return false;
|
|
81
|
+
const tools = pi.getAllTools();
|
|
82
|
+
const codemode = tools.find((tool) => tool.name === CODEMODE_TOOL_NAME);
|
|
83
|
+
if (typeof codemode?.reachesTools !== "function") return false;
|
|
84
|
+
if (!tools.some((tool) => tool.name === toolName)) return false;
|
|
85
|
+
// A word refers to every tool or namespace whose name contains it.
|
|
86
|
+
let reach: "all" | readonly string[] | undefined;
|
|
87
|
+
try {
|
|
88
|
+
reach = codemode.reachesTools(DISCOVERY_SCRIPT);
|
|
89
|
+
} catch {
|
|
90
|
+
// A `codemode` that cannot answer keeps the fallback: this is the conservative
|
|
91
|
+
// direction, and an exception must not cost the turn its only working path.
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return reach === "all" || (Array.isArray(reach) && reach.some((word) => typeof word === "string" && toolName.includes(word)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Skill tools one loaded extension instance has registered. Pi cannot
|
|
98
|
+
* unregister a tool, so a refresh withdraws the names a registry change removed
|
|
99
|
+
* by re-registering them hidden. */
|
|
100
|
+
const registeredSkillTools = new WeakMap<ExtensionAPI, string[]>();
|
|
101
|
+
|
|
102
|
+
/** Register every routed leaf skill of every enabled collection, and withdraw the
|
|
103
|
+
* tools of a collection that is gone or disabled since the last refresh. The
|
|
104
|
+
* extension calls this when it loads and after every `/skill-router` mutation, so
|
|
105
|
+
* the tools channel follows the registry without a reload. */
|
|
106
|
+
export function refreshSkillTools(pi: ExtensionAPI, root: string): void {
|
|
107
|
+
const names = new Set<string>();
|
|
108
|
+
const definitions = loadCollections(root)
|
|
109
|
+
.filter((collection) => collection.enabled)
|
|
110
|
+
.flatMap((collection) => {
|
|
111
|
+
const namespace = collectionNamespace(root, collection);
|
|
112
|
+
const upstream = upstreamDescriptions(root, collection);
|
|
113
|
+
return collection.routes.map((route) => {
|
|
114
|
+
const tool = skillTool(root, collection, route, searchableSkillText(route, upstream.get(route.skill) ?? ""), namespace);
|
|
115
|
+
names.add(tool.name);
|
|
116
|
+
return tool;
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
for (const tool of definitions) pi.registerTool(tool);
|
|
121
|
+
for (const name of registeredSkillTools.get(pi) ?? []) {
|
|
122
|
+
if (!names.has(name)) pi.registerTool(withdrawnSkillTool(name));
|
|
123
|
+
}
|
|
124
|
+
registeredSkillTools.set(pi, [...names]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A withdrawn skill tool: unreachable, and kept only because Pi cannot
|
|
128
|
+
* unregister a name. */
|
|
129
|
+
function withdrawnSkillTool(name: string) {
|
|
130
|
+
return defineTool<typeof SKILL_TOOL_PARAMS, SkillToolDetails>({
|
|
131
|
+
name,
|
|
132
|
+
label: name,
|
|
133
|
+
description: "Withdrawn skill tool.",
|
|
134
|
+
parameters: SKILL_TOOL_PARAMS,
|
|
135
|
+
outputSchema: SKILL_TOOL_OUTPUT,
|
|
136
|
+
exposure: "hidden",
|
|
137
|
+
renderShell: "self",
|
|
138
|
+
renderCall: () => emptyToolCall(),
|
|
139
|
+
renderResult: skillResultBand,
|
|
140
|
+
async execute(): Promise<SkillToolResult> {
|
|
141
|
+
return refuse(name, "", "", `Skill tool "${name}" is withdrawn: its collection is no longer routed.`);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Read a regular file, refusing a symlink that appears after the caller's own
|
|
147
|
+
* checks and before this open. */
|
|
148
|
+
function readRegularFile(path: string, validated: Stats): string {
|
|
149
|
+
const descriptor = openSync(path, constants.O_RDONLY | NO_FOLLOW);
|
|
150
|
+
try {
|
|
151
|
+
const opened = fstatSync(descriptor);
|
|
152
|
+
if (!opened.isFile()) throw new Error(`Not a regular file: ${path}`);
|
|
153
|
+
// The descriptor must be the very file that was validated: a directory swapped
|
|
154
|
+
// between the checks and this open would otherwise redirect the read.
|
|
155
|
+
if (opened.dev !== validated.dev || opened.ino !== validated.ino) {
|
|
156
|
+
throw new Error(`File changed between validation and read: ${path}`);
|
|
157
|
+
}
|
|
158
|
+
return readFileSync(descriptor, "utf8");
|
|
159
|
+
} finally {
|
|
160
|
+
closeSync(descriptor);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The searchable text of one route: the workflow summary and the skill author's
|
|
165
|
+
* own description, without repeating one inside the other. The summary judges
|
|
166
|
+
* relevance; the upstream description carries the words a request uses. */
|
|
167
|
+
function searchableSkillText(route: RegistryRoute, upstreamDescription: string): string {
|
|
168
|
+
const oneLine = (value: string) => value.replace(/\s+/g, " ").trim();
|
|
169
|
+
const summary = oneLine(route.summary ?? "");
|
|
170
|
+
const upstream = oneLine(upstreamDescription);
|
|
171
|
+
if (!upstream || upstream.startsWith(summary.replace(/\.$/, ""))) return upstream || summary;
|
|
172
|
+
return summary ? `${summary} ${upstream}` : upstream;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One namespace per collection: its capability summary and gateway index answer
|
|
176
|
+
* "what is in this collection" through one lookup. */
|
|
177
|
+
function collectionNamespace(root: string, collection: RegistryCollection): ToolNamespace {
|
|
178
|
+
const gateway = managedFile(root, join(gatewayDir(root, collection.id, collection.gateway), "SKILL.md"));
|
|
179
|
+
return {
|
|
180
|
+
name: collectionNamespaceName(collection.id),
|
|
181
|
+
aliases: [collection.id],
|
|
182
|
+
description: collection.description,
|
|
183
|
+
...(gateway === undefined ? {} : { instructions: gateway }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Upstream descriptions by skill name, from the collection's clone. A missing or
|
|
188
|
+
* unreadable clone leaves the workflow summaries as the searchable text. */
|
|
189
|
+
function upstreamDescriptions(root: string, collection: RegistryCollection): Map<string, string> {
|
|
190
|
+
try {
|
|
191
|
+
const skills = scanSkills(cacheDir(root, collection.source.cacheKey));
|
|
192
|
+
return new Map(skills.map((skill) => [skill.name, skill.description]));
|
|
193
|
+
} catch {
|
|
194
|
+
return new Map();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function skillTool(
|
|
199
|
+
root: string,
|
|
200
|
+
collection: RegistryCollection,
|
|
201
|
+
route: RegistryRoute,
|
|
202
|
+
searchable: string,
|
|
203
|
+
namespace: ToolNamespace,
|
|
204
|
+
) {
|
|
205
|
+
const name = skillToolName(collection.id, route.skill);
|
|
206
|
+
const subject = `${collection.id}/${route.skill}`;
|
|
207
|
+
return defineTool<typeof SKILL_TOOL_PARAMS, SkillToolDetails>({
|
|
208
|
+
name,
|
|
209
|
+
label: route.skill,
|
|
210
|
+
description: searchable,
|
|
211
|
+
promptGuidelines: [
|
|
212
|
+
`Loads the \`${route.skill}\` workflow of the \`${collection.id}\` skill collection. Follow the returned instructions and use the returned directory for its files.`,
|
|
213
|
+
"Call the workflow these instructions load; do not act on the collection's other workflows.",
|
|
214
|
+
],
|
|
215
|
+
parameters: SKILL_TOOL_PARAMS,
|
|
216
|
+
outputSchema: SKILL_TOOL_OUTPUT,
|
|
217
|
+
exposure: "codemode",
|
|
218
|
+
namespace,
|
|
219
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
220
|
+
renderShell: "self",
|
|
221
|
+
renderCall: () => emptyToolCall(),
|
|
222
|
+
renderResult: skillResultBand,
|
|
223
|
+
async execute(_toolCallId, _params, signal): Promise<SkillToolResult> {
|
|
224
|
+
signal?.throwIfAborted();
|
|
225
|
+
const current = loadCollections(root).find((entry) => entry.id === collection.id && entry.enabled);
|
|
226
|
+
const currentRoute = current?.routes.find((candidate) => candidate.skill === route.skill);
|
|
227
|
+
if (!current || !currentRoute) {
|
|
228
|
+
return refuse(
|
|
229
|
+
name,
|
|
230
|
+
collection.id,
|
|
231
|
+
route.skill,
|
|
232
|
+
`Collection "${collection.id}" is no longer installed and enabled, so "${route.skill}" is unavailable. Run /reload after changing collections.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const leaf = readLeafSkill(root, current, currentRoute);
|
|
237
|
+
const { body, truncated } = boundSkillBody(leaf.content, leaf.skillFile);
|
|
238
|
+
return {
|
|
239
|
+
content: [{ type: "text", text: body }],
|
|
240
|
+
details: {
|
|
241
|
+
subject,
|
|
242
|
+
label: "loaded",
|
|
243
|
+
details: [leaf.skillFile],
|
|
244
|
+
...(truncated ? { summary: `The body is cut at ${MAX_SKILL_BODY_CHARS} characters; the file above holds all of it.` } : {}),
|
|
245
|
+
},
|
|
246
|
+
structuredContent: {
|
|
247
|
+
ok: true,
|
|
248
|
+
collection: collection.id,
|
|
249
|
+
skill: route.skill,
|
|
250
|
+
tool: name,
|
|
251
|
+
directory: leaf.directory,
|
|
252
|
+
skillFile: leaf.skillFile,
|
|
253
|
+
body,
|
|
254
|
+
truncated,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return refuse(name, collection.id, route.skill, error instanceof Error ? error.message : String(error));
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function refuse(tool: string, collection: string, skill: string, error: string): SkillToolResult {
|
|
265
|
+
return {
|
|
266
|
+
content: [{ type: "text", text: `REFUSED · ${error}` }],
|
|
267
|
+
details: { subject: `${collection}/${skill}`, label: "refused", summary: error, tone: "error" },
|
|
268
|
+
structuredContent: { ok: false, collection, skill, tool, error },
|
|
269
|
+
isError: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface LeafSkill {
|
|
274
|
+
directory: string;
|
|
275
|
+
skillFile: string;
|
|
276
|
+
content: string;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Read one materialized leaf skill, refusing anything that is not a regular file
|
|
280
|
+
* inside the router's exposed collection tree. Containment is anchored on the
|
|
281
|
+
* router root, because a symlinked `exposed` would otherwise move the base that
|
|
282
|
+
* the leaf's own resolution is compared against. */
|
|
283
|
+
function readLeafSkill(root: string, collection: RegistryCollection, route: RegistryRoute): LeafSkill {
|
|
284
|
+
const exposedTree = join(root, "exposed");
|
|
285
|
+
const exposed = exposedCollectionsDir(root);
|
|
286
|
+
if (!isRealDirectory(exposedTree) || !isRealDirectory(exposed)) {
|
|
287
|
+
throw new Error(`Skill Router's exposed collections are unavailable at ${exposed}`);
|
|
288
|
+
}
|
|
289
|
+
const directory = leafSkillDir(root, collection.id, route.skill);
|
|
290
|
+
if (!isRealDirectory(directory)) {
|
|
291
|
+
throw new Error(`Skill "${route.skill}" of collection "${collection.id}" is not materialized at ${directory}`);
|
|
292
|
+
}
|
|
293
|
+
const skillFile = leafSkillFile(root, collection.id, route.skill);
|
|
294
|
+
let stats: Stats;
|
|
295
|
+
try {
|
|
296
|
+
stats = lstatSync(skillFile);
|
|
297
|
+
} catch {
|
|
298
|
+
throw new Error(`Skill "${route.skill}" of collection "${collection.id}" has no instruction file at ${skillFile}`);
|
|
299
|
+
}
|
|
300
|
+
if (!stats.isFile()) {
|
|
301
|
+
throw new Error(`Skill "${route.skill}" of collection "${collection.id}" has an unusable instruction file at ${skillFile}`);
|
|
302
|
+
}
|
|
303
|
+
const resolved = realpathSync(skillFile);
|
|
304
|
+
if (!resolved.startsWith(realpathSync(exposed) + sep) || !resolved.startsWith(realpathSync(root) + sep)) {
|
|
305
|
+
throw new Error(`Skill "${route.skill}" of collection "${collection.id}" resolves outside the exposed collections`);
|
|
306
|
+
}
|
|
307
|
+
return { directory, skillFile, content: readRegularFile(skillFile, stats) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Cut a body at the bound and name the file that keeps the rest. */
|
|
311
|
+
function boundSkillBody(content: string, skillFile: string): { body: string; truncated: boolean } {
|
|
312
|
+
if (content.length <= MAX_SKILL_BODY_CHARS) return { body: content, truncated: false };
|
|
313
|
+
let end = MAX_SKILL_BODY_CHARS;
|
|
314
|
+
// Never split a UTF-16 surrogate pair at the cut.
|
|
315
|
+
if (/[\uD800-\uDBFF]/.test(content[end - 1] ?? "")) end -= 1;
|
|
316
|
+
const omitted = content.length - end;
|
|
317
|
+
return { body: `${content.slice(0, end)}\n\n…${omitted} chars truncated…\nComplete instructions: ${skillFile}`, truncated: true };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isRealDirectory(path: string): boolean {
|
|
321
|
+
try {
|
|
322
|
+
const stats = lstatSync(path);
|
|
323
|
+
return stats.isDirectory() && !stats.isSymbolicLink();
|
|
324
|
+
} catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Read a managed file only when it is a regular file inside the router root. */
|
|
330
|
+
function managedFile(root: string, path: string): string | undefined {
|
|
331
|
+
try {
|
|
332
|
+
const stats = lstatSync(path);
|
|
333
|
+
if (!stats.isFile()) return undefined;
|
|
334
|
+
if (!realpathSync(path).startsWith(realpathSync(root) + sep)) return undefined;
|
|
335
|
+
return readRegularFile(path, stats);
|
|
336
|
+
} catch {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** One lifecycle geometry for every skill row, owned by pi-kit. */
|
|
342
|
+
const rows = bindLifecycleRenderers({
|
|
343
|
+
fit: truncateToWidth,
|
|
344
|
+
visibleWidth,
|
|
345
|
+
wrapDetail: (line, width) => wrapTextWithAnsi(line, Math.max(1, width)),
|
|
346
|
+
expandHint: () => keyHint("app.tools.expand", "to expand"),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const skillResultBand = rows.result<AgentToolResult<SkillToolDetails>>((value) => {
|
|
350
|
+
const details = (value.details ?? {}) as SkillToolDetails;
|
|
351
|
+
return {
|
|
352
|
+
tool: TOOL_TAG,
|
|
353
|
+
kind: "event" as const,
|
|
354
|
+
label: details.label,
|
|
355
|
+
subject: details.subject ?? "",
|
|
356
|
+
...(details.summary ? { summary: [details.summary] } : {}),
|
|
357
|
+
...(details.details ? { details: details.details } : {}),
|
|
358
|
+
...(details.tone ? { tone: details.tone } : {}),
|
|
359
|
+
};
|
|
360
|
+
});
|
package/src/sync.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "node:fs";
|
|
18
18
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
19
19
|
import { setTimeout as delay } from "node:timers/promises";
|
|
20
|
-
import { cacheDir, exposedDir, gatewayDir, leafSkillFile, legacyExposedDir } from "./paths";
|
|
20
|
+
import { cacheDir, exposedDir, gatewayDir, leafSkillFile, legacyExposedDir, skillToolName } from "./paths";
|
|
21
21
|
import { isCollectionId, isSafeGitRef, isSlug, loadCollections, saveCollections, type RegistryCollection, type RegistryRoute } from "./registry";
|
|
22
22
|
|
|
23
23
|
export interface RepoSpec {
|
|
@@ -343,7 +343,7 @@ function gatewayContent(root: string, collection: RegistryCollection, leaves: Up
|
|
|
343
343
|
const gateway = gatewayDir(root, collection.id, collection.gateway);
|
|
344
344
|
const workflows = leaves.map((skill) => [
|
|
345
345
|
`- \`${skill.name}\` — ${collection.routes.find((route) => route.skill === skill.name)?.summary ?? fallbackWorkflowSummary(skill.description)}`,
|
|
346
|
-
`
|
|
346
|
+
` Tool \`${skillToolName(collection.id, skill.name)}\`, file \`${relative(gateway, leafSkillFile(root, collection.id, skill.name))}\`.`,
|
|
347
347
|
]).flat();
|
|
348
348
|
|
|
349
349
|
return [
|
|
@@ -354,7 +354,7 @@ function gatewayContent(root: string, collection: RegistryCollection, leaves: Up
|
|
|
354
354
|
"",
|
|
355
355
|
"## Available workflows",
|
|
356
356
|
"",
|
|
357
|
-
"Select the one workflow that best fits the user's request,
|
|
357
|
+
"Select the one workflow that best fits the user's request, call its tool, and follow the returned instructions. The listed file holds the same instructions and resolves the workflow's own files.",
|
|
358
358
|
"",
|
|
359
359
|
...workflows,
|
|
360
360
|
"",
|