/** * Redundant-search prevention (session-scoped, in-process). * * Two complementary mechanisms stop the agent from researching the same thing * twice and dumping duplicate findings into context: * * 1. Near-duplicate QUERY short-circuit — `acquireSearch` normalizes the query * and, if a recent or in-flight search is (near-)identical, returns a pointer * to that prior search instead of running a second one. Catches reworded / * reordered / parallel duplicates. * 2. Result-URL ledger — `splitSeen` partitions a search's results into pages * already researched this session vs. genuinely new ones, so a differently- * worded search with overlapping results only reads what's new. * * The text helpers (normalizeQuery/queryTokens/jaccard) are pure and unit-tested; * the registry/ledger is module-level session state (the extension is a long- * lived process). `__resetDedup` clears it for tests. */ import type { SearchResult } from "../search/search.ts"; export interface PriorSearch { query: string; sources: { url: string; title: string }[]; } // --------------------------------------------------------------------------- // Pure text helpers // --------------------------------------------------------------------------- const STOPWORDS = new Set( ("a an the of to in for on and or vs is are was were be been being do does did how what why when " + "where which who whom whose this that these those with without into from about as at by it its their " + "his her can could should would will may might more most other some any all your you i we they me my") .split(" "), ); /** Normalized, deduped, sorted significant tokens of a query. */ export function queryTokens(q: string): string[] { const toks = q .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .split(" ") // keep words of length >= 2 and any token containing a digit (versions, years), // dropping stopwords and bare single letters. .filter((t) => (t.length >= 2 || /[0-9]/.test(t)) && !STOPWORDS.has(t)); return [...new Set(toks)].sort(); } /** Canonical string form of a query (order/casing/stopword-insensitive). */ export function normalizeQuery(q: string): string { return queryTokens(q).join(" "); } /** Jaccard similarity of two token sets (1 when both empty). */ export function jaccard(a: Set, b: Set): number { if (a.size === 0 && b.size === 0) return 1; let inter = 0; for (const x of a) if (b.has(x)) inter++; const union = a.size + b.size - inter; return union === 0 ? 0 : inter / union; } // --------------------------------------------------------------------------- // Session state // --------------------------------------------------------------------------- const DUP_TTL_MS = 30 * 60_000; // a search counts as "recent" for 30 min const DUP_THRESHOLD = 0.6; // token-set Jaccard at/above which two queries are "the same" const SEEN_TTL_MS = 30 * 60_000; const MAX_ENTRIES = 64; interface Record_ { query: string; sources: { url: string; title: string }[]; at: number; } interface Entry { tokens: Set; normalized: string; at: number; promise: Promise; } let entries: Entry[] = []; const seenUrls = new Map(); // url -> last-researched timestamp function pruneEntries(now: number): void { entries = entries.filter((e) => now - e.at < DUP_TTL_MS); if (entries.length > MAX_ENTRIES) entries = entries.slice(-MAX_ENTRIES); } function pruneSeen(now: number): void { for (const [url, at] of seenUrls) if (now - at >= SEEN_TTL_MS) seenUrls.delete(url); } export type Acquire = | { kind: "duplicate"; prior: PriorSearch } | { kind: "proceed"; finish: (sources: { url: string; title: string }[]) => void; abandon: () => void }; /** * Register intent to search `query`. If a recent or in-flight search is a * near-duplicate, returns `{kind:"duplicate", prior}` (await-resolved for * in-flight) — the caller should return a compact pointer instead of searching. * Otherwise returns `{kind:"proceed", finish, abandon}`: the caller MUST call * `finish(sources)` on success (records sources + marks URLs seen) or `abandon()` * otherwise (so concurrent waiters don't hang). */ export async function acquireSearch(query: string, now: number = Date.now()): Promise { pruneEntries(now); const tokens = new Set(queryTokens(query)); const normalized = [...tokens].join(" "); const match = entries.find((e) => e.normalized === normalized || jaccard(e.tokens, tokens) >= DUP_THRESHOLD); if (match) { const rec = await match.promise; // in-flight → wait for it; completed → immediate return { kind: "duplicate", prior: { query: rec.query, sources: rec.sources } }; } let resolveFn!: (r: Record_) => void; const promise = new Promise((res) => { resolveFn = res; }); const entry: Entry = { tokens, normalized, at: now, promise }; entries.push(entry); pruneEntries(now); let settled = false; return { kind: "proceed", finish: (sources) => { if (settled) return; settled = true; const at = Date.now(); entry.at = at; resolveFn({ query, sources, at }); for (const s of sources) seenUrls.set(s.url, at); }, abandon: () => { if (settled) return; settled = true; resolveFn({ query, sources: [], at: Date.now() }); entries = entries.filter((e) => e !== entry); }, }; } /** Partition results into already-researched (seen) vs. genuinely new (fresh). */ export function splitSeen(results: SearchResult[], now: number = Date.now()): { fresh: SearchResult[]; seen: SearchResult[] } { pruneSeen(now); const fresh: SearchResult[] = []; const seen: SearchResult[] = []; for (const r of results) (seenUrls.has(r.url) ? seen : fresh).push(r); return { fresh, seen }; } /** Test-only: clear all session dedup state. */ export function __resetDedup(): void { entries = []; seenUrls.clear(); }