diff --git a/agents/console/agents/in-page-console.mjs b/agents/console/agents/in-page-console.mjs new file mode 100644 index 0000000..3e916c9 --- /dev/null +++ b/agents/console/agents/in-page-console.mjs @@ -0,0 +1,176 @@ +/** + * In-page console agent — the freeform ▲ editor. The operator describes a change + * on any page; the agent (Read/Write/Edit/Glob/Grep/WebSearch, no Bash) edits the + * working tree; the host builds an isolated preview; publish/discard promote or + * revert. Owns: /run, /stream (SSE), /publish, /rebuild, /discard. + */ + +let H; // the host, set in register() + +function buildPrompt({ message, page, selections }) { + const parts = []; + parts.push( + "You are the editing agent for the Carlos Arias website (an Astro + Tailwind v4 static site).", + "Make ONLY the change the operator asks for. Keep everything on-brand:", + "the brand guide is brand/BRAND.md and the design tokens are in app/src/styles.css", + "(sumi-e — ink on washi paper, one vermillion seal, restraint, near-square radii).", + "Do NOT run builds or git commands — the console builds and publishes for you.", + "", + ); + if (page && page !== "/") { + parts.push(`The operator is on the page: ${page}`); + parts.push("Its source is almost certainly under app/src/pages (find it with Glob/Grep).", ""); + } else if (page === "/") { + parts.push("The operator is on the homepage (app/src/pages/index.astro).", ""); + } + if (Array.isArray(selections) && selections.length) { + parts.push("They selected these element(s) on the page:"); + for (const s of selections) { + const tag = s.tag || s.selector || "element"; + parts.push(`- <${tag}>${s.text ? ` — "${String(s.text).slice(0, 80)}"` : ""}${s.comment ? ` — note: ${s.comment}` : ""}`); + } + parts.push(""); + } + parts.push("Request:", message); + return parts.join("\n"); +} + +function shortPath(p) { + if (!p) return ""; + return String(p).replace(H.REPO + "/", ""); +} +function handleStreamMsg(job, msg) { + if (msg.session_id) job.sessionId = msg.session_id; + const content = msg?.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) H.emit(job, { kind: "text", text: block.text }); + else if (block.type === "tool_use") { + const label = block.name === "Edit" || block.name === "Write" + ? `${block.name} ${shortPath(block.input?.file_path)}` + : block.name; + H.emit(job, { kind: "tool", text: label }); + } + } + } + if (msg.type === "result" && typeof msg.result === "string" && msg.result.trim()) { + H.emit(job, { kind: "text", text: msg.result.trim() }); + } +} + +async function buildPreview(job) { + H.emit(job, { kind: "tool", text: "building preview…" }); + const out = H.join("..", "public-preview", job.jobId); + const base = `/_preview/${job.jobId}`; + const { ok } = await H.build({ PREVIEW_OUT: out, PREVIEW_BASE: base }); + if (!ok) { + H.emit(job, { type: "error", text: "The change broke the build, so it was not applied. Try rephrasing." }); + await H.git(["checkout", "--", ...H.gitScope]); + H.emit(job, { type: "done" }); + job.done = true; + return; + } + const target = job.page && job.page !== "/" ? job.page.replace(/^\//, "") : ""; + H.emit(job, { type: "preview", url: `${base}/${target}` }); + H.emit(job, { type: "done" }); + job.done = true; +} + +function runAgent(job, { message, page, selections, model }) { + const prompt = buildPrompt({ message, page, selections }); + H.runClaudeStream({ + prompt, tools: H.AGENT_TOOLS, model, resume: job.sessionId, + onMessage: (msg) => handleStreamMsg(job, msg), + onStderr: (t) => { const s = t.trim(); if (s) H.emit(job, { kind: "tool", text: s.slice(0, 200) }); }, + onClose: async (code) => { + if (code !== 0) { + H.emit(job, { type: "error", text: "The agent stopped unexpectedly. Nothing was changed." }); + H.emit(job, { type: "done" }); + job.done = true; + H.state.busy = false; + return; + } + await buildPreview(job); + H.state.busy = false; + }, + onError: (err) => { + H.emit(job, { type: "error", text: `Could not start the agent: ${err.message}` }); + H.emit(job, { type: "done" }); + job.done = true; + H.state.busy = false; + }, + }); +} + +export function register(host) { + H = host; + + host.route("POST", "/run", async (req, res) => { + if (H.state.busy) return H.json(res, 429, { error: "A change is already in progress — let it finish." }); + const body = await H.readBody(req); + const message = String(body.message || "").trim(); + if (!message) return H.json(res, 400, { error: "Say what you'd like changed." }); + const env = H.loadEnv(); + if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set. Add one with the 🔑 button." }); + + let job = body.conversationId && H.jobs.get(body.conversationId); + if (job) { job.done = false; job.events = []; job.jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; } + else job = H.newJob(body.page); + + H.state.busy = true; + runAgent(job, { message, page: body.page, selections: body.selections, model: body.model }); + H.json(res, 200, { conversationId: job.conversationId }); + }); + + host.route("ANY", "/stream", (req, res, { url }) => { + const id = url.searchParams.get("conversationId"); + const job = id && H.jobs.get(id); + if (!job) return H.json(res, 404, { error: "unknown conversation" }); + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }); + for (const ev of job.events) res.write(`data: ${JSON.stringify(ev)}\n\n`); + if (job.done) return res.end(); + job.clients.add(res); + req.on("close", () => job.clients.delete(res)); + }); + + host.route("POST", "/publish", async (req, res) => { + if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." }); + const { conversationId } = await H.readBody(req); + const job = conversationId && H.jobs.get(conversationId); + if (!job) return H.json(res, 404, { error: "Nothing to publish." }); + H.state.busy = true; + const built = await H.build({}); + if (!built.ok) { H.state.busy = false; return H.json(res, 500, { error: "Build failed — not published." }); } + await H.git(["add", "--", ...H.gitScope]); + const summary = (job.page && job.page !== "/" ? job.page : "homepage"); + await H.git(["commit", "-q", "-m", `console: edit ${summary}\n\n[published via astroagent console]`]); + H.rmSync(H.join(H.PREVIEW_DIR, job.jobId), { recursive: true, force: true }); + H.state.busy = false; + H.json(res, 200, { ok: true, live: job.page || "/" }); + }); + + // Rebuild the live site after DB-driven content edits (projects gallery, etc.). + host.route("POST", "/rebuild", async (req, res) => { + if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." }); + H.state.busy = true; + const built = await H.build({}); + if (!built.ok) { H.state.busy = false; return H.json(res, 500, { error: "Build failed." }); } + await H.git(["add", "--", "app/public/media", ...H.gitScope]); + await H.git(["commit", "-q", "-m", "console: content update"]); // ok if nothing to commit + H.state.busy = false; + H.json(res, 200, { ok: true }); + }); + + host.route("POST", "/discard", async (req, res) => { + if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." }); + const { conversationId } = await H.readBody(req); + const job = conversationId && H.jobs.get(conversationId); + H.state.busy = true; + await H.git(["checkout", "--", ...H.gitScope]); + await H.git(["clean", "-fd", ...H.gitScope]); + if (job) H.rmSync(H.join(H.PREVIEW_DIR, job.jobId), { recursive: true, force: true }); + H.state.busy = false; + if (job) H.jobs.delete(job.conversationId); + H.json(res, 200, { ok: true }); + }); +} diff --git a/agents/console/agents/project-builder.mjs b/agents/console/agents/project-builder.mjs new file mode 100644 index 0000000..96d3ad6 --- /dev/null +++ b/agents/console/agents/project-builder.mjs @@ -0,0 +1,145 @@ +/** + * Project builder — one-shot copy drafting and full structured-project authoring + * for the admin's New Project form. Returns JSON to the admin UI (which persists + * it via the PHP API); never mutates the repo. Owns: /draft, /build-project. + */ + +let H; + +const MEDIA_RE = /^\/media\/[\w.-]+$/; +const MAX_BUILD_IMAGES = 8; + +function stripFence(t) { + const m = String(t).match(/```(?:json)?\s*([\s\S]*?)```/); + return (m ? m[1] : t).trim(); +} + +// Turn a rough draft + prompt into polished project copy. No tools. +async function draftCopy({ name, category, draft, prompt }) { + const ask = [ + "You are writing copy for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.", + "Voice: precise, understated, confident. No hype, no buzzwords, no exclamation marks. Write a project case study.", + "", + `Project name: ${name}`, + category ? `Category: ${category}` : "", + draft ? `The author's rough draft / notes:\n${draft}` : "", + prompt ? `The author's instructions:\n${prompt}` : "", + "", + "Return ONLY a JSON object (no markdown fence, no commentary) with these string keys:", + '- "summary": one sentence (<=160 chars) for the projects list.', + '- "lede": one punchy opening line for the top of the project page.', + '- "body": the case study in Markdown. Use ## for section headings and - for bullets. Open with a short overview, and where it fits include a "## Highlights" bulleted section. 150-350 words.', + ].filter(Boolean).join("\n"); + + const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "" }); + if (!ok) return { ok: false }; + try { const obj = JSON.parse(stripFence(result)); return { ok: true, summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "" }; } + catch { return { ok: false }; } +} + +// Read the uploaded images and author a COMPLETE cja_projects record. Read-only. +async function buildProject({ name, category, draft, prompt, images, instagram }) { + const srcs = (Array.isArray(images) ? images : []) + .map((im) => String(im?.src || "")) + .filter((s) => MEDIA_RE.test(s)); + const useSrcs = srcs.slice(0, MAX_BUILD_IMAGES); + if (srcs.length > MAX_BUILD_IMAGES) console.log(`[build-project] capping images ${srcs.length} -> ${MAX_BUILD_IMAGES}`); + const paths = useSrcs.map((s) => "app/public" + s); + const imageLines = paths.length ? paths.map((p, i) => ` ${i + 1}. ${p}`).join("\n") : ""; + + const ask = [ + "You are the Web Designer for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.", + "Your job: turn the brief below into a COMPLETE project case study for a fixed, on-brand page template. You author STRUCTURED CONTENT, not HTML or layout.", + "", + "Brand (from brand/BRAND.md — honor it): sumi-e, ink on washi paper, a single vermillion seal, restraint. Voice is precise, understated, confident — no hype, no buzzwords, no exclamation marks. Never describe colours or layout; the template owns all styling.", + "", + "The page auto-renders these sections from the fields you return — populate the ones the brief supports, leave the rest empty:", + "- header: title + one-line lede", + "- facts rail: kind (project type), period (timeline label), role", + "- metrics: a few outcome stats, each {value, label, note?}", + "- gallery: your caption for each image below", + "- body: the case study", + "- skills: grouped disciplines, each {group, items[]}", + "- stack: technologies used, as plain strings", + "- categories: 1-3 short tags", + "", + `Project name: ${name}`, + category ? `Category hint: ${category}` : "", + instagram ? "There is an Instagram reel for this project." : "", + draft ? `The author's rough draft / notes:\n${draft}` : "", + prompt ? `The author's special instructions (follow these):\n${prompt}` : "", + "", + paths.length + ? `Uploaded images — READ each file and caption it from what is ACTUALLY shown. Keep captions short and specific; do not invent UI or content that isn't visible:\n${imageLines}` + : "No images were uploaded.", + "", + "Body rules: Markdown only, using ONLY ## / ### headings, paragraphs, - bullet lists, and **bold**. No images, no HTML, no tables. Open with a short overview; where it fits include a '## Highlights' bulleted section. 150-350 words.", + "", + "Return ONLY a JSON object (no markdown fence, no commentary) with these keys:", + '- "summary": one sentence (<=160 chars) for the projects list.', + '- "lede": one punchy opening line.', + '- "body": the Markdown case study.', + '- "kind": short project type, e.g. "Website" or "SaaS / Media" (or "").', + '- "period": a timeline label, e.g. "2024 — present" (or "").', + '- "role": Carlos\'s role on the project (or "").', + '- "categories": array of 1-3 short strings.', + '- "stack": array of technology strings.', + '- "skills": array of {"group": string, "items": [string, ...]}.', + '- "metrics": array of {"value": string, "label": string, "note"?: string}. Use an empty array if the brief has no real numbers — do NOT invent metrics.', + `- "captions": array of exactly ${paths.length} strings, one per uploaded image IN ORDER.`, + `- "alts": array of exactly ${paths.length} short alt-text strings, one per image IN ORDER.`, + ].filter(Boolean).join("\n"); + + const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "Read" }); + if (!ok) return { ok: false }; + try { + const obj = JSON.parse(stripFence(result)); + const caps = Array.isArray(obj.captions) ? obj.captions : []; + const alts = Array.isArray(obj.alts) ? obj.alts : []; + const gallery = useSrcs.map((src, i) => ({ + src, alt: String(alts[i] || "").slice(0, 200), caption: String(caps[i] || "").slice(0, 120), + })); + const strArr = (a) => (Array.isArray(a) ? a.map((x) => String(x)).filter(Boolean) : []); + return { + ok: true, + summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "", + kind: obj.kind || "", period: obj.period || "", role: obj.role || "", + categories: strArr(obj.categories), stack: strArr(obj.stack), + skills: Array.isArray(obj.skills) ? obj.skills : [], + metrics: Array.isArray(obj.metrics) ? obj.metrics : [], + gallery, + }; + } catch { return { ok: false }; } +} + +export function register(host) { + H = host; + + host.route("POST", "/draft", async (req, res) => { + if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." }); + const body = await H.readBody(req); + const name = String(body.name || "").trim(); + if (!name) return H.json(res, 400, { error: "Add a project name first." }); + const env = H.loadEnv(); + if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set." }); + H.state.busy = true; + const d = await draftCopy(body); + H.state.busy = false; + if (!d.ok) return H.json(res, 500, { error: "Couldn't generate a draft — try again." }); + H.json(res, 200, d); + }); + + host.route("POST", "/build-project", async (req, res) => { + if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." }); + const body = await H.readBody(req); + const name = String(body.name || "").trim(); + if (!name) return H.json(res, 400, { error: "Add a project name first." }); + const env = H.loadEnv(); + if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set." }); + H.state.busy = true; + const d = await buildProject(body); + H.state.busy = false; + if (!d.ok) return H.json(res, 500, { error: "The Web Designer couldn't finish — try again." }); + H.json(res, 200, d); + }); +} diff --git a/agents/console/agents/qa.mjs b/agents/console/agents/qa.mjs new file mode 100644 index 0000000..dc00f28 --- /dev/null +++ b/agents/console/agents/qa.mjs @@ -0,0 +1,235 @@ +/** + * QA agent — a deterministic HTTP crawler that tests the live static site (links, + * images, forms, API, SEO/meta, a11y), stores findings via the qa-*.php CLI, and + * a thin LLM step writes a summary. Read-only against the site. Auto-fixes safe + * findings by kicking the Web Designer queue (host.drainTasks). Owns: /qa/run + + * an in-process heartbeat. + */ + +import { writeFileSync, rmSync } from "node:fs"; + +let H; + +const QA_BASE = "https://carlosarias.co"; +const QA_UA = "Mozilla/5.0 (compatible; CarlosAriasQA/1.0; +https://carlosarias.co)"; +const QA_STATIC_ROUTES = [ + "/", "/about", "/services", "/services/website-design", + "/projects", "/blog", "/contact", "/changelog", "/resume", "/faq", +]; +const QA_AUTOFIX = (process.env.QA_AUTOFIX ?? "1") !== "0"; +const QA_HEARTBEAT_MIN = Number(process.env.QA_HEARTBEAT_MIN || 60); +let qaRunning = false; + +async function probe(url, { method = "GET", readBody = false, timeout = 12000, headers = {}, body = null } = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeout); + try { + const r = await fetch(url, { method, redirect: "follow", signal: ctrl.signal, headers: { "user-agent": QA_UA, ...headers }, body }); + let text = null; + if (readBody) text = await r.text(); + else { try { await r.body?.cancel(); } catch {} } + return { status: r.status, ok: r.ok, finalUrl: r.url, text }; + } catch (e) { + return { status: 0, ok: false, error: e.name === "AbortError" ? "timeout" : (e.message || "network error") }; + } finally { clearTimeout(t); } +} + +async function pMap(items, concurrency, fn) { + const out = []; let i = 0; + const workers = Array.from({ length: Math.min(concurrency, items.length || 1) }, async () => { + while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); } + }); + await Promise.all(workers); + return out; +} + +const qaNorm = (p) => { p = String(p).split("#")[0].split("?")[0]; if (p.length > 1) p = p.replace(/\/+$/, ""); return p || "/"; }; +const qaAbs = (href, pagePath) => { try { return new URL(href, QA_BASE + pagePath).href; } catch { return null; } }; +const qaGrabAll = (re, html) => [...String(html).matchAll(re)].map((m) => m[1]); +const qaFirst = (re, html) => { const m = String(html).match(re); return m ? m[1].trim() : ""; }; +function qaAltIssues(html) { + const out = []; + for (const m of String(html).matchAll(/]*>/gi)) { + const tag = m[0]; + const src = (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1]; + if (!src) continue; + const withVal = tag.match(/\salt\s*=\s*["']([^"']*)["']/i); + if (withVal) { if (withVal[1].trim() === "") out.push({ src, kind: "empty" }); } + else if (/\salt(\s|>|\/)/i.test(tag)) out.push({ src, kind: "empty" }); + else out.push({ src, kind: "missing" }); + } + return out; +} + +async function runQa() { + const findings = []; + const add = (check_type, severity, url, detail, fix_hint = null) => findings.push({ check_type, severity, url, detail, fix_hint }); + + let dynamic = []; + try { dynamic = JSON.parse((await H.phpCli(["api/cli/qa-routes.php"])).out || "[]"); } catch {} + const routes = [...new Set([...QA_STATIC_ROUTES, ...dynamic].map(qaNorm))]; + + const pages = await pMap(routes, 6, async (path) => ({ path, r: await probe(QA_BASE + path, { readBody: true }) })); + + const fetched = new Map(); + const titles = new Map(); + const internal = new Set(); + const images = new Set(); + const external = new Map(); + + for (const { path, r } of pages) { + fetched.set(path, r.ok); + if (!r.ok) { + add("page", "error", path, `Returns ${r.status || r.error} instead of 200.`, + `${path} returns ${r.status || r.error} instead of 200. Investigate why the page fails to render and fix it.`); + continue; + } + const html = r.text || ""; + const title = qaFirst(/]*>([^<]*)<\/title>/i, html); + const desc = qaFirst(/]+name=["']description["'][^>]+content=["']([^"']*)["']/i, html); + const canon = qaFirst(/]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i, html); + if (!title) add("seo", "warning", path, "Missing .", `${path} has no <title>. Add a page-specific title via its BaseLayout props.`); + else { if (!titles.has(title)) titles.set(title, []); titles.get(title).push(path); } + if (!desc) add("seo", "warning", path, "Missing meta description.", `${path} has no meta description. Add a page-specific description via its BaseLayout props.`); + if (canon) { try { const h = new URL(canon).host; if (h && h !== "carlosarias.co") add("seo", "error", path, `Canonical points to ${h}.`, `${path} canonical points to ${h} instead of carlosarias.co. Fix the site URL / canonical.`); } catch {} } + if (!/<html[^>]+lang=/i.test(html)) add("a11y", "warning", path, "<html> has no lang attribute.", `${path} <html> tag has no lang attribute. Add lang="en".`); + + for (const href of qaGrabAll(/<a\b[^>]*\bhref=["']([^"']+)["']/gi, html)) { + if (/^(mailto:|tel:|javascript:|#|data:)/i.test(href)) continue; + const u = qaAbs(href, path); if (!u) continue; + const noHash = u.split("#")[0]; + if (noHash.startsWith(QA_BASE)) internal.add(noHash); + else if (/^https?:\/\//i.test(noHash) && !external.has(noHash)) external.set(noHash, path); + } + for (const src of qaGrabAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi, html)) { const u = qaAbs(src, path); if (u && /^https?:/i.test(u)) images.add(u.split("#")[0]); } + for (const a of qaAltIssues(html)) { + if (a.kind === "empty") add("a11y", "warning", path, `Empty alt: ${a.src}`, + `On ${path}, the image "${a.src}" has an empty alt attribute. If it conveys meaning (a photo, cover, or screenshot), add descriptive alt text that explains what it shows; leave it empty only if it is purely decorative.`); + else add("a11y", "warning", path, `Missing alt: ${a.src}`, + `On ${path}, the image "${a.src}" has no alt attribute. Add descriptive alt text that explains what it shows.`); + } + } + + for (const [title, paths] of titles) { + if (paths.length > 1) add("seo", "warning", paths.join(", "), `${paths.length} pages share the title "${title}".`, + `These pages share one <title> ("${title}"): ${paths.join(", ")}. Give each a distinct, page-specific title.`); + } + + const internalPaths = [...new Set([...internal].map((u) => qaNorm(u.replace(QA_BASE, "") || "/")))]; + const toCheck = internalPaths.filter((p) => !fetched.has(p)); + await pMap(toCheck, 8, async (p) => { + const r = await probe(QA_BASE + p, {}); + if (!r.ok) add("link", "error", p, `Broken internal link (${r.status || r.error}).`, + `An internal link points to ${p}, which returns ${r.status || r.error}. Find that link in the page source and fix the URL or remove the link.`); + }); + + await pMap([...images], 8, async (u) => { + const r = await probe(u, {}); + if (!r.ok) add("image", "error", u, `Image returns ${r.status || r.error}.`, + `The image ${u} returns ${r.status || r.error}. Fix the image path or replace the image.`); + }); + + await pMap([...external.keys()], 6, async (u) => { + const r = await probe(u, { method: "GET", timeout: 12000 }); + const clearlyBad = r.status === 404 || r.status === 410 || (r.status === 0 && r.error && r.error !== "timeout"); + if (clearlyBad) add("external", "warning", u, `External link may be broken (${r.status || r.error}); found on ${external.get(u)}.`, + `The external link ${u} (on ${external.get(u)}) appears broken (${r.status || r.error}). Verify it and update or remove it.`); + }); + + const cHeaders = { "content-type": "application/json", origin: QA_BASE, referer: QA_BASE + "/contact" }; + const hp = await probe(QA_BASE + "/api/contact/submit", { + method: "POST", readBody: true, headers: cHeaders, + body: JSON.stringify({ name: "QA Bot", email: "qa@carlosarias.co", subject: "other", message: "QA honeypot probe — please ignore.", company: "qa-honeypot" }), + }); + if (hp.status !== 200) add("form", "error", "/api/contact/submit", `Contact honeypot probe returned ${hp.status || hp.error} (expected 200).`, + `POST /api/contact/submit returned ${hp.status || hp.error} instead of 200 for a probe. The contact form endpoint may be broken — check api/public/controllers/contact.php.`); + const val = await probe(QA_BASE + "/api/contact/submit", { + method: "POST", readBody: true, headers: cHeaders, + body: JSON.stringify({ name: "QA", email: "qa@carlosarias.co", subject: "other", message: "hi" }), + }); + if (val.status !== 422) add("form", "warning", "/api/contact/submit", `Validation probe returned ${val.status || val.error} (expected 422 for a too-short message).`, + `POST /api/contact/submit did not reject an invalid submission (got ${val.status || val.error}, expected 422). Server-side validation may be off.`); + + const health = await probe(QA_BASE + "/api/health", { readBody: true }); + let dbOk = false; try { const j = JSON.parse(health.text || "{}"); dbOk = (j.data?.db ?? j.db) === "connected"; } catch {} + if (health.status !== 200 || !dbOk) add("health", "error", "/api/health", `Status ${health.status || health.error}, db ${dbOk ? "connected" : "not connected"}.`, + `/api/health returned ${health.status || health.error}${dbOk ? "" : " and the database is not connected"}. The API or database may be down.`); + + const sm = await probe(QA_BASE + "/sitemap.xml", { readBody: true }); + if (sm.ok) { + const locs = new Set(qaGrabAll(/<loc>([^<]+)<\/loc>/gi, sm.text || "").map((l) => qaNorm(l.replace(QA_BASE, "")))); + for (const p of routes) if (!locs.has(p)) add("sitemap", "warning", p, "Not listed in sitemap.xml.", + `${p} is not in sitemap.xml. Add it in app/src/pages/sitemap.xml.js so search engines can find it.`); + } else { + add("sitemap", "warning", "/sitemap.xml", `sitemap.xml returned ${sm.status || sm.error}.`, `/sitemap.xml is unreachable (${sm.status || sm.error}). Check app/src/pages/sitemap.xml.js.`); + } + + const counts = { error: 0, warning: 0, info: 0, pages: pages.length, links: toCheck.length, images: images.size, external: external.size }; + for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1; + return { findings, counts }; +} + +async function qaTriage(findings, counts) { + const top = [ + ...findings.filter((f) => f.severity === "error").slice(0, 12), + ...findings.filter((f) => f.severity === "warning").slice(0, 12), + ]; + const lines = top.map((f) => `- [${f.severity}] ${f.check_type} ${f.url}: ${f.detail}`).join("\n") || "(no issues found)"; + const fallback = `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`; + const ask = [ + "You are the QA agent for carlosarias.co. A crawler just tested the live site. Consult your `qa` skill.", + `Counts: ${counts.error} errors, ${counts.warning} warnings across ${counts.pages} pages.`, + "Top findings:", lines, + "", + "Write a 2-4 sentence plain-English summary for the site owner: overall health, the most important things to fix first, and whether anything is urgent. No preamble — just the summary.", + ].join("\n"); + const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "Read Skill" }); + const s = ok && typeof result === "string" ? result.trim().slice(0, 800) : ""; + return s || fallback; +} + +async function runQaFlow(trigger) { + if (qaRunning) return; + qaRunning = true; + let runId = 0; + try { + const start = await H.phpCli(["api/cli/qa-start.php", `--trigger=${trigger}`]); + runId = JSON.parse(start.out || "{}").run_id || 0; + if (!runId) throw new Error("could not open a QA run"); + const { findings, counts } = await runQa(); + const summary = (trigger === "manual" || counts.error > 0) + ? await qaTriage(findings, counts) + : `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`; + const tmp = `/tmp/qa-${runId}.json`; + writeFileSync(tmp, JSON.stringify(findings)); + await H.phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]); + rmSync(tmp, { force: true }); + + if (QA_AUTOFIX) { + const af = await H.phpCli(["api/cli/qa-autofix.php", `--run=${runId}`]); + let queued = 0; try { queued = JSON.parse(af.out || "{}").queued || 0; } catch {} + if (queued > 0 && H.drainTasks) H.drainTasks(); // fire-and-forget + } + } catch (e) { + if (runId) await H.phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=failed", `--summary=QA run failed: ${String((e && e.message) || e).slice(0, 180)}`]); + } finally { + qaRunning = false; + } +} + +export function register(host) { + H = host; + + host.route("POST", "/qa/run", async (req, res) => { + if (qaRunning) return H.json(res, 429, { error: "A QA run is already in progress." }); + const body = await H.readBody(req); + const trigger = body.trigger === "scheduled" ? "scheduled" : "manual"; + runQaFlow(trigger); // fire-and-forget + H.json(res, 200, { ok: true }); + }); + + if (QA_HEARTBEAT_MIN > 0) { + host.everyMinutes(QA_HEARTBEAT_MIN, () => runQaFlow("scheduled"), { bootDelayMs: 90_000 }); + console.log(`[qa] heartbeat every ${QA_HEARTBEAT_MIN} min`); + } +} diff --git a/agents/console/agents/web-designer.mjs b/agents/console/agents/web-designer.mjs new file mode 100644 index 0000000..bb91a96 --- /dev/null +++ b/agents/console/agents/web-designer.mjs @@ -0,0 +1,157 @@ +/** + * Web Designer agent — a durable design queue (cja_tasks) drained one task at a + * time. Each task runs the full-builder agent on any page (skills + brief), then + * auto-publishes: build must pass, then a scoped git commit; a failing task + * reverts itself. Owns: /tasks/run. Exposes host.drainTasks for QA autofix. + */ + +let H; +let draining = false; + +async function claimNextTask() { + const { out } = await H.phpCli(["api/cli/tasks-next.php"]); + try { const t = JSON.parse((out || "{}").trim() || "{}"); return t && t.task_id ? t : null; } + catch { return null; } +} +async function finishTask(id, status, result) { + await H.phpCli(["api/cli/tasks-finish.php", `--id=${id}`, `--status=${status}`, `--result=${JSON.stringify(result)}`]); +} + +function liveUrl(target) { + if (!target) return "/"; + if (target.startsWith("new:")) return "/" + target.slice(4).replace(/^\/+/, ""); + return target; +} +function nowStamp() { + const d = new Date(); + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +function buildDesignPrompt(task) { + let assets = { images: [], videos: [] }; + try { assets = JSON.parse(task.assets || "{}") || {}; } catch {} + const images = Array.isArray(assets.images) ? assets.images : []; + const videos = Array.isArray(assets.videos) ? assets.videos : []; + const target = task.target_page || "/"; + const isNew = target.startsWith("new:"); + const slug = isNew ? target.slice(4).replace(/^\/+/, "") : ""; + + const p = []; + p.push( + "You are the Web Designer for the Carlos Arias website (carlosarias.co) — an Astro + Tailwind v4 static site.", + "You are a real designer: make considered, on-brand design decisions, not just literal edits.", + "", + "Before you start, consult your design skills and APPLY them: read .claude/skills/brand/SKILL.md and .claude/skills/ui-ux/SKILL.md.", + "The brand is sumi-e — ink on washi paper, a single vermillion seal, restraint, near-square 2px radii. Reuse the existing .ca-* classes and --ca-* tokens in app/src/styles.css and match nearby components; do not invent new one-off styles or add a second accent colour.", + "Do NOT run builds or git — the console builds and publishes for you.", + "", + ); + if (isNew) { + p.push( + `TASK: create a NEW page at /${slug}.`, + `- Create app/src/pages/${slug}.astro using BaseLayout and existing .ca-* section patterns (study app/src/pages/about.astro and services.astro for structure).`, + "- Register it in the nav: add it to the nav array in app/src/components/Header.astro AND the footer links in app/src/components/Footer.astro.", + "", + ); + } else { + p.push( + `TASK: work on the existing page ${target}.`, + "- Find its source under app/src/pages (Glob/Grep). Edit that file and any components it uses.", + "", + ); + } + p.push("What to do:", task.prompt || "(no instructions given)", ""); + if (task.draft && String(task.draft).trim()) { + p.push("Draft content to work from (polish it, don't paste it verbatim):", task.draft, ""); + } + if (images.length) { + p.push("Images you may use — Read each to see what it shows, then place it with its /media/... src and a real alt:"); + for (const im of images) p.push(`- ${im.url} (on disk: app/public${im.url})${im.alt ? ` — hint: ${im.alt}` : ""}`); + p.push(""); + } + if (videos.length) { + p.push("Short video links to embed where they fit (e.g. an Instagram reel — responsive 9:16, no autoplay sound):"); + for (const v of videos) p.push(`- ${v}`); + p.push(""); + } + p.push( + "", + "After you finish the change, log it to the public changelog using your `changelog` skill:", + "- Add ONE entry to the $entries array in api/cli/seed-changelog.php, in the site's visitor-facing voice.", + `- Use the timestamp '${nowStamp()}' and attribute it to 'Website Designer Agent' (the 5th array element).`, + "- Choose the right type (added / updated / fixed / removed).", + "- Do NOT run the reseed or the build — the console does that for you.", + "- If you ended up making no change to the site, do not add a changelog entry.", + "", + "Keep the change scoped to what's asked and leave the working tree with only your intended edits.", + ); + return p.join("\n"); +} + +async function runDesignTask(task) { + const prompt = buildDesignPrompt(task); + + // Pre-flight: a dirty tree would be swept into this task's scoped commit. + // Uploaded assets under app/public/media/ are this task's images and are fine. + const pre = await H.git(["status", "--porcelain", "--", ...H.gitScope]); + const dirty = pre.tail.split("\n").map((l) => l.slice(3).trim()).filter(Boolean).filter((p) => !p.startsWith("app/public/media/")); + if (dirty.length) return { ok: false, error: "Working tree wasn't clean — commit or discard pending changes before running the queue." }; + + const { result } = await H.runClaudeJson({ prompt, tools: "Read Write Edit Glob Grep WebSearch Skill" }); + const summary = typeof result === "string" ? result.trim().slice(0, 500) : ""; + + const status = await H.git(["status", "--porcelain", "--", ...H.gitScope]); + if (status.tail.trim() === "") { + return { ok: true, commit: "", summary: summary || "No changes were needed.", live: liveUrl(task.target_page) }; + } + // The agent logs to the changelog by editing seed-changelog.php; reseed so the + // build picks it up (the changelog page reads the DB at build time). + if (/seed-changelog\.php/.test(status.tail)) await H.phpCli(["api/cli/seed-changelog.php"]); + + const built = await H.build({}); + if (!built.ok) { + await H.git(["checkout", "--", ...H.gitScope]); + await H.git(["clean", "-fd", ...H.gitScope]); + return { ok: false, error: "The change broke the build, so it was reverted." }; + } + await H.git(["add", "--", ...H.gitScope]); + const title = (task.title || "task").toString().slice(0, 120); + await H.git(["commit", "-q", "-m", `web designer: ${title}\n\n[published via task queue]`]); + const head = await H.git(["rev-parse", "--short", "HEAD"]); + return { ok: true, commit: head.tail.trim(), summary, live: liveUrl(task.target_page) }; +} + +// Drain the queue sequentially. Shares host.state.busy; re-entrant-safe. +async function drainTasks() { + if (draining) return; + draining = true; + try { + for (;;) { + if (H.state.busy) break; // in-page console mid-edit; a later kick resumes + const task = await claimNextTask(); + if (!task) break; + H.state.busy = true; + let res; + try { res = await runDesignTask(task); } + catch (e) { res = { ok: false, error: String((e && e.message) || e) }; } + H.state.busy = false; + await finishTask(task.task_id, res.ok ? "published" : "failed", + res.ok ? { commit: res.commit, summary: res.summary, live: res.live } : { error: res.error || "failed" }); + } + } finally { + draining = false; + } +} + +export function register(host) { + H = host; + host.drainTasks = drainTasks; // QA autofix kicks the queue through this + + host.route("POST", "/tasks/run", async (req, res) => { + const env = H.loadEnv(); + if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set." }); + drainTasks(); // fire-and-forget + H.json(res, 200, { ok: true, draining, busy: H.state.busy }); + }); +} diff --git a/agents/console/host.mjs b/agents/console/host.mjs new file mode 100644 index 0000000..212f6a1 --- /dev/null +++ b/agents/console/host.mjs @@ -0,0 +1,225 @@ +/** + * AstroAgent host — the generic runtime that hosts pluggable agents. + * + * Provides the shared primitives every agent reuses (Claude spawn, build, git, + * the PHP-CLI DB bridge, SSE job model, a route registry, and a scheduler) and + * knows NOTHING agent-specific. Agent modules under ./agents/ call + * `host.route(...)` / `host.everyMinutes(...)` and use `host.runClaudeJson` etc. + * + * Reached only through nginx (auth_request against the PHP admin session); this + * process does not re-check auth. Node built-ins only — no dependencies. + */ + +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync } from "node:fs"; +import { dirname, resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); // agents/console +const REPO = resolve(HERE, "..", ".."); // repo root +const APP = join(REPO, "app"); +const PREVIEW_DIR = join(REPO, "public-preview"); +const ENV_FILE = join(REPO, "agents", ".env"); +const CLAUDE = "/usr/local/bin/claude"; +const PHP = "/usr/bin/php"; +const PORT = Number(process.env.ADMIN_PORT || 3011); +const AGENT_TOOLS = "Read Write Edit Glob Grep WebSearch"; +const DEFAULT_MODEL = "claude-sonnet-4-6"; +const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"]; // paths the console may commit + +// ---- env (.env) -------------------------------------------------------------- +function loadEnv() { + const out = {}; + if (existsSync(ENV_FILE)) { + for (const line of readFileSync(ENV_FILE, "utf8").split("\n")) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); + if (m) out[m[1]] = m[2]; + } + } + return out; +} +function saveToken(token) { + let s = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, "utf8") : ""; + if (/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m.test(s)) { + s = s.replace(/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m, `CLAUDE_CODE_OAUTH_TOKEN=${token}`); + } else { + s += `\nCLAUDE_CODE_OAUTH_TOKEN=${token}\n`; + } + writeFileSync(ENV_FILE, s, { mode: 0o600 }); +} +function agentEnv() { + const env = loadEnv(); + return { + ...process.env, + HOME: process.env.HOME || "/var/lib/carlos-arias-agent", + PATH: "/usr/local/bin:/usr/bin:/bin", + CLAUDE_CODE_OAUTH_TOKEN: env.CLAUDE_CODE_OAUTH_TOKEN || "", + }; +} + +// ---- Claude spawn (the ONE place agents invoke claude) ----------------------- +/** One-shot: `claude -p … --output-format json`. Resolves {ok, result}. */ +function runClaudeJson({ prompt, tools = "", model }) { + return new Promise((res) => { + const child = spawn(CLAUDE, ["-p", prompt, "--output-format", "json", "--model", model || DEFAULT_MODEL, "--allowedTools", tools], { cwd: REPO, env: agentEnv() }); + let out = ""; + child.stdout.on("data", (d) => (out += d)); + child.on("close", () => { + try { const env = JSON.parse(out); res({ ok: true, result: typeof env.result === "string" ? env.result : "" }); } + catch { res({ ok: false, result: "" }); } + }); + child.on("error", () => res({ ok: false, result: "" })); + }); +} +/** Streaming: `claude -p … --output-format stream-json`. Calls onMessage per NDJSON line; returns the child so the caller wires close/error. */ +function runClaudeStream({ prompt, tools = AGENT_TOOLS, model, resume, onMessage, onStderr, onClose, onError }) { + const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", tools, "--model", model || DEFAULT_MODEL]; + if (resume) args.push("--resume", resume); + const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); + let buf = ""; + child.stdout.on("data", (chunk) => { + buf += chunk.toString(); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + let msg; try { msg = JSON.parse(line); } catch { continue; } + onMessage && onMessage(msg); + } + }); + child.stderr.on("data", (d) => onStderr && onStderr(d.toString())); + child.on("close", (code) => onClose && onClose(code)); + child.on("error", (err) => onError && onError(err)); + return child; +} + +// ---- build / git / php-cli --------------------------------------------------- +function build(extraEnv) { + return new Promise((res) => { + const child = spawn("npm", ["run", "build"], { cwd: APP, env: { ...agentEnv(), ...extraEnv } }); + let tail = ""; + const grab = (d) => { tail = (tail + d.toString()).slice(-4000); }; + child.stdout.on("data", grab); + child.stderr.on("data", grab); + child.on("close", (code) => res({ ok: code === 0, tail })); + child.on("error", () => res({ ok: false, tail: "build failed to start" })); + }); +} +function git(args) { + return new Promise((res) => { + const child = spawn("git", args, { cwd: REPO, env: agentEnv() }); + let tail = ""; + child.stdout.on("data", (d) => (tail += d)); + child.stderr.on("data", (d) => (tail += d)); + child.on("close", (code) => res({ ok: code === 0, tail: tail.toString() })); + child.on("error", () => res({ ok: false, tail: "" })); + }); +} +function phpCli(args) { + return new Promise((res) => { + const child = spawn(PHP, args, { cwd: REPO, env: agentEnv() }); + let out = "", err = ""; + child.stdout.on("data", (d) => (out += d)); + child.stderr.on("data", (d) => (err += d)); + child.on("close", () => res({ out, err })); + child.on("error", () => res({ out: "", err: "php spawn failed" })); + }); +} + +// ---- HTTP helpers ------------------------------------------------------------ +function json(res, status, obj) { + res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(obj)); +} +function readBody(req) { + return new Promise((res) => { + let b = ""; + req.on("data", (c) => (b += c)); + req.on("end", () => { try { res(b ? JSON.parse(b) : {}); } catch { res({}); } }); + }); +} + +// ---- SSE job model ----------------------------------------------------------- +const jobs = new Map(); +function newJob(page) { + const conversationId = randomUUID(); + const job = { + conversationId, + jobId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, + sessionId: null, + page: page || "/", + events: [], + clients: new Set(), + done: false, + }; + jobs.set(conversationId, job); + return job; +} +function emit(job, ev) { + job.events.push(ev); + const line = `data: ${JSON.stringify(ev)}\n\n`; + for (const res of job.clients) { try { res.write(line); } catch {} } +} + +// ---- the host singleton ------------------------------------------------------ +const _routes = []; +const _schedules = []; + +export const host = { + // paths / constants (Phase 2: source from config) + REPO, APP, PREVIEW_DIR, CLAUDE, PHP, PORT, AGENT_TOOLS, DEFAULT_MODEL, gitScope: GIT_SCOPE, + // node fs re-exports agents need + rmSync, join, + // shared single-flight lock (mutable by reference across agent modules) + state: { busy: false }, + // env + claude + ops + http + SSE + loadEnv, saveToken, agentEnv, + runClaudeJson, runClaudeStream, + build, git, phpCli, + json, readBody, + jobs, newJob, emit, + + /** Register an HTTP route. method "ANY" matches any verb. */ + route(method, path, handler) { _routes.push({ method, path, handler }); }, + + /** Run fn every `min` minutes, plus one pulse `bootDelayMs` after listen(). */ + everyMinutes(min, fn, { bootDelayMs = 90_000 } = {}) { + if (min > 0) _schedules.push({ min, fn, bootDelayMs }); + }, + + /** Start the server + scheduled tasks. */ + listen() { + const server = createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + const path = url.pathname.replace(/^\/devconsole/, "") || "/"; + const method = req.method || "GET"; + try { + const route = _routes.find((r) => r.path === path && (r.method === "ANY" || r.method === method)); + if (!route) return json(res, 404, { error: "not found" }); + await route.handler(req, res, { url, path, method }); + } catch (err) { json(res, 500, { error: err.message }); } + }); + if (!existsSync(PREVIEW_DIR)) mkdirSync(PREVIEW_DIR, { recursive: true }); + server.listen(PORT, "127.0.0.1", () => console.log(`[console] runner on 127.0.0.1:${PORT}, repo ${REPO}`)); + for (const s of _schedules) { + setTimeout(s.fn, s.bootDelayMs); + setInterval(s.fn, s.min * 60_000); + } + }, +}; + +// ---- core routes (token/health — framework-level, agent-independent) --------- +host.route("ANY", "/ping", (req, res) => { + const env = loadEnv(); + json(res, 200, { authed: true, hasToken: Boolean(env.CLAUDE_CODE_OAUTH_TOKEN) }); +}); +host.route("POST", "/auth", async (req, res) => { + const { token } = await readBody(req); + if (!token || !/^sk-ant-/.test(token)) return json(res, 400, { error: "That doesn't look like a Claude token (expected sk-ant-…)." }); + saveToken(token.trim()); + json(res, 200, { ok: true }); +}); +host.route("POST", "/logout", (req, res) => json(res, 200, { ok: true })); // admin session cleared by PHP diff --git a/agents/console/server.mjs b/agents/console/server.mjs index f1b72d6..07b0d21 100644 --- a/agents/console/server.mjs +++ b/agents/console/server.mjs @@ -1,1037 +1,28 @@ #!/usr/bin/env node /** - * AstroAgent console runner. + * AstroAgent console runner — bootstrap. * - * Runs AS the confined `carlos-arias-agent` user (systemd User=), binds - * 127.0.0.1 only, and is reached exclusively through nginx, which gates every - * request with auth_request against the PHP admin session. If a request lands - * here, the operator is already an authenticated admin — this process does not - * re-check that; its job is orchestration. + * Loads the generic host, registers the agent modules, and starts listening. + * Runs AS the confined agent user (systemd User=), binds 127.0.0.1 only, and is + * reached exclusively through nginx, which gates every request with auth_request + * against the PHP admin session — this process does not re-check that. * - * Contract (matches app/src/components/DevConsole.astro): - * GET /devconsole/ping -> { authed, hasToken } - * POST /devconsole/run {message,page,...} -> { conversationId } (async) - * GET /devconsole/stream?conversationId=... -> SSE: text|tool|preview|published|error|done - * POST /devconsole/publish {conversationId} -> promotes preview, git commit - * POST /devconsole/discard {conversationId} -> reverts working tree - * POST /devconsole/auth {token} -> update the Claude token - * POST /devconsole/logout -> (admin logout is PHP; no-op here) - * - * Safety model: - * - The AGENT only edits files (tools: Read/Write/Edit/Glob/Grep/WebSearch, - * NO Bash). This runner — not the agent — runs every build and git op. - * - Every /run edits the working tree, then builds an ISOLATED preview to - * public-preview/<jobId>. Nothing is live. - * - /publish builds to public/, verifies the build passed, then git-commits - * (a rollback point). /discard does `git checkout -- .`. - * - One job at a time (single admin); a lock serialises run/publish/discard. + * The shared runtime lives in ./host.mjs; each capability is a module under + * ./agents/ exporting register(host). (Phase 3 will drive this roster from + * astroagent.config.json instead of the hardcoded list below.) * * Node built-ins only — no dependencies. */ -import { createServer } from "node:http"; -import { spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync } from "node:fs"; -import { dirname, resolve, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const REPO = resolve(HERE, "..", ".."); // /www/wwwroot/CarlosAriasPersonal -const APP = join(REPO, "app"); -const PREVIEW_DIR = join(REPO, "public-preview"); -const ENV_FILE = join(REPO, "agents", ".env"); -const CLAUDE = "/usr/local/bin/claude"; -const PHP = "/usr/bin/php"; -const PORT = Number(process.env.ADMIN_PORT || 3011); -const AGENT_TOOLS = "Read Write Edit Glob Grep WebSearch"; -const DEFAULT_MODEL = "claude-sonnet-4-6"; - -// ---- env (.env) -------------------------------------------------------------- -function loadEnv() { - const out = {}; - if (existsSync(ENV_FILE)) { - for (const line of readFileSync(ENV_FILE, "utf8").split("\n")) { - const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); - if (m) out[m[1]] = m[2]; - } - } - return out; -} -function saveToken(token) { - let s = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, "utf8") : ""; - if (/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m.test(s)) { - s = s.replace(/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m, `CLAUDE_CODE_OAUTH_TOKEN=${token}`); - } else { - s += `\nCLAUDE_CODE_OAUTH_TOKEN=${token}\n`; - } - writeFileSync(ENV_FILE, s, { mode: 0o600 }); -} - -// ---- job state --------------------------------------------------------------- -/** conversationId -> job */ -const jobs = new Map(); -let busy = false; // single-flight lock - -function newJob(page) { - const conversationId = randomUUID(); - const job = { - conversationId, - jobId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, - sessionId: null, // claude session, for --resume follow-ups - page: page || "/", - events: [], // buffered for late SSE subscribers - clients: new Set(), // active SSE responses - done: false, - }; - jobs.set(conversationId, job); - return job; -} -function emit(job, ev) { - job.events.push(ev); - const line = `data: ${JSON.stringify(ev)}\n\n`; - for (const res of job.clients) { - try { res.write(line); } catch {} - } -} - -// ---- prompt wrapper ---------------------------------------------------------- -function buildPrompt({ message, page, selections }) { - const parts = []; - parts.push( - "You are the editing agent for the Carlos Arias website (an Astro + Tailwind v4 static site).", - "Make ONLY the change the operator asks for. Keep everything on-brand:", - "the brand guide is brand/BRAND.md and the design tokens are in app/src/styles.css", - "(sumi-e — ink on washi paper, one vermillion seal, restraint, near-square radii).", - "Do NOT run builds or git commands — the console builds and publishes for you.", - "", - ); - if (page && page !== "/") { - parts.push(`The operator is on the page: ${page}`); - parts.push( - "Its source is almost certainly under app/src/pages (find it with Glob/Grep).", - "", - ); - } else if (page === "/") { - parts.push("The operator is on the homepage (app/src/pages/index.astro).", ""); - } - if (Array.isArray(selections) && selections.length) { - parts.push("They selected these element(s) on the page:"); - for (const s of selections) { - const tag = s.tag || s.selector || "element"; - parts.push(`- <${tag}>${s.text ? ` — "${String(s.text).slice(0, 80)}"` : ""}${s.comment ? ` — note: ${s.comment}` : ""}`); - } - parts.push(""); - } - parts.push("Request:", message); - return parts.join("\n"); -} - -// ---- run the agent, then build a preview ------------------------------------- -function agentEnv() { - const env = loadEnv(); - return { - ...process.env, - HOME: process.env.HOME || "/var/lib/carlos-arias-agent", - PATH: "/usr/local/bin:/usr/bin:/bin", - CLAUDE_CODE_OAUTH_TOKEN: env.CLAUDE_CODE_OAUTH_TOKEN || "", - }; -} - -function runAgent(job, { message, page, selections, model }) { - const prompt = buildPrompt({ message, page, selections }); - const args = [ - "-p", prompt, - "--output-format", "stream-json", - "--verbose", - "--allowedTools", AGENT_TOOLS, - "--model", model || DEFAULT_MODEL, - ]; - if (job.sessionId) args.push("--resume", job.sessionId); - - const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); - - let buf = ""; - child.stdout.on("data", (chunk) => { - buf += chunk.toString(); - let nl; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl).trim(); - buf = buf.slice(nl + 1); - if (!line) continue; - let msg; - try { msg = JSON.parse(line); } catch { continue; } - handleStreamMsg(job, msg); - } - }); - child.stderr.on("data", (d) => { - const t = d.toString().trim(); - if (t) emit(job, { kind: "tool", text: t.slice(0, 200) }); - }); - child.on("close", async (code) => { - if (code !== 0) { - emit(job, { type: "error", text: "The agent stopped unexpectedly. Nothing was changed." }); - emit(job, { type: "done" }); - job.done = true; - busy = false; - return; - } - await buildPreview(job); - busy = false; - }); - child.on("error", (err) => { - emit(job, { type: "error", text: `Could not start the agent: ${err.message}` }); - emit(job, { type: "done" }); - job.done = true; - busy = false; - }); -} - -function handleStreamMsg(job, msg) { - // capture the resumable session id - if (msg.session_id) job.sessionId = msg.session_id; - const content = msg?.message?.content; - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) emit(job, { kind: "text", text: block.text }); - else if (block.type === "tool_use") { - const label = block.name === "Edit" || block.name === "Write" - ? `${block.name} ${shortPath(block.input?.file_path)}` - : block.name; - emit(job, { kind: "tool", text: label }); - } - } - } - if (msg.type === "result" && typeof msg.result === "string" && msg.result.trim()) { - emit(job, { kind: "text", text: msg.result.trim() }); - } -} -function shortPath(p) { - if (!p) return ""; - return String(p).replace(REPO + "/", ""); -} - -// ---- builds ------------------------------------------------------------------ -function build(extraEnv) { - return new Promise((res) => { - const child = spawn("npm", ["run", "build"], { - cwd: APP, - env: { ...agentEnv(), ...extraEnv }, - }); - let tail = ""; - const grab = (d) => { tail = (tail + d.toString()).slice(-4000); }; - child.stdout.on("data", grab); - child.stderr.on("data", grab); - child.on("close", (code) => res({ ok: code === 0, tail })); - child.on("error", () => res({ ok: false, tail: "build failed to start" })); - }); -} - -async function buildPreview(job) { - emit(job, { kind: "tool", text: "building preview…" }); - const out = join("..", "public-preview", job.jobId); - const base = `/_preview/${job.jobId}`; - const { ok, tail } = await build({ PREVIEW_OUT: out, PREVIEW_BASE: base }); - if (!ok) { - emit(job, { type: "error", text: "The change broke the build, so it was not applied. Try rephrasing." }); - // revert the agent's edits so the working tree stays clean - await git(["checkout", "--", "app", "brand", "api/db", "api/cli"]); - emit(job, { type: "done" }); - job.done = true; - return; - } - const target = job.page && job.page !== "/" ? job.page.replace(/^\//, "") : ""; - emit(job, { type: "preview", url: `${base}/${target}` }); - emit(job, { type: "done" }); - job.done = true; -} - -// ---- git --------------------------------------------------------------------- -function git(args) { - return new Promise((res) => { - const child = spawn("git", args, { cwd: REPO, env: agentEnv() }); - let tail = ""; - child.stdout.on("data", (d) => (tail += d)); - child.stderr.on("data", (d) => (tail += d)); - child.on("close", (code) => res({ ok: code === 0, tail: tail.toString() })); - child.on("error", () => res({ ok: false, tail: "" })); - }); -} - -// ---- one-shot copy drafting -------------------------------------------------- -// Turn a rough draft + prompt into polished project copy. No tools, no file -// writes, no preview — just returns text for the admin to review before saving. -function stripFence(t) { - const m = String(t).match(/```(?:json)?\s*([\s\S]*?)```/); - return (m ? m[1] : t).trim(); -} -function draftCopy({ name, category, draft, prompt }) { - const ask = [ - "You are writing copy for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.", - "Voice: precise, understated, confident. No hype, no buzzwords, no exclamation marks. Write a project case study.", - "", - `Project name: ${name}`, - category ? `Category: ${category}` : "", - draft ? `The author's rough draft / notes:\n${draft}` : "", - prompt ? `The author's instructions:\n${prompt}` : "", - "", - "Return ONLY a JSON object (no markdown fence, no commentary) with these string keys:", - '- "summary": one sentence (<=160 chars) for the projects list.', - '- "lede": one punchy opening line for the top of the project page.', - '- "body": the case study in Markdown. Use ## for section headings and - for bullets. Open with a short overview, and where it fits include a "## Highlights" bulleted section. 150-350 words.', - ].filter(Boolean).join("\n"); - - return new Promise((resolve) => { - const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", ""]; - const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); - let out = ""; - child.stdout.on("data", (d) => (out += d)); - child.on("close", () => { - try { - const envelope = JSON.parse(out); - const text = typeof envelope.result === "string" ? envelope.result : ""; - const obj = JSON.parse(stripFence(text)); - resolve({ ok: true, summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "" }); - } catch { resolve({ ok: false }); } - }); - child.on("error", () => resolve({ ok: false })); - }); -} - -// ---- Web Designer: build a full structured project from a brief -------------- -// Reads the uploaded images and authors a COMPLETE cja_projects record (copy + -// skills + metrics + captions). Read-only (no Write/Edit/Bash) — it returns -// structured data the server validates; it never mutates the repo. -const MEDIA_RE = /^\/media\/[\w.-]+$/; -const MAX_BUILD_IMAGES = 8; - -function buildProject({ name, category, draft, prompt, images, instagram }) { - // keep only safe /media/ srcs, map to on-disk paths the agent can Read (cwd = REPO) - const srcs = (Array.isArray(images) ? images : []) - .map((im) => String(im?.src || "")) - .filter((s) => MEDIA_RE.test(s)); - const useSrcs = srcs.slice(0, MAX_BUILD_IMAGES); - if (srcs.length > MAX_BUILD_IMAGES) { - console.log(`[build-project] capping images ${srcs.length} -> ${MAX_BUILD_IMAGES} (${srcs.length - MAX_BUILD_IMAGES} not captioned)`); - } - const paths = useSrcs.map((s) => "app/public" + s); // /media/x -> app/public/media/x - const imageLines = paths.length ? paths.map((p, i) => ` ${i + 1}. ${p}`).join("\n") : ""; - - const ask = [ - "You are the Web Designer for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.", - "Your job: turn the brief below into a COMPLETE project case study for a fixed, on-brand page template. You author STRUCTURED CONTENT, not HTML or layout.", - "", - "Brand (from brand/BRAND.md — honor it): sumi-e, ink on washi paper, a single vermillion seal, restraint. Voice is precise, understated, confident — no hype, no buzzwords, no exclamation marks. Never describe colours or layout; the template owns all styling.", - "", - "The page auto-renders these sections from the fields you return — populate the ones the brief supports, leave the rest empty:", - "- header: title + one-line lede", - "- facts rail: kind (project type), period (timeline label), role", - "- metrics: a few outcome stats, each {value, label, note?}", - "- gallery: your caption for each image below", - "- body: the case study", - "- skills: grouped disciplines, each {group, items[]}", - "- stack: technologies used, as plain strings", - "- categories: 1-3 short tags", - "", - `Project name: ${name}`, - category ? `Category hint: ${category}` : "", - instagram ? "There is an Instagram reel for this project." : "", - draft ? `The author's rough draft / notes:\n${draft}` : "", - prompt ? `The author's special instructions (follow these):\n${prompt}` : "", - "", - paths.length - ? `Uploaded images — READ each file and caption it from what is ACTUALLY shown. Keep captions short and specific; do not invent UI or content that isn't visible:\n${imageLines}` - : "No images were uploaded.", - "", - "Body rules: Markdown only, using ONLY ## / ### headings, paragraphs, - bullet lists, and **bold**. No images, no HTML, no tables. Open with a short overview; where it fits include a '## Highlights' bulleted section. 150-350 words.", - "", - "Return ONLY a JSON object (no markdown fence, no commentary) with these keys:", - '- "summary": one sentence (<=160 chars) for the projects list.', - '- "lede": one punchy opening line.', - '- "body": the Markdown case study.', - '- "kind": short project type, e.g. "Website" or "SaaS / Media" (or "").', - '- "period": a timeline label, e.g. "2024 — present" (or "").', - '- "role": Carlos\'s role on the project (or "").', - '- "categories": array of 1-3 short strings.', - '- "stack": array of technology strings.', - '- "skills": array of {"group": string, "items": [string, ...]}.', - '- "metrics": array of {"value": string, "label": string, "note"?: string}. Use an empty array if the brief has no real numbers — do NOT invent metrics.', - `- "captions": array of exactly ${paths.length} strings, one per uploaded image IN ORDER.`, - `- "alts": array of exactly ${paths.length} short alt-text strings, one per image IN ORDER.`, - ].filter(Boolean).join("\n"); - - return new Promise((resolve) => { - const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", "Read"]; - const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); - let out = ""; - child.stdout.on("data", (d) => (out += d)); - child.on("close", () => { - try { - const envelope = JSON.parse(out); - const text = typeof envelope.result === "string" ? envelope.result : ""; - const obj = JSON.parse(stripFence(text)); - // Re-zip the gallery server-side: trust OUR srcs, take the model's - // caption/alt by index. The model never dictates a file path. - const caps = Array.isArray(obj.captions) ? obj.captions : []; - const alts = Array.isArray(obj.alts) ? obj.alts : []; - const gallery = useSrcs.map((src, i) => ({ - src, - alt: String(alts[i] || "").slice(0, 200), - caption: String(caps[i] || "").slice(0, 120), - })); - const strArr = (a) => (Array.isArray(a) ? a.map((x) => String(x)).filter(Boolean) : []); - resolve({ - ok: true, - summary: obj.summary || "", - lede: obj.lede || "", - body: obj.body || "", - kind: obj.kind || "", - period: obj.period || "", - role: obj.role || "", - categories: strArr(obj.categories), - stack: strArr(obj.stack), - skills: Array.isArray(obj.skills) ? obj.skills : [], - metrics: Array.isArray(obj.metrics) ? obj.metrics : [], - gallery, - }); - } catch { resolve({ ok: false }); } - }); - child.on("error", () => resolve({ ok: false })); - }); -} - -// ---- Web Designer task queue ------------------------------------------------- -// A durable design queue (cja_tasks) drained one task at a time. Each task runs -// the full-builder agent on any page (skills + brief), then auto-publishes: -// build must pass, then a scoped git commit. A failing task reverts itself and -// the queue moves on. The runner has no DB driver, so it claims/finishes rows -// via the api/cli/tasks-*.php helpers. -const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"]; -let draining = false; - -function phpCli(args) { - return new Promise((resolve) => { - const child = spawn(PHP, args, { cwd: REPO, env: agentEnv() }); - let out = "", err = ""; - child.stdout.on("data", (d) => (out += d)); - child.stderr.on("data", (d) => (err += d)); - child.on("close", () => resolve({ out, err })); - child.on("error", () => resolve({ out: "", err: "php spawn failed" })); - }); -} - -async function claimNextTask() { - const { out } = await phpCli(["api/cli/tasks-next.php"]); - try { const t = JSON.parse((out || "{}").trim() || "{}"); return t && t.task_id ? t : null; } - catch { return null; } -} -async function finishTask(id, status, result) { - await phpCli(["api/cli/tasks-finish.php", `--id=${id}`, `--status=${status}`, `--result=${JSON.stringify(result)}`]); -} - -function liveUrl(target) { - if (!target) return "/"; - if (target.startsWith("new:")) return "/" + target.slice(4).replace(/^\/+/, ""); - return target; -} - -function nowStamp() { - const d = new Date(); - const p = (n) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; -} - -function buildDesignPrompt(task) { - let assets = { images: [], videos: [] }; - try { assets = JSON.parse(task.assets || "{}") || {}; } catch {} - const images = Array.isArray(assets.images) ? assets.images : []; - const videos = Array.isArray(assets.videos) ? assets.videos : []; - const target = task.target_page || "/"; - const isNew = target.startsWith("new:"); - const slug = isNew ? target.slice(4).replace(/^\/+/, "") : ""; - - const p = []; - p.push( - "You are the Web Designer for the Carlos Arias website (carlosarias.co) — an Astro + Tailwind v4 static site.", - "You are a real designer: make considered, on-brand design decisions, not just literal edits.", - "", - "Before you start, consult your design skills and APPLY them: read .claude/skills/brand/SKILL.md and .claude/skills/ui-ux/SKILL.md.", - "The brand is sumi-e — ink on washi paper, a single vermillion seal, restraint, near-square 2px radii. Reuse the existing .ca-* classes and --ca-* tokens in app/src/styles.css and match nearby components; do not invent new one-off styles or add a second accent colour.", - "Do NOT run builds or git — the console builds and publishes for you.", - "", - ); - if (isNew) { - p.push( - `TASK: create a NEW page at /${slug}.`, - `- Create app/src/pages/${slug}.astro using BaseLayout and existing .ca-* section patterns (study app/src/pages/about.astro and services.astro for structure).`, - "- Register it in the nav: add it to the nav array in app/src/components/Header.astro AND the footer links in app/src/components/Footer.astro.", - "", - ); - } else { - p.push( - `TASK: work on the existing page ${target}.`, - "- Find its source under app/src/pages (Glob/Grep). Edit that file and any components it uses.", - "", - ); - } - p.push("What to do:", task.prompt || "(no instructions given)", ""); - if (task.draft && String(task.draft).trim()) { - p.push("Draft content to work from (polish it, don't paste it verbatim):", task.draft, ""); - } - if (images.length) { - p.push("Images you may use — Read each to see what it shows, then place it with its /media/... src and a real alt:"); - for (const im of images) { - p.push(`- ${im.url} (on disk: app/public${im.url})${im.alt ? ` — hint: ${im.alt}` : ""}`); - } - p.push(""); - } - if (videos.length) { - p.push("Short video links to embed where they fit (e.g. an Instagram reel — responsive 9:16, no autoplay sound):"); - for (const v of videos) p.push(`- ${v}`); - p.push(""); - } - p.push( - "", - "After you finish the change, log it to the public changelog using your `changelog` skill:", - "- Add ONE entry to the $entries array in api/cli/seed-changelog.php, in the site's visitor-facing voice.", - `- Use the timestamp '${nowStamp()}' and attribute it to 'Website Designer Agent' (the 5th array element).`, - "- Choose the right type (added / updated / fixed / removed).", - "- Do NOT run the reseed or the build — the console does that for you.", - "- If you ended up making no change to the site, do not add a changelog entry.", - "", - "Keep the change scoped to what's asked and leave the working tree with only your intended edits.", - ); - return p.join("\n"); -} - -async function runDesignTask(task) { - const prompt = buildDesignPrompt(task); - - // Pre-flight: the auto-publish commit is scoped to GIT_SCOPE, so a dirty tree - // would get swept into this task's commit. Refuse rather than clobber WIP. - // Uploaded assets under app/public/media/ are expected (they're this task's - // images) and get committed with it, so they don't count as "dirty". - const pre = await git(["status", "--porcelain", "--", ...GIT_SCOPE]); - const dirty = pre.tail - .split("\n") - .map((l) => l.slice(3).trim()) // strip the "XY " status prefix - .filter(Boolean) - .filter((p) => !p.startsWith("app/public/media/")); - if (dirty.length) { - return { ok: false, error: "Working tree wasn't clean — commit or discard pending changes before running the queue." }; - } - - return new Promise((resolve) => { - const args = [ - "-p", prompt, - "--output-format", "json", - "--allowedTools", "Read Write Edit Glob Grep WebSearch Skill", - "--model", DEFAULT_MODEL, - ]; - const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); - let out = ""; - child.stdout.on("data", (d) => (out += d)); - child.on("close", async () => { - let summary = ""; - try { - const env = JSON.parse(out); - if (typeof env.result === "string") summary = env.result.trim().slice(0, 500); - } catch {} - - // Any source changes to publish? - const status = await git(["status", "--porcelain", "--", ...GIT_SCOPE]); - const changed = status.tail.trim() !== ""; - if (!changed) { - return resolve({ ok: true, commit: "", summary: summary || "No changes were needed.", live: liveUrl(task.target_page) }); - } - - // The agent logs to the changelog by editing api/cli/seed-changelog.php - // (it has no shell). If it did, reseed cja_changelog so the build picks up - // the new entry — the changelog page reads the DB at build time. - if (/seed-changelog\.php/.test(status.tail)) { - await phpCli(["api/cli/seed-changelog.php"]); - } - - // Build must pass before anything is committed. - const built = await build({}); - if (!built.ok) { - await git(["checkout", "--", ...GIT_SCOPE]); - await git(["clean", "-fd", ...GIT_SCOPE]); // drop any new files the agent added - return resolve({ ok: false, error: "The change broke the build, so it was reverted." }); - } - await git(["add", "--", ...GIT_SCOPE]); - const title = (task.title || "task").toString().slice(0, 120); - await git(["commit", "-q", "-m", `web designer: ${title}\n\n[published via task queue]`]); - const head = await git(["rev-parse", "--short", "HEAD"]); - resolve({ ok: true, commit: head.tail.trim(), summary, live: liveUrl(task.target_page) }); - }); - child.on("error", () => resolve({ ok: false, error: "Could not start the agent." })); - }); -} - -// Drain the queue sequentially. Shares the global `busy` lock so it never runs -// concurrently with the in-page console. Re-entrant-safe via `draining`. -async function drainTasks() { - if (draining) return; - draining = true; - try { - for (;;) { - if (busy) break; // in-page console is mid-edit; a later kick resumes the queue - const task = await claimNextTask(); - if (!task) break; - busy = true; - let res; - try { res = await runDesignTask(task); } - catch (e) { res = { ok: false, error: String((e && e.message) || e) }; } - busy = false; - await finishTask( - task.task_id, - res.ok ? "published" : "failed", - res.ok ? { commit: res.commit, summary: res.summary, live: res.live } : { error: res.error || "failed" }, - ); - } - } finally { - draining = false; - } -} - -// ---- QA agent ---------------------------------------------------------------- -// A deterministic HTTP crawler tests the live static site (links, images, forms, -// API, SEO/meta), stores findings via the qa-*.php CLI, and a thin LLM step -// writes a plain-English summary. Read-only against the site — it never changes -// anything; fixes only happen when the admin clicks "Create fix task". -const QA_BASE = "https://carlosarias.co"; -const QA_UA = "Mozilla/5.0 (compatible; CarlosAriasQA/1.0; +https://carlosarias.co)"; -const QA_STATIC_ROUTES = [ - "/", "/about", "/services", "/services/website-design", - "/projects", "/blog", "/contact", "/changelog", "/resume", "/faq", -]; -let qaRunning = false; - -async function probe(url, { method = "GET", readBody = false, timeout = 12000, headers = {}, body = null } = {}) { - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), timeout); - try { - const r = await fetch(url, { - method, redirect: "follow", signal: ctrl.signal, - headers: { "user-agent": QA_UA, ...headers }, body, - }); - let text = null; - if (readBody) text = await r.text(); - else { try { await r.body?.cancel(); } catch {} } - return { status: r.status, ok: r.ok, finalUrl: r.url, text }; - } catch (e) { - return { status: 0, ok: false, error: e.name === "AbortError" ? "timeout" : (e.message || "network error") }; - } finally { clearTimeout(t); } -} - -async function pMap(items, concurrency, fn) { - const out = []; let i = 0; - const workers = Array.from({ length: Math.min(concurrency, items.length || 1) }, async () => { - while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); } - }); - await Promise.all(workers); - return out; -} - -const qaNorm = (p) => { p = String(p).split("#")[0].split("?")[0]; if (p.length > 1) p = p.replace(/\/+$/, ""); return p || "/"; }; -const qaAbs = (href, pagePath) => { try { return new URL(href, QA_BASE + pagePath).href; } catch { return null; } }; -const qaGrabAll = (re, html) => [...String(html).matchAll(re)].map((m) => m[1]); -const qaFirst = (re, html) => { const m = String(html).match(re); return m ? m[1].trim() : ""; }; -// Classify each <img>: alt missing entirely, or present-but-empty (alt="" or a -// bare `alt`). Both are surfaced — an empty alt is only correct for a purely -// decorative image, so a content image (photo, cover, screenshot) with empty -// alt is a real accessibility gap, not a pass. -function qaAltIssues(html) { - const out = []; - for (const m of String(html).matchAll(/<img\b[^>]*>/gi)) { - const tag = m[0]; - const src = (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1]; - if (!src) continue; - const withVal = tag.match(/\salt\s*=\s*["']([^"']*)["']/i); - if (withVal) { if (withVal[1].trim() === "") out.push({ src, kind: "empty" }); } - else if (/\salt(\s|>|\/)/i.test(tag)) out.push({ src, kind: "empty" }); // bare `alt` - else out.push({ src, kind: "missing" }); - } - return out; -} - -async function runQa() { - const findings = []; - const add = (check_type, severity, url, detail, fix_hint = null) => - findings.push({ check_type, severity, url, detail, fix_hint }); - - // ---- route set: static + DB-driven slugs ------------------------------- - let dynamic = []; - try { dynamic = JSON.parse((await phpCli(["api/cli/qa-routes.php"])).out || "[]"); } catch {} - const routes = [...new Set([...QA_STATIC_ROUTES, ...dynamic].map(qaNorm))]; - - // ---- fetch every page, parse HTML -------------------------------------- - const pages = await pMap(routes, 6, async (path) => ({ path, r: await probe(QA_BASE + path, { readBody: true }) })); - - const fetched = new Map(); // normPath -> ok - const titles = new Map(); // title -> [paths] - const internal = new Set(); // internal target URLs - const images = new Set(); // image URLs - const external = new Map(); // external URL -> page it was found on - - for (const { path, r } of pages) { - fetched.set(path, r.ok); - if (!r.ok) { - add("page", "error", path, `Returns ${r.status || r.error} instead of 200.`, - `${path} returns ${r.status || r.error} instead of 200. Investigate why the page fails to render and fix it.`); - continue; - } - const html = r.text || ""; - const title = qaFirst(/<title[^>]*>([^<]*)<\/title>/i, html); - const desc = qaFirst(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i, html); - const canon = qaFirst(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i, html); - if (!title) add("seo", "warning", path, "Missing <title>.", `${path} has no <title>. Add a page-specific title via its BaseLayout props.`); - else { if (!titles.has(title)) titles.set(title, []); titles.get(title).push(path); } - if (!desc) add("seo", "warning", path, "Missing meta description.", `${path} has no meta description. Add a page-specific description via its BaseLayout props.`); - if (canon) { try { const h = new URL(canon).host; if (h && h !== "carlosarias.co") add("seo", "error", path, `Canonical points to ${h}.`, `${path} canonical points to ${h} instead of carlosarias.co. Fix the site URL / canonical.`); } catch {} } - if (!/<html[^>]+lang=/i.test(html)) add("a11y", "warning", path, "<html> has no lang attribute.", `${path} <html> tag has no lang attribute. Add lang="en".`); - - for (const href of qaGrabAll(/<a\b[^>]*\bhref=["']([^"']+)["']/gi, html)) { - if (/^(mailto:|tel:|javascript:|#|data:)/i.test(href)) continue; - const u = qaAbs(href, path); if (!u) continue; - const noHash = u.split("#")[0]; - if (noHash.startsWith(QA_BASE)) internal.add(noHash); - else if (/^https?:\/\//i.test(noHash) && !external.has(noHash)) external.set(noHash, path); - } - for (const src of qaGrabAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi, html)) { const u = qaAbs(src, path); if (u && /^https?:/i.test(u)) images.add(u.split("#")[0]); } - for (const a of qaAltIssues(html)) { - if (a.kind === "empty") add("a11y", "warning", path, `Empty alt: ${a.src}`, - `On ${path}, the image "${a.src}" has an empty alt attribute. If it conveys meaning (a photo, cover, or screenshot), add descriptive alt text that explains what it shows; leave it empty only if it is purely decorative.`); - else add("a11y", "warning", path, `Missing alt: ${a.src}`, - `On ${path}, the image "${a.src}" has no alt attribute. Add descriptive alt text that explains what it shows.`); - } - } - - // duplicate titles across pages - for (const [title, paths] of titles) { - if (paths.length > 1) add("seo", "warning", paths.join(", "), `${paths.length} pages share the title "${title}".`, - `These pages share one <title> ("${title}"): ${paths.join(", ")}. Give each a distinct, page-specific title.`); - } - - // ---- internal links not already fetched -------------------------------- - const internalPaths = [...new Set([...internal].map((u) => qaNorm(u.replace(QA_BASE, "") || "/")))]; - const toCheck = internalPaths.filter((p) => !fetched.has(p)); - await pMap(toCheck, 8, async (p) => { - const r = await probe(QA_BASE + p, {}); - if (!r.ok) add("link", "error", p, `Broken internal link (${r.status || r.error}).`, - `An internal link points to ${p}, which returns ${r.status || r.error}. Find that link in the page source and fix the URL or remove the link.`); - }); - - // ---- images ------------------------------------------------------------ - await pMap([...images], 8, async (u) => { - const r = await probe(u, {}); - if (!r.ok) add("image", "error", u, `Image returns ${r.status || r.error}.`, - `The image ${u} returns ${r.status || r.error}. Fix the image path or replace the image.`); - }); - - // ---- external links (tolerant: only clear 404/410/DNS failures) --------- - await pMap([...external.keys()], 6, async (u) => { - const r = await probe(u, { method: "GET", timeout: 12000 }); - const clearlyBad = r.status === 404 || r.status === 410 || (r.status === 0 && r.error && r.error !== "timeout"); - if (clearlyBad) add("external", "warning", u, `External link may be broken (${r.status || r.error}); found on ${external.get(u)}.`, - `The external link ${u} (on ${external.get(u)}) appears broken (${r.status || r.error}). Verify it and update or remove it.`); - }); - - // ---- contact form: two non-polluting probes ---------------------------- - const cHeaders = { "content-type": "application/json", origin: QA_BASE, referer: QA_BASE + "/contact" }; - const hp = await probe(QA_BASE + "/api/contact/submit", { - method: "POST", readBody: true, headers: cHeaders, - body: JSON.stringify({ name: "QA Bot", email: "qa@carlosarias.co", subject: "other", message: "QA honeypot probe — please ignore.", company: "qa-honeypot" }), - }); - if (hp.status !== 200) add("form", "error", "/api/contact/submit", `Contact honeypot probe returned ${hp.status || hp.error} (expected 200).`, - `POST /api/contact/submit returned ${hp.status || hp.error} instead of 200 for a probe. The contact form endpoint may be broken — check api/public/controllers/contact.php.`); - const val = await probe(QA_BASE + "/api/contact/submit", { - method: "POST", readBody: true, headers: cHeaders, - body: JSON.stringify({ name: "QA", email: "qa@carlosarias.co", subject: "other", message: "hi" }), - }); - if (val.status !== 422) add("form", "warning", "/api/contact/submit", `Validation probe returned ${val.status || val.error} (expected 422 for a too-short message).`, - `POST /api/contact/submit did not reject an invalid submission (got ${val.status || val.error}, expected 422). Server-side validation may be off.`); - - // ---- API health -------------------------------------------------------- - const health = await probe(QA_BASE + "/api/health", { readBody: true }); - // The API wraps responses in a {ok, data, error} envelope, so db is at data.db. - let dbOk = false; try { const j = JSON.parse(health.text || "{}"); dbOk = (j.data?.db ?? j.db) === "connected"; } catch {} - if (health.status !== 200 || !dbOk) add("health", "error", "/api/health", `Status ${health.status || health.error}, db ${dbOk ? "connected" : "not connected"}.`, - `/api/health returned ${health.status || health.error}${dbOk ? "" : " and the database is not connected"}. The API or database may be down.`); - - // ---- sitemap coverage -------------------------------------------------- - const sm = await probe(QA_BASE + "/sitemap.xml", { readBody: true }); - if (sm.ok) { - const locs = new Set(qaGrabAll(/<loc>([^<]+)<\/loc>/gi, sm.text || "").map((l) => qaNorm(l.replace(QA_BASE, "")))); - for (const p of routes) if (!locs.has(p)) add("sitemap", "warning", p, "Not listed in sitemap.xml.", - `${p} is not in sitemap.xml. Add it in app/src/pages/sitemap.xml.js so search engines can find it.`); - } else { - add("sitemap", "warning", "/sitemap.xml", `sitemap.xml returned ${sm.status || sm.error}.`, `/sitemap.xml is unreachable (${sm.status || sm.error}). Check app/src/pages/sitemap.xml.js.`); - } - - const counts = { error: 0, warning: 0, info: 0, pages: pages.length, links: toCheck.length, images: images.size, external: external.size }; - for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1; - return { findings, counts }; -} - -// Thin LLM step: a plain-English run summary via the qa skill (best-effort). -function qaTriage(findings, counts) { - const top = [ - ...findings.filter((f) => f.severity === "error").slice(0, 12), - ...findings.filter((f) => f.severity === "warning").slice(0, 12), - ]; - const lines = top.map((f) => `- [${f.severity}] ${f.check_type} ${f.url}: ${f.detail}`).join("\n") || "(no issues found)"; - const fallback = `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`; - const ask = [ - "You are the QA agent for carlosarias.co. A crawler just tested the live site. Consult your `qa` skill.", - `Counts: ${counts.error} errors, ${counts.warning} warnings across ${counts.pages} pages.`, - "Top findings:", lines, - "", - "Write a 2-4 sentence plain-English summary for the site owner: overall health, the most important things to fix first, and whether anything is urgent. No preamble — just the summary.", - ].join("\n"); - return new Promise((resolve) => { - const child = spawn(CLAUDE, ["-p", ask, "--output-format", "json", "--allowedTools", "Read Skill", "--model", DEFAULT_MODEL], { cwd: REPO, env: agentEnv() }); - let out = ""; - child.stdout.on("data", (d) => (out += d)); - child.on("close", () => { - let s = ""; - try { const e = JSON.parse(out); if (typeof e.result === "string") s = e.result.trim().slice(0, 800); } catch {} - resolve(s || fallback); - }); - child.on("error", () => resolve(fallback)); - }); -} - -async function runQaFlow(trigger) { - if (qaRunning) return; - qaRunning = true; - let runId = 0; - try { - const start = await phpCli(["api/cli/qa-start.php", `--trigger=${trigger}`]); - runId = JSON.parse(start.out || "{}").run_id || 0; - if (!runId) throw new Error("could not open a QA run"); - const { findings, counts } = await runQa(); - // Triage (an LLM call) only when it's worth it: a manual run, or a - // scheduled heartbeat that actually found errors. Clean heartbeats get a - // cheap templated summary — no tokens spent when the site is fine. - const summary = (trigger === "manual" || counts.error > 0) - ? await qaTriage(findings, counts) - : `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`; - const tmp = `/tmp/qa-${runId}.json`; - writeFileSync(tmp, JSON.stringify(findings)); - await phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]); - rmSync(tmp, { force: true }); - - // Auto-fix the safe findings (alt text, broken internal links) — queue Web - // Designer tasks and kick the queue. Judgment calls are left for the admin. - if (QA_AUTOFIX) { - const af = await phpCli(["api/cli/qa-autofix.php", `--run=${runId}`]); - let queued = 0; try { queued = JSON.parse(af.out || "{}").queued || 0; } catch {} - if (queued > 0) drainTasks(); // fire-and-forget; the Web Designer fixes + self-logs - } - } catch (e) { - if (runId) await phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=failed", `--summary=QA run failed: ${String((e && e.message) || e).slice(0, 180)}`]); - } finally { - qaRunning = false; - } -} - -// ---- request helpers --------------------------------------------------------- -function json(res, status, obj) { - const body = JSON.stringify(obj); - res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); - res.end(body); -} -function readBody(req) { - return new Promise((res) => { - let b = ""; - req.on("data", (c) => (b += c)); - req.on("end", () => { - try { res(b ? JSON.parse(b) : {}); } catch { res({}); } - }); - }); -} - -// ---- server ------------------------------------------------------------------ -const server = createServer(async (req, res) => { - const url = new URL(req.url, "http://localhost"); - const path = url.pathname.replace(/^\/devconsole/, "") || "/"; - const method = req.method || "GET"; - - try { - if (path === "/ping") { - const env = loadEnv(); - return json(res, 200, { authed: true, hasToken: Boolean(env.CLAUDE_CODE_OAUTH_TOKEN) }); - } - - if (path === "/auth" && method === "POST") { - const { token } = await readBody(req); - if (!token || !/^sk-ant-/.test(token)) { - return json(res, 400, { error: "That doesn't look like a Claude token (expected sk-ant-…)." }); - } - saveToken(token.trim()); - return json(res, 200, { ok: true }); - } - - if (path === "/logout" && method === "POST") { - return json(res, 200, { ok: true }); // admin session is cleared by PHP - } - - if (path === "/run" && method === "POST") { - if (busy) return json(res, 429, { error: "A change is already in progress — let it finish." }); - const body = await readBody(req); - const message = String(body.message || "").trim(); - if (!message) return json(res, 400, { error: "Say what you'd like changed." }); - - const env = loadEnv(); - if (!env.CLAUDE_CODE_OAUTH_TOKEN) { - return json(res, 401, { error: "No Claude token set. Add one with the 🔑 button." }); - } - - // Continue an existing conversation, or start a new one. - let job = body.conversationId && jobs.get(body.conversationId); - if (job) { job.done = false; job.events = []; job.jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; } - else job = newJob(body.page); - - busy = true; - runAgent(job, { message, page: body.page, selections: body.selections, model: body.model }); - return json(res, 200, { conversationId: job.conversationId }); - } - - // Draft project copy from a rough note + prompt (used by the New Project form). - if (path === "/draft" && method === "POST") { - if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); - const body = await readBody(req); - const name = String(body.name || "").trim(); - if (!name) return json(res, 400, { error: "Add a project name first." }); - const env = loadEnv(); - if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." }); - - busy = true; - const d = await draftCopy(body); - busy = false; - if (!d.ok) return json(res, 500, { error: "Couldn't generate a draft — try again." }); - return json(res, 200, d); - } - - // Web Designer: build a full structured project from a brief + images. - if (path === "/build-project" && method === "POST") { - if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); - const body = await readBody(req); - const name = String(body.name || "").trim(); - if (!name) return json(res, 400, { error: "Add a project name first." }); - const env = loadEnv(); - if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." }); - - busy = true; - const d = await buildProject(body); - busy = false; - if (!d.ok) return json(res, 500, { error: "The Web Designer couldn't finish — try again." }); - return json(res, 200, d); - } - - // Kick the Web Designer task queue: drain any queued design tasks. Returns - // immediately; the admin UI polls /api/admintasks/list for status. - if (path === "/tasks/run" && method === "POST") { - const env = loadEnv(); - if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." }); - drainTasks(); // fire-and-forget - return json(res, 200, { ok: true, draining, busy }); - } - - // Kick a QA run: crawl the live site, store findings. Read-only; returns - // immediately. The admin polls /api/adminqa/runs for status. - if (path === "/qa/run" && method === "POST") { - if (qaRunning) return json(res, 429, { error: "A QA run is already in progress." }); - const body = await readBody(req); - const trigger = body.trigger === "scheduled" ? "scheduled" : "manual"; - runQaFlow(trigger); // fire-and-forget - return json(res, 200, { ok: true }); - } - - if (path === "/stream") { - const id = url.searchParams.get("conversationId"); - const job = id && jobs.get(id); - if (!job) return json(res, 404, { error: "unknown conversation" }); - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }); - // replay buffered, then stream live - for (const ev of job.events) res.write(`data: ${JSON.stringify(ev)}\n\n`); - if (job.done) return res.end(); - job.clients.add(res); - req.on("close", () => job.clients.delete(res)); - return; - } - - if (path === "/publish" && method === "POST") { - if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); - const { conversationId } = await readBody(req); - const job = conversationId && jobs.get(conversationId); - if (!job) return json(res, 404, { error: "Nothing to publish." }); - - busy = true; - const built = await build({}); // real build to public/ - if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed — not published." }); } - await git(["add", "--", "app", "brand", "api/db", "api/cli"]); - const summary = (job.page && job.page !== "/" ? job.page : "homepage"); - await git(["commit", "-q", "-m", `console: edit ${summary}\n\n[published via astroagent console]`]); - rmSync(join(PREVIEW_DIR, job.jobId), { recursive: true, force: true }); - busy = false; - return json(res, 200, { ok: true, live: job.page || "/" }); - } - - // Rebuild the live site — used after structured content edits (projects - // gallery, etc.) that changed the DB rather than files. Builds public/ and - // commits any new media files (a no-op commit is fine). - if (path === "/rebuild" && method === "POST") { - if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); - busy = true; - const built = await build({}); - if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed." }); } - await git(["add", "--", "app/public/media", "app", "brand", "api/db", "api/cli"]); - await git(["commit", "-q", "-m", "console: content update"]); // ok if nothing to commit - busy = false; - return json(res, 200, { ok: true }); - } - - if (path === "/discard" && method === "POST") { - if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); - const { conversationId } = await readBody(req); - const job = conversationId && jobs.get(conversationId); - busy = true; - const CONTENT = ["app", "brand", "api/db", "api/cli"]; - await git(["checkout", "--", ...CONTENT]); - await git(["clean", "-fd", ...CONTENT]); - if (job) rmSync(join(PREVIEW_DIR, job.jobId), { recursive: true, force: true }); - busy = false; - if (job) jobs.delete(job.conversationId); - return json(res, 200, { ok: true }); - } - - return json(res, 404, { error: "not found" }); - } catch (err) { - return json(res, 500, { error: err.message }); - } -}); - -if (!existsSync(PREVIEW_DIR)) mkdirSync(PREVIEW_DIR, { recursive: true }); -server.listen(PORT, "127.0.0.1", () => { - console.log(`[console] runner on 127.0.0.1:${PORT}, repo ${REPO}`); -}); - -// ---- QA heartbeat ------------------------------------------------------------ -// The runner is long-lived, so the QA heartbeat is just an in-process timer — -// no cron, no extra units. Runs a scheduled QA pass every QA_HEARTBEAT_MIN -// minutes (0 disables), plus one pulse shortly after boot. runQaFlow self-guards -// against overlap and is read-only against the live site. -const QA_AUTOFIX = (process.env.QA_AUTOFIX ?? "1") !== "0"; // auto-fix safe findings -const QA_HEARTBEAT_MIN = Number(process.env.QA_HEARTBEAT_MIN || 60); -if (QA_HEARTBEAT_MIN > 0) { - setTimeout(() => runQaFlow("scheduled"), 90_000); // first pulse ~90s after boot - setInterval(() => runQaFlow("scheduled"), QA_HEARTBEAT_MIN * 60_000); - console.log(`[qa] heartbeat every ${QA_HEARTBEAT_MIN} min`); -} +import { host } from "./host.mjs"; +import { register as registerInPageConsole } from "./agents/in-page-console.mjs"; +import { register as registerProjectBuilder } from "./agents/project-builder.mjs"; +import { register as registerWebDesigner } from "./agents/web-designer.mjs"; +import { register as registerQa } from "./agents/qa.mjs"; + +registerInPageConsole(host); +registerProjectBuilder(host); +registerWebDesigner(host); +registerQa(host); + +host.listen();