源码
packages/coding-agent/src/core/resources.ts
import { open, readdir, readFile, realpath, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import type { JsonObject } from "@zpi-dev/ai";
import { matchMarkdownFrontmatter, parseMarkdownFrontmatter } from "./markdown.ts";
import type { ResourceDiagnostic, ResourceLoader } from "./types.ts";
export interface SkillMetadata {
name: string;
description: string;
path: string;
baseDir: string;
source: "user" | "project" | "plugin";
}
export interface DiscoveredSkill extends SkillMetadata {
enabled: boolean;
overriddenBy?: string;
}
export interface SkillPolicyOptions {
projectDir: string;
homeDir?: string;
projectResources?: boolean;
isSkillEnabled?: (path: string) => boolean;
}
export interface SkillList {
skills: SkillMetadata[];
diagnostics: ResourceDiagnostic[];
}
export interface LoadedSkill extends SkillMetadata {
body: string;
}
function missing(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
async function metadataText(path: string): Promise<string> {
// Skip devices and pipes before opening: a FIFO without a writer blocks discovery indefinitely.
if (!(await stat(path)).isFile()) throw new Error("SKILL.md must be a regular file");
const file = await open(path, "r");
try {
let text = "";
const decoder = new TextDecoder("utf-8", { fatal: true });
for (let offset = 0; offset < 65536; offset += 1024) {
const buffer = Buffer.alloc(1024);
const { bytesRead } = await file.read(buffer, 0, buffer.length, offset);
text += decoder.decode(buffer.subarray(0, bytesRead), { stream: bytesRead !== 0 });
if (matchMarkdownFrontmatter(text)) return text;
if (bytesRead === 0) break;
}
throw new Error("Missing or oversized YAML frontmatter (limit 64 KiB)");
} finally {
await file.close();
}
}
function skillMarkdown(text: string) {
const parsed = parseMarkdownFrontmatter(text);
if (!parsed) throw new Error("SKILL.md requires closed YAML frontmatter");
return parsed;
}
function parseMetadata(path: string, source: SkillMetadata["source"], fields: JsonObject): SkillMetadata {
const { description } = fields;
const name = basename(dirname(path));
if (typeof description !== "string" || !description.trim() || description.length > 1024)
throw new Error("Skill description must contain 1–1024 characters");
return { name, description: description.trim(), path, baseDir: dirname(path), source };
}
export class SkillCatalog {
private discovered: DiscoveredSkill[] = [];
private winners: SkillMetadata[] = [];
private catalog: SkillList = { skills: [], diagnostics: [] };
constructor(privateOptions: SkillPolicyOptions) {
this.options = privateOptions;
}
private options: SkillPolicyOptions;
async reload(): Promise<void> {
const skills = new Map<string, SkillMetadata>();
const discovered: SkillMetadata[] = [];
const diagnostics: ResourceDiagnostic[] = [];
const roots: { path: string; source: SkillMetadata["source"] }[] = [
{ path: join(this.options.homeDir ?? homedir(), ".agents", "skills"), source: "user" },
{ path: join(this.options.homeDir ?? homedir(), ".zpi", "skills"), source: "user" },
...(this.options.projectResources === false
? []
: [
{ path: join(this.options.projectDir, ".agents", "skills"), source: "project" as const },
{ path: join(this.options.projectDir, ".zpi", "skills"), source: "project" as const },
]),
];
for (const root of roots) {
try {
const entries = await readdir(root.path, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const path = join(root.path, entry.name, "SKILL.md");
try {
const skill = parseMetadata(path, root.source, skillMarkdown(await metadataText(path)).fields);
skill.path = await realpath(skill.path);
discovered.push(skill);
skills.set(skill.name, skill);
} catch (e) {
if (!missing(e)) diagnostics.push({ path, message: String(e) });
}
}
} catch (e) {
if (!missing(e)) diagnostics.push({ path: root.path, message: String(e) });
}
}
this.winners = [...skills.values()];
const lastByPath = new Map(discovered.map((skill, index) => [skill.path, index]));
this.discovered = discovered
.filter((skill, index) => lastByPath.get(skill.path) === index)
.map((skill) => ({
...skill,
enabled: this.options.isSkillEnabled?.(skill.path) !== false,
...(skills.get(skill.name)?.path !== skill.path
? { overriddenBy: skills.get(skill.name)?.path }
: {}),
}));
this.catalog = {
skills: this.winners.filter((skill) => this.options.isSkillEnabled?.(skill.path) !== false),
diagnostics,
};
}
listSkills(): SkillList {
return structuredClone(this.catalog);
}
listDiscoveredSkills(): DiscoveredSkill[] {
return structuredClone(this.discovered);
}
async loadSkill(name: string): Promise<LoadedSkill> {
const winner = this.winners.find((s) => s.name === name);
if (winner && !this.catalog.skills.some((s) => s.path === winner.path))
throw new Error(`invalid_input: Skill disabled: ${name}`);
const skill = this.catalog.skills.find((s) => s.name === name);
if (!skill) throw new Error(`invalid_input: Skill not found: ${name}`);
const text = await readFile(skill.path, "utf8");
const parsed = skillMarkdown(text);
const current = parseMetadata(skill.path, skill.source, parsed.fields);
return { ...current, name: skill.name, body: parsed.body };
}
}
export class FileResourceLoader implements ResourceLoader {
private options: SkillPolicyOptions;
private catalog: SkillCatalog;
private instructions: string[] = [];
private diagnostics: ResourceDiagnostic[] = [];
constructor(options: SkillPolicyOptions) {
this.options = options;
this.catalog = new SkillCatalog(options);
}
getBaseInstructions(): undefined {
return undefined;
}
getInstructions(): string[] {
return [...this.instructions];
}
async reload(): Promise<void> {
const home = this.options.homeDir ?? homedir();
const project = resolve(this.options.projectDir);
const files = [
join(home, ".agents", "AGENTS.md"),
join(home, ".zpi", "AGENTS.md"),
...(this.options.projectResources === false
? []
: [join(project, "AGENTS.md"), join(project, ".zpi", "AGENTS.md")]),
];
// At most four instruction files; keep their precedence and diagnostics in source order.
const [instructions] = await Promise.all([
Promise.allSettled(
files.map(async (file) => {
if (!(await stat(file)).isFile()) throw new Error("AGENTS.md must be a regular file");
return `<project_instructions path="${xml(file)}">\n${await readFile(file, "utf8")}\n</project_instructions>`;
}),
),
this.catalog.reload(),
]);
const append: string[] = [];
const diagnostics: ResourceDiagnostic[] = [];
for (const [index, result] of instructions.entries()) {
if (result.status === "fulfilled") append.push(result.value);
else if (!missing(result.reason))
diagnostics.push({ path: files[index], message: String(result.reason) });
}
const skills = this.catalog.listSkills();
diagnostics.push(...skills.diagnostics);
if (append.length)
append.splice(
0,
append.length,
`<project_context>\nProject-specific instructions and guidelines:\n\n${append.join("\n\n")}\n</project_context>`,
);
if (skills.skills.length) append.push(formatSkillsForPrompt(skills.skills));
this.instructions = append;
this.diagnostics = diagnostics;
}
listSkills(): SkillList {
return this.catalog.listSkills();
}
listDiscoveredSkills(): DiscoveredSkill[] {
return this.catalog.listDiscoveredSkills();
}
loadSkill(name: string): Promise<LoadedSkill> {
return this.catalog.loadSkill(name);
}
getDiagnostics(): ResourceDiagnostic[] {
return structuredClone(this.diagnostics);
}
}
function xml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
export function formatSkillsForPrompt(
skills: SkillMetadata[],
tool: "read" | "bash" | "Read" | "Bash" | "Skill" = "read",
): string {
return [
"<skills>",
"The following skills provide specialized instructions for specific tasks.",
tool === "Skill"
? "Use the Skill tool with the skill name to load its instructions when the task matches its description."
: `Use the ${tool} tool to load a skill's file when the task matches its description.`,
"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>",
...skills.flatMap((s) => [
" <skill>",
` <name>${xml(s.name)}</name>`,
` <description>${xml(s.description)}</description>`,
` <location>${xml(s.path)}</location>`,
" </skill>",
]),
"</available_skills>",
"</skills>",
].join("\n");
}