小橘 e05c71d6b0 refactor(sense-generator): use createCursorRole factory, slim meta to routing-only
- planner/coder: replaced 80+ lines hand-written agent calls with createCursorRole()
- SenseMeta slimmed to routing signals only (senseName, filesCreated, passed/attempt)
- Roles read context from thread via nerve thread <id>, not from previous role's meta
- tester stays hand-written (pure CLI logic)
- Re-exported spawnSafe from workflow-utils for helper use

Refs uncaged/nerve#210

小橘 🍊(NEKO Team)
2026-04-28 02:22:38 +00:00

319 lines
10 KiB
TypeScript

import type {
RoleResult,
StartStep,
WorkflowDefinition,
WorkflowMessage,
} from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { createCursorRole, spawnSafe } from "@uncaged/nerve-workflow-utils";
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { z } from "zod";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const HOME = process.env.HOME ?? "/home/azureuser";
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
const SENSES_DIR = join(NERVE_ROOT, "senses");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatSpawnFailure(error: SpawnError): string {
if (error.kind === "spawn_failed") return error.message;
if (error.kind === "timeout") return `timeout (stdout=${error.stdout.slice(0, 200)})`;
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`;
}
async function cfgGet(key: string): Promise<string | null> {
const result = await spawnSafe("cfg", ["get", key], {
cwd: NERVE_ROOT,
env: null,
timeoutMs: 10_000,
});
if (!result.ok) return null;
return result.value.stdout.trim() || null;
}
async function resolveDashScopeProvider(): Promise<{
baseUrl: string;
apiKey: string;
model: string;
} | null> {
const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY"));
const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL"));
const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus";
if (!apiKey || !baseUrl) return null;
return { apiKey, baseUrl, model };
}
function getNerveYaml(): string {
try {
return readFileSync(join(NERVE_ROOT, "nerve.yaml"), "utf-8");
} catch {
return "# nerve.yaml unavailable";
}
}
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");
}
async function runSenseSmokeTest(
senseName: string,
): Promise<{ ok: boolean; log: string; reason: string }> {
const logParts: string[] = [];
const runNerve = async (
args: string[],
): Promise<{ ok: true; out: string } | { ok: false; err: string }> => {
const result = await spawnSafe("nerve", args, {
cwd: NERVE_ROOT,
env: null,
timeoutMs: 300_000,
});
if (!result.ok) return { ok: false, err: formatSpawnFailure(result.error) };
return { ok: true, out: result.value.stdout };
};
const statusRun = await runNerve(["status"]);
if (!statusRun.ok) {
return {
ok: false,
log: `=== nerve status ===\nERROR: ${statusRun.err}`,
reason: `Smoke test command failed: ${statusRun.err}`,
};
}
logParts.push("=== nerve status ===\n" + statusRun.out);
if (!statusRun.out.includes(senseName)) {
return {
ok: false,
log: logParts.join("\n\n"),
reason: `Sense "${senseName}" not listed in \`nerve status\` output`,
};
}
const triggerRun = await runNerve(["sense", "trigger", senseName]);
if (!triggerRun.ok) {
logParts.push(`=== nerve sense trigger ===\nERROR: ${triggerRun.err}`);
return { ok: false, log: logParts.join("\n\n"), reason: `Trigger failed: ${triggerRun.err}` };
}
logParts.push("=== nerve sense trigger ===\n" + triggerRun.out);
let lastQuery = "";
for (let i = 0; i < 25; i++) {
await new Promise((r) => setTimeout(r, 1000));
const queryRun = await runNerve(["sense", "query", senseName]);
if (!queryRun.ok) {
logParts.push(`=== query attempt ${i + 1} ===\nERROR: ${queryRun.err}`);
} else {
lastQuery = queryRun.out;
logParts.push(`=== 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"
: "Timed out waiting for successful sense query",
};
}
// ---------------------------------------------------------------------------
// Meta — routing-only signals for the moderator
// ---------------------------------------------------------------------------
type SenseMeta = {
planner: { senseName: string };
coder: { filesCreated: boolean };
tester: { passed: boolean; attempt: number };
};
// ---------------------------------------------------------------------------
// Bake static context (read once at module load, not per-call)
// ---------------------------------------------------------------------------
const senseExamples = buildSenseExamples();
const nerveYaml = getNerveYaml();
// ---------------------------------------------------------------------------
// Roles
// ---------------------------------------------------------------------------
async function buildPlannerRole() {
const provider = await resolveDashScopeProvider();
if (provider === null) {
throw new Error("Cannot create planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
}
return createCursorRole<SenseMeta["planner"]>({
cwd: NERVE_ROOT,
mode: "ask",
prompt: async (threadId) =>
`You are planning a new Nerve sense.
Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown:
## Sense Design
### Name — kebab-case
### Fields — name, type (integer/real/text), description
### Compute Logic — step-by-step, specific Node.js APIs or shell commands
### Trigger Config — group, interval, throttle, timeout
Reference senses:
${senseExamples}
Current nerve.yaml:
\`\`\`yaml
${nerveYaml}
\`\`\`
Output ONLY the plan. Be precise and implementation-ready.`,
extract: {
provider,
schema: z.object({
senseName: z.string().describe("kebab-case sense name from the plan"),
}),
},
});
}
async function buildCoderRole() {
const provider = await resolveDashScopeProvider();
if (provider === null) {
throw new Error("Cannot create coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
}
return createCursorRole<SenseMeta["coder"]>({
cwd: NERVE_ROOT,
mode: "default",
prompt: async (threadId) =>
`Read the workflow thread for the planner's sense design: \`nerve thread ${threadId}\`
Implement the sense. Create exactly:
1. The sense directory under ${SENSES_DIR}/<sense-name>/
2. index.js — export async function compute(db, _peers), import schema from "./schema.ts"
3. schema.ts — drizzle-orm/sqlite-core
4. migrations/0001_init.sql — must match schema.ts
5. Update ${NERVE_ROOT}/nerve.yaml — add sense config + reflex entry
Follow the patterns from existing senses. Create all files now.`,
extract: {
provider,
schema: z.object({
filesCreated: z.boolean().describe("true if the sense files were created"),
}),
},
});
}
// Tester: pure CLI logic — stays hand-written
async function tester(
_start: StartStep,
messages: WorkflowMessage[],
): Promise<RoleResult<SenseMeta["tester"]>> {
const attempt = messages.filter((m) => m.role === "tester").length + 1;
// Get senseName from planner meta
const plannerStep = messages.find((m) => m.role === "planner");
const senseName = plannerStep
? (plannerStep.meta as SenseMeta["planner"]).senseName
: "";
if (senseName.length === 0) {
return {
content: "FAIL — no senseName from planner",
meta: { passed: false, attempt },
};
}
// Check files exist
const senseDir = join(SENSES_DIR, senseName);
const missing = [
existsSync(join(senseDir, "index.js")) ? null : "index.js",
existsSync(join(senseDir, "schema.ts")) ? null : "schema.ts",
existsSync(join(senseDir, "migrations", "0001_init.sql")) ? null : "migrations/0001_init.sql",
].filter((x) => x !== null);
if (missing.length > 0) {
return {
content: `FAIL — missing files: ${missing.join(", ")}`,
meta: { passed: false, attempt },
};
}
// Smoke test
const smoke = await runSenseSmokeTest(senseName);
return {
content: `${smoke.ok ? "PASS" : "FAIL"}${smoke.reason}`,
meta: { passed: smoke.ok, attempt },
};
}
// ---------------------------------------------------------------------------
// Workflow definition
// ---------------------------------------------------------------------------
async function buildWorkflow(): Promise<WorkflowDefinition<SenseMeta>> {
const plannerRole = await buildPlannerRole();
const coderRole = await buildCoderRole();
return {
name: "sense-generator",
roles: {
planner: plannerRole,
coder: coderRole,
tester,
},
moderator(context) {
if (context.steps.length === 0) return "planner";
const last = context.steps[context.steps.length - 1];
if (last.role === "planner") return "coder";
if (last.role === "coder") return "tester";
if (last.role === "tester") {
if (last.meta.passed) return END;
return last.meta.attempt < 3 ? "coder" : END;
}
return END;
},
};
}
const workflow = await buildWorkflow();
export default workflow;