refactor(sense-generator): full DIP — all deps injected via build functions

Every role is self-contained (types.ts, prompt.ts, index.ts).
No shared.ts, no cross-role imports. All dependencies injected:

  index.ts          — wiring (resolve env, call buildSenseGenerator)
  build.ts          — buildSenseGenerator(deps) → WorkflowDefinition
  moderator.ts      — pure routing, composes meta from role types
  roles/planner/    — buildPlannerRole(deps), self-contained
  roles/coder/      — buildCoderRole(deps), self-contained
  roles/tester/     — buildTesterRole(deps), self-contained

Workflow is now reusable: buildSenseGenerator() can be called with
any provider/paths, not hardcoded to this machine.

小橘 🍊(NEKO Team)
This commit is contained in:
小橘 2026-04-28 04:00:38 +00:00
parent 6d3313223f
commit cb61e98979
11 changed files with 185 additions and 127 deletions

View File

@ -0,0 +1,41 @@
import type { WorkflowDefinition } from "@uncaged/nerve-core";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { buildPlannerRole } from "./roles/planner/index.js";
import { buildCoderRole } from "./roles/coder/index.js";
import { buildTesterRole } from "./roles/tester/index.js";
import { moderator } from "./moderator.js";
import type { SenseMeta } from "./moderator.js";
export type BuildSenseGeneratorDeps = {
provider: LlmProvider;
nerveRoot: string;
sensesDir: string;
senseExamples: string;
nerveYaml: string;
};
export function buildSenseGenerator(deps: BuildSenseGeneratorDeps): WorkflowDefinition<SenseMeta> {
return {
name: "sense-generator",
roles: {
planner: buildPlannerRole({
provider: deps.provider,
cwd: deps.nerveRoot,
senseExamples: deps.senseExamples,
nerveYaml: deps.nerveYaml,
}),
coder: buildCoderRole({
provider: deps.provider,
cwd: deps.nerveRoot,
sensesDir: deps.sensesDir,
nerveRoot: deps.nerveRoot,
}),
tester: buildTesterRole({
provider: deps.provider,
sensesDir: deps.sensesDir,
nerveRoot: deps.nerveRoot,
}),
},
moderator,
};
}

View File

@ -1,24 +1,77 @@
import type { WorkflowDefinition } from "@uncaged/nerve-core"; import { existsSync, readFileSync } from "node:fs";
import { buildPlannerRole } from "./roles/planner/index.js"; import { join } from "node:path";
import { buildCoderRole } from "./roles/coder/index.js"; import { spawnSafe } from "@uncaged/nerve-workflow-utils";
import { buildTesterRole } from "./roles/tester/index.js"; import { buildSenseGenerator } from "./build.js";
import { moderator } from "./moderator.js";
import { resolveDashScopeProvider } from "./roles/shared.js";
import type { SenseMeta } from "./roles/types.js";
const provider = await resolveDashScopeProvider(); // --- Environment ---
if (provider === null) {
throw new Error("Cannot build workflow: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL"); const HOME = process.env.HOME ?? "/home/azureuser";
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
const SENSES_DIR = join(NERVE_ROOT, "senses");
// --- Resolve provider ---
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;
} }
const workflow: WorkflowDefinition<SenseMeta> = { const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY"));
name: "sense-generator", const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL"));
roles: { const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus";
planner: buildPlannerRole(provider), if (!apiKey || !baseUrl) {
coder: buildCoderRole(provider), throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
tester: buildTesterRole(provider), }
},
moderator, // --- Build context ---
};
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");
}
// --- Wire up ---
const workflow = buildSenseGenerator({
provider: { apiKey, baseUrl, model },
nerveRoot: NERVE_ROOT,
sensesDir: SENSES_DIR,
senseExamples: buildSenseExamples(),
nerveYaml: getNerveYaml(),
});
export default workflow; export default workflow;

View File

@ -1,6 +1,14 @@
import { END } from "@uncaged/nerve-core"; import { END } from "@uncaged/nerve-core";
import type { Moderator } from "@uncaged/nerve-core"; import type { Moderator } from "@uncaged/nerve-core";
import type { SenseMeta } from "./roles/types.js"; import type { PlannerMeta } from "./roles/planner/types.js";
import type { CoderMeta } from "./roles/coder/types.js";
import type { TesterMeta } from "./roles/tester/types.js";
export type SenseMeta = {
planner: PlannerMeta;
coder: CoderMeta;
tester: TesterMeta;
};
function countRole(steps: { role: string }[], name: string): number { function countRole(steps: { role: string }[], name: string): number {
return steps.filter((s) => s.role === name).length; return steps.filter((s) => s.role === name).length;

View File

@ -1,15 +1,22 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createCursorRole } from "@uncaged/nerve-workflow-utils"; import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { NERVE_ROOT, SENSES_DIR } from "../shared.js"; import { coderMetaSchema } from "./types.js";
import { coderMetaSchema } from "../types.js"; import type { CoderMeta } from "./types.js";
import type { SenseMeta } from "../types.js";
import { coderPrompt } from "./prompt.js"; import { coderPrompt } from "./prompt.js";
export function buildCoderRole(provider: LlmProvider) { export type BuildCoderDeps = {
return createCursorRole<SenseMeta["coder"]>({ provider: LlmProvider;
cwd: NERVE_ROOT, cwd: string;
sensesDir: string;
nerveRoot: string;
};
export function buildCoderRole(deps: BuildCoderDeps) {
return createCursorRole<CoderMeta>({
cwd: deps.cwd,
mode: "default", mode: "default",
prompt: async (threadId) => coderPrompt({ threadId, sensesDir: SENSES_DIR, nerveRoot: NERVE_ROOT }), prompt: async (threadId) =>
extract: { provider, schema: coderMetaSchema }, coderPrompt({ threadId, sensesDir: deps.sensesDir, nerveRoot: deps.nerveRoot }),
extract: { provider: deps.provider, schema: coderMetaSchema },
}); });
} }

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export type CoderMeta = { filesCreated: boolean };
export const coderMetaSchema = z.object({
filesCreated: z.boolean().describe("true if the sense files were created"),
});

View File

@ -1,18 +1,22 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createCursorRole } from "@uncaged/nerve-workflow-utils"; import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { buildSenseExamples, getNerveYaml, NERVE_ROOT } from "../shared.js"; import { plannerMetaSchema } from "./types.js";
import { plannerMetaSchema } from "../types.js"; import type { PlannerMeta } from "./types.js";
import type { SenseMeta } from "../types.js";
import { plannerPrompt } from "./prompt.js"; import { plannerPrompt } from "./prompt.js";
const senseExamples = buildSenseExamples(); export type BuildPlannerDeps = {
const nerveYaml = getNerveYaml(); provider: LlmProvider;
cwd: string;
senseExamples: string;
nerveYaml: string;
};
export function buildPlannerRole(provider: LlmProvider) { export function buildPlannerRole(deps: BuildPlannerDeps) {
return createCursorRole<SenseMeta["planner"]>({ return createCursorRole<PlannerMeta>({
cwd: NERVE_ROOT, cwd: deps.cwd,
mode: "ask", mode: "ask",
prompt: async (threadId) => plannerPrompt({ threadId, senseExamples, nerveYaml }), prompt: async (threadId) =>
extract: { provider, schema: plannerMetaSchema }, plannerPrompt({ threadId, senseExamples: deps.senseExamples, nerveYaml: deps.nerveYaml }),
extract: { provider: deps.provider, schema: plannerMetaSchema },
}); });
} }

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export type PlannerMeta = { senseName: string };
export const plannerMetaSchema = z.object({
senseName: z.string().describe("kebab-case sense name from the plan"),
});

View File

@ -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");
}

View File

@ -1,13 +1,19 @@
import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils"; import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { NERVE_ROOT, SENSES_DIR } from "../shared.js"; import { testerMetaSchema } from "./types.js";
import { testerMetaSchema } from "../types.js"; import type { TesterMeta } from "./types.js";
import type { SenseMeta } from "../types.js";
import { testerPrompt } from "./prompt.js"; import { testerPrompt } from "./prompt.js";
export function buildTesterRole(provider: LlmProvider) { export type BuildTesterDeps = {
return createHermesRole<SenseMeta["tester"]>({ provider: LlmProvider;
prompt: async (threadId) => testerPrompt({ threadId, sensesDir: SENSES_DIR, nerveRoot: NERVE_ROOT }), sensesDir: string;
extract: { provider, schema: testerMetaSchema }, nerveRoot: string;
};
export function buildTesterRole(deps: BuildTesterDeps) {
return createHermesRole<TesterMeta>({
prompt: async (threadId) =>
testerPrompt({ threadId, sensesDir: deps.sensesDir, nerveRoot: deps.nerveRoot }),
extract: { provider: deps.provider, schema: testerMetaSchema },
}); });
} }

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export type TesterMeta = { passed: boolean };
export const testerMetaSchema = z.object({
passed: z.boolean().describe("true if all e2e checks passed"),
});

View File

@ -1,19 +0,0 @@
import { z } from "zod";
export type SenseMeta = {
planner: { senseName: string };
coder: { filesCreated: boolean };
tester: { passed: boolean };
};
export const plannerMetaSchema = z.object({
senseName: z.string().describe("kebab-case sense name from the plan"),
});
export const coderMetaSchema = z.object({
filesCreated: z.boolean().describe("true if the sense files were created"),
});
export const testerMetaSchema = z.object({
passed: z.boolean().describe("true if all e2e checks passed"),
});