import { promises as fs } from "node:fs"; import path from "node:path"; import { spawn } from "node:child_process"; import { CLAUDE_BIN, DEFAULT_MODEL, PYTHON, REPO_ROOT } from "./config"; import { assertSafeId, inProject } from "./paths"; import { techSpecPath } from "./technologies"; import { getProject, updateProject } from "./store"; import { appendConversation, readConversation } from "./chat"; import { spawnLogged } from "./spawn"; import type { ProjectManifest, SpecDoc } from "./types"; /** * Is this spec id a *known* library structure โ€” one the /technologies gallery * lists, or a packaged reference spec? If so a project adopts it as its tag; * otherwise the extracted structure is custom and the project stays "new". */ async function isKnownStructure(specId: string): Promise { const exists = async (p: string) => { try { await fs.access(p); return true; } catch { return false; } }; try { if (await exists(techSpecPath(specId))) return true; // spec/technologies/{id}.json if (await exists(path.join(REPO_ROOT, "spec", `${assertSafeId(specId)}.json`))) return true; } catch { /* assertSafeId rejected an unsafe id โ†’ treat as unknown */ } return false; } /** * Extract a spec from an uploaded protocol doc via `python -m extract from-doc`. * A one-off (not the QC RunRecord model): mirrors runner.ts's spawn/log/update * pattern, writing spec/spec.json + a streamable spec/extract.log. */ export interface ExtractState { status: "running" | "succeeded" | "failed"; doc: string; // project-relative startedAt: string; finishedAt?: string; specPath?: string; // project-relative error?: string; } const statePath = (projectId: string) => inProject(projectId, "spec/extract.json"); export const extractLogRel = "spec/extract.log"; const live = new Set(); export async function readExtractState(projectId: string): Promise { try { return JSON.parse(await fs.readFile(statePath(projectId), "utf8")) as ExtractState; } catch { return null; } } async function writeExtractState(projectId: string, s: ExtractState): Promise { await fs.mkdir(inProject(projectId, "spec"), { recursive: true }); await fs.writeFile(statePath(projectId), JSON.stringify(s, null, 2) + "\n"); } export function isExtracting(projectId: string): boolean { return live.has(projectId); } /** The user's free-text description (their chat turns, minus the upload markers). */ async function userDescription(projectId: string): Promise { const conv = await readConversation(projectId); return conv .filter((e) => e.role === "user" && !e.text.startsWith("๐Ÿ“Ž Uploaded")) .map((e) => e.text) .join("\n") .trim(); } function cleanTitle(raw: string): string { return raw .trim() .split("\n")[0] .replace(/^["'`*]+|["'`*.]+$/g, "") .trim() .slice(0, 120); } /** * Compose a "{sample} ยท {assay}" project title from the user's description + the * extracted assay via a quick headless claude call. Returns null on any failure * (caller falls back to the assay alone). Skips the call when there's no description. */ async function composeTitle(assay: string, description: string): Promise { const desc = description.slice(0, 800).trim(); if (!desc) return null; const prompt = `Name a sequencing-QC project. Assay: "${assay}".\n` + `The user described their sample/experiment:\n"""\n${desc}\n"""\n\n` + `Reply with ONLY a short one-line title (โ‰ค6 words) combining the sample with the assay, ` + `e.g. "PBMC 1k ยท 10x 3' GEX". No quotes, no trailing punctuation. ` + `If the description names no concrete sample, reply with exactly: ${assay}`; return new Promise((resolve) => { const child = spawn( CLAUDE_BIN, [ "-p", prompt, "--output-format", "json", "--model", DEFAULT_MODEL, // Pure text task โ€” deny every tool. "--disallowedTools", "Bash", "Write", "Edit", "NotebookEdit", "Task", "WebFetch", "WebSearch", "Read", "Grep", "Glob", ], { cwd: REPO_ROOT, env: process.env }, ); let out = ""; child.stdout?.on("data", (d: Buffer) => (out += d.toString())); const timer = setTimeout(() => { try { child.kill("SIGTERM"); } catch { /* already gone */ } resolve(null); }, 60_000); child.on("error", () => { clearTimeout(timer); resolve(null); }); child.on("exit", (code) => { clearTimeout(timer); if (code !== 0) return resolve(null); try { const j = JSON.parse(out) as { result?: unknown }; const text = typeof j.result === "string" ? cleanTitle(j.result) : ""; resolve(text || null); } catch { resolve(null); } }); }); } function summarizeSpec(spec: SpecDoc): string { const lines: string[] = [ "I extracted the expected read/library structure from your protocol โ€” open **Extracted spec** in the Files panel to review it. Here's the gist:", "", ]; if (spec.assay) { lines.push(`- **Assay:** ${spec.assay}${spec.chemistry_version ? ` (${spec.chemistry_version})` : ""}`); } const reads = spec.read_structure?.reads ?? []; const r1 = reads.find((r) => r.read === "R1"); if (r1?.segments?.length) { const seg = r1.segments .map((s) => `${s.name}${s.length ? ` ${s.length} bp` : ""}`) .join(" + "); lines.push(`- **Read structure:** R1 = ${seg}`); } if (spec.oligos?.length) { const ids = spec.oligos.slice(0, 4).map((o) => o.oligo_id).join(", "); lines.push(`- **Oligos:** ${spec.oligos.length} parts${ids ? ` (${ids}โ€ฆ)` : ""}`); } if (spec.library_generation?.length) { lines.push(`- **Library build:** ${spec.library_generation.length} wet-lab steps`); } lines.push(""); lines.push( "Open **Extracted spec** in the Files panel to inspect the oligos, read structure, and build steps โ€” or just ask me anything about it here.", ); return lines.join("\n"); } export async function startExtract(projectId: string, docRel: string): Promise { if (live.has(projectId)) return; live.add(projectId); const startedAt = new Date().toISOString(); const specRel = "spec/spec.json"; const specAbs = inProject(projectId, specRel); const docAbs = inProject(projectId, docRel); const logFile = inProject(projectId, extractLogRel); await fs.mkdir(inProject(projectId, "spec"), { recursive: true }); await fs.writeFile(logFile, ""); // truncate previous log await writeExtractState(projectId, { status: "running", doc: docRel, startedAt }); await updateProject(projectId, { phase: "extracting" }); const args = [ "-m", "extract", "from-doc", "--doc", docAbs, // Technology-agnostic inference โ€” respect the protocol's real read structure // (custom/novel assays included), not a forced 10x template. "--spec", "generic", "--model", DEFAULT_MODEL, "--out", specAbs, ]; void driveExtract(projectId, docRel, specRel, specAbs, logFile, args, startedAt).finally(() => live.delete(projectId), ); } async function fail( projectId: string, docRel: string, startedAt: string, logFile: string, reason: string, ): Promise { const finishedAt = new Date().toISOString(); let tail = ""; try { tail = (await fs.readFile(logFile, "utf8")).trim().split("\n").slice(-4).join("\n"); } catch { /* no log */ } await writeExtractState(projectId, { status: "failed", doc: docRel, startedAt, finishedAt, error: reason, }); await updateProject(projectId, { phase: "awaiting_inputs" }); await appendConversation(projectId, [ { role: "assistant", text: `I couldn't extract a spec from that protocol (${reason}). Extraction runs Claude locally, ` + `so it needs Claude configured.` + (tail ? `\n\nLog tail:\n\n\`\`\`\n${tail}\n\`\`\`` : ""), ts: finishedAt, }, ]); } async function driveExtract( projectId: string, docRel: string, specRel: string, specAbs: string, logFile: string, args: string[], startedAt: string, ): Promise { const proc = spawnLogged({ cmd: PYTHON, args, cwd: REPO_ROOT, logFile }); const code = await proc.done; if (code !== 0) { await fail(projectId, docRel, startedAt, logFile, `extraction exited with code ${code}`); return; } let spec: SpecDoc | null = null; try { spec = JSON.parse(await fs.readFile(specAbs, "utf8")) as SpecDoc; } catch { spec = null; } if (!spec) { await fail(projectId, docRel, startedAt, logFile, "the extractor produced no readable spec"); return; } const patch: Partial = { activeSpecPath: specRel, phase: "awaiting_spec_review", specConfirmed: false, }; if (spec.assay) patch.assay = spec.assay; if (spec.platform) patch.platform = spec.platform; // nanopore โ‡’ single-read flow // Adopt the identified id only when it's a known structure; a custom library // keeps the neutral "new" tag rather than inventing a fake catalog id. if (spec.spec_id && (await isKnownStructure(spec.spec_id))) patch.specId = spec.spec_id; // Auto-name only while still "Untitled": "{sample} ยท {assay}" when the user // described a sample, else the assay alone. const project = await getProject(projectId); if (project.name === "Untitled" && spec.assay) { const description = await userDescription(projectId); patch.name = (await composeTitle(spec.assay, description)) || spec.assay; } await updateProject(projectId, patch); await writeExtractState(projectId, { status: "succeeded", doc: docRel, startedAt, finishedAt: new Date().toISOString(), specPath: specRel, }); await appendConversation(projectId, [ { role: "assistant", text: summarizeSpec(spec), ts: new Date().toISOString() }, ]); }