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:
parent
d7e2913d99
commit
56c7588c82
13
nerve.yaml
13
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
|
||||
|
||||
355
workflows/sense-generator/index.ts
Normal file
355
workflows/sense-generator/index.ts
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user