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:
parent
6d3313223f
commit
cb61e98979
41
workflows/sense-generator/build.ts
Normal file
41
workflows/sense-generator/build.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -1,24 +1,77 @@
|
||||
import type { WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
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 { resolveDashScopeProvider } from "./roles/shared.js";
|
||||
import type { SenseMeta } from "./roles/types.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import { buildSenseGenerator } from "./build.js";
|
||||
|
||||
const provider = await resolveDashScopeProvider();
|
||||
if (provider === null) {
|
||||
throw new Error("Cannot build workflow: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
|
||||
// --- Environment ---
|
||||
|
||||
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> = {
|
||||
name: "sense-generator",
|
||||
roles: {
|
||||
planner: buildPlannerRole(provider),
|
||||
coder: buildCoderRole(provider),
|
||||
tester: buildTesterRole(provider),
|
||||
},
|
||||
moderator,
|
||||
};
|
||||
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) {
|
||||
throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import { END } 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 {
|
||||
return steps.filter((s) => s.role === name).length;
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
import type { LlmProvider } 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 type { SenseMeta } from "../types.js";
|
||||
import { coderMetaSchema } from "./types.js";
|
||||
import type { CoderMeta } from "./types.js";
|
||||
import { coderPrompt } from "./prompt.js";
|
||||
|
||||
export function buildCoderRole(provider: LlmProvider) {
|
||||
return createCursorRole<SenseMeta["coder"]>({
|
||||
cwd: NERVE_ROOT,
|
||||
export type BuildCoderDeps = {
|
||||
provider: LlmProvider;
|
||||
cwd: string;
|
||||
sensesDir: string;
|
||||
nerveRoot: string;
|
||||
};
|
||||
|
||||
export function buildCoderRole(deps: BuildCoderDeps) {
|
||||
return createCursorRole<CoderMeta>({
|
||||
cwd: deps.cwd,
|
||||
mode: "default",
|
||||
prompt: async (threadId) => coderPrompt({ threadId, sensesDir: SENSES_DIR, nerveRoot: NERVE_ROOT }),
|
||||
extract: { provider, schema: coderMetaSchema },
|
||||
prompt: async (threadId) =>
|
||||
coderPrompt({ threadId, sensesDir: deps.sensesDir, nerveRoot: deps.nerveRoot }),
|
||||
extract: { provider: deps.provider, schema: coderMetaSchema },
|
||||
});
|
||||
}
|
||||
|
||||
7
workflows/sense-generator/roles/coder/types.ts
Normal file
7
workflows/sense-generator/roles/coder/types.ts
Normal 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"),
|
||||
});
|
||||
@ -1,18 +1,22 @@
|
||||
import type { LlmProvider } 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 type { SenseMeta } from "../types.js";
|
||||
import { plannerMetaSchema } from "./types.js";
|
||||
import type { PlannerMeta } from "./types.js";
|
||||
import { plannerPrompt } from "./prompt.js";
|
||||
|
||||
const senseExamples = buildSenseExamples();
|
||||
const nerveYaml = getNerveYaml();
|
||||
export type BuildPlannerDeps = {
|
||||
provider: LlmProvider;
|
||||
cwd: string;
|
||||
senseExamples: string;
|
||||
nerveYaml: string;
|
||||
};
|
||||
|
||||
export function buildPlannerRole(provider: LlmProvider) {
|
||||
return createCursorRole<SenseMeta["planner"]>({
|
||||
cwd: NERVE_ROOT,
|
||||
export function buildPlannerRole(deps: BuildPlannerDeps) {
|
||||
return createCursorRole<PlannerMeta>({
|
||||
cwd: deps.cwd,
|
||||
mode: "ask",
|
||||
prompt: async (threadId) => plannerPrompt({ threadId, senseExamples, nerveYaml }),
|
||||
extract: { provider, schema: plannerMetaSchema },
|
||||
prompt: async (threadId) =>
|
||||
plannerPrompt({ threadId, senseExamples: deps.senseExamples, nerveYaml: deps.nerveYaml }),
|
||||
extract: { provider: deps.provider, schema: plannerMetaSchema },
|
||||
});
|
||||
}
|
||||
|
||||
7
workflows/sense-generator/roles/planner/types.ts
Normal file
7
workflows/sense-generator/roles/planner/types.ts
Normal 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"),
|
||||
});
|
||||
@ -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,13 +1,19 @@
|
||||
import type { LlmProvider } 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 type { SenseMeta } from "../types.js";
|
||||
import { testerMetaSchema } from "./types.js";
|
||||
import type { TesterMeta } from "./types.js";
|
||||
import { testerPrompt } from "./prompt.js";
|
||||
|
||||
export function buildTesterRole(provider: LlmProvider) {
|
||||
return createHermesRole<SenseMeta["tester"]>({
|
||||
prompt: async (threadId) => testerPrompt({ threadId, sensesDir: SENSES_DIR, nerveRoot: NERVE_ROOT }),
|
||||
extract: { provider, schema: testerMetaSchema },
|
||||
export type BuildTesterDeps = {
|
||||
provider: LlmProvider;
|
||||
sensesDir: string;
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
7
workflows/sense-generator/roles/tester/types.ts
Normal file
7
workflows/sense-generator/roles/tester/types.ts
Normal 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"),
|
||||
});
|
||||
@ -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"),
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user