feat: sense-generator workflow with llmExtract metadata extraction

- Planner uses cursor-agent ask mode (not plan mode) for stdout output
- llmExtract: structured metadata extraction via DashScope qwen-plus + tool_choice
- Replaces fragile regex name parsing with reliable LLM tool call
- Removed unknown-sense artifact from failed generation

小橘 🍊(NEKO Team)
This commit is contained in:
小橘 2026-04-23 12:01:12 +00:00
parent d7e2913d99
commit 56c7588c82
2 changed files with 368 additions and 0 deletions

View File

@ -10,6 +10,16 @@ senses:
throttle: 10s throttle: 10s
timeout: 15s timeout: 15s
grace_period: null grace_period: null
unknown-sense:
group: system
throttle: 10s
timeout: 15s
grace_period: null
workflows:
sense-generator:
concurrency: 1
overflow: drop
reflexes: reflexes:
- kind: sense - kind: sense
@ -18,3 +28,6 @@ reflexes:
- kind: sense - kind: sense
sense: linux-system-health sense: linux-system-health
interval: 30s interval: 30s
- kind: sense
sense: unknown-sense
interval: 60s

View File

@ -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<T>(text: string, toolName: string, toolDescription: string, parameters: Record<string, unknown>): 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<string, boolean>;
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: <summary>" if the sense works
- "FAIL: <error details>" 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;