- Remove cpu-usage sense (redundant with system-health loadavg) - Remove linux-tcp-socket-stats (merged into linux-system-health) - Remove disk-usage-mounts (unused) - Add tcp socket fields to system-health schema + migration - Simplify nerve.yaml: 4 senses → 2 小橘 <xiaoju@shazhou.work>
395 lines
13 KiB
TypeScript
395 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 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<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,
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
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;
|