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
This commit is contained in:
小橘 2026-04-28 02:20:01 +00:00
parent c5ea790447
commit 8ff6003a75

View File

@ -7,8 +7,7 @@ import type {
import { END } from "@uncaged/nerve-core"; import { END } from "@uncaged/nerve-core";
import type { SpawnError } from "@uncaged/nerve-workflow-utils"; import type { SpawnError } from "@uncaged/nerve-workflow-utils";
import { import {
cursorAgent, createCursorRole,
llmExtract,
nerveAgentContext, nerveAgentContext,
readNerveYaml, readNerveYaml,
spawnSafe, spawnSafe,
@ -20,6 +19,7 @@ import { z } from "zod";
const HOME = process.env.HOME ?? "/home/azureuser"; const HOME = process.env.HOME ?? "/home/azureuser";
const NERVE_ROOT = join(HOME, ".uncaged-nerve"); const NERVE_ROOT = join(HOME, ".uncaged-nerve");
const SENSES_DIR = join(NERVE_ROOT, "senses"); const SENSES_DIR = join(NERVE_ROOT, "senses");
const AGENT_TIMEOUT_MS = 3_600_000;
function getNerveYaml(): string { function getNerveYaml(): string {
const result = readNerveYaml({ nerveRoot: NERVE_ROOT }); const result = readNerveYaml({ nerveRoot: NERVE_ROOT });
@ -67,7 +67,9 @@ function formatSpawnFailure(error: SpawnError): string {
* Run the same checks the workflow used to ask Hermes to perform, but locally. * 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. * 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 }> { async function runSenseSmokeTest(
senseName: string,
): Promise<{ ok: boolean; log: string; reason: string }> {
const logParts: string[] = []; const logParts: string[] = [];
const runNerve = async (args: string[]): Promise<{ ok: true; out: string } | { ok: false; err: string }> => { const runNerve = async (args: string[]): Promise<{ ok: true; out: string } | { ok: false; err: string }> => {
@ -170,43 +172,69 @@ function buildSenseExamples(): string {
return examples.join("\n\n---\n\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 = { type SenseMeta = {
planner: { plan: string; senseName: string; userInput: string }; planner: { senseName: string };
coder: { senseName: string; files: Record<string, boolean>; cursorOutput: string }; coder: { filesCreated: boolean };
tester: { passed: boolean; senseName: string; reason: string; attempt: number }; tester: { passed: boolean; attempt: number };
}; };
const senseMetaSchema = z const plannerMetaSchema = z
.object({ .object({
name: z.string().describe("kebab-case sense name, e.g. 'disk-usage'"), senseName: z
description: z.string().describe("One-line description of what this sense monitors"), .string()
.describe("kebab-case sense name from the plan, e.g. 'disk-usage'"),
}) })
.describe("Extract the sense name and a one-line description from the plan"); .describe("Extract the kebab-case sense name from the plan text");
const workflow: WorkflowDefinition<SenseMeta> = { const coderMetaSchema = z
name: "sense-generator", .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");
roles: { async function runPlanner(
async planner( start: StartStep,
start: StartStep, _messages: WorkflowMessage[],
_messages: WorkflowMessage[], ): Promise<RoleResult<SenseMeta["planner"]>> {
): Promise<RoleResult<SenseMeta["planner"]>> { const userInput = start.content;
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 provider = await resolveDashScopeProvider(); const role = createCursorRole<SenseMeta["planner"]>({
if (provider === null) { cwd: NERVE_ROOT,
return { mode: "ask",
content: timeoutMs: AGENT_TIMEOUT_MS,
"Cannot run planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " + prompt: async (threadId) => {
"and optionally DASHSCOPE_MODEL.", return `You are planning a new Nerve sense.
meta: { plan: "", senseName: "", userInput },
};
}
const planPrompt = `You are planning a new Nerve sense.
${nerveAgentContext} ${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} User request: ${userInput}
Pick a good kebab-case name for this sense. Pick a good kebab-case name for this sense.
@ -239,50 +267,45 @@ ${getNerveYaml()}
\`\`\` \`\`\`
Output ONLY the plan in markdown. Be precise and implementation-ready.`; Output ONLY the plan in markdown. Be precise and implementation-ready.`;
const planResult = await cursorAgent({
prompt: planPrompt,
mode: "ask",
cwd: NERVE_ROOT,
env: null,
timeoutMs: null,
});
if (!planResult.ok) {
return {
content: `cursor-agent failed: ${formatSpawnFailure(planResult.error)}`,
meta: { plan: "", senseName: "", userInput },
};
}
const plan = planResult.value;
const extracted = await llmExtract({
text: plan,
schema: senseMetaSchema,
provider,
});
if (!extracted.ok) {
return {
content: `${plan}\n\n[llmExtract error] ${JSON.stringify(extracted.error)}`,
meta: { plan, senseName: "", userInput },
};
}
return {
content: plan,
meta: { plan, senseName: extracted.value.name, userInput },
};
}, },
extract: { provider, schema: plannerMetaSchema },
});
async coder( try {
_start: StartStep, return await role(start, _messages);
messages: WorkflowMessage[], } catch (e) {
): Promise<RoleResult<SenseMeta["coder"]>> { const message = e instanceof Error ? e.message : String(e);
const last = messages[messages.length - 1]; return { content: message, meta: { senseName: "" } };
const { plan, senseName } = last.meta as { plan: string; senseName: string }; }
}
const codePrompt = `You are implementing a new Nerve sense called "${senseName}" in the directory ${SENSES_DIR}/${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 },
};
}
Here is the plan: 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} ${plan}
@ -311,77 +334,74 @@ IMPORTANT RULES:
- nerve.yaml: add under \`senses:\` and add a reflex under \`reflexes:\` - nerve.yaml: add under \`senses:\` and add a reflex under \`reflexes:\`
- Use the interval specified in the plan for the reflex - Use the interval specified in the plan for the reflex
Create all files now.`; Create all files now. End with a clear statement of whether all files and updates were created successfully, or what is still missing.`;
const agentResult = await cursorAgent({
prompt: codePrompt,
mode: "default",
cwd: NERVE_ROOT,
env: null,
timeoutMs: null,
});
if (!agentResult.ok) {
const resultText = `cursor-agent failed: ${formatSpawnFailure(agentResult.error)}`;
return {
content: resultText,
meta: {
senseName,
files: { index: false, schema: false, migration: false },
cursorOutput: resultText,
},
};
}
const result = agentResult.value;
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")),
};
return {
content: result,
meta: { senseName, files, cursorOutput: result },
};
}, },
extract: { provider, schema: coderMetaSchema },
});
async tester( try {
_start: StartStep, return await role(_start, messages);
messages: WorkflowMessage[], } catch (e) {
): Promise<RoleResult<SenseMeta["tester"]>> { const message = e instanceof Error ? e.message : String(e);
const last = messages[messages.length - 1]; return { content: message, meta: { filesCreated: false } };
const { senseName, files } = last.meta as { senseName: string; files: Record<string, boolean> }; }
}
const attempt = messages.filter((m) => m.role === "tester").length + 1; async function runTester(
_start: StartStep,
messages: WorkflowMessage[],
): Promise<RoleResult<SenseMeta["tester"]>> {
const senseName = getSenseNameFromThread(messages);
const senseDir = join(SENSES_DIR, senseName);
const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k); const files = {
if (missing.length > 0) { index: existsSync(join(senseDir, "index.js")),
return { schema: existsSync(join(senseDir, "schema.ts")),
content: `FAIL — missing files: ${missing.join(", ")}`, migration: existsSync(join(senseDir, "migrations", "0001_init.sql")),
meta: { passed: false, senseName, reason: `Missing files: ${missing.join(", ")}`, attempt }, };
};
}
const smoke = await runSenseSmokeTest(senseName); const attempt = messages.filter((m) => m.role === "tester").length + 1;
if (smoke.ok) { if (!senseName) {
return { return {
content: `PASS — ${smoke.reason}`, content: "FAIL — no senseName from planner meta",
meta: { passed: true, senseName, reason: smoke.reason, attempt }, meta: { passed: false, attempt },
}; };
} }
return { const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k);
content: `FAIL — ${smoke.reason}`, if (missing.length > 0) {
meta: { return {
passed: false, content: `FAIL — missing files: ${missing.join(", ")}`,
senseName, meta: { passed: false, attempt },
reason: `${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`, };
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) { moderator(context) {