agents
Version:
A home for your AI agents
310 lines (309 loc) • 11.6 kB
JavaScript
import { compileSkillScript, isCompilableSkillScript } from "./skills/compile.js";
import babel from "@rolldown/plugin-babel";
import { createHash } from "node:crypto";
import { readFile, readdir, stat } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import { parse } from "yaml";
//#region src/vite.ts
const SKILLS_SPECIFIER = "agents:skills";
const SKILLS_VIRTUAL_PREFIX = "\0agents:skills:";
const SKILL_RESOURCE_ROOTS = /* @__PURE__ */ new Set([
"references",
"scripts",
"assets",
"graphics",
"fonts",
"templates",
"rendered-files",
"illustrations"
]);
const SKILL_IGNORED_ROOTS = /* @__PURE__ */ new Set([
".git",
".hg",
".svn",
".DS_Store",
".idea",
".vscode",
"dist",
"build",
"coverage",
"node_modules"
]);
const SPEC_RESOURCE_ROOTS = /* @__PURE__ */ new Set([
"references",
"scripts",
"assets"
]);
const SKILL_ASSET_WARN_BYTES = 256 * 1024;
const SKILL_BUNDLE_WARN_BYTES = 1024 * 1024;
function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)}KB`;
return `${bytes}B`;
}
function parseFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return {
data: {},
body: raw
};
const parsed = parse(match[1] ?? "");
return {
data: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {},
body: match[2] ?? ""
};
}
function stringField(value) {
return typeof value === "string" && value.trim() ? value.trim() : void 0;
}
function recordField(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
}
function resourceKind(path) {
if (path.startsWith("references/")) return "reference";
if (path.startsWith("scripts/")) return "script";
if (path.startsWith("assets/") || path.startsWith("graphics/") || path.startsWith("fonts/") || path.startsWith("templates/") || path.startsWith("rendered-files/") || path.startsWith("illustrations/")) return "asset";
return "file";
}
const TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
".bash",
".css",
".csv",
".html",
".js",
".json",
".jsx",
".md",
".mjs",
".py",
".sh",
".svg",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml"
]);
const MIME_TYPES = /* @__PURE__ */ new Map([
[".css", "text/css"],
[".gif", "image/gif"],
[".html", "text/html"],
[".jpg", "image/jpeg"],
[".jpeg", "image/jpeg"],
[".js", "text/javascript"],
[".json", "application/json"],
[".md", "text/markdown"],
[".mjs", "text/javascript"],
[".pdf", "application/pdf"],
[".png", "image/png"],
[".py", "text/x-python"],
[".sh", "text/x-shellscript"],
[".svg", "image/svg+xml"],
[".ts", "text/typescript"],
[".tsx", "text/typescript"],
[".txt", "text/plain"],
[".webp", "image/webp"],
[".woff", "font/woff"],
[".woff2", "font/woff2"],
[".xml", "application/xml"],
[".yaml", "application/yaml"],
[".yml", "application/yaml"]
]);
function extensionOf(path) {
const file = path.split("/").at(-1) ?? path;
const index = file.lastIndexOf(".");
return index === -1 ? "" : file.slice(index).toLowerCase();
}
function resourceEncoding(path) {
return TEXT_EXTENSIONS.has(extensionOf(path)) ? "text" : "base64";
}
function resourceMimeType(path) {
return MIME_TYPES.get(extensionOf(path));
}
async function collectFiles(root, relativeRoot = "", warn) {
const entries = await readdir(join(root, relativeRoot), { withFileTypes: true }).catch(() => []);
const files = [];
for (const entry of entries) {
if (SKILL_IGNORED_ROOTS.has(entry.name)) continue;
const relativePath = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name;
const absolutePath = join(root, relativePath);
if (entry.isDirectory()) {
const resourceRoot = relativePath.split("/")[0];
if (!resourceRoot || SKILL_IGNORED_ROOTS.has(resourceRoot)) continue;
if (!SKILL_RESOURCE_ROOTS.has(resourceRoot)) {
if (!relativeRoot) warn?.(`Ignoring skill directory "${relativePath}". Bundled skill resources should live under references/, scripts/, assets/, or a known asset root.`);
continue;
}
if (!SPEC_RESOURCE_ROOTS.has(resourceRoot) && !relativeRoot) warn?.(`Bundling non-standard skill resource root "${resourceRoot}/". Prefer assets/ for portable Agent Skills when possible.`);
files.push(...await collectFiles(root, relativePath, warn));
} else if (entry.isFile() && relativePath !== "SKILL.md") {
const resourceRoot = relativePath.split("/")[0];
if (!resourceRoot || SKILL_IGNORED_ROOTS.has(resourceRoot)) continue;
if (!SKILL_RESOURCE_ROOTS.has(resourceRoot)) {
warn?.(`Ignoring skill file "${relativePath}". Bundled skill resources should live under references/, scripts/, assets/, or a known asset root.`);
continue;
}
const info = await stat(absolutePath);
files.push({
path: relativePath,
absolutePath,
size: info.size
});
}
}
return files.sort((a, b) => a.path.localeCompare(b.path));
}
async function collectWatchTargets(root, relativeRoot = "") {
const directory = join(root, relativeRoot);
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
const targets = [directory];
for (const entry of entries) {
if (SKILL_IGNORED_ROOTS.has(entry.name)) continue;
const relativePath = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name;
const resourceRoot = relativePath.split("/")[0];
if (!resourceRoot || SKILL_IGNORED_ROOTS.has(resourceRoot)) continue;
const absolutePath = join(root, relativePath);
targets.push(absolutePath);
if (entry.isDirectory()) targets.push(...await collectWatchTargets(root, relativePath));
}
return targets;
}
async function readSkill(skillDir, warn) {
const rawContent = await readFile(join(skillDir, "SKILL.md"), "utf8").catch(() => null);
if (rawContent === null) return null;
const { data, body } = parseFrontmatter(rawContent);
const name = stringField(data.name);
const description = stringField(data.description);
if (!name || !description) return null;
const resources = await Promise.all((await collectFiles(skillDir, "", warn)).map(async (file) => {
const encoding = resourceEncoding(file.path);
const bytes = await readFile(file.absolutePath);
const kind = resourceKind(file.path);
let content = encoding === "base64" ? bytes.toString("base64") : bytes.toString();
let precompiled = false;
let size = file.size;
if (kind === "script" && encoding === "text" && isCompilableSkillScript(file.path)) try {
content = (await compileSkillScript(file.absolutePath)).content;
precompiled = true;
size = Buffer.byteLength(content);
} catch (error) {
warn?.(`Failed to compile skill script "${file.path}": ${error instanceof Error ? error.message : String(error)}. Skill scripts must be self-contained, compilable modules to run at runtime.`);
}
return {
path: file.path,
kind,
size,
encoding,
mimeType: resourceMimeType(file.path),
content,
precompiled
};
}));
return {
name,
description,
body,
rawContent,
compatibility: stringField(data.compatibility),
license: stringField(data.license),
allowedTools: stringField(data["allowed-tools"]),
metadata: recordField(data.metadata),
resources
};
}
async function buildSkillsModule(dir, warn) {
const entries = await readdir(dir, { withFileTypes: true });
const skills = [];
const seen = /* @__PURE__ */ new Set();
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skill = await readSkill(join(dir, entry.name), warn);
if (!skill) continue;
if (seen.has(skill.name)) {
warn?.(`Duplicate bundled skill name "${skill.name}" in "${entry.name}/"; keeping the first occurrence and ignoring this one.`);
continue;
}
seen.add(skill.name);
skills.push(skill);
}
skills.sort((a, b) => a.name.localeCompare(b.name));
let totalBytes = 0;
for (const skill of skills) for (const resource of skill.resources) {
totalBytes += resource.size;
if (resource.size > SKILL_ASSET_WARN_BYTES) warn?.(`Bundled skill resource "${skill.name}/${resource.path}" is ${formatBytes(resource.size)}; large assets bloat the Worker bundle (base64, ~1.33x). Prefer an R2-backed source via skills.r2().`);
}
if (totalBytes > SKILL_BUNDLE_WARN_BYTES) warn?.(`Bundled skills total ${formatBytes(totalBytes)} of embedded resources; this competes with the Worker bundle-size budget. Consider serving large skills from R2 via skills.r2().`);
const hash = createHash("sha256");
hash.update(JSON.stringify(skills));
const manifest = {
id: `bundle:${basename(dir)}`,
fingerprint: hash.digest("hex"),
skills
};
return `const manifest = ${JSON.stringify(manifest)};\nexport default {\n id: manifest.id,\n fingerprint: manifest.fingerprint,\n async list() {\n return manifest.skills.map(({ body, rawContent, resources, ...skill }) => skill);\n },\n async load(name) {\n const skill = manifest.skills.find((entry) => entry.name === name);\n if (!skill) return null;\n return {\n ...skill,\n resources: skill.resources.map(({ content, ...resource }) => resource)\n };\n },\n async readResource(name, path) {\n const skill = manifest.skills.find((entry) => entry.name === name);\n const resource = skill?.resources.find((entry) => entry.path === path);\n return resource ? { ...resource } : null;\n }\n};\n`;
}
const TURNDOWN_STUB_ID = "\0agents:turndown-stub";
const TURNDOWN_STUB_MESSAGE = "The Agents Vite plugin stubbed the 'turndown' package to keep Workers bundles compatible with just-bash. If your app uses turndown directly, configure the plugin with agents({ stubTurndown: false }).";
function turndownStubPlugin() {
return {
name: "agents-turndown-stub",
enforce: "pre",
resolveId(source) {
if (source === "turndown") return TURNDOWN_STUB_ID;
return null;
},
load(id) {
if (id !== TURNDOWN_STUB_ID) return null;
return `const message = ${JSON.stringify(TURNDOWN_STUB_MESSAGE)};
class TurndownService {
constructor() {}
use() { return this; }
addRule() { return this; }
keep() { return this; }
remove() { return this; }
turndown() { throw new Error(message); }
}
export default TurndownService;
`;
}
};
}
function skillsImportPlugin() {
return {
name: "agents-skills-import",
async resolveId(source, importer) {
if (source !== SKILLS_SPECIFIER && !source.startsWith(`${SKILLS_SPECIFIER}/`)) return null;
if (!importer) return null;
return `${SKILLS_VIRTUAL_PREFIX}${resolve(importer, "..", source === SKILLS_SPECIFIER ? "skills" : source.slice(14))}`;
},
async load(id) {
if (!id.startsWith(SKILLS_VIRTUAL_PREFIX)) return null;
const dir = id.slice(15);
for (const target of await collectWatchTargets(dir)) this.addWatchFile(target);
return buildSkillsModule(dir, (message) => this.warn(message));
}
};
}
/**
* Vite plugin for Agents SDK projects.
*
* Handles TC39 decorator transforms (Oxc doesn't support them yet, oxc#9170) so
* `@callable()` works at runtime, the `agents:skills` import transform, and
* stubbing `turndown` to keep Workers deploys clean. Will grow to cover other
* Agents-specific build concerns as needed.
*/
function agents(options = {}) {
const { stubTurndown = true } = options;
return [
...stubTurndown ? [turndownStubPlugin()] : [],
skillsImportPlugin(),
babel({ presets: [{
preset: () => ({ plugins: [["@babel/plugin-proposal-decorators", { version: "2023-11" }]] }),
rolldown: { filter: { code: "@" } }
}] })
];
}
//#endregion
export { agents as default };
//# sourceMappingURL=vite.js.map