/** * WorthDoing.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: src/lib/agent/state.ts * Description: InvestigationState loading and compact digest construction (state compression for the agent loop). */ import { asc, eq } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { investigations, hypotheses, evidence, sources, searches, opportunities, type BudgetLimits, type BudgetUsed, } from "@/lib/db/schema"; import { PHASE_GUIDANCE } from "./prompts"; export type Investigation = typeof investigations.$inferSelect; export type InvestigationState = { investigation: Investigation; hypotheses: (typeof hypotheses.$inferSelect)[]; evidence: (typeof evidence.$inferSelect)[]; searches: (typeof searches.$inferSelect)[]; visitedSources: { id: string; canonicalUrl: string; title: string | null }[]; opportunities: (typeof opportunities.$inferSelect)[]; }; export async function loadState(investigationId: string): Promise { const [inv] = await db.select().from(investigations).where(eq(investigations.id, investigationId)); if (!inv) throw new Error(`Investigation ${investigationId} not found`); const [hyps, evs, srch, opps] = await Promise.all([ db.select().from(hypotheses).where(eq(hypotheses.investigationId, investigationId)).orderBy(asc(hypotheses.createdAt)), db.select().from(evidence).where(eq(evidence.investigationId, investigationId)).orderBy(asc(evidence.createdAt)), db.select().from(searches).where(eq(searches.investigationId, investigationId)).orderBy(asc(searches.createdAt)), db.select().from(opportunities).where(eq(opportunities.investigationId, investigationId)).orderBy(asc(opportunities.createdAt)), ]); const sourceIds = [...new Set(evs.map((e) => e.sourceId))]; const visited = sourceIds.length > 0 ? await db .select({ id: sources.id, canonicalUrl: sources.canonicalUrl, title: sources.title }) .from(sources) : []; return { investigation: inv, hypotheses: hyps, evidence: evs, searches: srch, visitedSources: visited.filter((s) => sourceIds.includes(s.id)), opportunities: opps, }; } /** Compute the heuristic phase from state + budget consumption. */ export function computePhase(state: InvestigationState): Investigation["phase"] { const { budget, budgetUsed } = state.investigation; const stepRatio = budgetUsed.agentSteps / budget.maxAgentSteps; const highConf = state.hypotheses.filter((h) => h.confidence >= 0.6 && h.status !== "rejected"); const unchecked = highConf.filter((h) => !h.adversarialChecked); if (state.investigation.status === "completed") return "done"; if (stepRatio > 0.8 || state.opportunities.length > 0) return "synthesizing"; if (highConf.length > 0 && (unchecked.length > 0 && stepRatio > 0.45)) return "skeptic"; if (state.hypotheses.length > 0) return "investigating"; return "scouting"; } function pct(n: number): string { return `${Math.round(n * 100)}%`; } /** * Build the compact state digest sent to Claude each step. This is the state * compression layer: full scraped pages are never resent — only hypotheses, * evidence summaries, search history, budget, and phase guidance. */ export function buildStateDigest(state: InvestigationState): string { const inv = state.investigation; const b: BudgetLimits = inv.budget; const u: BudgetUsed = inv.budgetUsed; const phase = computePhase(state); const lines: string[] = []; lines.push(`OBJECTIVE: ${inv.objective}`); lines.push(""); lines.push( `BUDGET REMAINING: steps ${b.maxAgentSteps - u.agentSteps}/${b.maxAgentSteps} | searches ${b.maxSearches - u.searches}/${b.maxSearches} | scrapes ${b.maxScrapes - u.scrapes}/${b.maxScrapes} | crawls ${b.maxCrawls - u.crawls}/${b.maxCrawls}`, ); lines.push(""); if (state.hypotheses.length) { lines.push("HYPOTHESES:"); for (const h of state.hypotheses) { const flags = [ h.status, `conf ${pct(h.confidence)}`, h.adversarialChecked ? "skeptic✓" : "skeptic✗", h.parentHypothesisId ? "(branch)" : "", ] .filter(Boolean) .join(", "); lines.push(`- [${h.id}] ${h.title} — ${flags}`); lines.push(` statement: ${h.statement}`); } lines.push(""); } if (state.evidence.length) { lines.push(`EVIDENCE (${state.evidence.length} items):`); // Most recent 25 in full; older compressed to a count. const recent = state.evidence.slice(-25); const older = state.evidence.length - recent.length; if (older > 0) lines.push(` (… ${older} earlier items omitted — already reflected in hypothesis confidences)`); for (const e of recent) { lines.push(`- [${e.id}] (${e.kind}, strength ${pct(e.strength)}) ${e.summary}`); } lines.push(""); } if (state.searches.length) { lines.push(`SEARCHES ALREADY RUN (do not repeat): ${state.searches.map((s) => `"${s.query}"`).join("; ")}`); lines.push(""); } if (state.visitedSources.length) { lines.push( `SOURCES ALREADY SCRAPED: ${state.visitedSources.map((s) => s.canonicalUrl).join(" | ")}`, ); lines.push(""); } if (state.opportunities.length) { lines.push("OPPORTUNITIES CREATED:"); for (const o of state.opportunities) { lines.push( `- [${o.id}] ${o.title} — worth ${o.worthScore ?? "?"} (evidence confidence ${o.evidenceConfidence != null ? pct(o.evidenceConfidence) : "?"}) — report ${o.reportMd ? "written" : "NOT YET SYNTHESIZED"}`, ); } lines.push(""); } lines.push(PHASE_GUIDANCE[phase] ?? ""); const stepsLeft = b.maxAgentSteps - u.agentSteps; if (stepsLeft <= 4) { lines.push( `⚠ ONLY ${stepsLeft} STEPS LEFT. Stop exploring. Create opportunities from surviving hypotheses (if any), synthesize their reports, and call finish_investigation NOW.`, ); } return lines.join("\n"); }