import { spawn } from "child_process"; import * as os from "os"; const SYSTEM_PROMPT = `You read a draft document as a reader would. Reply with the single next obvious question that a reader would ask after reading it: the most basic thing they would want to know next that the document does not yet answer. Keep the question short and plain, ideally under ten words. Ask exactly one thing: never join two questions with "and" or "or". Do not ask about details when a more basic question is still open. If the document is empty or nearly empty, ask something like "What is this?". The writer may leave placeholders for planned content, such as TODO, TBD, FIXME or \\todo{...}. Treat each one as already written: imagine it filled in with what it describes (or, for a bare TODO, with what a reader would expect at that spot), and ask the next question a reader of that completed draft would have. Do not ask for what a placeholder will provide. A question may build on what a placeholder will contain, when that is what the reader would most want to know next. Reply with the question only, with no preamble, quotes or explanation.`; export type Backend = | { kind: "claudeCode"; path: string; model: string } | { kind: "openrouter"; apiKey: string; model: string }; export async function nextQuestion( backend: Backend, fileName: string, text: string, avoid: string[] = [], ): Promise { let userPrompt = `Document (${fileName}):\n\n\n${text}\n`; if (avoid.length > 0) { userPrompt += `\n\nThe reader has already been shown these questions and wants a different one. ` + `Ask the next most obvious question that is not one of these or a rewording of one:\n` + avoid.map((q) => `- ${q}`).join("\n"); } const raw = backend.kind === "claudeCode" ? await runClaudeCode(backend.path, backend.model, userPrompt) : await runOpenRouter(backend.apiKey, backend.model, userPrompt); return cleanup(raw); } function cleanup(raw: string): string { // Keep the first non-empty line and drop surrounding quotes. const line = raw.split("\n").map((s) => s.trim()).find((s) => s.length > 0) ?? ""; return line.replace(/^["'“]+|["'”]+$/g, "").trim(); } function runClaudeCode(path: string, model: string, prompt: string): Promise { // No tools, settings, MCP servers or skills, and no saved session: just one model call. const args = [ "-p", "--model", model, "--system-prompt", SYSTEM_PROMPT, "--tools", "", "--setting-sources", "", "--strict-mcp-config", "--disable-slash-commands", "--no-session-persistence", ]; return new Promise((resolve, reject) => { // Run outside any workspace so no project CLAUDE.md is picked up. const proc = spawn(path, args, { cwd: os.tmpdir() }); let stdout = ""; let stderr = ""; const timer = setTimeout(() => { proc.kill(); reject(new Error("Claude Code timed out after 60 s")); }, 60_000); proc.stdout.on("data", (d) => (stdout += d)); proc.stderr.on("data", (d) => (stderr += d)); proc.on("error", (err) => { clearTimeout(timer); reject(new Error(`Could not run "${path}": ${err.message}. Set nextObviousQuestion.claudeCodePath.`)); }); proc.on("close", (code) => { clearTimeout(timer); if (code === 0) resolve(stdout); else reject(new Error(`Claude Code exited with code ${code}: ${(stderr || stdout).trim()}`)); }); proc.stdin.end(prompt); }); } async function runOpenRouter(apiKey: string, model: string, prompt: string): Promise { const resp = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "X-Title": "Next Obvious Question", }, body: JSON.stringify({ model, max_tokens: 100, messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: prompt }, ], }), signal: AbortSignal.timeout(60_000), }); if (!resp.ok) { throw new Error(`OpenRouter returned ${resp.status}: ${(await resp.text()).slice(0, 300)}`); } const data = (await resp.json()) as { choices?: { message?: { content?: string } }[] }; return data.choices?.[0]?.message?.content ?? ""; }