import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import { createLlmRole } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; import { resolveQueueForQuestioner } from "../lib/knowledge-queue.js"; import { resolveWorkdir } from "../lib/workdir.js"; const questionerExtractSchema = z.object({ questions: z .array( z.object({ id: z.string(), question: z.string(), domain: z.string(), }), ) .length(3), }); export type QuestionerMeta = { /** Empty when no .knowledge cards and no work to do. */ card: string; questions: { id: string; question: string; domain: string }[]; remaining_queue: string[]; }; export type CreateQuestionerRoleDeps = { extract: LlmExtractorConfig; }; function questionerSystem(): string { return `You are the **questioner** in a knowledge-extraction workflow. Read the given markdown knowledge card. Propose exactly **three** technical questions that are **not** already answered or covered by that card. Rules: - Questions must be concrete and technical. - Each question needs a stable string id (e.g. q1, q2, q3), a short domain label (e.g. routing, storage), and the question text. - Do not assume access to other files or tools — reason only from the card content shown.`; } function questionerUser(card: string, cardBody: string, remainingHint: string[]): string { return `Current card path: ${card} Remaining queue after this card (paths, may be empty): ${JSON.stringify(remainingHint)} --- Card content --- ${cardBody}`; } export function createQuestionerRole(adapterExtract: CreateQuestionerRoleDeps): Role { const { extract } = adapterExtract; return async (start: StartStep, messages: WorkflowMessage[]) => { const cwd = resolveWorkdir(start); const queue = await resolveQueueForQuestioner(start, messages, cwd); if (queue.length === 0) { return { content: "questioner: no `.knowledge` markdown files found and no seed path in the trigger prompt; queue is empty.", meta: { card: "", questions: [], remaining_queue: [], }, }; } const card = queue[0]!; const remaining_queue = queue.slice(1); let cardBody: string; try { cardBody = await readFile(join(cwd, card), "utf8"); } catch (e) { const msg = e instanceof Error ? e.message : String(e); throw new Error(`questioner: failed to read ${card}: ${msg}`); } const inner = createLlmRole({ provider: extract.provider, prompt: async () => [ { role: "system", content: questionerSystem() }, { role: "user", content: questionerUser(card, cardBody, remaining_queue) }, ], extract: { schema: questionerExtractSchema, provider: extract.provider, }, }); const r = await inner(start, messages); return { content: r.content, meta: { card, questions: r.meta.questions, remaining_queue, }, }; }; }