- Fix hermes invocation: 'hermes -q' → 'hermes chat -q' with proper flags
- Replace fragile string.includes('PASS') with llmExtract judge
(previous false positive: matched '--pass-session-id' in usage text)
小橘 🍊(NEKO Team)
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
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 chat -q '${escaped}' --model anthropic/claude-sonnet-4 -t terminal --yolo 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: raw result — ${result.substring(0, 300)}`);
|
|
|
|
// Use LLM to judge pass/fail instead of fragile string matching
|
|
const verdict = llmExtract<{ passed: boolean; reason: string }>(
|
|
`Test output for sense "${senseName}":\n\n${result.substring(0, 4000)}`,
|
|
"judge_test_result",
|
|
"Determine whether the test passed or failed based on the output",
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
passed: { type: "boolean", description: "true if the sense was successfully triggered and returned valid data" },
|
|
reason: { type: "string", description: "Brief explanation of why it passed or failed" },
|
|
},
|
|
required: ["passed", "reason"],
|
|
},
|
|
);
|
|
ctx.log(`tester: verdict — passed=${verdict.passed}, reason="${verdict.reason}"`);
|
|
|
|
if (verdict.passed) {
|
|
return { type: "test_passed", senseName, result: verdict.reason };
|
|
}
|
|
|
|
return {
|
|
type: "test_failed",
|
|
senseName,
|
|
reason: verdict.reason,
|
|
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;
|