/** * Search-box.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/research/src/index.ts * Description: Durable ResearchState service — every mutation persists and emits exactly one event. */ import type { Claim, ClaimStatus, Contradiction, Evidence, SessionStatus, Source, Stance } from "@search-box/shared"; import type { CitationMapEntry, ResearchEventPayload } from "@search-box/events"; import { claims as claimsRepo, contradictions as contradictionsRepo, events as eventsRepo, evidence as evidenceRepo, sessions as sessionsRepo, sources as sourcesRepo } from "@search-box/db"; /** * ResearchState lives in PostgreSQL, outside the model's context window. * This service is the only mutation path: state write + event append stay * together so the UI never shows fabricated progress. */ export class ResearchState { constructor(public readonly sessionId: string) {} async emit(payload: ResearchEventPayload): Promise { await eventsRepo.append(this.sessionId, payload); } async setStatus(status: SessionStatus): Promise { await sessionsRepo.setStatus(this.sessionId, status); await this.emit({ type: "session.status", status }); } async setObjectives(objectives: string[], publicReason: string): Promise { await sessionsRepo.setObjectives(this.sessionId, objectives); await this.emit({ type: "plan.updated", objectives, publicReason }); } async thought(publicReason: string): Promise { await this.emit({ type: "thought", publicReason }); } async addFoundSource(url: string, title: string | null): Promise { const source = await sourcesRepo.upsertFound(this.sessionId, url, title); await this.emit({ type: "source.added", source }); return source; } async markSourceFetched(sourceId: string, title: string | null, content: string): Promise { const source = await sourcesRepo.markFetched(sourceId, title, content); await this.emit({ type: "source.updated", source }); return source; } async markSourceFailed(sourceId: string): Promise { const source = await sourcesRepo.markFailed(sourceId); await this.emit({ type: "source.updated", source }); return source; } async addClaim(text: string, confidence: number): Promise { const claim = await claimsRepo.add(this.sessionId, text, confidence); await this.emit({ type: "claim.added", claim }); return claim; } async updateClaim( claimId: string, patch: { status?: ClaimStatus; confidence?: number; publicReason?: string } ): Promise { const claim = await claimsRepo.update(claimId, patch); await this.emit({ type: "claim.updated", claim }); return claim; } async addEvidence( sourceId: string, quote: string, stance: Stance, claimId: string | null, note: string | null ): Promise { const ev = await evidenceRepo.add(this.sessionId, sourceId, quote, stance, claimId, note); const source = await sourcesRepo.get(sourceId); await this.emit({ type: "evidence.added", evidence: ev, sourceUrl: source?.url ?? "", sourceTitle: source?.title ?? null }); return ev; } async addContradiction( claimId: string, description: string, evidenceIds: string[] ): Promise { const c = await contradictionsRepo.add(this.sessionId, claimId, description, evidenceIds); await this.emit({ type: "contradiction.added", contradiction: c }); return c; } /* ------------------------------ read snapshot ----------------------------- */ async snapshot(): Promise<{ sources: Source[]; claims: Claim[]; evidence: Evidence[]; contradictions: Contradiction[]; }> { const [sources, claims, evidence, contradictions] = await Promise.all([ sourcesRepo.listBySession(this.sessionId), claimsRepo.listBySession(this.sessionId), evidenceRepo.listBySession(this.sessionId), contradictionsRepo.listBySession(this.sessionId) ]); return { sources, claims, evidence, contradictions }; } /** * Mechanical citation assignment: sources that carry evidence receive * stable indices ordered by first evidence use. Claude never invents these. */ async assignCitations(): Promise { const cited = await sourcesRepo.assignCitationIndices(this.sessionId); return cited .filter((s) => s.citationIndex !== null) .map((s) => ({ index: s.citationIndex as number, sourceId: s.id, url: s.url, title: s.title })); } async getSourceContent(sourceId: string): Promise { return sourcesRepo.getContent(sourceId); } }