import type { WorkflowDefinition } from "@uncaged/nerve-daemon"; import { execSync } from "node:child_process"; import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; const HOME = process.env.HOME ?? "/home/azureuser"; const NERVE_ROOT = join(HOME, ".uncaged-nerve"); const SENSES_DIR = join(NERVE_ROOT, "senses"); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function nerveCommandEnv(): NodeJS.ProcessEnv { const pnpmHome = join(HOME, ".local/share/pnpm"); const npmUserBin = join(HOME, ".local/share/npm/bin"); return { ...process.env, PNPM_HOME: pnpmHome, PATH: `${npmUserBin}:${pnpmHome}:${process.env.PATH ?? ""}`, }; } function run(cmd: string, cwd?: string): string { return execSync(cmd, { encoding: "utf-8", cwd: cwd ?? NERVE_ROOT, timeout: 300_000, env: nerveCommandEnv(), }).trim(); } /** * 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. */ function runSenseSmokeTest(senseName: string): { ok: boolean; log: string; reason: string } { const logParts: string[] = []; try { const status = run("nerve status"); 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 triggerOut = run(`nerve sense trigger ${senseName}`); logParts.push("=== nerve sense trigger ===\n" + triggerOut); let lastQuery = ""; for (let i = 0; i < 25; i++) { run("sleep 1"); try { lastQuery = run(`nerve sense query ${senseName}`); } catch (e) { logParts.push(`=== nerve sense query (attempt ${i + 1}) ===\nERROR: ${String(e)}`); continue; } 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", }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { ok: false, log: logParts.join("\n\n"), reason: `Smoke test command failed: ${msg}`, }; } } /** * Call a cheap LLM with tool_choice to extract structured metadata from text. * Uses DashScope (Alibaba Cloud, OpenAI-compatible) with qwen-plus. */ function llmExtract(text: string, toolName: string, toolDescription: string, parameters: Record): T { const apiKey = run("bash -c 'source ~/.profile && cfg get DASHSCOPE_API_KEY'"); const baseUrl = run("bash -c 'source ~/.profile && cfg get DASHSCOPE_BASE_URL'"); const body = JSON.stringify({ model: "qwen-plus", messages: [ { role: "system", content: "Extract the requested information from the provided text. Be precise." }, { role: "user", content: text }, ], tools: [{ type: "function" as const, function: { name: toolName, description: toolDescription, parameters }, }], tool_choice: { type: "function" as const, function: { name: toolName } }, }); const escaped = body.replace(/'/g, "'\\''"); const result = run(`curl -s '${baseUrl}/chat/completions' -H 'Authorization: Bearer ${apiKey}' -H 'Content-Type: application/json' -d '${escaped}'`); const parsed = JSON.parse(result); const toolCall = parsed.choices?.[0]?.message?.tool_calls?.[0]?.function?.arguments; if (!toolCall) throw new Error(`llmExtract failed: ${result.slice(0, 500)}`); return JSON.parse(toolCall) as T; } function cursorAgent(prompt: string, mode: "plan" | "ask" | "default", cwd: string): string { const escaped = prompt.replace(/'/g, "'\\''"); const modeFlag = mode === "plan" ? " --mode=plan" : mode === "ask" ? " --mode=ask" : ""; return run( `cursor-agent -p '${escaped}' --model auto${modeFlag} --output-format text --trust --force`, cwd, ); } // 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"); } // Read current nerve.yaml function readNerveYaml(): string { return readFileSync(join(NERVE_ROOT, "nerve.yaml"), "utf-8"); } // --------------------------------------------------------------------------- // Workflow Definition // --------------------------------------------------------------------------- const workflow: WorkflowDefinition = { roles: { // ----------------------------------------------------------------------- // PLANNER: Generates a structured plan for the sense // ----------------------------------------------------------------------- planner: { async execute(prompt: unknown, ctx) { const userInput = String(prompt); ctx.log(`planner: designing sense from input: "${userInput.substring(0, 100)}"`); const planPrompt = `You are planning a new Nerve 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 ${readNerveYaml()} \`\`\` Output ONLY the plan in markdown. Be precise and implementation-ready.`; const plan = cursorAgent(planPrompt, "ask", NERVE_ROOT); ctx.log(`planner: plan generated (${plan.length} chars)`); // Extract sense metadata from plan using structured LLM call const meta = llmExtract<{ name: string; description: string }>( plan, "extract_sense_metadata", "Extract the sense name and a one-line description from the plan", { type: "object", properties: { name: { type: "string", description: "kebab-case sense name, e.g. 'disk-usage'" }, description: { type: "string", description: "One-line description of what this sense monitors" }, }, required: ["name", "description"], }, ); const senseName = meta.name; ctx.log(`planner: extracted sense name="${senseName}", desc="${meta.description}"`); return { type: "plan_ready", plan, senseName, userInput }; }, }, // ----------------------------------------------------------------------- // CODER: Generates sense files + updates nerve.yaml // ----------------------------------------------------------------------- coder: { async execute(prompt: unknown, ctx) { const { plan, senseName } = prompt as { plan: string; senseName: string; }; ctx.log(`coder: implementing sense "${senseName}"`); 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 ${readNerveYaml()} \`\`\` 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 result = cursorAgent(codePrompt, "default", NERVE_ROOT); ctx.log(`coder: implementation done`); // Verify files were created 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")), }; ctx.log(`coder: files created — index:${files.index} schema:${files.schema} migration:${files.migration}`); return { type: "code_ready", senseName, files, cursorOutput: result, }; }, }, // ----------------------------------------------------------------------- // TESTER: Triggers the sense and validates the result // ----------------------------------------------------------------------- tester: { async execute(prompt: unknown, ctx) { const { senseName, files, attempt = 1 } = prompt as { senseName: string; files: Record; attempt?: number; }; ctx.log(`tester: validating sense "${senseName}" (attempt ${attempt})`); // Check all files exist const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k); if (missing.length > 0) { ctx.log(`tester: FAIL — missing files: ${missing.join(", ")}`); return { type: "test_failed", senseName, reason: `Missing files: ${missing.join(", ")}`, attempt, }; } const smoke = runSenseSmokeTest(senseName); ctx.log(`tester: smoke — ok=${smoke.ok}, reason="${smoke.reason}"`); ctx.log(`tester: log head — ${smoke.log.substring(0, 400)}`); if (smoke.ok) { return { type: "test_passed", senseName, result: smoke.reason }; } return { type: "test_failed", senseName, reason: `${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`, attempt, }; }, }, }, // ------------------------------------------------------------------------- // MODERATOR: Routes the workflow through planner → coder → tester // ------------------------------------------------------------------------- moderate(thread, event) { // Initial trigger if (event.type === "thread_start") { return { role: "planner", prompt: event.triggerPayload ?? "" }; } // Plan is ready → hand to coder if (event.type === "plan_ready") { return { role: "coder", prompt: { plan: event.plan, senseName: event.senseName }, }; } // Code is ready → hand to tester if (event.type === "code_ready") { return { role: "tester", prompt: { senseName: event.senseName, files: event.files }, }; } // Test failed → retry coder (max 2 retries) if (event.type === "test_failed") { const attempt = (event.attempt as number) ?? 1; if (attempt < 3) { // Find the plan from history const planEvent = thread.events.find((e) => e.type === "plan_ready"); if (planEvent) { return { role: "coder", prompt: { plan: `${planEvent.plan}\n\n## PREVIOUS FAILURE (attempt ${attempt}):\n${event.reason}\n\nFix the issues above.`, senseName: event.senseName, }, }; } } // Give up after 3 attempts return null; } // Test passed → done if (event.type === "test_passed") { return null; } return null; }, }; export default workflow;