{"version":3,"file":"context-assembler.d.ts","sourceRoot":"","sources":["../../../src/core/search/context-assembler.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAajD,MAAM,WAAW,eAAe;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,YAAY,EAAE,MAAM,CAAC;CACrB;AAMD,wBAAgB,eAAe,CAAC,UAAU,EAAE,SAAS,cAAc,EAAE,EAAE,OAAO,EAAE,eAAe,GAAG,gBAAgB,CAyDjH","sourcesContent":["/**\n * Token-budgeted span expansion (docs/hybrid-retrieval-design.md, step 5 of\n * the shipping order).\n *\n * Retrieval works on chunk ids; only here — after fusion — are line windows\n * read from disk. Every candidate gets a compact `path:start-end [sources]`\n * header; snippets are added top-down until the budget runs out, so the model\n * always sees the full ranked list but never an unbounded dump.\n */\n\nimport { readFileSync } from \"fs\";\nimport path from \"path\";\nimport type { FusedCandidate } from \"./types.js\";\n\n/** Rough chars-per-token for budgeting (index-time uses the same heuristic). */\nconst CHARS_PER_TOKEN = 4;\nconst DEFAULT_TOKEN_BUDGET = 2000;\n/** Snippet caps keep one giant chunk from eating the whole budget. Results\n *  past the top few get a shallower snippet: rank carries most of the value,\n *  and full-depth snippets for every result roughly doubles the token cost. */\nconst MAX_SNIPPET_LINES = 20;\nconst TOP_FULL_SNIPPETS = 3;\nconst TAIL_SNIPPET_LINES = 8;\nconst MAX_SNIPPET_LINE_CHARS = 200;\n\nexport interface AssembleOptions {\n\tcwd: string;\n\t/** Approximate token budget for the whole result text. */\n\ttokenBudget?: number;\n}\n\nexport interface AssembledContext {\n\ttext: string;\n\t/** How many candidates got an inline snippet (the rest are bare headers). */\n\tsnippetCount: number;\n}\n\nfunction sourcesLabel(candidate: FusedCandidate): string {\n\treturn Object.keys(candidate.ranks).sort().join(\"+\");\n}\n\nexport function assembleContext(candidates: readonly FusedCandidate[], options: AssembleOptions): AssembledContext {\n\tconst budgetChars = (options.tokenBudget ?? DEFAULT_TOKEN_BUDGET) * CHARS_PER_TOKEN;\n\tconst fileCache = new Map<string, string[] | undefined>();\n\n\tconst readLines = (rel: string): string[] | undefined => {\n\t\tif (!fileCache.has(rel)) {\n\t\t\ttry {\n\t\t\t\tconst content = readFileSync(path.resolve(options.cwd, rel), \"utf-8\");\n\t\t\t\tfileCache.set(rel, content.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\"));\n\t\t\t} catch {\n\t\t\t\tfileCache.set(rel, undefined);\n\t\t\t}\n\t\t}\n\t\treturn fileCache.get(rel);\n\t};\n\n\tconst sections: string[] = [];\n\tlet usedChars = 0;\n\tlet snippetCount = 0;\n\tlet snippetsExhausted = false;\n\n\tfor (const candidate of candidates) {\n\t\tconst lines = readLines(candidate.path);\n\t\t// Clamp the span to the file as it exists now (fallback spans may\n\t\t// overshoot; the file may have changed since indexing).\n\t\tconst start = Math.max(1, candidate.startLine);\n\t\tconst end = lines ? Math.min(candidate.endLine, lines.length) : candidate.endLine;\n\t\tconst header = `${candidate.path}:${start}-${end} [${sourcesLabel(candidate)}]`;\n\t\tusedChars += header.length + 1;\n\n\t\tif (!snippetsExhausted && lines && end >= start) {\n\t\t\tconst depth = snippetCount < TOP_FULL_SNIPPETS ? MAX_SNIPPET_LINES : TAIL_SNIPPET_LINES;\n\t\t\tconst snippetEnd = Math.min(end, start + depth - 1);\n\t\t\tconst rawLines = lines.slice(start - 1, snippetEnd);\n\t\t\t// Chunk spans often end on a blank line (trailing-newline artifact);\n\t\t\t// trailing blanks carry no signal, so drop them.\n\t\t\twhile (rawLines.length > 0 && rawLines[rawLines.length - 1].trim() === \"\") rawLines.pop();\n\t\t\tconst snippetLines = rawLines.map(\n\t\t\t\t(text, i) =>\n\t\t\t\t\t`  ${start + i}: ${text.length > MAX_SNIPPET_LINE_CHARS ? `${text.slice(0, MAX_SNIPPET_LINE_CHARS)}…` : text}`,\n\t\t\t);\n\t\t\tconst snippet = snippetLines.join(\"\\n\");\n\t\t\tif (snippet) {\n\t\t\t\tif (usedChars + snippet.length <= budgetChars) {\n\t\t\t\t\tsections.push(`${header}\\n${snippet}`);\n\t\t\t\t\tusedChars += snippet.length + 1;\n\t\t\t\t\tsnippetCount++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\t// Budget hit: stop expanding, keep listing bare headers.\n\t\t\t\tsnippetsExhausted = true;\n\t\t\t}\n\t\t}\n\t\tsections.push(header);\n\t}\n\n\treturn { text: sections.join(\"\\n\\n\"), snippetCount };\n}\n"]}