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