/* RFC-0954: framework-free core of the agent semantic search route. Embeds the query, queries Vectorize, joins matches to manifest chunks, and emits the SearchResponse. RFC-1115: results carry the flattened citation shape and tookMs is measured from real elapsed time — both testable here because this module has no astro/cloudflare imports. Do not resolve env bindings or fetch the manifest — the Astro adapter (agent-gate/astro.ts) wires ports and HTTP. Do not parse or validate the query — parseSearchQuery stays in the adapter. RFC-1115: extracted from astro.ts — runAgentSearch with flattened SearchResult mapping and measured tookMs. */ import { SEARCH_EMBEDDING_MODEL, SEARCH_DEFAULT_TOP_K, type SearchChunk, type SearchManifest, type SearchQuery, type SearchResult, type SearchResponse, } from "@warpgogol/werkstatt-shared/agent"; /** Minimal Workers AI + Vectorize port surface the search core needs. */ export interface AgentSearchPorts { ai: { run(model: string, options: { text: string[] }): Promise<{ data: number[][] }> }; searchIndex: { query( vector: number[], options: { topK: number; returnMetadata?: "all" | "none" }, ): Promise<{ matches?: Array<{ id: string; score: number; metadata?: Record }>; }>; }; } /** * Run one semantic search: embed the query, query the vector index, join * matches to manifest chunks, and emit flattened citation results. * `now` is injectable so tookMs is verifiable in tests. */ export async function runAgentSearch( query: SearchQuery, manifest: SearchManifest, ports: AgentSearchPorts, now: () => number = Date.now, ): Promise { const chunkById = new Map(manifest.chunks.map((c) => [c.id, c])); const startedAt = now(); const embeddingResult = await ports.ai.run(SEARCH_EMBEDDING_MODEL, { text: [query.q], }); const queryVector = embeddingResult.data[0]; const vectorizeResult = await ports.searchIndex.query(queryVector, { topK: query.topK ?? SEARCH_DEFAULT_TOP_K, returnMetadata: "all", }); // RFC-1115: flattened citation shape — agents cite without a follow-up fetch. const results: SearchResult[] = []; for (const match of vectorizeResult.matches ?? []) { const chunk = chunkById.get(match.id); if (!chunk) continue; results.push({ title: chunk.title, canonicalUrl: chunk.canonicalUrl, updatedAt: chunk.updatedAt, lang: chunk.lang, type: chunk.type, score: match.score, text: chunk.text, }); } return { query: query.q, results, tookMs: now() - startedAt, }; }