小橘 9a3c50c257 refactor: consolidate senses — merge tcp-socket-stats into system-health, remove cpu-usage
- 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>
2026-04-24 06:13:51 +00:00

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;