SPB Git

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%
5.8 KB · 158 lines typescript
Raw Blame History
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