import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { AgentFn, Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import { createRole } 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(5), }); 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 an extract-knowledge workflow. Read the given markdown knowledge card. Propose exactly **five** 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, q4, q5), 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 async function questionerPrompt(ctx: ThreadContext): Promise { const messages = ctx.steps as unknown as WorkflowMessage[]; const cwd = resolveWorkdir(ctx.start); const queue = await resolveQueueForQuestioner(ctx.start, messages, cwd); if (queue.length === 0) { throw new Error( "questioner: prompt invoked with empty queue — wrapped role should short-circuit before LLM", ); } 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}`); } return `${questionerSystem()}\n\n${questionerUser(card, cardBody, remaining_queue)}`; } export function createQuestionerRole(adapter: AgentFn, { extract }: CreateQuestionerRoleDeps): Role { const inner = createRole(adapter, questionerPrompt, questionerExtractSchema, extract); return async (ctx: ThreadContext) => { const messages = ctx.steps as unknown as WorkflowMessage[]; const cwd = resolveWorkdir(ctx.start); const queue = await resolveQueueForQuestioner(ctx.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); const r = await inner(ctx); return { content: r.content, meta: { card, questions: r.meta.questions, remaining_queue, }, }; }; }