import { readFile } from "node:fs/promises"; import { dirname } from "node:path"; import { Type } from "typebox"; import { truncateHead, type ExtensionAPI, type ResolvedSkillInfo } from "@selesai/code"; import type { Skill } from "../../core/skills.ts"; export const SKILLS_INDEX_INSTRUCTION = "Skills are reusable procedures. Use skill_show without a name to list them, or pass query to filter names and descriptions. Pass an exact listed name to load its instructions before applying it. References in a loaded skill are relative to its directory. Tool discovery is separate: use tool_search or codemode searchTools()."; /** The host owns trust and settings filtering. Never scan additional directories. */ export function eligibleSkills(skills: readonly ResolvedSkillInfo[]): ResolvedSkillInfo[] { const seen = new Set(); return skills.filter((skill) => { if (skill.disableModelInvocation || seen.has(skill.name)) return false; seen.add(skill.name); return true; }); } /** An excluded show tool must not strand the model behind an unusable synthetic index. */ export function compactSkillIndex(skills: Skill[], showAvailable: boolean): Skill[] { if (!showAvailable || skills.length === 0) return skills; return [{ name: "skills-index", description: SKILLS_INDEX_INSTRUCTION, filePath: "", baseDir: "", sourceInfo: { path: "", source: "builtin", scope: "user", origin: "top-level" }, disableModelInvocation: false, }]; } export interface SkillShowArgs { name?: string; query?: string; limit?: number; } function escapeAttribute(value: string): string { return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } export type SkillShowDetails = | { loaded: true; complete: boolean; skill: string; filePath: string } | { loaded: false; entries: Array<{ name: string; description: string }>; total: number; truncated: boolean }; export interface SkillShowResult { content: Array<{ type: "text"; text: string }>; details: SkillShowDetails; } /** List metadata or load one exact resolved skill; no classification or tool activation. */ export async function showSkill(skills: readonly ResolvedSkillInfo[], args: SkillShowArgs, signal?: AbortSignal): Promise { signal?.throwIfAborted(); const eligible = eligibleSkills(skills); if (args.name !== undefined) { const skill = eligible.find((entry) => entry.name === args.name); if (!skill) throw new Error(`Unknown or model-ineligible skill "${args.name}". Use skill_show without a name to list eligible skills.`); const body = await readFile(skill.filePath, { encoding: "utf8", signal }); const output = truncateHead(body); const guidance = output.truncated ? `Instructions are incomplete (50KB/2000-line limit). Read the remaining instructions from ${skill.filePath} before applying this skill.` : "The full instructions are embedded below; do not read the file again."; return { content: [{ type: "text" as const, text: `\n${guidance}\nReferences are relative to ${dirname(skill.filePath)}.\n\n${output.content}\n` }], details: { loaded: true, complete: !output.truncated, skill: skill.name, filePath: skill.filePath }, }; } const terms = (args.query ?? "").toLocaleLowerCase().split(/\s+/u).filter(Boolean); const matches = eligible.filter((skill) => { const text = `${skill.name} ${skill.description}`.toLocaleLowerCase(); return terms.every((term) => text.includes(term)); }); const limit = Math.max(1, Math.min(50, Math.floor(args.limit ?? 20))); const entries = matches.slice(0, limit).map(({ name, description }) => ({ name, description })); const listing = entries.map((entry) => `${entry.name} — ${entry.description}`).join("\n"); const output = truncateHead(listing); return { content: [{ type: "text" as const, text: matches.length === 0 ? "No eligible skills match." : `${output.content}\n\n${entries.length} of ${matches.length} matches. Pass an exact name to load instructions.${matches.length > entries.length || output.truncated ? " Narrow query to see remaining matches." : ""}` }], details: { loaded: false, entries, total: matches.length, truncated: output.truncated || matches.length > entries.length }, }; } export default function skillsIndexExtension(pi: ExtensionAPI): void { pi.registerTool({ name: "skill_show", label: "Skills", exposure: "direct", description: "List or filter the resolved skill index, or load one skill's instructions by exact name. Omit name to list; query filters names/descriptions using all supplied words. Output is limited to 50KB or 2000 lines; incomplete instructions must be read from the returned file path before use. Does not discover or activate tools.", promptSnippet: "List reusable procedures or load one skill's instructions", promptGuidelines: ["Use skill_show for the skills-index entry; is a synthetic index, not a file to read."], parameters: Type.Object({ name: Type.Optional(Type.String({ minLength: 1, description: "Exact listed skill name to load." })), query: Type.Optional(Type.String({ minLength: 1, description: "Filter names/descriptions when listing." })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50, description: "Maximum listed matches (default 20)." })), }), execute: async (_id, args, signal) => showSkill(pi.getResolvedSkills(), args, signal), }); // Runtime APIs are bound at session_start. The callback reads the current loadout, // so later explicit exclusions restore the ordinary index rather than hide skills. pi.on("session_start", () => { pi.setSkillsIndexFilter((skills) => compactSkillIndex(skills, pi.getActiveTools().includes("skill_show"))); }); }