#!/usr/bin/env node // catalog.mjs — generate the agent catalog from @agent-manifest headers. // // Scans agents/scripts/**/*.mjs plus the runtime PHP agents listed below, extracts each // fenced `@agent-manifest … @end` block, and emits: // agents/AGENTS.md — human catalog, one table per class (GENERATED — do not edit) // agents/agents.json — machine manifest; clones diff this to find missing deps // // Run after adding/editing an agent: node agents/catalog.mjs // (also runs automatically at the end of scripts/configure.mjs) import { readFileSync, writeFileSync, readdirSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, ".."); // Runtime agents living outside agents/scripts (the console runner's pluggable // modules) — discovered from their JS `manifest` export (see the loop below). const RUNTIME_FILES = []; // legacy seam for PHP runtime agents; console agents handled below const CLASSES = ["content", "operational", "runtime", "plumbing"]; const LIST_KEYS = ["prompt", "skills", "tools", "reads", "writes"]; function parseManifest(src) { const m = src.match(/@agent-manifest\s*\n([\s\S]*?)\n\s*\*?\s*@end/); if (!m) return null; const out = {}; for (const raw of m[1].split("\n")) { const line = raw.replace(/^\s*\*\s?/, ""); // strip comment leader const kv = line.match(/^([a-z-]+):\s*(.*)$/); if (kv) out[kv[1]] = kv[2].trim(); } for (const k of LIST_KEYS) { const v = out[k]; out[k] = !v || v === "-" ? [] : v.split(",").map((s) => s.trim()).filter(Boolean); } return out; } const files = readdirSync(join(HERE, "scripts")) .filter((f) => f.endsWith(".mjs")) .map((f) => join("agents", "scripts", f)) .concat(RUNTIME_FILES); const agents = []; for (const rel of files.sort()) { const manifest = parseManifest(readFileSync(join(ROOT, rel), "utf8")); if (!manifest) continue; // no manifest → not an agent (e.g. future helpers) if (!manifest.name || !manifest.class) { console.error(`[catalog] ${rel}: manifest missing name/class — skipped`); continue; } if (!CLASSES.includes(manifest.class)) { console.error(`[catalog] ${rel}: unknown class "${manifest.class}" — skipped`); continue; } agents.push({ file: rel, ...manifest }); } // ---- runtime (console) agents: JS `manifest` exports ---- // Each agents/console/agents/*.mjs exports a `manifest` object. Importing a // module only defines it (register()/listen() aren't called), so this is safe. const consoleDir = join(HERE, "console", "agents"); let aiModel = "-"; try { aiModel = JSON.parse(readFileSync(join(ROOT, "astroagent.config.json"), "utf8")).ai?.model || "-"; } catch {} let consoleFiles = []; try { consoleFiles = readdirSync(consoleDir).filter((f) => f.endsWith(".mjs")); } catch {} for (const f of consoleFiles.sort()) { const rel = ["agents", "console", "agents", f].join("/"); let mod; try { mod = await import(pathToFileURL(join(consoleDir, f)).href); } catch (e) { console.error(`[catalog] ${rel}: import failed — ${e.message}`); continue; } const m = mod.manifest; if (!m || !m.name || !m.class) continue; const trigger = (m.triggers || []).map((t) => t.type === "endpoint" ? `${t.method} ${t.path}` : t.type === "schedule" ? `schedule(${t.settingKey})` : t.type ).join("; "); const toolsArr = typeof m.tools === "string" ? m.tools.split(/\s+/).filter(Boolean) : (m.tools || []); agents.push({ file: rel, name: m.name, title: m.title || m.name, class: m.class, trigger, model: aiModel, description: m.description || "", prompt: [], skills: m.skills || [], tools: [...toolsArr, ...(m.cli || []).map((c) => `api/cli/${c}.php`)], reads: m.tables || [], writes: m.tables || [], created: "", }); } agents.sort((a, b) => a.name.localeCompare(b.name)); // ---- agents.json (machine manifest) ---- writeFileSync(join(HERE, "agents.json"), JSON.stringify({ agents }, null, 2) + "\n"); // ---- AGENTS.md (human catalog) ---- const esc = (s) => String(s).replace(/\|/g, "\\|"); const classBlurb = { content: "Cron/manual-triggered producers of gated drafts (events auto-publishes).", operational: "Event/queue-triggered reviewers and responders (gated + logged).", runtime: "HTTP-triggered agents answering live user requests.", plumbing: "Deterministic helpers — not agents (no LLM), catalogued for completeness.", }; let md = `# Agent catalog > **GENERATED — do not edit.** Regenerate with \`node agents/catalog.mjs\` > (source of truth: the \`@agent-manifest\` header in each script). An **agent** = an LLM orchestrator script + its role prompt (\`prompt:\`) + its skills (MCP servers, \`skills:\`) + its tools (\`api/cli/*.php\` bridges and \`lib/*.mjs\`, \`tools:\`). To port an agent to another SeedProject clone, satisfy its manifest: copy its prompts, ensure its tools exist, migrate each table in \`reads:\`/\`writes:\`, configure its MCP servers. \`agents.json\` is the machine-readable version — diff it against a clone to find gaps. `; for (const cls of CLASSES) { const group = agents.filter((a) => a.class === cls); if (!group.length) continue; md += `\n## ${cls}\n\n${classBlurb[cls]}\n\n`; md += `| agent | trigger | model | skills | tools | reads → writes | description |\n`; md += `|---|---|---|---|---|---|---|\n`; for (const a of group) { const rw = `${a.reads.join(", ") || "—"} → ${a.writes.join(", ") || "—"}`; md += `| **${esc(a.name)}** ([${esc(a.file)}](../${a.file})) | ${esc(a.trigger || "—")} | ${esc(a.model || "—")} | ${esc(a.skills.join(", ") || "—")} | ${esc(a.tools.join(", ") || "—")} | ${esc(rw)} | ${esc(a.description || "")} |\n`; } } writeFileSync(join(HERE, "AGENTS.md"), md); console.log(`[catalog] ${agents.length} manifests → agents/AGENTS.md + agents/agents.json`);