import type { RoleResult, StartStep, WorkflowDefinition, WorkflowMessage, } from "@uncaged/nerve-core"; import { END } from "@uncaged/nerve-core"; import type { SpawnError } from "@uncaged/nerve-workflow-utils"; import { createCursorRole, nerveAgentContext, readNerveYaml, spawnSafe, } from "@uncaged/nerve-workflow-utils"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { z } from "zod"; const HOME = process.env.HOME ?? "/home/azureuser"; const NERVE_ROOT = join(HOME, ".uncaged-nerve"); const SENSES_DIR = join(NERVE_ROOT, "senses"); const AGENT_TIMEOUT_MS = 3_600_000; function getNerveYaml(): string { const result = readNerveYaml({ nerveRoot: NERVE_ROOT }); return result.ok ? result.value : "# nerve.yaml unavailable"; } async function cfgGet(key: string): Promise { const result = await spawnSafe("cfg", ["get", key], { cwd: NERVE_ROOT, env: null, timeoutMs: 10_000, }); if (!result.ok) { return null; } return result.value.stdout.trim() || null; } async function resolveDashScopeProvider(): Promise<{ baseUrl: string; apiKey: string; model: string; } | null> { const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY")); const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL")); const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus"; if (!apiKey || !baseUrl) { return null; } return { apiKey, baseUrl, model }; } function formatSpawnFailure(error: SpawnError): string { if (error.kind === "spawn_failed") { return error.message; } if (error.kind === "timeout") { return `timeout (stdout=${error.stdout.slice(0, 200)})`; } return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`; } /** * Run the same checks the workflow used to ask Hermes to perform, but locally. * Hermes chat often returns UI prose instead of shell output, which caused false failures. */ async function runSenseSmokeTest( senseName: string, ): Promise<{ ok: boolean; log: string; reason: string }> { const logParts: string[] = []; const runNerve = async (args: string[]): Promise<{ ok: true; out: string } | { ok: false; err: string }> => { const result = await spawnSafe("nerve", args, { cwd: NERVE_ROOT, env: null, timeoutMs: 300_000, }); if (!result.ok) { return { ok: false, err: formatSpawnFailure(result.error) }; } return { ok: true, out: result.value.stdout }; }; const statusRun = await runNerve(["status"]); if (!statusRun.ok) { return { ok: false, log: `=== nerve status ===\nERROR: ${statusRun.err}`, reason: `Smoke test command failed: ${statusRun.err}`, }; } const status = statusRun.out; logParts.push("=== nerve status ===\n" + status); if (!status.includes(senseName)) { return { ok: false, log: logParts.join("\n\n"), reason: `Sense "${senseName}" not listed in \`nerve status\` output`, }; } const triggerRun = await runNerve(["sense", "trigger", senseName]); if (!triggerRun.ok) { logParts.push(`=== nerve sense trigger ===\nERROR: ${triggerRun.err}`); return { ok: false, log: logParts.join("\n\n"), reason: `Smoke test command failed: ${triggerRun.err}`, }; } logParts.push("=== nerve sense trigger ===\n" + triggerRun.out); let lastQuery = ""; for (let i = 0; i < 25; i++) { const sleepR = await spawnSafe("sleep", ["1"], { cwd: NERVE_ROOT, env: null, timeoutMs: 10_000 }); if (!sleepR.ok) { logParts.push(`=== sleep (attempt ${i + 1}) ===\nERROR: ${formatSpawnFailure(sleepR.error)}`); } const queryRun = await runNerve(["sense", "query", senseName]); if (!queryRun.ok) { logParts.push(`=== nerve sense query (attempt ${i + 1}) ===\nERROR: ${queryRun.err}`); } else { lastQuery = queryRun.out; logParts.push(`=== nerve sense query (attempt ${i + 1}) ===\n${lastQuery}`); if (!lastQuery.includes("(0 rows)")) { return { ok: true, log: logParts.join("\n\n"), reason: "Trigger succeeded and query returned at least one row", }; } } } return { ok: false, log: logParts.join("\n\n"), reason: lastQuery.includes("(0 rows)") ? "Query still returned 0 rows after trigger (compute error, throttle drop, or DB not written)" : "Timed out waiting for successful sense query", }; } // Build context string with existing sense examples function buildSenseExamples(): string { const examples: string[] = []; for (const name of ["cpu-usage", "linux-system-health"]) { const dir = join(SENSES_DIR, name); if (!existsSync(dir)) continue; const indexFile = existsSync(join(dir, "index.js")) ? readFileSync(join(dir, "index.js"), "utf-8") : ""; const schema = existsSync(join(dir, "schema.ts")) ? readFileSync(join(dir, "schema.ts"), "utf-8") : ""; const migrationDir = join(dir, "migrations"); let migration = ""; if (existsSync(join(migrationDir, "0001_init.sql"))) { migration = readFileSync(join(migrationDir, "0001_init.sql"), "utf-8"); } examples.push( `### Example sense: ${name}\n\n` + `**index.js:**\n\`\`\`js\n${indexFile}\n\`\`\`\n\n` + `**schema.ts:**\n\`\`\`ts\n${schema}\n\`\`\`\n\n` + `**migrations/0001_init.sql:**\n\`\`\`sql\n${migration}\n\`\`\``, ); } return examples.join("\n\n---\n\n"); } function getSenseNameFromThread(messages: WorkflowMessage[]): string { const p = messages.find((m) => m.role === "planner"); if (p === undefined || typeof p.meta !== "object" || p.meta === null) { return ""; } return String((p.meta as { senseName: string }).senseName); } function getPlanFromThread(messages: WorkflowMessage[]): string { const p = messages.find((m) => m.role === "planner"); return p !== undefined ? p.content : ""; } type SenseMeta = { planner: { senseName: string }; coder: { filesCreated: boolean }; tester: { passed: boolean; attempt: number }; }; const plannerMetaSchema = z .object({ senseName: z .string() .describe("kebab-case sense name from the plan, e.g. 'disk-usage'"), }) .describe("Extract the kebab-case sense name from the plan text"); const coderMetaSchema = z .object({ filesCreated: z .boolean() .describe("true if index.js, schema.ts, migrations/0001_init.sql exist and nerve.yaml was updated"), }) .describe("Whether the agent completed all file work for the sense"); async function runPlanner( start: StartStep, _messages: WorkflowMessage[], ): Promise> { const userInput = start.content; const provider = await resolveDashScopeProvider(); if (provider === null) { return { content: "Cannot run planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " + "and optionally DASHSCOPE_MODEL.", meta: { senseName: "" }, }; } const role = createCursorRole({ cwd: NERVE_ROOT, mode: "ask", timeoutMs: AGENT_TIMEOUT_MS, prompt: async (threadId) => { return `You are planning a new Nerve sense. ${nerveAgentContext} **Context:** Read this workflow run for background before you plan. From a shell in \`${NERVE_ROOT}\`, run: \`nerve thread show ${threadId} --budget 50000\` Use the thread transcript (prior user messages and rounds) when deciding the sense. User request: ${userInput} Pick a good kebab-case name for this sense. Your job is to produce a PLAN (not code) for this sense. Output a structured plan in markdown with these sections: ## Sense Design ### Name (decide a kebab-case name) ### Fields List every field the sense should collect, with name, type (integer/real/text), and description. ### Compute Logic Describe step-by-step what the compute() function should do. Be specific about which Node.js APIs or shell commands to use. ### Trigger Config - group: (suggest a group name) - interval: (decide based on the use case, e.g. 30s, 1m, 5m) - throttle: (suggest) - timeout: (suggest) Here are existing senses for reference on the format and patterns used: ${buildSenseExamples()} Current nerve.yaml: \`\`\`yaml ${getNerveYaml()} \`\`\` Output ONLY the plan in markdown. Be precise and implementation-ready.`; }, extract: { provider, schema: plannerMetaSchema }, }); try { return await role(start, _messages); } catch (e) { const message = e instanceof Error ? e.message : String(e); return { content: message, meta: { senseName: "" } }; } } async function runCoder( _start: StartStep, messages: WorkflowMessage[], ): Promise> { const plan = getPlanFromThread(messages); const senseName = getSenseNameFromThread(messages); const provider = await resolveDashScopeProvider(); if (provider === null) { return { content: "Cannot run coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " + "and optionally DASHSCOPE_MODEL.", meta: { filesCreated: false }, }; } const role = createCursorRole({ cwd: NERVE_ROOT, mode: "default", timeoutMs: AGENT_TIMEOUT_MS, prompt: async (threadId) => { return `You are implementing a new Nerve sense called "${senseName}" in the directory ${SENSES_DIR}/${senseName}/. **Context:** Read this workflow run for background before you edit files. From a shell in \`${NERVE_ROOT}\`, run: \`nerve thread show ${threadId} --budget 50000\` Here is the plan (from the planner step): ${plan} You need to create exactly 3 files: 1. \`${SENSES_DIR}/${senseName}/index.js\` — the compute() function 2. \`${SENSES_DIR}/${senseName}/schema.ts\` — Drizzle ORM schema 3. \`${SENSES_DIR}/${senseName}/migrations/0001_init.sql\` — SQLite migration And UPDATE the existing file: 4. \`${NERVE_ROOT}/nerve.yaml\` — add the new sense config and reflex entry Here are existing senses for reference — follow the EXACT same patterns: ${buildSenseExamples()} Current nerve.yaml (append to it, don't overwrite existing entries): \`\`\`yaml ${getNerveYaml()} \`\`\` IMPORTANT RULES: - index.js uses \`export async function compute(db, _peers)\` signature - index.js imports the schema table from "./schema.ts" and uses \`await db.insert(table).values({...})\` to persist - schema.ts uses drizzle-orm/sqlite-core imports - migration SQL must match schema.ts exactly - nerve.yaml: add under \`senses:\` and add a reflex under \`reflexes:\` - Use the interval specified in the plan for the reflex Create all files now. End with a clear statement of whether all files and updates were created successfully, or what is still missing.`; }, extract: { provider, schema: coderMetaSchema }, }); try { return await role(_start, messages); } catch (e) { const message = e instanceof Error ? e.message : String(e); return { content: message, meta: { filesCreated: false } }; } } async function runTester( _start: StartStep, messages: WorkflowMessage[], ): Promise> { const senseName = getSenseNameFromThread(messages); const senseDir = join(SENSES_DIR, senseName); const files = { index: existsSync(join(senseDir, "index.js")), schema: existsSync(join(senseDir, "schema.ts")), migration: existsSync(join(senseDir, "migrations", "0001_init.sql")), }; const attempt = messages.filter((m) => m.role === "tester").length + 1; if (!senseName) { return { content: "FAIL — no senseName from planner meta", meta: { passed: false, attempt }, }; } const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k); if (missing.length > 0) { return { content: `FAIL — missing files: ${missing.join(", ")}`, meta: { passed: false, attempt }, }; } const smoke = await runSenseSmokeTest(senseName); if (smoke.ok) { return { content: `PASS — ${smoke.reason}`, meta: { passed: true, attempt }, }; } return { content: `FAIL — ${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`, meta: { passed: false, attempt, }, }; } const workflow: WorkflowDefinition = { name: "sense-generator", roles: { planner: runPlanner, coder: runCoder, tester: runTester, }, moderator(context) { if (context.steps.length === 0) { return "planner"; } const signal = context.steps[context.steps.length - 1]; if (signal.role === "planner") { return "coder"; } if (signal.role === "coder") { return "tester"; } if (signal.role === "tester") { const meta = signal.meta; if (meta.passed) { return END; } if (meta.attempt < 3) { return "coder"; } return END; } return END; }, }; export default workflow;