spb/worthdoing Public
Autonomous investigation agent that discovers, challenges, and ranks things genuinely worth doing — Claude + Firecrawl, Next.js 16, PostgreSQL
TypeScript 91.5%
SQL 5.8%
CSS 2.2%
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/lib/agent/state.ts6 * Description: InvestigationState loading and compact digest construction (state compression for the agent loop).7 */8import { asc, eq } from "drizzle-orm";9import { db } from "@/lib/db/client";10import {11 investigations,12 hypotheses,13 evidence,14 sources,15 searches,16 opportunities,17 type BudgetLimits,18 type BudgetUsed,19} from "@/lib/db/schema";20import { PHASE_GUIDANCE } from "./prompts";2122export type Investigation = typeof investigations.$inferSelect;2324export type InvestigationState = {25 investigation: Investigation;26 hypotheses: (typeof hypotheses.$inferSelect)[];27 evidence: (typeof evidence.$inferSelect)[];28 searches: (typeof searches.$inferSelect)[];29 visitedSources: { id: string; canonicalUrl: string; title: string | null }[];30 opportunities: (typeof opportunities.$inferSelect)[];31};3233export async function loadState(investigationId: string): Promise<InvestigationState> {34 const [inv] = await db.select().from(investigations).where(eq(investigations.id, investigationId));35 if (!inv) throw new Error(`Investigation ${investigationId} not found`);36 const [hyps, evs, srch, opps] = await Promise.all([37 db.select().from(hypotheses).where(eq(hypotheses.investigationId, investigationId)).orderBy(asc(hypotheses.createdAt)),38 db.select().from(evidence).where(eq(evidence.investigationId, investigationId)).orderBy(asc(evidence.createdAt)),39 db.select().from(searches).where(eq(searches.investigationId, investigationId)).orderBy(asc(searches.createdAt)),40 db.select().from(opportunities).where(eq(opportunities.investigationId, investigationId)).orderBy(asc(opportunities.createdAt)),41 ]);42 const sourceIds = [...new Set(evs.map((e) => e.sourceId))];43 const visited =44 sourceIds.length > 045 ? await db46 .select({ id: sources.id, canonicalUrl: sources.canonicalUrl, title: sources.title })47 .from(sources)48 : [];49 return {50 investigation: inv,51 hypotheses: hyps,52 evidence: evs,53 searches: srch,54 visitedSources: visited.filter((s) => sourceIds.includes(s.id)),55 opportunities: opps,56 };57}5859/** Compute the heuristic phase from state + budget consumption. */60export function computePhase(state: InvestigationState): Investigation["phase"] {61 const { budget, budgetUsed } = state.investigation;62 const stepRatio = budgetUsed.agentSteps / budget.maxAgentSteps;63 const highConf = state.hypotheses.filter((h) => h.confidence >= 0.6 && h.status !== "rejected");64 const unchecked = highConf.filter((h) => !h.adversarialChecked);6566 if (state.investigation.status === "completed") return "done";67 if (stepRatio > 0.8 || state.opportunities.length > 0) return "synthesizing";68 if (highConf.length > 0 && (unchecked.length > 0 && stepRatio > 0.45)) return "skeptic";69 if (state.hypotheses.length > 0) return "investigating";70 return "scouting";71}7273function pct(n: number): string {74 return `${Math.round(n * 100)}%`;75}7677/**78 * Build the compact state digest sent to Claude each step. This is the state79 * compression layer: full scraped pages are never resent — only hypotheses,80 * evidence summaries, search history, budget, and phase guidance.81 */82export function buildStateDigest(state: InvestigationState): string {83 const inv = state.investigation;84 const b: BudgetLimits = inv.budget;85 const u: BudgetUsed = inv.budgetUsed;86 const phase = computePhase(state);8788 const lines: string[] = [];89 lines.push(`OBJECTIVE: ${inv.objective}`);90 lines.push("");91 lines.push(92 `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}`,93 );94 lines.push("");9596 if (state.hypotheses.length) {97 lines.push("HYPOTHESES:");98 for (const h of state.hypotheses) {99 const flags = [100 h.status,101 `conf ${pct(h.confidence)}`,102 h.adversarialChecked ? "skeptic✓" : "skeptic✗",103 h.parentHypothesisId ? "(branch)" : "",104 ]105 .filter(Boolean)106 .join(", ");107 lines.push(`- [${h.id}] ${h.title} — ${flags}`);108 lines.push(` statement: ${h.statement}`);109 }110 lines.push("");111 }112113 if (state.evidence.length) {114 lines.push(`EVIDENCE (${state.evidence.length} items):`);115 // Most recent 25 in full; older compressed to a count.116 const recent = state.evidence.slice(-25);117 const older = state.evidence.length - recent.length;118 if (older > 0) lines.push(` (… ${older} earlier items omitted — already reflected in hypothesis confidences)`);119 for (const e of recent) {120 lines.push(`- [${e.id}] (${e.kind}, strength ${pct(e.strength)}) ${e.summary}`);121 }122 lines.push("");123 }124125 if (state.searches.length) {126 lines.push(`SEARCHES ALREADY RUN (do not repeat): ${state.searches.map((s) => `"${s.query}"`).join("; ")}`);127 lines.push("");128 }129130 if (state.visitedSources.length) {131 lines.push(132 `SOURCES ALREADY SCRAPED: ${state.visitedSources.map((s) => s.canonicalUrl).join(" | ")}`,133 );134 lines.push("");135 }136137 if (state.opportunities.length) {138 lines.push("OPPORTUNITIES CREATED:");139 for (const o of state.opportunities) {140 lines.push(141 `- [${o.id}] ${o.title} — worth ${o.worthScore ?? "?"} (evidence confidence ${o.evidenceConfidence != null ? pct(o.evidenceConfidence) : "?"}) — report ${o.reportMd ? "written" : "NOT YET SYNTHESIZED"}`,142 );143 }144 lines.push("");145 }146147 lines.push(PHASE_GUIDANCE[phase] ?? "");148149 const stepsLeft = b.maxAgentSteps - u.agentSteps;150 if (stepsLeft <= 4) {151 lines.push(152 `⚠ ONLY ${stepsLeft} STEPS LEFT. Stop exploring. Create opportunities from surviving hypotheses (if any), synthesize their reports, and call finish_investigation NOW.`,153 );154 }155156 return lines.join("\n");157}158