小橘 8ff6003a75 refactor(sense-generator): use createCursorRole and slim SenseMeta
Replace hand-written planner and coder with createCursorRole from nerve-workflow-utils. Prompts instruct reading the Nerve thread via nerve thread show. Extract uses resolveDashScopeProvider. SenseMeta keeps routing-only fields; tester remains hand-written with filesystem and smoke checks.

Made-with: Cursor
2026-04-28 02:20:01 +00:00

437 lines
13 KiB
TypeScript

import type {
RoleResult,
StartStep,
WorkflowDefinition,
WorkflowMessage,
} from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
import {
createCursorRole,
nerveAgentContext,
readNerveYaml,
spawnSafe,
} from "@uncaged/nerve-workflow-utils";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { z } from "zod";
const HOME = process.env.HOME ?? "/home/azureuser";
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
const SENSES_DIR = join(NERVE_ROOT, "senses");
const AGENT_TIMEOUT_MS = 3_600_000;
function getNerveYaml(): string {
const result = readNerveYaml({ nerveRoot: NERVE_ROOT });
return result.ok ? result.value : "# nerve.yaml unavailable";
}
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 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)}`;
}
/**
* 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.
*/
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}`,
};
}
const status = statusRun.out;
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 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: `Smoke test command failed: ${triggerRun.err}`,
};
}
logParts.push("=== nerve sense trigger ===\n" + triggerRun.out);
let lastQuery = "";
for (let i = 0; i < 25; i++) {
const sleepR = await spawnSafe("sleep", ["1"], { cwd: NERVE_ROOT, env: null, timeoutMs: 10_000 });
if (!sleepR.ok) {
logParts.push(`=== sleep (attempt ${i + 1}) ===\nERROR: ${formatSpawnFailure(sleepR.error)}`);
}
const queryRun = await runNerve(["sense", "query", senseName]);
if (!queryRun.ok) {
logParts.push(`=== nerve sense query (attempt ${i + 1}) ===\nERROR: ${queryRun.err}`);
} else {
lastQuery = queryRun.out;
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",
};
}
// 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");
}
function getSenseNameFromThread(messages: WorkflowMessage[]): string {
const p = messages.find((m) => m.role === "planner");
if (p === undefined || typeof p.meta !== "object" || p.meta === null) {
return "";
}
return String((p.meta as { senseName: string }).senseName);
}
function getPlanFromThread(messages: WorkflowMessage[]): string {
const p = messages.find((m) => m.role === "planner");
return p !== undefined ? p.content : "";
}
type SenseMeta = {
planner: { senseName: string };
coder: { filesCreated: boolean };
tester: { passed: boolean; attempt: number };
};
const plannerMetaSchema = z
.object({
senseName: z
.string()
.describe("kebab-case sense name from the plan, e.g. 'disk-usage'"),
})
.describe("Extract the kebab-case sense name from the plan text");
const coderMetaSchema = z
.object({
filesCreated: z
.boolean()
.describe("true if index.js, schema.ts, migrations/0001_init.sql exist and nerve.yaml was updated"),
})
.describe("Whether the agent completed all file work for the sense");
async function runPlanner(
start: StartStep,
_messages: WorkflowMessage[],
): Promise<RoleResult<SenseMeta["planner"]>> {
const userInput = start.content;
const provider = await resolveDashScopeProvider();
if (provider === null) {
return {
content:
"Cannot run planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " +
"and optionally DASHSCOPE_MODEL.",
meta: { senseName: "" },
};
}
const role = createCursorRole<SenseMeta["planner"]>({
cwd: NERVE_ROOT,
mode: "ask",
timeoutMs: AGENT_TIMEOUT_MS,
prompt: async (threadId) => {
return `You are planning a new Nerve sense.
${nerveAgentContext}
**Context:** Read this workflow run for background before you plan. From a shell in \`${NERVE_ROOT}\`, run:
\`nerve thread show ${threadId} --budget 50000\`
Use the thread transcript (prior user messages and rounds) when deciding the 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
${getNerveYaml()}
\`\`\`
Output ONLY the plan in markdown. Be precise and implementation-ready.`;
},
extract: { provider, schema: plannerMetaSchema },
});
try {
return await role(start, _messages);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { content: message, meta: { senseName: "" } };
}
}
async function runCoder(
_start: StartStep,
messages: WorkflowMessage[],
): Promise<RoleResult<SenseMeta["coder"]>> {
const plan = getPlanFromThread(messages);
const senseName = getSenseNameFromThread(messages);
const provider = await resolveDashScopeProvider();
if (provider === null) {
return {
content:
"Cannot run coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " +
"and optionally DASHSCOPE_MODEL.",
meta: { filesCreated: false },
};
}
const role = createCursorRole<SenseMeta["coder"]>({
cwd: NERVE_ROOT,
mode: "default",
timeoutMs: AGENT_TIMEOUT_MS,
prompt: async (threadId) => {
return `You are implementing a new Nerve sense called "${senseName}" in the directory ${SENSES_DIR}/${senseName}/.
**Context:** Read this workflow run for background before you edit files. From a shell in \`${NERVE_ROOT}\`, run:
\`nerve thread show ${threadId} --budget 50000\`
Here is the plan (from the planner step):
${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
${getNerveYaml()}
\`\`\`
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. End with a clear statement of whether all files and updates were created successfully, or what is still missing.`;
},
extract: { provider, schema: coderMetaSchema },
});
try {
return await role(_start, messages);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { content: message, meta: { filesCreated: false } };
}
}
async function runTester(
_start: StartStep,
messages: WorkflowMessage[],
): Promise<RoleResult<SenseMeta["tester"]>> {
const senseName = getSenseNameFromThread(messages);
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")),
};
const attempt = messages.filter((m) => m.role === "tester").length + 1;
if (!senseName) {
return {
content: "FAIL — no senseName from planner meta",
meta: { passed: false, attempt },
};
}
const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k);
if (missing.length > 0) {
return {
content: `FAIL — missing files: ${missing.join(", ")}`,
meta: { passed: false, attempt },
};
}
const smoke = await runSenseSmokeTest(senseName);
if (smoke.ok) {
return {
content: `PASS — ${smoke.reason}`,
meta: { passed: true, attempt },
};
}
return {
content: `FAIL — ${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`,
meta: {
passed: false,
attempt,
},
};
}
const workflow: WorkflowDefinition<SenseMeta> = {
name: "sense-generator",
roles: {
planner: runPlanner,
coder: runCoder,
tester: runTester,
},
moderator(context) {
if (context.steps.length === 0) {
return "planner";
}
const signal = context.steps[context.steps.length - 1];
if (signal.role === "planner") {
return "coder";
}
if (signal.role === "coder") {
return "tester";
}
if (signal.role === "tester") {
const meta = signal.meta;
if (meta.passed) {
return END;
}
if (meta.attempt < 3) {
return "coder";
}
return END;
}
return END;
},
};
export default workflow;