{"version":3,"file":"retrieve.d.ts","sourceRoot":"","sources":["../../../src/core/workspace/retrieve.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAQH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,aAAa,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAGpH,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,aAAa,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAChC,MAAM,EAAE,sBAAsB,CAAC;IAC/B,OAAO,EAAE,wBAAwB,EAAE,CAAC;IACpC,YAAY,EAAE,MAAM,CAAC;CACrB;AAeD,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,WAAW,EAAE,OAAO,EAAE,eAAe,GAAG,gBAAgB,CAqK5F;AAiCD,2EAA2E;AAC3E,wBAAgB,gBAAgB,CAC/B,MAAM,EAAE,wBAAwB,EAChC,IAAI,EAAE,MAAM,GACV,wBAAwB,CAAC,WAAW,CAAC,CASvC","sourcesContent":["/**\n * Retrieval facade: executes a plan against the ready index generation and\n * produces addressable, evidence-backed, freshness-labeled results.\n *\n * Results never authorize mutation. Revalidation against current file content\n * hashes must be performed before any mutation planning.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { FixtureEmbeddingBackend, resolveEmbeddingBackend } from \"./embedding.js\";\nimport { type FusionCandidate, fuseCandidates, rerankHeuristic } from \"./fusion.js\";\nimport { sha256 } from \"./guard.js\";\nimport { type LexicalHit, searchByPath, searchLexical, searchSymbolName } from \"./lexical.js\";\nimport type { WorkspaceDb } from \"./storage.js\";\nimport type { EmbeddingBackend, RetrievalContextPacket, RetrievalPlan, WorkspaceRetrievalResult } from \"./types.js\";\nimport { VectorIndex } from \"./vectors.js\";\n\nexport interface RetrieveOptions {\n\tplan: RetrievalPlan;\n\tgenerationId?: string;\n\tembedding?: EmbeddingBackend;\n\troot?: string; // workspace root for revalidation\n\tfreshOnly?: boolean;\n\tcurrentFile?: string;\n}\n\nexport interface RetrievalOutcome {\n\tpacket: RetrievalContextPacket;\n\tresults: WorkspaceRetrievalResult[];\n\tgenerationId: string;\n}\n\nfunction chunkSymbolFor(\n\tdb: WorkspaceDb,\n\tgen: string,\n\tchunkId: string,\n): { name?: string; qualifiedName?: string; kind?: string } | undefined {\n\tconst chunk = db.chunkById(gen, chunkId);\n\tif (!chunk?.symbolId) return undefined;\n\tconst row = db.db\n\t\t.prepare(\"SELECT name, qualified_name, kind FROM symbols WHERE generation_id = ? AND symbol_id = ?\")\n\t\t.get(gen, chunk.symbolId) as { name: string; qualified_name: string | null; kind: string } | undefined;\n\treturn row ? { name: row.name, qualifiedName: row.qualified_name ?? undefined, kind: row.kind } : undefined;\n}\n\nexport function executeRetrieval(db: WorkspaceDb, options: RetrieveOptions): RetrievalOutcome {\n\tconst gen = options.generationId ?? db.currentReadyGeneration()?.generationId;\n\tif (!gen) {\n\t\treturn {\n\t\t\tpacket: {\n\t\t\t\tquery: options.plan.normalizedQuery,\n\t\t\t\tretrievalPlanId: options.plan.queryId,\n\t\t\t\tindexGenerationId: \"\",\n\t\t\t\tresults: [],\n\t\t\t\ttotalEstimatedTokens: 0,\n\t\t\t\ttruncated: false,\n\t\t\t},\n\t\t\tresults: [],\n\t\t\tgenerationId: \"\",\n\t\t};\n\t}\n\n\tconst embedding: EmbeddingBackend = options.embedding ?? resolveEmbeddingBackend();\n\tconst embedEnabled = embedding.backendId !== \"disabled\";\n\n\tconst generatorLists: FusionCandidate[][] = [];\n\tconst _rank = 0;\n\tconst pushRanked = (list: LexicalHit[], kind: string) => {\n\t\tlist.forEach((h, i) => {\n\t\t\tgeneratorLists.push([{ chunkId: h.chunkId, file: h.path, ranking: i + 1, generator: kind, reasonCode: kind }]);\n\t\t});\n\t};\n\n\t// Run the planned generators.\n\tconst pathFilter = options.plan.normalizedQuery ?? \"\";\n\n\t// 1. symbol generator (exact name / symbol lookup)\n\tif (options.plan.generators.some((g) => g.kind === \"symbol\")) {\n\t\tconst firstToken = options.plan.normalizedQuery.split(\" \")[0] ?? \"\";\n\t\tconst symbolHits = searchSymbolName(db, gen, firstToken, options.plan.maximumResults);\n\t\tsymbolHits.forEach((s, i) => {\n\t\t\tconst fileName = filePathFor(db, gen, s.fileId);\n\t\t\tgeneratorLists.push([\n\t\t\t\t{\n\t\t\t\t\tchunkId: symbolChunkFor(db, gen, s.symbolId, s.startLine) ?? fallbackChunkFor(db, gen, s.fileId),\n\t\t\t\t\tfile: fileName,\n\t\t\t\t\tranking: i + 1,\n\t\t\t\t\tgenerator: \"symbol\",\n\t\t\t\t\treasonCode: \"symbol_name_match\",\n\t\t\t\t\tbonus: 2,\n\t\t\t\t},\n\t\t\t]);\n\t\t});\n\t}\n\n\t// 2. lexical generator\n\tif (options.plan.generators.some((g) => g.kind === \"lexical\")) {\n\t\tconst lex = searchLexical(db, gen, {\n\t\t\tquery: options.plan.normalizedQuery,\n\t\t\tlimit: options.plan.maximumResults,\n\t\t\tprefix: false,\n\t\t\tpathFilter,\n\t\t});\n\t\tpushRanked(lex, \"lexical\");\n\t}\n\n\t// 3. path generator\n\tif (options.plan.generators.some((g) => g.kind === \"path\")) {\n\t\tconst pathHits = searchByPath(db, gen, pathFilter, 20);\n\t\tpushRanked(\n\t\t\tpathHits.map((h) => ({ ...h, score: h.score })),\n\t\t\t\"path\",\n\t\t);\n\t}\n\n\t// 4. semantic generator (bounded)\n\tlet semanticVector: number[] | undefined;\n\tif (embedEnabled && options.plan.generators.some((g) => g.kind === \"semantic\")) {\n\t\ttry {\n\t\t\tsemanticVector = embedQuery(embedding, options.plan.normalizedQuery);\n\t\t} catch {\n\t\t\tsemanticVector = undefined;\n\t\t}\n\t\tif (semanticVector) {\n\t\t\tconst hits = new VectorIndex(db).search(gen, semanticVector, {\n\t\t\t\tlimit: options.plan.maximumResults,\n\t\t\t});\n\t\t\thits.forEach((h, i) => {\n\t\t\t\tconst chunk = db.chunkById(gen, h.chunkId);\n\t\t\t\tgeneratorLists.push([\n\t\t\t\t\t{\n\t\t\t\t\t\tchunkId: h.chunkId,\n\t\t\t\t\t\tfile: chunk?.fileId ? filePathFor(db, gen, chunk.fileId) : \"\",\n\t\t\t\t\t\tranking: i + 1,\n\t\t\t\t\t\tgenerator: \"semantic\",\n\t\t\t\t\t\treasonCode: \"semantic_similarity\",\n\t\t\t\t\t},\n\t\t\t\t]);\n\t\t\t});\n\t\t}\n\t}\n\n\t// Fuse via RRF.\n\tlet fused = fuseCandidates(generatorLists, {\n\t\tmaxPerFile: 4,\n\t\tmaximumResults: options.plan.maximumResults,\n\t});\n\n\t// Rerank deterministically.\n\tconst symbolChunks = new Set<string>();\n\tfused = rerankHeuristic(fused, {\n\t\texactTerm: options.plan.mode === \"exact_identifier\" ? options.plan.normalizedQuery : undefined,\n\t\tsymbolMatch: symbolChunks,\n\t});\n\n\t// Assemble addressable results with freshness.\n\tconst results: WorkspaceRetrievalResult[] = [];\n\tfor (const fr of fused.slice(0, options.plan.maximumResults)) {\n\t\tconst chunk = db.chunkById(gen, fr.chunkId);\n\t\tif (!chunk) continue;\n\t\tconst file = db.getFile(gen, chunk.fileId);\n\t\tif (!file) continue;\n\t\tconst sym = chunkSymbolFor(db, gen, fr.chunkId);\n\t\tconst snippet = (chunk.text ?? \"\").slice(0, 800);\n\t\tconst freshness = options.freshOnly ? freshnessFor(file, options.root) : \"current\";\n\t\tif (options.freshOnly && freshness === \"stale\") continue;\n\t\tconst resultId = `${gen}_${fr.chunkId}`;\n\t\tresults.push({\n\t\t\tresultId,\n\t\t\tworkspaceId: db.workspaceId,\n\t\t\tindexGenerationId: gen,\n\t\t\tfile: {\n\t\t\t\tworkspaceRelativePath: file.workspaceRelativePath,\n\t\t\t\tcontentSha256: file.contentSha256,\n\t\t\t\tclassification: file.classification,\n\t\t\t\tlanguageId: file.languageId,\n\t\t\t},\n\t\t\tlocation: {\n\t\t\t\tstartLine: chunk.startLine,\n\t\t\t\tendLine: chunk.endLine,\n\t\t\t},\n\t\t\tsymbol: sym ? { name: sym.name ?? \"\", qualifiedName: sym.qualifiedName, kind: sym.kind } : undefined,\n\t\t\tsnippet,\n\t\t\tscore: fr.score,\n\t\t\tevidenceId: resultId,\n\t\t\tfreshness,\n\t\t});\n\t}\n\n\t// Bounded context packet.\n\tconst totalChars = results.reduce((a, r) => a + r.snippet.length, 0);\n\tconst maxTokens = options.plan.maximumContextTokens ?? 4096;\n\tconst totalEstimatedTokens = Math.ceil(totalChars / 4);\n\tconst truncated = totalEstimatedTokens > maxTokens;\n\tconst bounded = truncated\n\t\t? results.slice(0, Math.max(1, Math.floor((maxTokens * 4) / Math.max(1, avgSnippet(results)))))\n\t\t: results;\n\n\treturn {\n\t\tgenerationId: gen,\n\t\tresults,\n\t\tpacket: {\n\t\t\tquery: options.plan.normalizedQuery,\n\t\t\tretrievalPlanId: options.plan.queryId,\n\t\t\tindexGenerationId: gen,\n\t\t\tresults: bounded,\n\t\t\ttotalEstimatedTokens: Math.ceil(bounded.reduce((a, r) => a + r.snippet.length, 0) / 4),\n\t\t\ttruncated,\n\t\t},\n\t};\n}\n\nfunction avgSnippet(results: WorkspaceRetrievalResult[]): number {\n\tconst total = results.reduce((a, r) => a + r.snippet.length, 0);\n\treturn results.length ? Math.max(1, Math.floor(total / results.length)) : 1;\n}\n\nfunction embedQuery(embedding: EmbeddingBackend, text: string): number[] | undefined {\n\tif (embedding instanceof FixtureEmbeddingBackend) return embedding.vectorFor(text);\n\treturn undefined;\n}\n\nfunction filePathFor(db: WorkspaceDb, gen: string, fileId: string): string {\n\tconst row = db.db.prepare(\"SELECT path FROM files WHERE generation_id = ? AND file_id = ?\").get(gen, fileId) as\n\t\t| { path: string }\n\t\t| undefined;\n\treturn row?.path ?? \"\";\n}\n\nfunction symbolChunkFor(db: WorkspaceDb, gen: string, symbolId: string, startLine: number): string | undefined {\n\tconst row = db.db\n\t\t.prepare(\n\t\t\t\"SELECT chunk_id FROM chunks WHERE generation_id = ? AND symbol_id = ? AND start_line <= ? ORDER BY start_line LIMIT 1\",\n\t\t)\n\t\t.get(gen, symbolId, startLine) as { chunk_id: string } | undefined;\n\treturn row?.chunk_id;\n}\n\nfunction fallbackChunkFor(db: WorkspaceDb, gen: string, fileId: string): string {\n\tconst rows = db.chunksForFile(gen, fileId);\n\treturn rows.length ? rows[0].chunkId : \"\";\n}\n\n/** Revalidate a result's content hash against the current file on disk. */\nexport function revalidateResult(\n\tresult: WorkspaceRetrievalResult,\n\troot: string,\n): WorkspaceRetrievalResult[\"freshness\"] {\n\ttry {\n\t\tconst abs = path.resolve(root, result.file.workspaceRelativePath);\n\t\tconst current = readFileSync(abs, \"utf-8\");\n\t\tconst currentSha = sha256(current);\n\t\treturn currentSha === result.file.contentSha256 ? \"current\" : \"stale\";\n\t} catch {\n\t\treturn \"unknown\";\n\t}\n}\n\nfunction freshnessFor(\n\tfile: { workspaceRelativePath: string; contentSha256: string },\n\troot?: string,\n): WorkspaceRetrievalResult[\"freshness\"] {\n\tif (!root) return \"current\";\n\ttry {\n\t\tconst abs = path.resolve(root, file.workspaceRelativePath);\n\t\tconst current = readFileSync(abs, \"utf-8\");\n\t\treturn sha256(current) === file.contentSha256 ? \"current\" : \"stale\";\n\t} catch {\n\t\treturn \"possibly_stale\";\n\t}\n}\n"]}