Compare commits
No commits in common. "2d63639ed19531925b32c71effb9dad21498808e" and "61c834930740f84288c48e46b74c93b6b4a0354b" have entirely different histories.
2d63639ed1
...
61c8349307
@ -26,15 +26,9 @@ workflows:
|
|||||||
pr-summarizer:
|
pr-summarizer:
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
overflow: drop
|
overflow: drop
|
||||||
pr-code-reviewer:
|
|
||||||
concurrency: 1
|
|
||||||
overflow: drop
|
|
||||||
hello-world:
|
hello-world:
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
overflow: drop
|
overflow: drop
|
||||||
gitea-issue-solver:
|
|
||||||
concurrency: 1
|
|
||||||
overflow: drop
|
|
||||||
|
|
||||||
reflexes:
|
reflexes:
|
||||||
- kind: sense
|
- kind: sense
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "gitea-issue-solver-workflow",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/nerve-core": "latest",
|
|
||||||
"@uncaged/nerve-workflow-utils": "latest",
|
|
||||||
"zod": "^4.3.6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"typescript": "^5.7.0"
|
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"@uncaged/nerve-daemon": "link:../../../repos/nerve/packages/daemon",
|
|
||||||
"@uncaged/nerve-core": "link:../../../repos/nerve/packages/core",
|
|
||||||
"@uncaged/nerve-workflow-utils": "link:../../../repos/nerve/packages/workflow-utils"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
59
workflows/gitea-issue-solver/pnpm-lock.yaml
generated
59
workflows/gitea-issue-solver/pnpm-lock.yaml
generated
@ -1,59 +0,0 @@
|
|||||||
lockfileVersion: '9.0'
|
|
||||||
|
|
||||||
settings:
|
|
||||||
autoInstallPeers: true
|
|
||||||
excludeLinksFromLockfile: false
|
|
||||||
|
|
||||||
overrides:
|
|
||||||
'@uncaged/nerve-daemon': link:../../../repos/nerve/packages/daemon
|
|
||||||
'@uncaged/nerve-core': link:../../../repos/nerve/packages/core
|
|
||||||
'@uncaged/nerve-workflow-utils': link:../../../repos/nerve/packages/workflow-utils
|
|
||||||
|
|
||||||
importers:
|
|
||||||
|
|
||||||
.:
|
|
||||||
dependencies:
|
|
||||||
'@uncaged/nerve-core':
|
|
||||||
specifier: link:../../../repos/nerve/packages/core
|
|
||||||
version: link:../../../repos/nerve/packages/core
|
|
||||||
'@uncaged/nerve-workflow-utils':
|
|
||||||
specifier: link:../../../repos/nerve/packages/workflow-utils
|
|
||||||
version: link:../../../repos/nerve/packages/workflow-utils
|
|
||||||
zod:
|
|
||||||
specifier: ^4.3.6
|
|
||||||
version: 4.3.6
|
|
||||||
devDependencies:
|
|
||||||
'@types/node':
|
|
||||||
specifier: ^22.0.0
|
|
||||||
version: 22.19.17
|
|
||||||
typescript:
|
|
||||||
specifier: ^5.7.0
|
|
||||||
version: 5.9.3
|
|
||||||
|
|
||||||
packages:
|
|
||||||
|
|
||||||
'@types/node@22.19.17':
|
|
||||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
|
||||||
|
|
||||||
typescript@5.9.3:
|
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
|
||||||
engines: {node: '>=14.17'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
undici-types@6.21.0:
|
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
|
||||||
|
|
||||||
zod@4.3.6:
|
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
|
||||||
|
|
||||||
snapshots:
|
|
||||||
|
|
||||||
'@types/node@22.19.17':
|
|
||||||
dependencies:
|
|
||||||
undici-types: 6.21.0
|
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
|
||||||
|
|
||||||
zod@4.3.6: {}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"include": ["./**/*.ts"]
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "pr-code-reviewer-workflow",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/nerve-core": "latest",
|
|
||||||
"@uncaged/nerve-workflow-utils": "latest",
|
|
||||||
"zod": "^4.3.6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"typescript": "^5.7.0"
|
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"@uncaged/nerve-daemon": "link:../../../repos/nerve/packages/daemon",
|
|
||||||
"@uncaged/nerve-core": "link:../../../repos/nerve/packages/core",
|
|
||||||
"@uncaged/nerve-workflow-utils": "link:../../../repos/nerve/packages/workflow-utils"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
59
workflows/pr-code-reviewer/pnpm-lock.yaml
generated
59
workflows/pr-code-reviewer/pnpm-lock.yaml
generated
@ -1,59 +0,0 @@
|
|||||||
lockfileVersion: '9.0'
|
|
||||||
|
|
||||||
settings:
|
|
||||||
autoInstallPeers: true
|
|
||||||
excludeLinksFromLockfile: false
|
|
||||||
|
|
||||||
overrides:
|
|
||||||
'@uncaged/nerve-daemon': link:../../../repos/nerve/packages/daemon
|
|
||||||
'@uncaged/nerve-core': link:../../../repos/nerve/packages/core
|
|
||||||
'@uncaged/nerve-workflow-utils': link:../../../repos/nerve/packages/workflow-utils
|
|
||||||
|
|
||||||
importers:
|
|
||||||
|
|
||||||
.:
|
|
||||||
dependencies:
|
|
||||||
'@uncaged/nerve-core':
|
|
||||||
specifier: link:../../../repos/nerve/packages/core
|
|
||||||
version: link:../../../repos/nerve/packages/core
|
|
||||||
'@uncaged/nerve-workflow-utils':
|
|
||||||
specifier: link:../../../repos/nerve/packages/workflow-utils
|
|
||||||
version: link:../../../repos/nerve/packages/workflow-utils
|
|
||||||
zod:
|
|
||||||
specifier: ^4.3.6
|
|
||||||
version: 4.3.6
|
|
||||||
devDependencies:
|
|
||||||
'@types/node':
|
|
||||||
specifier: ^22.0.0
|
|
||||||
version: 22.19.17
|
|
||||||
typescript:
|
|
||||||
specifier: ^5.7.0
|
|
||||||
version: 5.9.3
|
|
||||||
|
|
||||||
packages:
|
|
||||||
|
|
||||||
'@types/node@22.19.17':
|
|
||||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
|
||||||
|
|
||||||
typescript@5.9.3:
|
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
|
||||||
engines: {node: '>=14.17'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
undici-types@6.21.0:
|
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
|
||||||
|
|
||||||
zod@4.3.6:
|
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
|
||||||
|
|
||||||
snapshots:
|
|
||||||
|
|
||||||
'@types/node@22.19.17':
|
|
||||||
dependencies:
|
|
||||||
undici-types: 6.21.0
|
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
|
||||||
|
|
||||||
zod@4.3.6: {}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"include": ["./**/*.ts"]
|
|
||||||
}
|
|
||||||
@ -1,31 +1,416 @@
|
|||||||
import type { WorkflowDefinition } from "@uncaged/nerve-core";
|
import type {
|
||||||
|
RoleResult,
|
||||||
|
StartStep,
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowMessage,
|
||||||
|
} from "@uncaged/nerve-core";
|
||||||
import { END } from "@uncaged/nerve-core";
|
import { END } from "@uncaged/nerve-core";
|
||||||
import { buildPlannerRole } from "./roles/planner/index.js";
|
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||||
import { buildCoderRole } from "./roles/coder/index.js";
|
import {
|
||||||
import { tester } from "./roles/tester/index.js";
|
cursorAgent,
|
||||||
|
llmExtract,
|
||||||
|
nerveAgentContext,
|
||||||
|
readNerveYaml,
|
||||||
|
spawnSafe,
|
||||||
|
} from "@uncaged/nerve-workflow-utils";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { SenseMeta } from "./roles/types.js";
|
const HOME = process.env.HOME ?? "/home/azureuser";
|
||||||
|
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
|
||||||
|
const SENSES_DIR = join(NERVE_ROOT, "senses");
|
||||||
|
|
||||||
async function buildWorkflow(): Promise<WorkflowDefinition<SenseMeta>> {
|
function getNerveYaml(): string {
|
||||||
const planner = await buildPlannerRole();
|
const result = readNerveYaml({ nerveRoot: NERVE_ROOT });
|
||||||
const coder = await buildCoderRole();
|
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 {
|
return {
|
||||||
name: "sense-generator",
|
ok: false,
|
||||||
roles: { planner, coder, tester },
|
log: logParts.join("\n\n"),
|
||||||
moderator(context) {
|
reason: lastQuery.includes("(0 rows)")
|
||||||
if (context.steps.length === 0) return "planner";
|
? "Query still returned 0 rows after trigger (compute error, throttle drop, or DB not written)"
|
||||||
const last = context.steps[context.steps.length - 1];
|
: "Timed out waiting for successful sense query",
|
||||||
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();
|
// 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
type SenseMeta = {
|
||||||
|
planner: { plan: string; senseName: string; userInput: string };
|
||||||
|
coder: { senseName: string; files: Record<string, boolean>; cursorOutput: string };
|
||||||
|
tester: { passed: boolean; senseName: string; reason: string; attempt: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const senseMetaSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().describe("kebab-case sense name, e.g. 'disk-usage'"),
|
||||||
|
description: z.string().describe("One-line description of what this sense monitors"),
|
||||||
|
})
|
||||||
|
.describe("Extract the sense name and a one-line description from the plan");
|
||||||
|
|
||||||
|
const workflow: WorkflowDefinition<SenseMeta> = {
|
||||||
|
name: "sense-generator",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
async planner(
|
||||||
|
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: { plan: "", senseName: "", userInput },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const planPrompt = `You are planning a new Nerve sense.
|
||||||
|
|
||||||
|
${nerveAgentContext}
|
||||||
|
|
||||||
|
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.`;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async coder(
|
||||||
|
_start: StartStep,
|
||||||
|
messages: WorkflowMessage[],
|
||||||
|
): Promise<RoleResult<SenseMeta["coder"]>> {
|
||||||
|
const last = messages[messages.length - 1];
|
||||||
|
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}/.
|
||||||
|
|
||||||
|
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
|
||||||
|
${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.`;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async tester(
|
||||||
|
_start: StartStep,
|
||||||
|
messages: WorkflowMessage[],
|
||||||
|
): Promise<RoleResult<SenseMeta["tester"]>> {
|
||||||
|
const last = messages[messages.length - 1];
|
||||||
|
const { senseName, files } = last.meta as { senseName: string; files: Record<string, boolean> };
|
||||||
|
|
||||||
|
const attempt = messages.filter((m) => m.role === "tester").length + 1;
|
||||||
|
|
||||||
|
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, senseName, reason: `Missing files: ${missing.join(", ")}`, attempt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoke = await runSenseSmokeTest(senseName);
|
||||||
|
|
||||||
|
if (smoke.ok) {
|
||||||
|
return {
|
||||||
|
content: `PASS — ${smoke.reason}`,
|
||||||
|
meta: { passed: true, senseName, reason: smoke.reason, attempt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: `FAIL — ${smoke.reason}`,
|
||||||
|
meta: {
|
||||||
|
passed: false,
|
||||||
|
senseName,
|
||||||
|
reason: `${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`,
|
||||||
|
attempt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
export default workflow;
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { resolveDashScopeProvider, NERVE_ROOT, SENSES_DIR } from "../shared.js";
|
|
||||||
|
|
||||||
import type { SenseMeta } from "../types.js";
|
|
||||||
|
|
||||||
export 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"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { resolveDashScopeProvider, buildSenseExamples, getNerveYaml, NERVE_ROOT } from "../shared.js";
|
|
||||||
import type { SenseMeta } from "../types.js";
|
|
||||||
|
|
||||||
const senseExamples = buildSenseExamples();
|
|
||||||
const nerveYaml = getNerveYaml();
|
|
||||||
|
|
||||||
export 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"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import { spawnSafe } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
export const HOME = process.env.HOME ?? "/home/azureuser";
|
|
||||||
export const NERVE_ROOT = join(HOME, ".uncaged-nerve");
|
|
||||||
export const SENSES_DIR = join(NERVE_ROOT, "senses");
|
|
||||||
|
|
||||||
export 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNerveYaml(): string {
|
|
||||||
try {
|
|
||||||
return readFileSync(join(NERVE_ROOT, "nerve.yaml"), "utf-8");
|
|
||||||
} catch {
|
|
||||||
return "# nerve.yaml unavailable";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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");
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
import type { RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
|
||||||
import { spawnSafe } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { NERVE_ROOT, SENSES_DIR } from "../shared.js";
|
|
||||||
|
|
||||||
import type { SenseMeta } from "../types.js";
|
|
||||||
|
|
||||||
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 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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function tester(
|
|
||||||
_start: StartStep,
|
|
||||||
messages: WorkflowMessage[],
|
|
||||||
): Promise<RoleResult<SenseMeta["tester"]>> {
|
|
||||||
const attempt = messages.filter((m) => m.role === "tester").length + 1;
|
|
||||||
|
|
||||||
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 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const smoke = await runSenseSmokeTest(senseName);
|
|
||||||
return {
|
|
||||||
content: `${smoke.ok ? "PASS" : "FAIL"} — ${smoke.reason}`,
|
|
||||||
meta: { passed: smoke.ok, attempt },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export type SenseMeta = {
|
|
||||||
planner: { senseName: string };
|
|
||||||
coder: { filesCreated: boolean };
|
|
||||||
tester: { passed: boolean; attempt: number };
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user