Z ZPI 文档 v0.3.8
介绍页

源码

packages/coding-agent/src/core/resources.ts

typescript242 行查看原文
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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}
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");
}