import type { LayerHit } from "./search/leafPipeline"; // Search grounding: a completed transcript search's results, serialized so the // /ask chat can answer *grounded in exactly those results* instead of running // its own search. The workspace shares one SearchSession across `/` and `/ask`, // so the chat reads the live search's SearchHandoff straight from context — no // sessionStorage hand-off is involved. // // This lives in `common` (shared by both search + chat) so it cannot import the // export-only `RetrievedVideo`. HandoffVideo is deliberately shape-compatible // with `RetrievedVideo`, so the chat treats `videos` as grounding with no adapter. export type HandoffSnippet = { clock: string; seconds: number; text: string }; export type HandoffVideo = { key: string; videoId: string; title: string; channel: string; siteTitle?: string; uploadDate: string; url?: string; snippets: HandoffSnippet[]; }; export type SearchHandoff = { // Human-readable summary of the search that produced these (the joined leaf // queries), shown in the pinned panel. label: string; videos: HandoffVideo[]; // Total matching videos before capping — so the UI can say "showing first N". totalVideos: number; // Capped videos, per-video hit overflow, or the search itself hit its batch cap. truncated: boolean; }; // The minimal view of a search result group the builder needs. export type HandoffGroup = { slug: string; title: string; channel: string; uploadDate: string; hits: LayerHit[]; }; // Per-slug metadata joined in from the summaries (video id + canonical URL, and // the source site title in hub mode). export type HandoffSummaryRef = { id: string; webpageUrl?: string; siteTitle?: string; }; export function hms(s: number): string { const n = Math.max(0, Math.floor(s)); const h = Math.floor(n / 3600); const m = Math.floor((n % 3600) / 60); const ss = n % 60; const mm = String(m).padStart(2, "0"); const sss = String(ss).padStart(2, "0"); return h > 0 ? `${h}:${mm}:${sss}` : `${m}:${sss}`; } // Turn a completed search's result groups into a SearchHandoff. Passes the whole // result set (up to `maxVideos`) so the chat can present every match, but tiers // the payload: the top `fullExcerptVideos` carry full excerpts (`maxHitsPerVideo` // each) while the rest carry a single top snippet — enough for the chat's index, // with the AI able to fetch more on demand. `truncated` records whether the video // list itself was capped. export function buildSearchHandoff( groups: HandoffGroup[], lookup: (slug: string) => HandoffSummaryRef | undefined, queries: string[], opts: { maxVideos?: number; maxHitsPerVideo?: number; fullExcerptVideos?: number; searchCapped?: boolean; } = {}, ): SearchHandoff { const maxVideos = opts.maxVideos ?? 100; const maxHits = opts.maxHitsPerVideo ?? 8; const fullExcerptVideos = opts.fullExcerptVideos ?? 12; const shown = groups.slice(0, maxVideos); let hitOverflow = false; const videos: HandoffVideo[] = shown.map((g, i) => { const ref = lookup(g.slug); const perVideoCap = i < fullExcerptVideos ? maxHits : 1; const hits = g.hits.slice(0, perVideoCap); // Only count dropped hits on the full-excerpt tier as "truncation" — the tail // is indexed by design, and the AI/user can pull more on demand. if (i < fullExcerptVideos && g.hits.length > hits.length) hitOverflow = true; return { key: g.slug, videoId: ref?.id ?? g.slug, title: g.title, channel: g.channel, siteTitle: ref?.siteTitle, uploadDate: g.uploadDate, url: ref?.webpageUrl, snippets: hits.map((h) => ({ clock: hms(h.start), seconds: h.start, text: h.text.trim().replace(/\s+/g, " "), })), }; }); const cleaned = queries.map((q) => q.trim()).filter(Boolean); const label = cleaned.length ? cleaned.join(" AND ") : "your search"; const truncated = groups.length > shown.length || hitOverflow || !!opts.searchCapped; return { label, videos, totalVideos: groups.length, truncated }; }