import fs from "fs"; /** * Vite plugin that resolves `virtual:agents-bundle` to a statically-inlined * ES module containing the template's AGENTS.md + .agents/skills/ content. * The legacy singular .agent/skills/ directory is also watched as an alias. * * This is how the framework's agent gets its instructions and skills into the * system prompt on EVERY deployment target — Node, Netlify Functions, Vercel * serverless, and Cloudflare Workers. The content is baked into the server * bundle at build time, so nothing needs to exist on the runtime filesystem. * * In dev mode, the plugin re-reads from disk on each import and triggers HMR * when any AGENTS.md or SKILL.md file changes, so edits show up instantly * without restarting the server. */ import path from "path"; import type { Plugin } from "vite"; import { getWorkspaceCoreExports } from "../deploy/workspace-core.js"; import { readAgentsBundleFromFs, type WorkspaceAgentsSource, } from "../server/agents-bundle.js"; const VIRTUAL_ID = "virtual:agents-bundle"; const RESOLVED_ID = "\0" + VIRTUAL_ID; const TEMPLATE_SKILLS_DIRS = [ path.join(".agents", "skills"), path.join(".agent", "skills"), ] as const; /** * Coalesce window for the dev-server full-reload sent after AGENTS.md / * SKILL.md changes. Agents (and Fusion editing sessions) frequently write * several skill files back-to-back, and editor atomic saves surface as * unlink+add pairs — without coalescing, every one of those watcher events * triggered its own full page reload, which reads as the app "constantly * refreshing" while an agent works. Module invalidation still happens per * event (it's cheap and keeps the next request fresh); only the browser * reload is batched. */ const FULL_RELOAD_COALESCE_MS = 500; async function emitBundleModule(projectRoot: string): Promise { // If the project is inside an enterprise monorepo with a workspace core, // merge in its AGENTS.md + skills. Template skills override workspace // core skills on name collision. let workspaceSource: WorkspaceAgentsSource | null = null; try { const ws = await getWorkspaceCoreExports(projectRoot); if (ws) { workspaceSource = { skillsDir: ws.skillsDir, agentsMdPath: ws.agentsMdPath, rootDir: ws.packageDir, }; } } catch { // fall back to template-only } const bundle = readAgentsBundleFromFs(projectRoot, workspaceSource); // Serialize as JSON and wrap in `export default` — this produces a valid // ES module that any bundler (Rollup, esbuild, Rolldown) can statically // analyze and tree-shake if nothing imports it. return `// AUTO-GENERATED by @agent-native/core/vite/agents-bundle-plugin // Contains the inlined AGENTS.md + .agents/skills/ content from the template, // merged with any workspace AGENTS.md + .agents/skills/ when present. const bundle = ${JSON.stringify(bundle)}; export default bundle; `; } export function agentsBundlePlugin(): Plugin { let projectRoot = ""; return { name: "agent-native-agents-bundle", configResolved(config) { projectRoot = config.root; }, resolveId(id) { if (id === VIRTUAL_ID) return RESOLVED_ID; return null; }, async load(id) { if (id !== RESOLVED_ID) return null; if (!projectRoot) projectRoot = process.cwd(); return await emitBundleModule(projectRoot); }, async configureServer(server) { // Watch AGENTS.md + any SKILL.md changes and invalidate the virtual // module so the next request picks up fresh content. Also watches // the workspace core's AGENTS.md + skills directory (if present) // so edits to the enterprise mid-layer propagate to every app. const watcher = server.watcher; // Resolve the workspace core up front so we can tell which files // outside projectRoot should trigger invalidation. let workspaceAgentsMdPath: string | null = null; let workspaceSkillsDir: string | null = null; try { const ws = await getWorkspaceCoreExports(projectRoot); if (ws) { workspaceAgentsMdPath = ws.agentsMdPath; workspaceSkillsDir = ws.skillsDir; } } catch { // ignore } const shouldInvalidate = (file: string): boolean => { const rel = path.relative(projectRoot, file); if (!rel.startsWith("..")) { if (rel === "AGENTS.md") return true; for (const skillsDir of TEMPLATE_SKILLS_DIRS) { // Any file under a skills directory can affect the bundle now — // `readSkillsDir` reads reference sub-files (not just SKILL.md) // into `Skill.files`, so dev HMR must match that or reference // edits go stale until a manual restart. if (rel.startsWith(skillsDir + path.sep)) return true; } } // Workspace-core files if (workspaceAgentsMdPath && file === workspaceAgentsMdPath) { return true; } if ( workspaceSkillsDir && file.startsWith(workspaceSkillsDir + path.sep) ) { return true; } return false; }; let reloadTimer: ReturnType | null = null; const scheduleFullReload = () => { if (reloadTimer) clearTimeout(reloadTimer); reloadTimer = setTimeout(() => { reloadTimer = null; server.ws.send({ type: "full-reload" }); }, FULL_RELOAD_COALESCE_MS); }; server.httpServer?.once("close", () => { if (reloadTimer) clearTimeout(reloadTimer); reloadTimer = null; }); const invalidate = (file: string) => { if (!shouldInvalidate(file)) return; const mod = server.moduleGraph.getModuleById(RESOLVED_ID); if (mod) { server.moduleGraph.invalidateModule(mod); scheduleFullReload(); } }; // Explicitly add template + workspace-core paths to the watcher so // edits outside the normal Vite watch set still trigger invalidation. const agentsMdPath = path.join(projectRoot, "AGENTS.md"); const skillsDirs = TEMPLATE_SKILLS_DIRS.map((rel) => path.join(projectRoot, rel), ); if (fs.existsSync(agentsMdPath)) watcher.add(agentsMdPath); for (const skillsDir of skillsDirs) { if (fs.existsSync(skillsDir)) watcher.add(skillsDir); } if (workspaceAgentsMdPath && fs.existsSync(workspaceAgentsMdPath)) { watcher.add(workspaceAgentsMdPath); } if (workspaceSkillsDir && fs.existsSync(workspaceSkillsDir)) { watcher.add(workspaceSkillsDir); } watcher.on("change", invalidate); watcher.on("add", invalidate); watcher.on("unlink", invalidate); }, }; }