UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

1,100 lines • 52.1 kB
// shared-retrieval.ts — shared retrieval core used by hooks and MCP search.
import { getProjectRemote, readProjectConfig } from "../project-config.js";
import { getQualityMultiplier, entryScoreKey, } from "./governance.js";
import { queryDocRows, queryRows, cosineFallback, extractSnippet, getDocSourceKey, getFragmentBoostDocs, decodeFiniteNumber, rowToDocWithRowid, buildIndex, } from "./index.js";
import { filterTrustedFindingsDetailed, } from "./content.js";
import { cachedCitationFreshness, citationFreshnessAnnotation } from "../content/citation-freshness.js";
import { parseCitationComment } from "../content/citation.js";
import { getHighImpactFindings } from "../finding/impact.js";
import { buildFtsQueryVariants, buildRelaxedFtsQuery, isFeatureEnabled, STOP_WORDS, errorMessage } from "../utils.js";
import { logger } from "../logger.js";
import * as fs from "fs";
import * as path from "path";
import { getProjectGlobBoost } from "../cli/hooks-globs.js";
import { vectorFallback, deterministicSeed } from "./search-fallback.js";
import { getOllamaUrl, getCloudEmbeddingUrl } from "./ollama.js";
import { keywordFallbackSearch } from "../core/search.js";
import { debugLog } from "../shared.js";
// ── Scoring constants ─────────────────────────────────────────────────────────
/** Number of docs sampled for token-overlap semantic fallback search. */
const SEMANTIC_FALLBACK_SAMPLE_LIMIT = 100;
const SEMANTIC_FALLBACK_WINDOW_COUNT = 4;
/** Minimum overlap score for a doc to be included in semantic fallback results. */
const SEMANTIC_OVERLAP_MIN_SCORE = 0.25;
const VECTOR_FALLBACK_SKIP_COUNT = 3;
const VECTOR_FALLBACK_STRONG_MATCH_SCORE = 0.2;
const LOCAL_QUERY_OVERLAP_WEIGHT = 3.5;
const CROSS_PROJECT_QUERY_OVERLAP_WEIGHT = 1.35;
const WEAK_CROSS_PROJECT_OVERLAP_MAX = 0.18;
const WEAK_CROSS_PROJECT_OVERLAP_PENALTY = 0.75;
const LOW_FOCUS_SNIPPET_SCORE = 0.3;
const VERY_LOW_FOCUS_SNIPPET_SCORE = 0.14;
/**
 * Per-snippet overhead charged against the token budget: the header line
 * (`[<source key>] (<type>) fb:<score key>`) plus its blank separator.
 *
 * Selection and rendering MUST charge the same amount. When they drifted
 * apart, `selectSnippets` admitted a snippet on one estimate and
 * `buildHookOutput` re-checked it against a larger one, silently dropping a
 * middle snippet that legitimately fit.
 */
export const SNIPPET_OVERHEAD_TOKENS = 24;
const LOW_FOCUS_SNIPPET_LINE_CAP = 3;
const LOW_FOCUS_SNIPPET_CHAR_FRACTION = 0.55;
/** Query-relevance floor (see applyRelevanceFloor). A doc with no structural
 * signal must clear this query-overlap score to be worth injecting; on the
 * overlapScore scale (matched / max(2, min(tokens, 10))) ~0.12 means "shares at
 * least one query token" — enough to drop priors-only noise without hurting
 * recall. Cross-project docs face a higher bar. Tunable via
 * PHREN_MIN_QUERY_RELEVANCE (0 disables the floor). */
const DEFAULT_MIN_QUERY_RELEVANCE = 0.12;
const CROSS_PROJECT_MIN_QUERY_RELEVANCE = 0.25;
/** A chunk (one bullet or paragraph) must match this many distinct prompt
 * keywords before its doc is worth injecting. */
const MIN_CHUNK_MATCHES = 2;
/** The matched keywords' summed rarity (normalised IDF, 0..1 each) a chunk
 * needs. Common words across the index ("main", "push", "agent") weigh little,
 * project and technical terms a lot, so a prompt of common words clears
 * nothing. With no detected project (the conductor) the bar is higher. */
const MIN_CHUNK_RARITY = 0.55;
const NO_PROJECT_MIN_CHUNK_RARITY = 0.6;
/** Rarity for a token the index never saw, or when no index is at hand. */
const DEFAULT_TOKEN_RARITY = 0.5;
const TASK_RESCUE_MIN_OVERLAP = 0.3;
const TASK_RESCUE_OVERLAP_MARGIN = 0.12;
const TASK_RESCUE_SCORE_MARGIN = 0.6;
/** Fraction of bullets that must be low-value before applying the low-value penalty. */
const LOW_VALUE_BULLET_FRACTION = 0.5;
// ── Intent and scoring helpers ───────────────────────────────────────────────
const INTENT_SKILL_CMD_RE = /(?:^|\s)\/(?!(?:home|usr|var|tmp|etc|opt|api|mnt)\b)[a-z][\w-]*\b/;
const INTENT_SKILL_KW_RE = /\bskill\b/;
const INTENT_DEBUG_RE = /(bug|error|fix|broken|regression|fail|stack trace)/;
const INTENT_REVIEW_RE = /(review|audit|pr|pull request|nit|refactor)/;
const INTENT_BUILD_RE = /(build|deploy|release|ci|workflow|pipeline|test)/;
const INTENT_DOCS_RE = /\b(doc|docs|readme|explain|guide|instructions?)\b/;
const WANTS_TASKS_RE = /\b(tasks?|todos?|backlog|queue|what'?s (left|next|remaining)|what is (left|next|remaining)|next up|remaining work|priorit(y|ies))\b/i;
/**
 * Tasks are a backlog, not memory: a to-do list injected into a debugging
 * question is noise that costs findings their budget. They go in only when the
 * prompt is about building or asks about the work itself.
 */
export function wantsTasks(prompt, intent) {
    return intent === "build" || WANTS_TASKS_RE.test(prompt);
}
export function detectTaskIntent(prompt) {
    const p = prompt.toLowerCase();
    if (INTENT_SKILL_CMD_RE.test(p) || INTENT_SKILL_KW_RE.test(p))
        return "skill";
    if (INTENT_DEBUG_RE.test(p))
        return "debug";
    if (INTENT_REVIEW_RE.test(p))
        return "review";
    if (INTENT_BUILD_RE.test(p))
        return "build";
    if (INTENT_DOCS_RE.test(p))
        return "docs";
    return "general";
}
function intentBoost(intent, docType) {
    if (intent === "debug" && (docType === "findings" || docType === "reference"))
        return 3;
    if (intent === "review" && (docType === "canonical" || docType === "changelog"))
        return 3;
    if (intent === "build" && (docType === "task" || docType === "reference"))
        return 2;
    if (intent === "docs" && (docType === "summary" || docType === "claude"))
        return 2;
    if (docType === "canonical")
        return 2;
    return 0;
}
// fileRelevanceBoost runs once per candidate doc during ranking, all sharing the
// same changedFiles Set. Cache the normalized view (keyed by the Set instance) so
// path normalization happens once per ranking pass instead of once per doc.
const _normalizedChangedFiles = new WeakMap();
function normalizeChangedFiles(changedFiles) {
    let cached = _normalizedChangedFiles.get(changedFiles);
    if (cached)
        return cached;
    const basenames = new Set();
    const paths = [];
    for (const cf of changedFiles) {
        const n = cf.replace(/\\/g, "/");
        basenames.add(path.basename(n));
        paths.push(n);
    }
    cached = { basenames, paths };
    _normalizedChangedFiles.set(changedFiles, cached);
    return cached;
}
export function fileRelevanceBoost(filePath, changedFiles) {
    if (changedFiles.size === 0)
        return 0;
    const normalized = filePath.replace(/\\/g, "/");
    const docBasename = path.basename(normalized);
    const { basenames, paths } = normalizeChangedFiles(changedFiles);
    // Exact basename match to avoid 'index.ts' matching 'shared-index.ts'
    if (basenames.has(docBasename))
        return 3;
    // Also match if the full changed-file path is a suffix of the doc path
    for (const n of paths) {
        if (normalized.endsWith(`/${n}`))
            return 3;
    }
    return 0;
}
function branchTokens(branch) {
    return branch
        .split(/[\/._-]/g)
        .map((s) => s.trim().toLowerCase())
        .filter((s) => s.length > 2 && !["main", "master", "feature", "fix", "bugfix", "hotfix"].includes(s));
}
export function branchMatchBoost(content, branch) {
    if (!branch)
        return 0;
    const text = content.toLowerCase();
    const tokens = branchTokens(branch);
    let score = 0;
    for (const token of tokens) {
        if (text.includes(token))
            score += 1;
    }
    return Math.min(3, score);
}
let _lowValueRegex = null;
let _lowValuePatternKey = "";
function getLowValuePattern() {
    const key = (process.env.PHREN_LOW_VALUE_PATTERNS) || "";
    if (_lowValueRegex && _lowValuePatternKey === key)
        return _lowValueRegex;
    const defaults = ["fixed stuff", "updated things", "misc", "temp", "wip", "todo", "placeholder", "cleanup"];
    const configured = key.split(",").map((s) => s.trim()).filter(Boolean);
    const fragments = configured.length ? configured : defaults;
    _lowValueRegex = new RegExp(`(${fragments.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "i");
    _lowValuePatternKey = key;
    return _lowValueRegex;
}
function lowValuePenalty(content, docType) {
    if (docType !== "findings")
        return 0;
    const bullets = content.split("\n").filter((l) => l.startsWith("- "));
    if (bullets.length === 0)
        return 0;
    const pattern = getLowValuePattern();
    const low = bullets.filter((b) => pattern.test(b) || b.length < 16).length;
    return low >= Math.ceil(bullets.length * LOW_VALUE_BULLET_FRACTION) ? 2 : 0;
}
// ── Token and snippet helpers ────────────────────────────────────────────────
function normalizeToken(token) {
    let normalized = token.toLowerCase().replace(/[^a-z0-9_-]/g, "");
    if (normalized.length > 4 && normalized.endsWith("s") && !normalized.endsWith("ss"))
        normalized = normalized.slice(0, -1);
    return normalized;
}
export function tokenizeForOverlap(text, maxTokens = 24) {
    const tokens = text
        .toLowerCase()
        .replace(/[^a-z0-9_\-\s]/g, " ")
        .split(/\s+/)
        .map(normalizeToken)
        .filter((t) => t.length > 1 && !STOP_WORDS.has(t));
    const uniqueTokens = [...new Set(tokens)];
    if (!Number.isFinite(maxTokens) || maxTokens < 1)
        return uniqueTokens;
    return uniqueTokens.slice(0, maxTokens);
}
function overlapScore(queryTokens, content) {
    if (!queryTokens.length)
        return 0;
    const contentTokens = new Set(tokenizeForOverlap(content, Number.POSITIVE_INFINITY));
    if (!contentTokens.size)
        return 0;
    let matched = 0;
    for (const token of queryTokens) {
        if (contentTokens.has(token))
            matched += 1;
    }
    const denominator = Math.max(2, Math.min(queryTokens.length, 10));
    return matched / denominator;
}
function docOverlapScore(queryTokens, doc) {
    // Never the path: every store path contains "/.phren/", so "phren" would match every doc.
    const corpus = `${doc.project} ${doc.filename} ${doc.type}\n${doc.content.slice(0, 5000)}`;
    return overlapScore(queryTokens, corpus);
}
/** A doc's bullets (with their continuation lines) or, with none, its
 * paragraphs: the unit relevance is judged on. Topic archives are one doc
 * with hundreds of bullets, so a whole-file score matches almost anything. */
export function contentChunks(content, maxChunks = 600) {
    const chunks = [];
    let current = [];
    const flush = () => { if (current.length)
        chunks.push(current.join("\n")); current = []; };
    for (const line of content.split("\n")) {
        const trimmed = line.trim();
        if (!trimmed) {
            flush();
            continue;
        }
        if (/^([-*+]|\d+[.)])\s/.test(trimmed) || trimmed.startsWith("#"))
            flush();
        current.push(line);
        if (chunks.length >= maxChunks)
            break;
    }
    flush();
    return chunks.slice(0, maxChunks);
}
/** The doc's best chunk for this prompt: most summed keyword rarity among
 * chunks with the most distinct keyword matches. */
export function bestChunkMatch(queryTokens, doc, rarity) {
    if (!queryTokens.length)
        return null;
    const wanted = new Set(queryTokens);
    let best = null;
    for (const chunk of contentChunks(doc.content)) {
        const tokens = new Set(tokenizeForOverlap(chunk, Number.POSITIVE_INFINITY));
        let distinct = 0, weight = 0;
        for (const token of wanted) {
            if (!tokens.has(token))
                continue;
            distinct += 1;
            weight += rarity?.get(token) ?? DEFAULT_TOKEN_RARITY;
        }
        if (!distinct)
            continue;
        if (!best || weight > best.rarity || (weight === best.rarity && distinct > best.distinct))
            best = { chunk, distinct, rarity: weight };
    }
    return best;
}
/** How rare each prompt token is across the index: normalised IDF, 1 for a
 * token in one doc, near 0 for one in every doc. One FTS count per token. */
/** `tokenRarity` for a prompt's keywords, tokenised as the floor tokenises them. */
export function promptRarity(db, keywords) {
    return tokenRarity(db, tokenizeForOverlap(keywords));
}
export function tokenRarity(db, queryTokens) {
    const rarity = new Map();
    if (!db || !queryTokens.length)
        return rarity;
    try {
        const total = Number(queryRows(db, "SELECT COUNT(*) FROM docs", [])?.[0]?.[0] ?? 0);
        if (!(total > 1))
            return rarity;
        const scale = Math.log(total + 1);
        for (const token of queryTokens.slice(0, 12)) {
            if (!/^[a-z0-9_-]+$/.test(token))
                continue;
            const df = Number(queryRows(db, "SELECT COUNT(*) FROM docs WHERE docs MATCH ?", [`"${token}"*`])?.[0]?.[0] ?? 0);
            rarity.set(token, Math.max(0, Math.min(1, Math.log((total + 1) / (df + 1)) / scale)));
        }
    }
    catch { /* the default rarity stands in */ }
    return rarity;
}
function loadSemanticFallbackWindow(db, startRowid, limit, project, wrapBefore) {
    const where = [
        project ? "project = ?" : "",
        wrapBefore === undefined ? "rowid >= ?" : "rowid < ?",
    ].filter(Boolean).join(" AND ");
    const params = [
        ...(project ? [project] : []),
        wrapBefore ?? startRowid,
        limit,
    ];
    const rows = queryRows(db, `SELECT rowid, project, filename, type, content, path FROM docs WHERE ${where} ORDER BY rowid LIMIT ?`, params) || [];
    return rows.map((row) => rowToDocWithRowid(row));
}
// k=60 is the standard RRF constant from Cormack et al. (2009); higher values reduce
// the impact of top-ranked results, lower values amplify them. 60 is the community default.
const RRF_K = 60;
/**
 * Item 4: Reciprocal Rank Fusion — merges ranked result lists from multiple search tiers.
 * Documents appearing in multiple tiers get a higher combined score.
 * Formula: score(d) = Σ 1/(k + rank_i) for each tier i containing d, where k=60 (standard).
 */
/** @internal Exported for tests. */
export function rrfMerge(tiers, k = RRF_K) {
    const scores = new Map();
    const docs = new Map();
    for (const tier of tiers) {
        for (let rank = 0; rank < tier.length; rank++) {
            const doc = tier[rank];
            const key = doc.path || `${doc.project}/${doc.filename}`;
            if (!docs.has(key))
                docs.set(key, doc);
            scores.set(key, (scores.get(key) ?? 0) + 1 / (k + rank + 1));
        }
    }
    return [...scores.entries()]
        .sort((a, b) => b[1] - a[1])
        .map(([key]) => docs.get(key));
}
function semanticFallbackDocs(db, prompt, project) {
    const terms = tokenizeForOverlap(prompt);
    if (!terms.length)
        return [];
    const sampleLimit = SEMANTIC_FALLBACK_SAMPLE_LIMIT;
    const statsRows = queryRows(db, project
        ? "SELECT MIN(rowid), MAX(rowid), COUNT(*) FROM docs WHERE project = ?"
        : "SELECT MIN(rowid), MAX(rowid), COUNT(*) FROM docs", project ? [project] : []);
    if (!statsRows?.length)
        return [];
    let minRowid = 0;
    let maxRowid = 0;
    let rowCount = 0;
    try {
        minRowid = decodeFiniteNumber(statsRows[0][0], "semanticFallbackDocs.minRowid");
        maxRowid = decodeFiniteNumber(statsRows[0][1], "semanticFallbackDocs.maxRowid");
        rowCount = decodeFiniteNumber(statsRows[0][2], "semanticFallbackDocs.rowCount");
    }
    catch {
        return [];
    }
    if (rowCount <= 0 || maxRowid < minRowid)
        return [];
    const cappedLimit = Math.min(sampleLimit, rowCount);
    const docs = [];
    const seenRowids = new Set();
    const pushRows = (rows) => {
        for (const row of rows) {
            if (seenRowids.has(row.rowid))
                continue;
            seenRowids.add(row.rowid);
            docs.push(row.doc);
            if (docs.length >= cappedLimit)
                break;
        }
    };
    if (rowCount <= cappedLimit) {
        pushRows(loadSemanticFallbackWindow(db, minRowid, cappedLimit, project));
    }
    else {
        const span = Math.max(1, maxRowid - minRowid + 1);
        const windowCount = Math.min(SEMANTIC_FALLBACK_WINDOW_COUNT, cappedLimit);
        const perWindow = Math.max(1, Math.ceil(cappedLimit / windowCount));
        const stride = Math.max(1, Math.floor(span / windowCount));
        const seed = deterministicSeed(`${project ?? "*"}\n${terms.join(" ")}`);
        for (let i = 0; i < windowCount && docs.length < cappedLimit; i++) {
            const offset = (seed + i * stride) % span;
            const startRowid = minRowid + offset;
            pushRows(loadSemanticFallbackWindow(db, startRowid, perWindow, project));
            if (docs.length >= cappedLimit)
                break;
            pushRows(loadSemanticFallbackWindow(db, startRowid, perWindow, project, startRowid));
        }
    }
    if (docs.length < cappedLimit) {
        pushRows(loadSemanticFallbackWindow(db, minRowid, cappedLimit - docs.length, project));
    }
    const scored = docs
        .map((doc) => {
        const score = docOverlapScore(terms, doc);
        return { doc, score };
    })
        .filter((x) => x.score >= SEMANTIC_OVERLAP_MIN_SCORE)
        .sort((a, b) => b.score - a.score)
        .slice(0, 8)
        .map((x) => x.doc);
    return scored;
}
/** @internal Exported for tests. */
function shouldRunVectorExpansion(rows, prompt, desiredResults = VECTOR_FALLBACK_SKIP_COUNT) {
    if (!rows || rows.length === 0)
        return true;
    const targetCount = Math.max(2, Math.min(VECTOR_FALLBACK_SKIP_COUNT, desiredResults));
    if (rows.length >= targetCount)
        return false;
    const queryTokens = tokenizeForOverlap(prompt);
    if (queryTokens.length === 0)
        return false;
    const bestOverlap = rows
        .slice(0, 2)
        .reduce((maxScore, doc) => Math.max(maxScore, docOverlapScore(queryTokens, doc)), 0);
    return bestOverlap < VECTOR_FALLBACK_STRONG_MATCH_SCORE;
}
function approximateTokens(text) {
    return Math.ceil(text.length / 3.5 + (text.match(/\s+/g) || []).length * 0.1);
}
function compactSnippet(snippet, maxLines, maxChars) {
    const lines = snippet
        .split("\n")
        .map((l) => l.trimEnd())
        .filter((l) => l.trim().length > 0)
        .slice(0, Math.max(1, maxLines));
    let out = lines.join("\n");
    if (out.length > maxChars)
        out = out.slice(0, Math.max(24, maxChars - 1)).trimEnd() + "\u2026";
    return out;
}
// ── Task priority filtering ───────────────────────────────────────────────
const PRIORITY_TAG_RE = /\[(high|medium|low)\]/i;
export function filterTaskByPriority(items, allowedPriorities) {
    const envPriorities = (process.env.PHREN_TASK_PRIORITY);
    const allowed = new Set((allowedPriorities || (envPriorities ? envPriorities.split(",").map(s => s.trim().toLowerCase()) : ["high", "medium"])));
    return items.filter(item => {
        const match = item.match(PRIORITY_TAG_RE);
        if (!match) {
            return allowed.has("high") || allowed.has("medium");
        }
        return allowed.has(match[1].toLowerCase());
    });
}
// ── Search ───────────────────────────────────────────────────────────────────
const SHARED_PROJECTS = ["shared", "org"];
export function searchDocuments(db, safeQuery, prompt, keywords, detectedProject, searchAllProjects = false, phrenPath) {
    // Tier 1: FTS5 — run project-scoped and global in one pass, dedup
    const ftsDocs = [];
    const ftsSeenKeys = new Set();
    const relaxedQuery = buildRelaxedFtsQuery(keywords || prompt, detectedProject, phrenPath);
    const addFtsRows = (rows) => {
        if (!rows)
            return;
        for (const doc of rows) {
            const key = doc.path || `${doc.project}/${doc.filename}`;
            if (!ftsSeenKeys.has(key)) {
                ftsSeenKeys.add(key);
                ftsDocs.push(doc);
            }
        }
    };
    const runScopedFtsQuery = (query) => {
        if (!query)
            return;
        if (detectedProject) {
            addFtsRows(queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE docs MATCH ? AND project = ? ORDER BY rank LIMIT 7", [query, detectedProject]));
        }
        if (searchAllProjects || !detectedProject) {
            addFtsRows(queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE docs MATCH ? ORDER BY rank LIMIT 10", [query]));
            return;
        }
        const scopeProjects = [detectedProject, ...SHARED_PROJECTS];
        const placeholders = scopeProjects.map(() => "?").join(", ");
        addFtsRows(queryDocRows(db, `SELECT project, filename, type, content, path FROM docs WHERE docs MATCH ? AND project IN (${placeholders}) ORDER BY rank LIMIT 10`, [query, ...scopeProjects]));
    };
    runScopedFtsQuery(safeQuery);
    if (ftsDocs.length === 0 && relaxedQuery && relaxedQuery !== safeQuery) {
        runScopedFtsQuery(relaxedQuery);
    }
    // Tier 2: Token-overlap semantic — always run, scored independently
    const semanticDocs = semanticFallbackDocs(db, `${prompt}\n${keywords}`, detectedProject);
    // Merge with Reciprocal Rank Fusion so documents found by both tiers rank highest
    const merged = rrfMerge([ftsDocs, semanticDocs]);
    if (merged.length === 0)
        return null;
    return merged.slice(0, 12);
}
/**
 * Async variant of searchDocuments that also runs real vector search (Tier 3)
 * when cloud embeddings (PHREN_EMBEDDING_API_URL) or Ollama are available.
 * Falls back to the sync result if vector search is unavailable or fails.
 */
export async function searchDocumentsAsync(db, safeQuery, prompt, keywords, detectedProject, searchAllProjects = false, phrenPath) {
    // Sync result (Tier 1 + Tier 2)
    let syncResult = searchDocuments(db, safeQuery, prompt, keywords, detectedProject, searchAllProjects, phrenPath);
    if (!syncResult || syncResult.length === 0) {
        const keywordRows = keywordFallbackSearch(db, prompt, { project: detectedProject ?? undefined, limit: 8 });
        if (keywordRows?.length)
            syncResult = keywordRows;
    }
    // Tier 3: Real vector search — only if embeddings are available and phrenPath provided
    const hasVectorBackend = Boolean(getCloudEmbeddingUrl() || getOllamaUrl());
    if (!phrenPath || !hasVectorBackend || !shouldRunVectorExpansion(syncResult, `${prompt}\n${keywords}`)) {
        return syncResult;
    }
    try {
        const existingPaths = new Set((syncResult ?? []).map((d) => d.path || `${d.project}/${d.filename}`));
        const vectorDocs = await vectorFallback(phrenPath, `${prompt}\n${keywords}`, existingPaths, 8, detectedProject);
        if (vectorDocs.length === 0)
            return syncResult;
        // RRF-merge all three tiers
        const tiers = [syncResult ?? [], vectorDocs];
        const merged = rrfMerge(tiers);
        if (merged.length === 0)
            return syncResult;
        return merged.slice(0, 12);
    }
    catch (err) {
        // Vector search failure is non-fatal — return sync result
        logger.debug("hybridSearch vectorFallback", errorMessage(err));
        return syncResult;
    }
}
export async function searchKnowledgeRows(db, options) {
    const { query, maxResults, fetchLimit = maxResults, filterProject, filterType, phrenPath, } = options;
    const queryVariants = buildFtsQueryVariants(query, filterProject, phrenPath);
    const safeQuery = queryVariants[0] ?? "";
    if (!safeQuery)
        return { safeQuery, rows: null, usedFallback: false };
    let sql = "SELECT project, filename, type, content, path FROM docs WHERE docs MATCH ?";
    const params = [safeQuery];
    if (filterProject) {
        sql += " AND project = ?";
        params.push(filterProject);
    }
    if (filterType) {
        sql += " AND type = ?";
        params.push(filterType);
    }
    sql += " ORDER BY rank LIMIT ?";
    params.push(fetchLimit);
    let activeFtsQuery = safeQuery;
    let rows = queryDocRows(db, sql, params);
    if ((!rows || rows.length === 0) && queryVariants.length > 1) {
        for (const variant of queryVariants.slice(1)) {
            const relaxedParams = [...params];
            relaxedParams[0] = variant;
            rows = queryDocRows(db, sql, relaxedParams);
            if (rows?.length) {
                activeFtsQuery = variant;
                break;
            }
        }
    }
    let usedFallback = false;
    if (rows && rows.length < 3) {
        const ftsRowids = new Set();
        try {
            let rowidSql = "SELECT rowid, project, filename, type, content, path FROM docs WHERE docs MATCH ?";
            const rowidParams = [activeFtsQuery];
            if (filterProject) {
                rowidSql += " AND project = ?";
                rowidParams.push(filterProject);
            }
            if (filterType) {
                rowidSql += " AND type = ?";
                rowidParams.push(filterType);
            }
            rowidSql += " ORDER BY rank LIMIT ?";
            rowidParams.push(maxResults);
            const rowidResult = db.exec(rowidSql, rowidParams);
            if (rowidResult?.length && rowidResult[0]?.values?.length) {
                for (const row of rowidResult[0].values) {
                    ftsRowids.add(rowToDocWithRowid(row).rowid);
                }
            }
        }
        catch (err) {
            debugLog(`rowid dedup query failed: ${errorMessage(err)}`);
        }
        const cosineResults = cosineFallback(db, query, ftsRowids, maxResults - rows.length)
            .filter((doc) => (!filterProject || doc.project === filterProject) && (!filterType || doc.type === filterType));
        if (cosineResults.length > 0) {
            rows = [...rows, ...cosineResults];
            usedFallback = true;
        }
    }
    if (!rows) {
        const cosineResults = cosineFallback(db, query, new Set(), maxResults)
            .filter((doc) => (!filterProject || doc.project === filterProject) && (!filterType || doc.type === filterType));
        if (cosineResults.length > 0) {
            rows = cosineResults;
            usedFallback = true;
        }
    }
    if (!rows) {
        const fallbackRows = keywordFallbackSearch(db, query, {
            project: filterProject ?? undefined,
            type: filterType ?? undefined,
            limit: maxResults,
        });
        if (fallbackRows) {
            rows = fallbackRows;
            usedFallback = true;
        }
    }
    if (shouldRunVectorExpansion(rows, query, maxResults)) {
        try {
            const existingRows = rows ?? [];
            const alreadyFoundPaths = new Set(existingRows.map((row) => row.path));
            const vecRows = await vectorFallback(phrenPath, query, alreadyFoundPaths, Math.max(0, maxResults - existingRows.length), filterProject ?? undefined);
            const filteredVecRows = filterType ? vecRows.filter((row) => row.type === filterType) : vecRows;
            if (filteredVecRows.length > 0) {
                rows = [...existingRows, ...filteredVecRows];
                usedFallback = true;
            }
        }
        catch (err) {
            logger.debug("vectorFallback", errorMessage(err));
        }
    }
    return { safeQuery, rows, usedFallback };
}
/**
 * Search additional phren stores defined in the store registry (or PHREN_FEDERATION_PATHS).
 * Returns an array of results tagged with their source store. Read-only — no mutations.
 */
export async function searchFederatedStores(localPhrenPath, options) {
    // Registered non-primary stores are already included in the main FTS index
    // by buildIndex (via getAllStoreProjectDirs). Only search unregistered
    // federation paths from PHREN_FEDERATION_PATHS to avoid double indexing.
    let registeredStorePaths;
    try {
        const { getNonPrimaryStores } = await import("../store-registry.js");
        registeredStorePaths = new Set(getNonPrimaryStores(localPhrenPath).map((s) => s.path));
    }
    catch {
        registeredStorePaths = new Set();
    }
    const raw = process.env.PHREN_FEDERATION_PATHS ?? "";
    const nonPrimaryStores = raw.split(":").map((p) => p.trim())
        .filter((p) => p.length > 0 && p !== localPhrenPath && !registeredStorePaths.has(p) && fs.existsSync(p))
        .map((p) => ({ path: p, name: path.basename(p), id: "" }));
    if (nonPrimaryStores.length === 0)
        return [];
    const allRows = [];
    for (const store of nonPrimaryStores) {
        try {
            const federatedDb = await buildIndex(store.path);
            const result = await searchKnowledgeRows(federatedDb, { ...options, phrenPath: store.path });
            if (result.rows && result.rows.length > 0) {
                for (const row of result.rows) {
                    allRows.push({
                        ...row,
                        federationSource: store.path,
                        storeName: store.name,
                        storeId: store.id || undefined,
                    });
                }
            }
        }
        catch (err) {
            logger.debug(`federatedSearch store=${store.name}`, errorMessage(err));
            // Federation errors are non-fatal — continue with other stores
        }
    }
    return allRows;
}
// ── Trust filter ─────────────────────────────────────────────────────────────
// "knowledge" was a doc type until the 0.0.x renames; classifyFile can no
// longer produce it, so listing it here was dead weight that made the set
// read as broader than it is.
const TRUST_FILTERED_TYPES = new Set(["findings", "reference"]);
/**
 * Doc types that must never be pushed into an agent's prompt automatically.
 *
 * - `notes` — personal scratch context; the user's, not the agent's.
 * - `review-queue` — review.md, which is a *quarantine*. Its entries are candidates
 *   nobody has approved yet, and the trust filter does not even look at them (they are
 *   not in TRUST_FILTERED_TYPES), so an unreviewed line would reach a prompt with no
 *   staleness, confidence, or citation checks at all. Injecting the queue would also
 *   defeat the point of having a queue: approve/reject would decide nothing, because
 *   the content is already in play.
 *
 * Both remain indexed and reachable through explicit `search_knowledge` — a pull the
 * caller asked for, tagged with its doc type — which is how a user answers "why is this
 * in my review queue?" without the content leaking into every prompt.
 */
export const NON_INJECTABLE_TYPES = new Set(["notes", "review-queue"]);
/** True when a doc type is allowed on the automatic injection path. */
export function isInjectableDocType(type) {
    return !NON_INJECTABLE_TYPES.has(type);
}
/** Restore only citation metadata for retained bullets; the FTS index omits comments. */
function restoreCitationComments(content, sourceFile) {
    try {
        const key = (line) => /<!--\s*fid:([a-z0-9]{8})/i.exec(line)?.[1]
            ?? line.replace(/<!--[\s\S]*?-->/g, "").trim();
        const source = fs.readFileSync(sourceFile, "utf8").split("\n");
        const citations = new Map();
        for (let i = 0; i < source.length - 1; i++) {
            if (source[i].startsWith("- ") && parseCitationComment(source[i + 1]))
                citations.set(key(source[i]), source[i + 1]);
        }
        return content.split("\n").map((line, index, lines) => {
            const citation = line.startsWith("- ") ? citations.get(key(line)) : undefined;
            return citation && !parseCitationComment(lines[index + 1] ?? "") ? `${line}\n${citation}` : line;
        }).join("\n");
    }
    catch {
        return content;
    }
}
/** Apply trust filter to rows. Returns filtered rows plus any queue/audit items to be written
 * by the caller — retrieval itself should remain side-effect-free.
 *
 * This runs only on the automatic injection path, so it is also where non-injectable
 * doc types (notes, review-queue) are dropped. */
export function applyTrustFilter(rows, ttlDays, minConfidence, decay, phrenPath) {
    const queueItems = [];
    const auditEntries = [];
    const highImpactFindingIds = phrenPath ? getHighImpactFindings(phrenPath, 3) : undefined;
    const filtered = rows
        .filter((doc) => isInjectableDocType(doc.type))
        .map((doc) => {
        if (!TRUST_FILTERED_TYPES.has(doc.type))
            return doc;
        const trust = filterTrustedFindingsDetailed(doc.content, {
            ttlDays,
            minConfidence,
            decay,
            project: doc.project,
            highImpactFindingIds,
        });
        if (trust.issues.length > 0) {
            const stale = trust.issues.filter((i) => i.reason === "stale").map((i) => i.bullet);
            const conflicts = trust.issues.filter((i) => i.reason === "invalid_citation").map((i) => i.bullet);
            if (stale.length)
                queueItems.push({ project: doc.project, section: "Stale", items: stale });
            if (conflicts.length)
                queueItems.push({ project: doc.project, section: "Conflicts", items: conflicts });
            auditEntries.push(`project=${doc.project} type=${doc.type} stale=${stale.length} invalid_citation=${conflicts.length}`);
        }
        return { ...doc, content: restoreCitationComments(trust.content, doc.path) };
    })
        .filter((doc) => {
        return !TRUST_FILTERED_TYPES.has(doc.type) || Boolean(doc.content.trim());
    });
    return { rows: filtered, queueItems, auditEntries };
}
// ── Ranking ──────────────────────────────────────────────────────────────────
function mostRecentDate(content) {
    const matches = content.match(/^## (\d{4}-\d{2}-\d{2})/gm);
    if (!matches || matches.length === 0)
        return "0000-00-00";
    return matches.map((m) => m.slice(3)).sort().reverse()[0];
}
/** Shared helper: compute age in days from a YYYY-MM-DD date string. Returns Infinity for invalid/missing dates. */
function ageInDaysFromDate(dateStr) {
    if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr) || dateStr === "0000-00-00")
        return Infinity;
    const todayUtc = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate());
    const entryUtc = Date.parse(`${dateStr}T00:00:00Z`);
    if (Number.isNaN(entryUtc))
        return Infinity;
    return Math.max(0, Math.floor((todayUtc - entryUtc) / 86_400_000));
}
/** Item 3: Recency boost for findings. Recent findings rank higher. Accepts pre-computed date string. */
export function recencyBoost(docType, latestDate) {
    if (docType !== "findings")
        return 0;
    const age = ageInDaysFromDate(latestDate);
    if (age <= 7)
        return 0.3;
    if (age <= 30)
        return 0.15;
    return 0;
}
function crossProjectAgeMultiplier(doc, detectedProject, latestDate) {
    if (doc.type !== "findings" || !detectedProject || doc.project === detectedProject)
        return 1;
    const decayDaysRaw = Number.parseInt((process.env.PHREN_CROSS_PROJECT_DECAY_DAYS) ?? "30", 10);
    const decayDays = Number.isFinite(decayDaysRaw) && decayDaysRaw > 0 ? decayDaysRaw : 30;
    const age = ageInDaysFromDate(latestDate);
    const ageInDays = Number.isFinite(age) ? age : 90;
    return Math.max(0.1, 1 - (ageInDays / decayDays));
}
export function rankResults(rows, intent, gitCtx, detectedProject, phrenPathLocal, db, cwd, query, opts) {
    let ranked = [...rows];
    const queryTokens = query ? tokenizeForOverlap(query) : [];
    if (detectedProject) {
        const localByType = new Set(ranked.filter((r) => r.project === detectedProject).map((r) => r.type));
        // Keep all local docs, and allow up to 2 shared/org docs per type even if
        // that type exists locally — avoids suppressing cross-project knowledge.
        const sharedCountByType = new Map();
        const MAX_SHARED_PER_TYPE = 2;
        ranked = ranked.filter((r) => {
            if (r.project === detectedProject)
                return true;
            if (!localByType.has(r.type))
                return true;
            const count = sharedCountByType.get(r.type) ?? 0;
            if (count < MAX_SHARED_PER_TYPE) {
                sharedCountByType.set(r.type, count + 1);
                return true;
            }
            return false;
        });
        // Always-inject: truths for detected project are prepended regardless of search
        // relevance. Dedup against rows already in the result set.
        const canonicalRows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ? AND type = 'canonical'", [detectedProject]);
        if (canonicalRows) {
            const existingPaths = new Set(ranked.map((r) => r.path));
            const newCanonicals = canonicalRows.filter((r) => !existingPaths.has(r.path));
            if (newCanonicals.length > 0)
                ranked = [...newCanonicals, ...ranked];
        }
    }
    const entityBoost = query ? getFragmentBoostDocs(db, query) : new Set();
    const entityBoostPaths = new Set();
    for (const doc of ranked) {
        // Use getDocSourceKey to build the full project/relFile key, matching what
        // entity_links stores (e.g. project/reference/arch.md, not project/arch.md).
        const docKey = getDocSourceKey(doc, phrenPathLocal);
        if (entityBoost.has(docKey))
            entityBoostPaths.add(doc.path);
    }
    // Pre-compute mostRecentDate once per findings doc to avoid O(n log n) regex rescans in sort.
    const recentDateCache = new Map();
    for (const doc of ranked) {
        if (doc.type === "findings") {
            const key = doc.path || `${doc.project}/${doc.filename}`;
            recentDateCache.set(key, mostRecentDate(doc.content));
        }
    }
    const getRecentDate = (doc) => recentDateCache.get(doc.path || `${doc.project}/${doc.filename}`) ?? "0000-00-00";
    // Precompute per-doc ranking metadata once — avoids recomputing inside sort comparator.
    const changedFiles = gitCtx?.changedFiles || new Set();
    const FILE_MATCH_BOOST = 1.5;
    const scored = ranked.map((doc) => {
        const globBoost = getProjectGlobBoost(phrenPathLocal, doc.project, cwd, gitCtx?.changedFiles);
        const key = entryScoreKey(doc.project, doc.filename, doc.content);
        const entity = entityBoostPaths.has(doc.path) ? 1.3 : 1;
        const date = getRecentDate(doc);
        const fileRel = fileRelevanceBoost(doc.path, changedFiles);
        const branchMat = branchMatchBoost(doc.content, gitCtx?.branch);
        const qualityMult = getQualityMultiplier(phrenPathLocal, key);
        const queryOverlap = queryTokens.length > 0 ? docOverlapScore(queryTokens, doc) : 0;
        const queryOverlapWeight = detectedProject && doc.project === detectedProject
            ? LOCAL_QUERY_OVERLAP_WEIGHT
            : CROSS_PROJECT_QUERY_OVERLAP_WEIGHT;
        const weakCrossProjectPenalty = detectedProject
            && doc.project !== detectedProject
            && queryTokens.length > 0
            && queryOverlap < WEAK_CROSS_PROJECT_OVERLAP_MAX
            ? WEAK_CROSS_PROJECT_OVERLAP_PENALTY
            : 0;
        const score = Math.round((intentBoost(intent, doc.type) +
            fileRel +
            branchMat +
            globBoost +
            qualityMult +
            entity +
            queryOverlap * queryOverlapWeight +
            recencyBoost(doc.type, date) -
            weakCrossProjectPenalty -
            lowValuePenalty(doc.content, doc.type)) * crossProjectAgeMultiplier(doc, detectedProject, date) * 10000) / 10000;
        const fileMatch = fileRel > 0 || branchMat > 0;
        return { doc, score, fileMatch, globBoost, qualityMult, entity, date, queryOverlap };
    });
    // Single composite sort on cached values.
    scored.sort((a, b) => {
        if (isFeatureEnabled("PHREN_FEATURE_GIT_CONTEXT_FILTER", false)) {
            if (gitCtx && gitCtx.changedFiles.size > 0) {
                const scoreDiff = (b.fileMatch ? FILE_MATCH_BOOST : 1) - (a.fileMatch ? FILE_MATCH_BOOST : 1);
                if (scoreDiff !== 0)
                    return scoreDiff;
            }
        }
        const isFindingsA = a.doc.type === "findings";
        const isFindingsB = b.doc.type === "findings";
        if (isFindingsA !== isFindingsB)
            return isFindingsA ? -1 : 1;
        if (isFindingsA && isFindingsB) {
            const byDate = b.date.localeCompare(a.date);
            if (byDate !== 0)
                return byDate;
        }
        const scoreDelta = b.score - a.score;
        if (Math.abs(scoreDelta) > 0.01)
            return scoreDelta;
        const overlapDelta = b.queryOverlap - a.queryOverlap;
        if (Math.abs(overlapDelta) > 0.01)
            return overlapDelta;
        const globDelta = b.globBoost - a.globBoost;
        if (Math.abs(globDelta) > 0.01)
            return globDelta;
        const qualityDelta = b.qualityMult - a.qualityMult;
        if (qualityDelta !== 0)
            return qualityDelta;
        if (b.entity !== a.entity)
            return b.entity - a.entity;
        return (a.doc.path || `${a.doc.project}/${a.doc.filename}`).localeCompare(b.doc.path || `${b.doc.project}/${b.doc.filename}`);
    });
    const shouldFilterTask = intent !== "build" && !opts?.skipTaskFilter && opts?.filterType !== "task";
    const rescuedTaskPaths = new Set();
    if (shouldFilterTask && queryTokens.length > 0) {
        const bestTask = scored.find((entry) => entry.doc.type === "task");
        if (bestTask && bestTask.queryOverlap >= TASK_RESCUE_MIN_OVERLAP) {
            const bestNonTask = scored.find((entry) => entry.doc.type !== "task");
            if (!bestNonTask
                || bestTask.queryOverlap >= bestNonTask.queryOverlap + TASK_RESCUE_OVERLAP_MARGIN
                || bestTask.score >= bestNonTask.score + TASK_RESCUE_SCORE_MARGIN) {
                rescuedTaskPaths.add(bestTask.doc.path || `${bestTask.doc.project}/${bestTask.doc.filename}`);
            }
        }
    }
    ranked = scored.map((s) => s.doc);
    ranked = ranked.slice(0, 8);
    if (shouldFilterTask) {
        ranked = ranked.filter((r) => {
            if (r.type !== "task")
                return true;
            const key = r.path || `${r.project}/${r.filename}`;
            return rescuedTaskPaths.has(key);
        });
    }
    return ranked;
}
/** Mark snippet lines with stale citations (cited file missing or line content changed).
 * @internal Exported for tests. */
export function markStaleCitations(snippet, opts = {}) {
    const lines = snippet.split("\n");
    const result = [];
    for (let i = 0; i < lines.length; i++) {
        let line = lines[i];
        // Check if the next line is a citation comment
        const nextLine = lines[i + 1];
        if (nextLine) {
            const citation = parseCitationComment(nextLine);
            if (citation && citation.file) {
                line += citationFreshnessAnnotation(citation, cachedCitationFreshness(citation, opts));
                const resolvedFile = citation.repo
                    ? path.resolve(citation.repo, citation.file)
                    : (path.isAbsolute(citation.file) ? citation.file : null);
                if (resolvedFile) {
                    let stale = false;
                    if (!fs.existsSync(resolvedFile)) {
                        stale = true;
                    }
                    else if (citation.line !== undefined && citation.line >= 1) {
                        // Verify the cited line still has content (not beyond EOF)
                        try {
                            const fileLines = fs.readFileSync(resolvedFile, "utf8").split("\n");
                            if (citation.line > fileLines.length) {
                                stale = true;
                            }
                            else if (fileLines[citation.line - 1].trim() === "") {
                                // Line exists but is now empty — content has drifted
                                stale = true;
                            }
                        }
                        catch (err) {
                            logger.debug("applyCitationAnnotations fileRead", errorMessage(err));
                            stale = true;
                        }
                    }
                    if (stale) {
                        result.push(line + " [stale citation]");
                        i++; // skip the citation comment line
                        continue;
                    }
                }
            }
        }
        result.push(line);
    }
    return result.join("\n");
}
export { DEFAULT_MIN_QUERY_RELEVANCE };
/**
 * Relevance floor: drop ranked docs that aren't actually relevant to *this*
 * prompt, so the hook injects signal or nothing — never noise to fill a quota.
 *
 * `rankResults` intentionally scores on priors too (intent, recency, project,
 * feedback history), so a doc can rank well with zero query overlap. That's the
 * right call for *ordering*, but the wrong thing to *inject*. This stage keeps a
 * doc only when it has a real connection to the prompt:
 *   - a structural signal: it's a changed file, or matches the branch name, or
 *   - it's a canonical doc for the project you're actually in, or
 *   - its text clears a query-overlap floor (higher for cross-project docs).
 *
 * When there's no usable query signal (no tokens) it is a no-op. Setting the
 * floor to 0 (env PHREN_MIN_QUERY_RELEVANCE=0) disables it and restores the
 * previous always-fill behavior.
 */
export function applyRelevanceFloor(rows, keywords, gitCtx, detectedProject, floor = DEFAULT_MIN_QUERY_RELEVANCE, rarity) {
    if (!(floor > 0))
        return rows;
    const queryTokens = tokenizeForOverlap(keywords);
    const changedFiles = gitCtx?.changedFiles ?? new Set();
    const crossFloor = Math.max(floor, CROSS_PROJECT_MIN_QUERY_RELEVANCE);
    // Fewer meaningful keywords than a chunk must match: nothing can be about
    // this prompt ("Yes"), so only the structural signals remain.
    const minRarity = detectedProject ? MIN_CHUNK_RARITY : NO_PROJECT_MIN_CHUNK_RARITY;
    return rows.filter((doc) => {
        // A changed file is a tie to this work; a branch name is not: its words
        // ("audio") run through a whole project's memory. It still orders results.
        if (fileRelevanceBoost(doc.path, changedFiles) > 0)
            return true;
        if (queryTokens.length < MIN_CHUNK_MATCHES)
            return false;
        const match = bestChunkMatch(queryTokens, doc, rarity);
        if (!match || match.distinct < MIN_CHUNK_MATCHES || match.rarity < minRarity)
            return false;
        const isCrossProject = Boolean(detectedProject) && doc.project !== detectedProject;
        return overlapScore(queryTokens, match.chunk) >= (isCrossProject ? crossFloor : floor);
    });
}
export function selectSnippets(rows, keywords, tokenBudget, lineBudget, charBudget) {
    const selected = [];
    let usedTokens = 36;
    const queryTokens = tokenizeForOverlap(keywords);
    const seenBullets = new Set();
    const citationOptions = new Map();
    // For each snippet being added, hash its bullet lines and skip duplicates
    function dedupSnippetBullets(snippet) {
        return snippet.split('\n').filter(line => {
            if (!line.startsWith('- '))
                return true; // Keep non-bullet lines (headers, etc)
            const normalized = line.replace(/<!--.*?-->/g, '').trim().toLowerCase();
            if (seenBullets.has(normalized))
                return false;
            seenBullets.add(normalized);
            return true;
        }).join('\n');
    }
    for (const doc of rows) {
        // Last gate before content becomes prompt text. Quarantined and personal doc types
        // never get this far on the normal path, but this is the only place that is
        // unconditionally true of everything injected, so it is checked here too.
        if (!isInjectableDocType(doc.type))
            continue;
        // The bullet that matched, not the doc's opening lines.
        const match = bestChunkMatch(queryTokens, doc);
        const source = match && match.distinct >= MIN_CHUNK_MATCHES ? match.chunk : extractSnippet(doc.content, keywords, 8);
        // Read cached freshness before compaction can cut a citation comment. No refresh here.
        let annotated = source;
        if (TRUST_FILTERED_TYPES.has(doc.type)) {
            const root = doc.path ? path.dirname(path.dirname(doc.path)) : undefined;
            const optionsKey = JSON.stringify([root, doc.project]);
            let opts = citationOptions.get(optionsKey);
            if (!opts) {
                opts = root ? { phrenPath: root, projectRemote: getProjectRemote(root, doc.project, readProjectConfig(root, doc.project)) } : {};
                citationOptions.set(optionsKey, opts);
            }
            annotated = markStaleCitations(source, opts);
        }
        let snippet = compactSnippet(annotated, lineBudget, charBudget);
        if (!snippet.trim())
            continue;
        snippet = dedupSnippetBullets(snippet);
        if (!snippet.trim())
            continue;
        let focusScore = queryTokens.length > 0
            ? overlapScore(queryTokens, `${doc.filename}\n${snippet}`)
            : 1;
        if (focusScore < LOW_FOCUS_SNIPPET_SCORE) {
            snippet = compactSnippet(snippet, Math.min(lineBudget, LOW_FOCUS_SNIPPET_LINE_CAP), Math.max(120, Math.floor(charBudget * LOW_FOCUS_SNIPPET_CHAR_FRACTION)));
            focusScore = queryTokens.length > 0
                ? overlapScore(queryTokens, `${doc.filename}\n${snippet}`)
                : focusScore;
        }
        let est = approximateTokens(snippet) + SNIPPET_OVERHEAD_TOKENS;
        if (selected.length > 0 && focusScore < VERY_LOW_FOCUS_SNIPPET_SCORE && usedTokens + est > Math.floor(tokenBudget * 0.8)) {
            continue;
        }
        if (selected.length > 0 && usedTokens + est > tokenBudget)
            break;
        if (selected.length === 0 && usedTokens + est > tokenBudget) {
            snippet = compactSnippet(snippet, 3, Math.floor(charBudget * 0.55));
            est = approximateTokens(snippet) + SNIPPET_OVERHEAD_TOKENS;
        }
        const key = entryScoreKey(doc.project, doc.filename, doc.content);
        selected.push({ doc, snippet, key });
        usedTokens += est;
        if (selected.length >= 3)
            break;
    }
    // Final pass: trim from the end if token budget is exceeded (guards against
    // rounding / compaction producing more tokens than estimated during selection)
    while (selected.length > 1 && usedTokens > tokenBudget) {
        const removed = selected.pop();
        usedTokens -= approximateTokens(removed.snippet) + SNIPPET_OVERHEAD_TOKENS;
    }
    return { selected, usedTokens };
}
// Re-export approximateTokens for use in output module
export { approximateTokens };