diff --git a/nerve.yaml b/nerve.yaml index c1c7ce4..e0d995f 100644 --- a/nerve.yaml +++ b/nerve.yaml @@ -10,6 +10,16 @@ senses: throttle: 10s timeout: 15s grace_period: null + unknown-sense: + group: system + throttle: 10s + timeout: 15s + grace_period: null + +workflows: + sense-generator: + concurrency: 1 + overflow: drop reflexes: - kind: sense @@ -18,3 +28,6 @@ reflexes: - kind: sense sense: linux-system-health interval: 30s + - kind: sense + sense: unknown-sense + interval: 60s diff --git a/workflows/sense-generator/index.ts b/workflows/sense-generator/index.ts new file mode 100644 index 0000000..c6de988 --- /dev/null +++ b/workflows/sense-generator/index.ts @@ -0,0 +1,355 @@ +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 NERVE_ROOT = join(process.env.HOME ?? "/home/azureuser", ".uncaged-nerve"); +const SENSES_DIR = join(NERVE_ROOT, "senses"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function run(cmd: string, cwd?: string): string { + return execSync(cmd, { + encoding: "utf-8", + cwd: cwd ?? NERVE_ROOT, + timeout: 300_000, + env: { + ...process.env, + PNPM_HOME: join(process.env.HOME ?? "/home/azureuser", ".local/share/pnpm"), + PATH: `${join(process.env.HOME ?? "/home/azureuser", ".local/share/pnpm")}:${process.env.PATH}`, + }, + }).trim(); +} + +/** + * 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, + ); +} + +function hermesRun(prompt: string): string { + const escaped = prompt.replace(/'/g, "'\\''"); + return run( + `hermes -q '${escaped}' --model anthropic/claude-sonnet-4 --no-memory 2>&1 || true`, + ); +} + +// 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, + }; + } + + // Reload daemon to pick up new sense, then trigger it + const testPrompt = `You need to test a newly created Nerve sense called "${senseName}". + +Steps: +1. Restart the nerve daemon: run "nerve stop && nerve start" (make sure PNPM_HOME and PATH are set) +2. Wait 2 seconds +3. Check status: run "nerve status" and verify "${senseName}" appears in senses list +4. Trigger the sense: run "nerve sense trigger ${senseName}" +5. Check the result: run "nerve sense query ${senseName} --limit 1" + +Report whether the sense works. If there are errors, include the full error output. +The nerve binary is at ~/.local/share/pnpm/nerve. + +Reply with either: +- "PASS: " if the sense works +- "FAIL: " if it doesn't`; + + const result = hermesRun(testPrompt); + ctx.log(`tester: result — ${result.substring(0, 200)}`); + + const passed = result.toUpperCase().includes("PASS"); + if (passed) { + return { type: "test_passed", senseName, result }; + } + + return { + type: "test_failed", + senseName, + reason: result, + 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;