Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
20 kB · 676 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677import { getLogger } from "@earendil-works/pi-ai";import { existsSync, readdirSync, readFileSync, statSync } from "fs";import ignore from "ignore";import { homedir } from "os";import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";import { CONFIG_DIR_NAME, getAgentDir, getBundledSkillsDir } from "../config.js";import { parseFrontmatter } from "../utils/frontmatter.js";import { canonicalizePath } from "../utils/paths.js";import type { ResourceDiagnostic } from "./diagnostics.js";import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
const log = getLogger("coding-agent.skills");
/** Max name length per spec */const MAX_NAME_LENGTH = 64;
/** Max description length per spec */const MAX_DESCRIPTION_LENGTH = 1024;
/** A call signature in a description restates an api the generated skill surface already publishes. */const DESCRIPTION_CALL_SIGNATURE_PATTERNS = [/\w+\([^)]*[=,][^)]*\)/, /\bawait\s+\w+\s*\(/];
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
type IgnoreMatcher = ReturnType<typeof ignore>;
function toPosixPath(p: string): string { return p.split(sep).join("/");}
function prefixIgnorePattern(line: string, prefix: string): string | null { const trimmed = line.trim(); if (!trimmed) return null; if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null;
let pattern = line; let negated = false;
if (pattern.startsWith("!")) { negated = true; pattern = pattern.slice(1); } else if (pattern.startsWith("\\!")) { pattern = pattern.slice(1); }
if (pattern.startsWith("/")) { pattern = pattern.slice(1); }
const prefixed = prefix ? `${prefix}${pattern}` : pattern; return negated ? `!${prefixed}` : prefixed;}
function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void { const relativeDir = relative(rootDir, dir); const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : "";
for (const filename of IGNORE_FILE_NAMES) { const ignorePath = join(dir, filename); if (!existsSync(ignorePath)) continue; try { const content = readFileSync(ignorePath, "utf-8"); const patterns = content .split(/\r?\n/) .map((line) => prefixIgnorePattern(line, prefix)) .filter((line): line is string => Boolean(line)); if (patterns.length > 0) { ig.add(patterns); } } catch { // Unreadable ignore file: skip it rather than failing skill discovery. } }}
export interface SkillFrontmatter { name?: string; description?: string; "disable-model-invocation"?: boolean; [key: string]: unknown;}
export type SkillKind = "markdown" | "python";
export interface SkillPythonMetadata { importName: string; packagePath: string; pyprojectPath: string;}
interface BaseSkill { name: string; description: string; filePath: string; baseDir: string; sourceInfo: SourceInfo; disableModelInvocation: boolean;}
export interface MarkdownSkill extends BaseSkill { kind: "markdown"; python?: undefined;}
export interface PythonSkill extends BaseSkill { kind: "python"; python: SkillPythonMetadata;}
export type Skill = MarkdownSkill | PythonSkill;
export interface PythonSkillRuntimeInfo extends SkillPythonMetadata { name: string;}
export interface LoadSkillsResult { skills: Skill[]; diagnostics: ResourceDiagnostic[];}
/** * Validate skill name per Agent Skills spec. * Returns array of validation error messages (empty if valid). */function validateName(name: string): string[] { const errors: string[] = [];
if (name.length > MAX_NAME_LENGTH) { errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); }
if (!/^[a-z0-9-]+$/.test(name)) { errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`); }
if (name.startsWith("-") || name.endsWith("-")) { errors.push(`name must not start or end with a hyphen`); }
if (name.includes("--")) { errors.push(`name must not contain consecutive hyphens`); }
return errors;}
/** * Validate description per Agent Skills spec. */function validateDescription(description: string | undefined): string[] { const errors: string[] = [];
if (!description || description.trim() === "") { errors.push("description is required"); } else if (description.length > MAX_DESCRIPTION_LENGTH) { errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); }
return errors;}
/** Skill identity is the directory name; a disagreeing frontmatter name is reported, never used. */function validateNames(name: string, declaredName: string): string[] { if (!declaredName || declaredName === name) { return validateName(name); } return [ `name "${declaredName}" does not match parent directory "${name}"`, ...validateName(name), ...validateName(declaredName), ];}
function foldSkillName(value: string): string { return value.toLowerCase().replaceAll(/[^a-z0-9]+/g, "");}
/** * A body opens with a heading that names this skill, so a copied or renamed body is visible. * The comparison folds case and separators (headings here are `# Title Case`) and accepts a * qualifier after the name, as in `# Web Search (web-extract / Kagi)`. */function titleDiagnostic(body: string, name: string): string | null { const firstLine = body.split("\n").find((line) => line.trim() !== ""); if (firstLine === undefined) { return null; } const heading = /^#\s+(.+)$/.exec(firstLine.trim()); if (!heading) { return `body must start with a "# ${name}" heading; the first non-empty line is not a heading`; } if (!foldSkillName(heading[1]).startsWith(foldSkillName(name))) { return `body heading "# ${heading[1].trim()}" does not name this skill (expected "# ${name}")`; } return null;}
function descriptionSignatureDiagnostic(description: string): string | null { for (const pattern of DESCRIPTION_CALL_SIGNATURE_PATTERNS) { const match = pattern.exec(description); if (match) { return `description contains a call signature (${match[0]}); signatures belong in the generated skill surface`; } } return null;}
export interface LoadSkillsFromDirOptions { /** Directory to scan for skills */ dir: string; /** Source identifier for these skills */ source: string;}
function createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo { switch (source) { case "user": return createSyntheticSourceInfo(filePath, { source: "local", scope: "user", baseDir, }); case "project": return createSyntheticSourceInfo(filePath, { source: "local", scope: "project", baseDir, }); case "path": return createSyntheticSourceInfo(filePath, { source: "local", baseDir, }); default: return createSyntheticSourceInfo(filePath, { source, baseDir }); }}
function pythonImportNameForSkill(name: string): string { return name.replaceAll("-", "_");}
function isValidPythonImportName(name: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);}
function detectPythonSkill( skillDir: string, name: string, diagnostics: ResourceDiagnostic[],): SkillPythonMetadata | null { const pyprojectPath = join(skillDir, "pyproject.toml"); if (!existsSync(pyprojectPath)) { return null; }
try { if (!statSync(pyprojectPath).isFile()) { return null; } } catch { return null; }
const importName = pythonImportNameForSkill(name); if (!isValidPythonImportName(importName)) { diagnostics.push({ type: "warning", message: `python skill import name "${importName}" is invalid`, path: pyprojectPath, }); return null; }
const packageInitPath = join(skillDir, "src", importName, "__init__.py"); try { if (!statSync(packageInitPath).isFile()) { diagnostics.push({ type: "warning", message: `python skill package src/${importName}/__init__.py not found`, path: pyprojectPath, }); return null; } } catch { diagnostics.push({ type: "warning", message: `python skill package src/${importName}/__init__.py not found`, path: pyprojectPath, }); return null; }
return { importName, packagePath: skillDir, pyprojectPath, };}
export function getPythonSkillRuntimeInfo(skills: readonly Skill[]): PythonSkillRuntimeInfo[] { return skills .filter((skill): skill is PythonSkill => skill.kind === "python") .map((skill) => ({ name: skill.name, importName: skill.python.importName, packagePath: skill.python.packagePath, pyprojectPath: skill.python.pyprojectPath, }));}
export function loadBundledPythonSkills(): PythonSkillRuntimeInfo[] { const dir = getBundledSkillsDir(); if (!existsSync(dir)) { return []; } const result = loadSkillsFromDir({ dir, source: "builtin" }); return getPythonSkillRuntimeInfo(result.skills);}
/** * Load skills from a directory. * * Discovery rules: * - if a directory contains SKILL.md, treat it as a skill root and do not recurse further * - otherwise, load direct .md children in the root * - recurse into subdirectories to find SKILL.md */export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult { const { dir, source } = options; return loadSkillsFromDirInternal(dir, source, true);}
function loadSkillsFromDirInternal( dir: string, source: string, includeRootFiles: boolean, ignoreMatcher?: IgnoreMatcher, rootDir?: string,): LoadSkillsResult { const skills: Skill[] = []; const diagnostics: ResourceDiagnostic[] = [];
if (!existsSync(dir)) { return { skills, diagnostics }; }
const root = rootDir ?? dir; const ig = ignoreMatcher ?? ignore(); addIgnoreRules(ig, dir, root);
try { const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) { if (entry.name !== "SKILL.md") { continue; }
const fullPath = join(dir, entry.name);
let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { isFile = statSync(fullPath).isFile(); } catch { continue; } }
const relPath = toPosixPath(relative(root, fullPath)); if (!isFile || ig.ignores(relPath)) { continue; }
const result = loadSkillFromFile(fullPath, source); if (result.skill) { skills.push(result.skill); } diagnostics.push(...result.diagnostics); return { skills, diagnostics }; }
for (const entry of entries) { if (entry.name.startsWith(".")) { continue; }
if (entry.name === "node_modules") { continue; }
const fullPath = join(dir, entry.name);
let isDirectory = entry.isDirectory(); let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { const stats = statSync(fullPath); isDirectory = stats.isDirectory(); isFile = stats.isFile(); } catch { continue; } }
const relPath = toPosixPath(relative(root, fullPath)); const ignorePath = isDirectory ? `${relPath}/` : relPath; if (ig.ignores(ignorePath)) { continue; }
if (isDirectory) { const subResult = loadSkillsFromDirInternal(fullPath, source, false, ig, root); skills.push(...subResult.skills); diagnostics.push(...subResult.diagnostics); continue; }
if (!isFile || !includeRootFiles || !entry.name.endsWith(".md")) { continue; }
const result = loadSkillFromFile(fullPath, source); if (result.skill) { skills.push(result.skill); } diagnostics.push(...result.diagnostics); } } catch (error) { log.warn("skill directory scan failed", { dir, error: error instanceof Error ? error.message : String(error), }); }
return { skills, diagnostics };}
function loadSkillFromFile( filePath: string, source: string,): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } { const diagnostics: ResourceDiagnostic[] = [];
try { const rawContent = readFileSync(filePath, "utf-8"); const { frontmatter, body } = parseFrontmatter<SkillFrontmatter>(rawContent); const skillDir = dirname(filePath); const name = basename(skillDir); const declaredName = typeof frontmatter.name === "string" ? frontmatter.name : "";
const problems = [ ...validateDescription(frontmatter.description), ...validateNames(name, declaredName), ...(frontmatter.description ? [descriptionSignatureDiagnostic(frontmatter.description)] : []), titleDiagnostic(body, name), ].filter((problem): problem is string => problem !== null); for (const problem of problems) { diagnostics.push({ type: "warning", message: problem, path: filePath }); }
if (!frontmatter.description || frontmatter.description.trim() === "") { return { skill: null, diagnostics }; }
const python = basename(filePath) === "SKILL.md" ? detectPythonSkill(skillDir, name, diagnostics) : null; const baseSkill: BaseSkill = { name, description: frontmatter.description, filePath, baseDir: skillDir, sourceInfo: createSkillSourceInfo(filePath, skillDir, source), disableModelInvocation: frontmatter["disable-model-invocation"] === true, };
return { skill: python ? { ...baseSkill, kind: "python", python } : { ...baseSkill, kind: "markdown" }, diagnostics, }; } catch (error) { const message = error instanceof Error ? error.message : "failed to parse skill file"; diagnostics.push({ type: "warning", message, path: filePath }); return { skill: null, diagnostics }; }}
/** * Format skills for inclusion in a system prompt. * Uses XML format per Agent Skills standard. * See: https://agentskills.io/integrate-skills * * Skills with disableModelInvocation=true are excluded from the prompt * (they can only be invoked explicitly via /skill:name commands). */export function formatSkillsForPrompt(skills: Skill[]): string { const visibleSkills = skills.filter((s) => !s.disableModelInvocation);
if (visibleSkills.length === 0) { return ""; }
const lines = [ "\n\nThe following skills provide specialized instructions for specific tasks.", "Use ipython to inspect a skill's file when the task matches its description.", "Skills with a python_import are prepared in the persistent Python kernel when available and can be called directly by that import name.", "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", "", "<available_skills>", ];
for (const skill of visibleSkills) { lines.push(" <skill>"); lines.push(` <name>${escapeXml(skill.name)}</name>`); lines.push(` <type>${skill.kind}</type>`); if (skill.kind === "python") { lines.push(` <python_import>${escapeXml(skill.python.importName)}</python_import>`); } lines.push(` <description>${escapeXml(skill.description)}</description>`); lines.push(` <location>${escapeXml(skill.filePath)}</location>`); lines.push(" </skill>"); }
lines.push("</available_skills>");
return lines.join("\n");}
function escapeXml(str: string): string { return str .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'");}
export interface LoadSkillsOptions { /** Working directory for project-local skills. */ cwd: string; /** Agent config directory for global skills. */ agentDir: string; /** Explicit skill paths (files or directories) */ skillPaths: string[]; /** Include default skills directories. */ includeDefaults: boolean;}
function normalizePath(input: string): string { const trimmed = input.trim(); if (trimmed === "~") return homedir(); if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2)); if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1)); return trimmed;}
function resolveSkillPath(p: string, cwd: string): string { const normalized = normalizePath(p); return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);}
/** * Load skills from all configured locations. * Returns skills and any validation diagnostics. */export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { const { cwd, agentDir, skillPaths, includeDefaults } = options;
const resolvedAgentDir = agentDir ?? getAgentDir();
const skillMap = new Map<string, Skill>(); const realPathSet = new Set<string>(); const pythonImportMap = new Map<string, Skill>(); const allDiagnostics: ResourceDiagnostic[] = []; const collisionDiagnostics: ResourceDiagnostic[] = []; const pythonImportDiagnostics: ResourceDiagnostic[] = [];
function addSkills(result: LoadSkillsResult) { allDiagnostics.push(...result.diagnostics); for (const skill of result.skills) { const realPath = canonicalizePath(skill.filePath);
if (realPathSet.has(realPath)) { continue; }
const existing = skillMap.get(skill.name); if (existing) { collisionDiagnostics.push({ type: "collision", message: `name "${skill.name}" collision`, path: skill.filePath, collision: { resourceType: "skill", name: skill.name, winnerPath: existing.filePath, loserPath: skill.filePath, }, }); } else { skillMap.set(skill.name, skill); realPathSet.add(realPath); if (skill.kind === "python") { const existingPythonSkill = pythonImportMap.get(skill.python.importName); if (existingPythonSkill) { pythonImportDiagnostics.push({ type: "warning", message: `python import name "${skill.python.importName}" is shared by skills "${existingPythonSkill.name}" and "${skill.name}"`, path: skill.filePath, }); } else { pythonImportMap.set(skill.python.importName, skill); } } } } }
if (includeDefaults) { addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true)); addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true)); }
const userSkillsDir = join(resolvedAgentDir, "skills"); const projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills");
const isUnderPath = (target: string, root: string): boolean => { const normalizedRoot = resolve(root); if (target === normalizedRoot) { return true; } const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; return target.startsWith(prefix); };
const getSource = (resolvedPath: string): "user" | "project" | "path" => { if (!includeDefaults) { if (isUnderPath(resolvedPath, userSkillsDir)) return "user"; if (isUnderPath(resolvedPath, projectSkillsDir)) return "project"; } return "path"; };
for (const rawPath of skillPaths) { const resolvedPath = resolveSkillPath(rawPath, cwd); if (!existsSync(resolvedPath)) { allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath }); continue; }
try { const stats = statSync(resolvedPath); const source = getSource(resolvedPath); if (stats.isDirectory()) { addSkills(loadSkillsFromDirInternal(resolvedPath, source, true)); } else if (stats.isFile() && resolvedPath.endsWith(".md")) { const result = loadSkillFromFile(resolvedPath, source); if (result.skill) { addSkills({ skills: [result.skill], diagnostics: result.diagnostics }); } else { allDiagnostics.push(...result.diagnostics); } } else { allDiagnostics.push({ type: "warning", message: "skill path is not a markdown file", path: resolvedPath }); } } catch (error) { const message = error instanceof Error ? error.message : "failed to read skill path"; allDiagnostics.push({ type: "warning", message, path: resolvedPath }); } }
return { skills: Array.from(skillMap.values()), diagnostics: [...allDiagnostics, ...collisionDiagnostics, ...pythonImportDiagnostics], };}