SPB Git

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