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 { cursorAgent, llmExtract, 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"); 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"); } type SenseMeta = { planner: { plan: string; senseName: string; userInput: string }; coder: { senseName: string; files: Record; cursorOutput: string }; tester: { passed: boolean; senseName: string; reason: string; attempt: number }; }; const senseMetaSchema = z .object({ name: z.string().describe("kebab-case sense name, e.g. 'disk-usage'"), description: z.string().describe("One-line description of what this sense monitors"), }) .describe("Extract the sense name and a one-line description from the plan"); const workflow: WorkflowDefinition = { name: "sense-generator", roles: { async planner( 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: { plan: "", senseName: "", userInput }, }; } const planPrompt = `You are planning a new Nerve sense. ${nerveAgentContext} 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.`; const planResult = await cursorAgent({ prompt: planPrompt, mode: "ask", cwd: NERVE_ROOT, env: null, timeoutMs: null, }); if (!planResult.ok) { return { content: `cursor-agent failed: ${formatSpawnFailure(planResult.error)}`, meta: { plan: "", senseName: "", userInput }, }; } const plan = planResult.value; const extracted = await llmExtract({ text: plan, schema: senseMetaSchema, provider, }); if (!extracted.ok) { return { content: `${plan}\n\n[llmExtract error] ${JSON.stringify(extracted.error)}`, meta: { plan, senseName: "", userInput }, }; } return { content: plan, meta: { plan, senseName: extracted.value.name, userInput }, }; }, async coder( _start: StartStep, messages: WorkflowMessage[], ): Promise> { const last = messages[messages.length - 1]; const { plan, senseName } = last.meta as { plan: string; senseName: string }; const codePrompt = `You are implementing a new Nerve sense called "${senseName}" in the directory ${SENSES_DIR}/${senseName}/. Here is the plan: ${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.`; const agentResult = await cursorAgent({ prompt: codePrompt, mode: "default", cwd: NERVE_ROOT, env: null, timeoutMs: null, }); if (!agentResult.ok) { const resultText = `cursor-agent failed: ${formatSpawnFailure(agentResult.error)}`; return { content: resultText, meta: { senseName, files: { index: false, schema: false, migration: false }, cursorOutput: resultText, }, }; } const result = agentResult.value; 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")), }; return { content: result, meta: { senseName, files, cursorOutput: result }, }; }, async tester( _start: StartStep, messages: WorkflowMessage[], ): Promise> { const last = messages[messages.length - 1]; const { senseName, files } = last.meta as { senseName: string; files: Record }; const attempt = messages.filter((m) => m.role === "tester").length + 1; 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, senseName, reason: `Missing files: ${missing.join(", ")}`, attempt }, }; } const smoke = await runSenseSmokeTest(senseName); if (smoke.ok) { return { content: `PASS — ${smoke.reason}`, meta: { passed: true, senseName, reason: smoke.reason, attempt }, }; } return { content: `FAIL — ${smoke.reason}`, meta: { passed: false, senseName, reason: `${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`, attempt, }, }; }, }, 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;