spb/search-box Public
Agentic web research engine — hypotheses, verbatim evidence, contradictions, sourced answers streamed live. Claude Opus 5 + Firecrawl + PostgreSQL.
TypeScript 76.9%
CSS 18.7%
SQL 2.1%
JavaScript 1.8%
Shell 0.5%
1/**2 * Search-box.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: packages/research/src/index.ts6 * Description: Durable ResearchState service — every mutation persists and emits exactly one event.7 */89import type {10 Claim,11 ClaimStatus,12 Contradiction,13 Evidence,14 SessionStatus,15 Source,16 Stance17} from "@search-box/shared";18import type { CitationMapEntry, ResearchEventPayload } from "@search-box/events";19import {20 claims as claimsRepo,21 contradictions as contradictionsRepo,22 events as eventsRepo,23 evidence as evidenceRepo,24 sessions as sessionsRepo,25 sources as sourcesRepo26} from "@search-box/db";2728/**29 * ResearchState lives in PostgreSQL, outside the model's context window.30 * This service is the only mutation path: state write + event append stay31 * together so the UI never shows fabricated progress.32 */33export class ResearchState {34 constructor(public readonly sessionId: string) {}3536 async emit(payload: ResearchEventPayload): Promise<void> {37 await eventsRepo.append(this.sessionId, payload);38 }3940 async setStatus(status: SessionStatus): Promise<void> {41 await sessionsRepo.setStatus(this.sessionId, status);42 await this.emit({ type: "session.status", status });43 }4445 async setObjectives(objectives: string[], publicReason: string): Promise<void> {46 await sessionsRepo.setObjectives(this.sessionId, objectives);47 await this.emit({ type: "plan.updated", objectives, publicReason });48 }4950 async thought(publicReason: string): Promise<void> {51 await this.emit({ type: "thought", publicReason });52 }5354 async addFoundSource(url: string, title: string | null): Promise<Source> {55 const source = await sourcesRepo.upsertFound(this.sessionId, url, title);56 await this.emit({ type: "source.added", source });57 return source;58 }5960 async markSourceFetched(sourceId: string, title: string | null, content: string): Promise<Source> {61 const source = await sourcesRepo.markFetched(sourceId, title, content);62 await this.emit({ type: "source.updated", source });63 return source;64 }6566 async markSourceFailed(sourceId: string): Promise<Source> {67 const source = await sourcesRepo.markFailed(sourceId);68 await this.emit({ type: "source.updated", source });69 return source;70 }7172 async addClaim(text: string, confidence: number): Promise<Claim> {73 const claim = await claimsRepo.add(this.sessionId, text, confidence);74 await this.emit({ type: "claim.added", claim });75 return claim;76 }7778 async updateClaim(79 claimId: string,80 patch: { status?: ClaimStatus; confidence?: number; publicReason?: string }81 ): Promise<Claim> {82 const claim = await claimsRepo.update(claimId, patch);83 await this.emit({ type: "claim.updated", claim });84 return claim;85 }8687 async addEvidence(88 sourceId: string,89 quote: string,90 stance: Stance,91 claimId: string | null,92 note: string | null93 ): Promise<Evidence> {94 const ev = await evidenceRepo.add(this.sessionId, sourceId, quote, stance, claimId, note);95 const source = await sourcesRepo.get(sourceId);96 await this.emit({97 type: "evidence.added",98 evidence: ev,99 sourceUrl: source?.url ?? "",100 sourceTitle: source?.title ?? null101 });102 return ev;103 }104105 async addContradiction(106 claimId: string,107 description: string,108 evidenceIds: string[]109 ): Promise<Contradiction> {110 const c = await contradictionsRepo.add(this.sessionId, claimId, description, evidenceIds);111 await this.emit({ type: "contradiction.added", contradiction: c });112 return c;113 }114115 /* ------------------------------ read snapshot ----------------------------- */116117 async snapshot(): Promise<{118 sources: Source[];119 claims: Claim[];120 evidence: Evidence[];121 contradictions: Contradiction[];122 }> {123 const [sources, claims, evidence, contradictions] = await Promise.all([124 sourcesRepo.listBySession(this.sessionId),125 claimsRepo.listBySession(this.sessionId),126 evidenceRepo.listBySession(this.sessionId),127 contradictionsRepo.listBySession(this.sessionId)128 ]);129 return { sources, claims, evidence, contradictions };130 }131132 /**133 * Mechanical citation assignment: sources that carry evidence receive134 * stable indices ordered by first evidence use. Claude never invents these.135 */136 async assignCitations(): Promise<CitationMapEntry[]> {137 const cited = await sourcesRepo.assignCitationIndices(this.sessionId);138 return cited139 .filter((s) => s.citationIndex !== null)140 .map((s) => ({141 index: s.citationIndex as number,142 sourceId: s.id,143 url: s.url,144 title: s.title145 }));146 }147148 async getSourceContent(sourceId: string): Promise<string | null> {149 return sourcesRepo.getContent(sourceId);150 }151}152