BangSo/packages/core/src/agent-skill.ts

515 lines
18 KiB
TypeScript

/**
* Claude Agent Skills: reusable SKILL.md recipes shared across assistants.
* The Pi runtime already understands this format; we persist and inject them.
* Distinct from taught skills (demo/record playbooks on a single bot).
*/
const FRONTMATTER_FENCE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
export type SkillSource = "user" | "builtin" | "plugin";
export type ParsedSkillMd = {
name: string;
description: string;
/** Markdown body after the frontmatter fence. */
body: string;
/**
* All frontmatter keys as parsed scalars/structures. Unknown keys are preserved
* so Claude Agent Skills with extra fields round-trip safely.
*/
frontmatter: Record<string, unknown>;
};
export type SkillCatalogEntry = {
name: string;
description: string;
source: SkillSource;
readOnly: boolean;
};
export type SkillRecord = SkillCatalogEntry & {
id?: string;
content: string;
};
const FORCE_SKILL_PREFIX = /^(?:\/|use\s+skill:\s*)(.+?)(?:\n|$)/i;
const ROUTINE_SKILL_STOP = new Set([
"then",
"and",
"or",
"to",
"for",
"with",
"from",
"via",
"before",
"after",
"please",
"the",
"a",
"an",
]);
/** Fallback when no catalog is available: @Token with optional Title-case words. */
const ROUTINE_SKILL_MENTION =
/(?:^|[\s(,])@([A-Za-z][\w-]*(?:[ ]+[A-Za-z][\w-]*){0,5})(?=[\s,.)]|$)/g;
export function isSkillReadOnly(source: SkillSource): boolean {
return source === "builtin" || source === "plugin";
}
/**
* Parse a SKILL.md document. Requires YAML frontmatter with `name` and `description`.
* Extra frontmatter keys are kept in `frontmatter`.
*/
export function parseSkillMd(content: string): ParsedSkillMd | { error: string } {
const trimmed = content.replace(/^\uFEFF/, "");
const match = FRONTMATTER_FENCE.exec(trimmed);
if (!match) {
return { error: "SKILL.md must start with YAML frontmatter (--- ... ---)." };
}
const frontmatterText = match[1] ?? "";
const body = trimmed.slice(match[0].length).replace(/^\r?\n/, "");
let frontmatter: Record<string, unknown>;
try {
frontmatter = parseSimpleYamlObject(frontmatterText);
} catch (error) {
return {
error: error instanceof Error ? error.message : "Invalid SKILL.md frontmatter.",
};
}
const name = stringifyScalar(frontmatter.name).trim();
const description = stringifyScalar(frontmatter.description).trim();
if (!name) return { error: "SKILL.md frontmatter requires name." };
if (!description) return { error: "SKILL.md frontmatter requires description." };
if (name.length > 80) return { error: "Skill name must be at most 80 characters." };
if (description.length > 2000) {
return { error: "Skill description must be at most 2000 characters." };
}
return { name, description, body, frontmatter };
}
/** Build SKILL.md, preserving unknown frontmatter keys from a prior parse. */
export function buildSkillMd(input: {
name: string;
description: string;
body: string;
frontmatter?: Record<string, unknown>;
}): string {
const name = input.name.trim();
const description = input.description.trim();
if (!name) throw new Error("Skill name is required.");
if (!description) throw new Error("Skill description is required.");
if (name.length > 80) throw new Error("Skill name must be at most 80 characters.");
if (description.length > 2000) {
throw new Error("Skill description must be at most 2000 characters.");
}
const merged: Record<string, unknown> = { ...(input.frontmatter ?? {}) };
merged.name = name;
merged.description = description;
// Stable order: name, description, then remaining keys alphabetically.
const rank = (key: string) => (key === "name" ? 0 : key === "description" ? 1 : 2);
const keys = Object.keys(merged).sort((a, b) => {
const diff = rank(a) - rank(b);
return diff !== 0 ? diff : a.localeCompare(b);
});
const lines = keys.map((key) => formatYamlLine(key, merged[key]));
const body = input.body.replace(/^\r?\n/, "");
return `---\n${lines.join("\n")}\n---\n${body.startsWith("\n") ? body : `\n${body}`}`;
}
export function skillCatalogLine(entry: Pick<SkillCatalogEntry, "name" | "description">): string {
return `- ${entry.name}: ${entry.description}`;
}
export function formatSkillsCatalogInstruction(entries: SkillCatalogEntry[]): string | undefined {
if (entries.length === 0) return undefined;
const lines = entries.slice(0, 50).map(skillCatalogLine).join("\n");
return [
"Available Claude Agent Skills (SKILL.md recipes shared across assistants; generic how-tos, not account-specific routines). The Pi runtime already understands this format; we persist and inject them:",
lines,
"When a skill matches the user's request, call skill_read for that name and follow it immediately. Prefer matching skills over improvising multi-step recipes.",
"Users can force a skill with /Name in the composer. Routines may mention a skill as @Name — that loads the skill at fire time.",
"Create a skill with skill_create when a multi-step task is worth repeating (or when asked). After creating one, mention /Name so the user can open it.",
"Only skill_update / skill_delete user-created skills (not builtin or plugin).",
].join("\n");
}
export function formatForcedSkillPrompt(name: string, content: string, rest?: string): string {
const parts = [`Use skill: ${name}`, "", content.trim()];
const trailing = rest?.trim();
if (trailing) parts.push("", trailing);
return parts.join("\n");
}
/** Detect a composer-forced skill (`/Name` or `Use skill: Name`) at the start of a prompt. */
export function extractForcedSkillName(prompt: string): { name: string; rest: string } | null {
const text = prompt.trimStart();
const match = FORCE_SKILL_PREFIX.exec(text);
if (!match) return null;
const name = (match[1] ?? "").trim().replace(/^\/+/, "").trim();
if (!name || name.length > 80) return null;
// Avoid treating `/` settings actions as skills.
if (/^(chat\s+settings|settings:)/i.test(name)) return null;
const rest = text.slice(match[0].length).trimStart();
return { name, rest };
}
/**
* Mentions like `@Daily standup` inside a routine prompt. Returns unique names in order.
* When `knownNames` is provided, matches those names (longest first). Otherwise uses a
* heuristic that stops before common connector words and skips `@everyone`.
*/
export function extractRoutineSkillMentions(prompt: string, knownNames?: string[]): string[] {
if (knownNames && knownNames.length > 0) {
const sorted = [...knownNames].sort((a, b) => b.length - a.length);
const found: string[] = [];
const seen = new Set<string>();
for (const name of sorted) {
const re = new RegExp(`(?:^|[\\s(,])@${escapeRegExp(name)}(?=[\\s,.)]|$)`, "gi");
if (!re.test(prompt)) continue;
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
found.push(name);
}
return found;
}
const names: string[] = [];
const seen = new Set<string>();
ROUTINE_SKILL_MENTION.lastIndex = 0;
let match = ROUTINE_SKILL_MENTION.exec(prompt);
while (match !== null) {
const raw = (match[1] ?? "").trim();
if (!raw) {
match = ROUTINE_SKILL_MENTION.exec(prompt);
continue;
}
const parts = raw.split(/\s+/);
while (parts.length > 1 && ROUTINE_SKILL_STOP.has(parts[parts.length - 1]!.toLowerCase())) {
parts.pop();
}
const name = parts.join(" ").trim();
if (!name || name.toLowerCase() === "everyone") {
match = ROUTINE_SKILL_MENTION.exec(prompt);
continue;
}
const key = name.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
names.push(name);
}
match = ROUTINE_SKILL_MENTION.exec(prompt);
}
return names;
}
export function findSkillByName<T extends { name: string }>(
skills: readonly T[],
name: string,
): T | undefined {
const needle = name.trim().toLowerCase();
if (!needle) return undefined;
return skills.find((skill) => skill.name.trim().toLowerCase() === needle);
}
/** True when `rest` already begins with the skill body (prior expand pass). */
function restAlreadyIncludesSkillContent(rest: string, content: string): boolean {
const trimmedRest = rest.trim();
const trimmedContent = content.trim();
if (!trimmedContent) return false;
return (
trimmedRest === trimmedContent ||
trimmedRest.startsWith(`${trimmedContent}\n`) ||
trimmedRest.startsWith(`${trimmedContent}\r\n`)
);
}
/** Expand forced `/Name` or routine `@Name` mentions into full skill bodies for the task prompt. */
export function expandSkillReferencesInPrompt(
prompt: string,
skills: readonly SkillRecord[],
): string {
const forced = extractForcedSkillName(prompt);
if (forced) {
const skill = findSkillByName(skills, forced.name);
if (skill) {
// Routines expand at fire time into `Use skill: …`; run time expands again — stay idempotent.
if (restAlreadyIncludesSkillContent(forced.rest, skill.content)) {
return prompt.trimStart();
}
return formatForcedSkillPrompt(skill.name, skill.content, forced.rest);
}
}
const mentions = extractRoutineSkillMentions(
prompt,
skills.map((skill) => skill.name),
);
if (mentions.length === 0) return prompt;
const blocks: string[] = [];
let remaining = prompt;
for (const mention of mentions) {
const skill = findSkillByName(skills, mention);
if (!skill) continue;
blocks.push(formatForcedSkillPrompt(skill.name, skill.content));
// Strip the @mention token so the agent is not confused by a dangling pointer.
remaining = remaining.replace(
new RegExp(`(^|[\\s(,])@${escapeRegExp(skill.name)}(?=[\\s,.)]|$)`, "gi"),
"$1",
);
}
if (blocks.length === 0) return prompt;
const rest = remaining.replace(/[ \t]{2,}/g, " ").trim();
return [...blocks, rest].filter(Boolean).join("\n\n");
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stringifyScalar(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
type YamlBlockScalarHeader = {
style: "|" | ">";
chomp: "clip" | "strip" | "keep";
indent: number | null;
};
function parseYamlBlockScalarHeader(raw: string): YamlBlockScalarHeader | null {
const match = /^([>|])(?:([1-9]\d*)([+-]?)|([+-])([1-9]\d*)?)?$/.exec(raw);
if (!match) return null;
const style = match[1] as "|" | ">";
const indentToken = match[2] ?? match[5] ?? "";
const chompToken = match[3] || match[4] || "";
const chomp = chompToken === "+" ? "keep" : chompToken === "-" ? "strip" : "clip";
const indent = indentToken ? Number(indentToken) : null;
return { style, chomp, indent };
}
function leadingSpaces(line: string): number {
const match = /^( *)/.exec(line);
return match?.[1]?.length ?? 0;
}
function decodeYamlBlockScalar(rawLines: string[], header: YamlBlockScalarHeader): string {
let indent = header.indent;
if (indent == null) {
indent = 0;
for (const line of rawLines) {
if (line.trim() === "") continue;
const spaces = leadingSpaces(line);
if (indent === 0 || spaces < indent) indent = spaces;
}
}
const contentLines = rawLines.map((line) => {
if (line.trim() === "") return "";
if (leadingSpaces(line) < indent!) return line.trimStart();
return line.slice(indent!);
});
// Count trailing blank lines before they are folded away for chomp=keep.
let trailingBlanks = 0;
for (let i = contentLines.length - 1; i >= 0; i -= 1) {
if (contentLines[i] !== "") break;
trailingBlanks += 1;
}
let text: string;
if (header.style === "|") {
text = contentLines.join("\n");
} else {
// Folded (YAML 1.2 §8.1.3): same-indent lines join with spaces; empty source
// lines become paragraph breaks; more-indented lines stay literal. Parts are
// always joined with "\n" so folded paragraphs and indented fragments keep
// line-break separation (never concatenated into one folded run).
const parts: string[] = [];
let paragraph: string[] = [];
const flush = () => {
if (paragraph.length === 0) return;
parts.push(paragraph.join(" "));
paragraph = [];
};
for (const line of contentLines) {
if (line === "") {
flush();
parts.push("");
continue;
}
if (/^ /.test(line)) {
flush();
parts.push(line);
continue;
}
paragraph.push(line);
}
flush();
text = parts.join("\n");
}
if (header.chomp === "strip") {
return text.replace(/\n+$/, "");
}
if (header.chomp === "keep") {
if (trailingBlanks === 0) return text.endsWith("\n") ? text : `${text}\n`;
const stripped = text.replace(/\n+$/, "");
return `${stripped}\n${"\n".repeat(trailingBlanks)}`;
}
// clip: exactly one trailing newline when the block had content.
const stripped = text.replace(/\n+$/, "");
return stripped === "" ? "" : `${stripped}\n`;
}
/**
* Minimal YAML object parser for SKILL.md frontmatter.
* Supports scalars, folded/literal block scalars (`>`, `|`, `>-`, `|+`, `|2`, …),
* inline arrays, and nested maps one level deep.
* Unrecognized lines are kept as string values under their key when possible.
*/
function parseSimpleYamlObject(text: string): Record<string, unknown> {
const result: Record<string, unknown> = {};
const lines = text.split(/\r?\n/);
let i = 0;
while (i < lines.length) {
const line = lines[i] ?? "";
if (!line.trim() || line.trimStart().startsWith("#")) {
i += 1;
continue;
}
if (/^\s/.test(line)) {
throw new Error("Unexpected indented frontmatter line without a key.");
}
const keyed = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
if (!keyed) {
throw new Error(`Invalid frontmatter line: ${line}`);
}
const key = keyed[1]!;
const raw = keyed[2] ?? "";
const blockHeader = parseYamlBlockScalarHeader(raw);
if (blockHeader) {
const blockLines: string[] = [];
i += 1;
while (i < lines.length && (/^\s+/.test(lines[i] ?? "") || (lines[i] ?? "").trim() === "")) {
blockLines.push(lines[i] ?? "");
i += 1;
}
result[key] = decodeYamlBlockScalar(blockLines, blockHeader);
continue;
}
if (raw === "") {
// Nested map or list on following indented lines.
const nested: Record<string, unknown> = {};
const list: unknown[] = [];
let mode: "empty" | "map" | "list" = "empty";
i += 1;
while (i < lines.length && /^\s+/.test(lines[i] ?? "")) {
const nestedLine = (lines[i] ?? "").replace(/^\s+/, "");
const listItem = /^-\s+(.*)$/.exec(nestedLine);
if (listItem) {
mode = "list";
list.push(parseYamlScalar(listItem[1] ?? ""));
i += 1;
continue;
}
const nestedKey = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(nestedLine);
if (nestedKey) {
mode = "map";
nested[nestedKey[1]!] = parseYamlScalar(nestedKey[2] ?? "");
i += 1;
continue;
}
break;
}
result[key] = mode === "list" ? list : mode === "map" ? nested : "";
continue;
}
if (raw.startsWith("[") && raw.endsWith("]")) {
result[key] = raw
.slice(1, -1)
.split(",")
.map((part) => parseYamlScalar(part.trim()))
.filter((part) => part !== "");
i += 1;
continue;
}
result[key] = parseYamlScalar(raw);
i += 1;
}
return result;
}
function parseYamlScalar(raw: string): string | number | boolean {
const value = raw.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
if (value === "true") return true;
if (value === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
return value;
}
function formatYamlBlockScalar(key: string, value: string): string {
// Choose chomp so decodeYamlBlockScalar round-trips trailing newlines:
// strip (0), clip (1), keep (2+). Folded (`>`) style is not preserved — only the string value.
const trailing = /\n*$/.exec(value)?.[0].length ?? 0;
let indicator = "|";
let lines: string[];
if (trailing === 0) {
indicator = "|-";
lines = value.split("\n");
} else if (trailing === 1) {
indicator = "|";
lines = value.slice(0, -1).split("\n");
} else {
indicator = "|+";
const core = value.slice(0, -trailing);
// keep decoder yields 1 + trailingBlanks newlines; emit trailingBlanks blank lines.
lines = [core, ...Array.from({ length: trailing - 1 }, () => "")];
}
const indented = lines.map((line) => ` ${line}`).join("\n");
return `${key}: ${indicator}\n${indented}`;
}
function formatYamlLine(key: string, value: unknown): string {
if (value === null || value === undefined) return `${key}:`;
if (typeof value === "string") {
if (value.includes("\n") || value.includes(": ")) {
return formatYamlBlockScalar(key, value);
}
if (value === "" || /[#{}[\],&*?|>!%@`]/.test(value) || value !== value.trim()) {
return `${key}: ${JSON.stringify(value)}`;
}
return `${key}: ${value}`;
}
if (typeof value === "number" || typeof value === "boolean") return `${key}: ${value}`;
if (Array.isArray(value)) {
if (value.every((item) => typeof item === "string" || typeof item === "number")) {
return `${key}: [${value.map((item) => JSON.stringify(String(item))).join(", ")}]`;
}
const items = value
.map((item) => ` - ${typeof item === "string" ? item : JSON.stringify(item)}`)
.join("\n");
return `${key}:\n${items}`;
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return `${key}:`;
const nested = entries
.map(([nestedKey, nestedValue]) => ` ${formatYamlLine(nestedKey, nestedValue)}`)
.join("\n");
return `${key}:\n${nested}`;
}
return `${key}: ${JSON.stringify(String(value))}`;
}